main.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  1. // mock-edge-device is a local HTTPS edge-device stub for manual provisioning tests.
  2. package main
  3. import (
  4. "crypto/ed25519"
  5. "crypto/rand"
  6. "crypto/sha256"
  7. "crypto/tls"
  8. "crypto/x509"
  9. "crypto/x509/pkix"
  10. "encoding/base64"
  11. "encoding/hex"
  12. "encoding/json"
  13. "encoding/pem"
  14. "flag"
  15. "fmt"
  16. "log"
  17. "math/big"
  18. "net"
  19. "net/http"
  20. "os"
  21. "path/filepath"
  22. "strings"
  23. "sync"
  24. "time"
  25. mqtt "github.com/eclipse/paho.mqtt.golang"
  26. )
  27. const (
  28. deviceID = "MOCK-EDGE-001"
  29. pairingCode = "246810"
  30. )
  31. type mqttConfig struct {
  32. Host string `json:"host"`
  33. Port int `json:"port"`
  34. TLS bool `json:"tls"`
  35. ClientID string `json:"client_id"`
  36. }
  37. type routeConfig struct {
  38. ParkingLotID uint `json:"parking_lot_id"`
  39. BoothID uint `json:"booth_id"`
  40. ChannelID uint `json:"channel_id"`
  41. Direction string `json:"direction"`
  42. }
  43. type provisionRequest struct {
  44. Schema string `json:"schema"`
  45. RequestID string `json:"request_id"`
  46. DeviceID string `json:"device_id"`
  47. DeviceCode string `json:"device_code"`
  48. MQTT mqttConfig `json:"mqtt"`
  49. Route routeConfig `json:"route"`
  50. }
  51. type state struct {
  52. mu sync.RWMutex
  53. provisioned bool
  54. paired bool
  55. config provisionRequest
  56. client mqtt.Client
  57. certPEM []byte
  58. keyPEM []byte
  59. certFP string
  60. publicKeyFP string
  61. signingKey ed25519.PrivateKey
  62. dataPath string
  63. }
  64. func main() {
  65. listen := flag.String("listen", "0.0.0.0:18443", "HTTPS listen address")
  66. dataDir := flag.String("data-dir", ".run/mock-edge-device", "directory for certificate and provision data")
  67. mqttFallbackPlain := flag.Bool("mqtt-fallback-plain", false, "test-only: use plain MQTT even when provision says tls=true")
  68. flag.Parse()
  69. if err := os.MkdirAll(*dataDir, 0700); err != nil {
  70. log.Fatal(err)
  71. }
  72. certPEM, keyPEM, certFP, err := loadOrCreateCertificate(*dataDir)
  73. if err != nil {
  74. log.Fatal(err)
  75. }
  76. publicKey, signingKey, err := loadOrCreateSigningKey(*dataDir)
  77. if err != nil {
  78. log.Fatal(err)
  79. }
  80. publicHash := sha256.Sum256(publicKey)
  81. s := &state{certPEM: certPEM, keyPEM: keyPEM, certFP: certFP,
  82. publicKeyFP: "SHA256:" + strings.ToUpper(hex.EncodeToString(publicHash[:])),
  83. signingKey: signingKey,
  84. dataPath: filepath.Join(*dataDir, "provision.json")}
  85. if raw, err := os.ReadFile(s.dataPath); err == nil {
  86. _ = json.Unmarshal(raw, &s.config)
  87. s.provisioned = s.config.DeviceCode != ""
  88. }
  89. mux := http.NewServeMux()
  90. mux.HandleFunc("/api/v1/identity", s.identity)
  91. mux.HandleFunc("/api/v1/pair", s.pair)
  92. mux.HandleFunc("/api/v1/provision", func(w http.ResponseWriter, r *http.Request) {
  93. s.provision(w, r, *mqttFallbackPlain)
  94. })
  95. mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
  96. writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
  97. })
  98. server := &http.Server{Addr: *listen, Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second}
  99. cert, err := tls.X509KeyPair(certPEM, keyPEM)
  100. if err != nil {
  101. log.Fatal(err)
  102. }
  103. server.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{cert}}
  104. log.Printf("mock edge device %s listening on https://%s", deviceID, *listen)
  105. log.Printf("pairing code: %s; certificate fingerprint: %s", pairingCode, certFP)
  106. log.Printf("identity endpoint: https://<this-host>:%s/api/v1/identity", portOf(*listen))
  107. go s.serveDiscovery()
  108. if err := server.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
  109. log.Fatal(err)
  110. }
  111. }
  112. type discoveryRequest struct {
  113. Schema string `json:"schema"`
  114. RequestID string `json:"request_id"`
  115. IssuedAt int64 `json:"issued_at"`
  116. ExpiresAt int64 `json:"expires_at"`
  117. SystemID string `json:"system_id"`
  118. Nonce string `json:"nonce"`
  119. }
  120. func (s *state) serveDiscovery() {
  121. conn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 31001})
  122. if err != nil {
  123. log.Printf("UDP discovery disabled: %v", err)
  124. return
  125. }
  126. defer conn.Close()
  127. log.Printf("UDP discovery listening on 0.0.0.0:31001")
  128. buffer := make([]byte, 4*1024)
  129. for {
  130. n, remote, err := conn.ReadFromUDP(buffer)
  131. if err != nil {
  132. log.Printf("UDP discovery read failed: %v", err)
  133. return
  134. }
  135. if n == len(buffer) {
  136. continue
  137. }
  138. var req discoveryRequest
  139. if json.Unmarshal(buffer[:n], &req) != nil || req.Schema != "discovery.request.v1" || strings.TrimSpace(req.RequestID) == "" || strings.TrimSpace(req.Nonce) == "" || req.ExpiresAt <= time.Now().Unix() || req.ExpiresAt > time.Now().Add(2*time.Minute).Unix() {
  140. continue
  141. }
  142. ip := discoverySourceIP(remote.IP)
  143. if ip == nil {
  144. continue
  145. }
  146. configURL := fmt.Sprintf("https://%s:18443/api/v1/provision", ip.String())
  147. packet := map[string]string{
  148. "schema": "discovery.response.v1", "request_id": req.RequestID, "device_id": deviceID,
  149. "device_code": s.currentDeviceCode(), "device_type": "lpr-gate", "device_model": "MOCK-LPR-GATE",
  150. "firmware_version": "mock-1.0.0", "ip": ip.String(), "config_url": configURL,
  151. "public_key_fingerprint": s.publicKeyFP, "signing_public_key": base64.StdEncoding.EncodeToString(s.signingKey.Public().(ed25519.PublicKey)), "nonce": req.Nonce,
  152. }
  153. payload := strings.Join([]string{"discovery.response.v1", req.RequestID, deviceID, ip.String(), configURL, req.Nonce}, "\n")
  154. packet["signature"] = base64.StdEncoding.EncodeToString(ed25519.Sign(s.signingKey, []byte(payload)))
  155. body, _ := json.Marshal(packet)
  156. if _, err := conn.WriteToUDP(body, remote); err != nil {
  157. log.Printf("UDP discovery response failed: %v", err)
  158. }
  159. }
  160. }
  161. func (s *state) currentDeviceCode() string {
  162. s.mu.RLock()
  163. defer s.mu.RUnlock()
  164. return s.config.DeviceCode
  165. }
  166. func discoverySourceIP(remote net.IP) net.IP {
  167. conn, err := net.DialUDP("udp4", nil, &net.UDPAddr{IP: remote, Port: 31001})
  168. if err != nil {
  169. return nil
  170. }
  171. defer conn.Close()
  172. local, ok := conn.LocalAddr().(*net.UDPAddr)
  173. if !ok || local.IP == nil || local.IP.IsUnspecified() || local.IP.IsLoopback() {
  174. return nil
  175. }
  176. return local.IP.To4()
  177. }
  178. func (s *state) identity(w http.ResponseWriter, r *http.Request) {
  179. if r.Method != http.MethodGet {
  180. writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
  181. return
  182. }
  183. s.mu.RLock()
  184. defer s.mu.RUnlock()
  185. writeJSON(w, http.StatusOK, map[string]interface{}{
  186. "device_id": deviceID, "device_code": s.config.DeviceCode, "device_type": "lpr-gate",
  187. "device_model": "MOCK-LPR-GATE", "firmware_version": "mock-1.0.0",
  188. "certificate_fingerprint": s.certFP, "public_key_fingerprint": s.publicKeyFP,
  189. "pairing_required": !s.paired,
  190. })
  191. }
  192. func (s *state) pair(w http.ResponseWriter, r *http.Request) {
  193. if r.Method != http.MethodPost {
  194. writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
  195. return
  196. }
  197. var req struct {
  198. PairingCode string `json:"pairing_code"`
  199. }
  200. if json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req) != nil || req.PairingCode != pairingCode {
  201. writeJSON(w, http.StatusForbidden, map[string]string{"error": "invalid pairing code"})
  202. return
  203. }
  204. s.mu.Lock()
  205. s.paired = true
  206. s.mu.Unlock()
  207. writeJSON(w, http.StatusOK, map[string]interface{}{"result": "paired", "device_id": deviceID, "expires_at": time.Now().Add(24 * time.Hour).Unix()})
  208. }
  209. func (s *state) provision(w http.ResponseWriter, r *http.Request, mqttFallbackPlain bool) {
  210. if r.Method != http.MethodPost {
  211. writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
  212. return
  213. }
  214. var req provisionRequest
  215. if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64*1024)).Decode(&req); err != nil {
  216. writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
  217. return
  218. }
  219. s.mu.RLock()
  220. paired := s.paired
  221. s.mu.RUnlock()
  222. if !paired {
  223. writeJSON(w, http.StatusForbidden, map[string]string{"error": "device is not paired"})
  224. return
  225. }
  226. if req.Schema != "provision.request.v1" || req.DeviceID != deviceID || req.DeviceCode == "" || req.MQTT.Host == "" || req.MQTT.Port < 1 || req.MQTT.Port > 65535 || req.Route.ParkingLotID == 0 || req.Route.BoothID == 0 || req.Route.ChannelID == 0 {
  227. writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid provision fields"})
  228. return
  229. }
  230. raw, _ := json.MarshalIndent(req, "", " ")
  231. if err := atomicWrite(s.dataPath, raw); err != nil {
  232. writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
  233. return
  234. }
  235. s.mu.Lock()
  236. s.config, s.provisioned = req, true
  237. old := s.client
  238. s.client = nil
  239. s.mu.Unlock()
  240. if old != nil {
  241. old.Disconnect(250)
  242. }
  243. go s.connectMQTT(req, mqttFallbackPlain)
  244. writeJSON(w, http.StatusOK, map[string]string{"result": "accepted", "request_id": req.RequestID, "message": "configuration saved; mqtt reconnecting"})
  245. }
  246. func (s *state) connectMQTT(req provisionRequest, mqttFallbackPlain bool) {
  247. server := fmt.Sprintf("tcp://%s:%d", req.MQTT.Host, req.MQTT.Port)
  248. useTLS := req.MQTT.TLS && !mqttFallbackPlain
  249. if useTLS {
  250. log.Printf("MQTT test stub received tls=true; embedded broker is plain TCP, connection may fail")
  251. }
  252. opts := mqtt.NewClientOptions().AddBroker(server).SetClientID(req.MQTT.ClientID).
  253. SetKeepAlive(30*time.Second).SetAutoReconnect(true).
  254. SetWill(topic(req.Route, "gate/lwt"), fmt.Sprintf(`{"schema":"gate.lwt.v1","device_code":%q}`, req.DeviceCode), 1, true)
  255. if useTLS {
  256. opts.SetTLSConfig(&tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: true})
  257. }
  258. opts.SetOnConnectHandler(func(client mqtt.Client) {
  259. client.Subscribe(topic(req.Route, "gate/cmd"), 1, func(c mqtt.Client, msg mqtt.Message) { s.handleCommand(c, msg, req) })
  260. client.Publish(topic(req.Route, "gate/lwt"), 1, true, []byte{})
  261. payload, _ := json.Marshal(map[string]interface{}{"schema": "gate.state.v1", "device_code": req.DeviceCode, "state": "closed", "ts": time.Now().Unix()})
  262. client.Publish(topic(req.Route, "gate/state"), 1, true, payload)
  263. log.Printf("MQTT online; published gate.state.v1 to %s", topic(req.Route, "gate/state"))
  264. })
  265. client := mqtt.NewClient(opts)
  266. s.mu.Lock()
  267. s.client = client
  268. s.mu.Unlock()
  269. if token := client.Connect(); token.Wait() && token.Error() != nil {
  270. log.Printf("MQTT connect failed: %v", token.Error())
  271. }
  272. }
  273. func (s *state) handleCommand(client mqtt.Client, msg mqtt.Message, req provisionRequest) {
  274. var command struct{ Schema, CmdID, DeviceCode, Action string }
  275. if json.Unmarshal(msg.Payload(), &command) != nil || command.Schema != "gate.cmd.v1" || command.DeviceCode != req.DeviceCode || command.CmdID == "" {
  276. return
  277. }
  278. payload, _ := json.Marshal(map[string]string{"schema": "gate.ack.v1", "cmd_id": command.CmdID, "device_code": req.DeviceCode, "result": "success", "error": ""})
  279. client.Publish(topic(req.Route, "gate/cmd/ack"), 1, false, payload)
  280. }
  281. func topic(route routeConfig, suffix string) string {
  282. return fmt.Sprintf("parking/lot/%d/booth/%d/channel/%d/%s", route.ParkingLotID, route.BoothID, route.ChannelID, suffix)
  283. }
  284. func writeJSON(w http.ResponseWriter, status int, value interface{}) {
  285. w.Header().Set("Content-Type", "application/json")
  286. w.WriteHeader(status)
  287. _ = json.NewEncoder(w).Encode(value)
  288. }
  289. func atomicWrite(path string, data []byte) error {
  290. tmp := path + ".tmp"
  291. if err := os.WriteFile(tmp, data, 0600); err != nil {
  292. return err
  293. }
  294. return os.Rename(tmp, path)
  295. }
  296. func portOf(addr string) string {
  297. _, port, err := net.SplitHostPort(addr)
  298. if err != nil {
  299. return "18443"
  300. }
  301. return port
  302. }
  303. func loadOrCreateCertificate(dir string) ([]byte, []byte, string, error) {
  304. certPath, keyPath := filepath.Join(dir, "device.crt.pem"), filepath.Join(dir, "device.key.pem")
  305. if certPEM, err := os.ReadFile(certPath); err == nil {
  306. if keyPEM, keyErr := os.ReadFile(keyPath); keyErr == nil {
  307. fp, fpErr := certificateFingerprint(certPEM)
  308. return certPEM, keyPEM, fp, fpErr
  309. }
  310. }
  311. publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
  312. if err != nil {
  313. return nil, nil, "", err
  314. }
  315. serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 120))
  316. now := time.Now()
  317. template := &x509.Certificate{SerialNumber: serial, Subject: pkix.Name{CommonName: deviceID}, NotBefore: now.Add(-time.Minute), NotAfter: now.AddDate(10, 0, 0), KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true, DNSNames: []string{"localhost"}}
  318. for _, ip := range localIPs() {
  319. template.IPAddresses = append(template.IPAddresses, ip)
  320. }
  321. der, err := x509.CreateCertificate(rand.Reader, template, template, publicKey, privateKey)
  322. if err != nil {
  323. return nil, nil, "", err
  324. }
  325. certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
  326. keyBytes, _ := x509.MarshalPKCS8PrivateKey(privateKey)
  327. keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes})
  328. if err := os.WriteFile(certPath, certPEM, 0600); err != nil {
  329. return nil, nil, "", err
  330. }
  331. if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil {
  332. return nil, nil, "", err
  333. }
  334. hash := sha256.Sum256(der)
  335. return certPEM, keyPEM, "SHA256:" + strings.ToUpper(hex.EncodeToString(hash[:])), nil
  336. }
  337. func certificateFingerprint(certPEM []byte) (string, error) {
  338. block, _ := pem.Decode(certPEM)
  339. if block == nil {
  340. return "", fmt.Errorf("invalid certificate PEM")
  341. }
  342. hash := sha256.Sum256(block.Bytes)
  343. return "SHA256:" + strings.ToUpper(hex.EncodeToString(hash[:])), nil
  344. }
  345. func loadOrCreateSigningKey(dir string) (ed25519.PublicKey, ed25519.PrivateKey, error) {
  346. path := filepath.Join(dir, "signing-key.bin")
  347. if raw, err := os.ReadFile(path); err == nil && len(raw) == ed25519.PrivateKeySize {
  348. privateKey := ed25519.PrivateKey(raw)
  349. return privateKey.Public().(ed25519.PublicKey), privateKey, nil
  350. }
  351. publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
  352. if err != nil {
  353. return nil, nil, err
  354. }
  355. if err := os.WriteFile(path, privateKey, 0600); err != nil {
  356. return nil, nil, err
  357. }
  358. return publicKey, privateKey, nil
  359. }
  360. func localIPs() []net.IP {
  361. var result []net.IP
  362. interfaces, _ := net.Interfaces()
  363. for _, iface := range interfaces {
  364. addrs, _ := iface.Addrs()
  365. for _, addr := range addrs {
  366. var ip net.IP
  367. switch value := addr.(type) {
  368. case *net.IPNet:
  369. ip = value.IP
  370. case *net.IPAddr:
  371. ip = value.IP
  372. }
  373. if ip != nil && ip.To4() != nil && !ip.IsLoopback() {
  374. result = append(result, ip.To4())
  375. }
  376. }
  377. }
  378. return result
  379. }