service.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. package service
  2. import (
  3. "bytes"
  4. "crypto/sha256"
  5. "crypto/tls"
  6. "encoding/hex"
  7. "encoding/json"
  8. "errors"
  9. "fmt"
  10. "io"
  11. "net"
  12. "net/http"
  13. "net/url"
  14. "strconv"
  15. "strings"
  16. "time"
  17. "github.com/gofrs/uuid/v5"
  18. "gorm.io/gorm"
  19. "wails-app/internal/dao"
  20. "wails-app/internal/global"
  21. "wails-app/internal/modules/deviceprovisioning/model/request"
  22. responseModel "wails-app/internal/modules/deviceprovisioning/model/response"
  23. "wails-app/internal/modules/deviceprovisioning/repository"
  24. )
  25. const (
  26. defaultConfigPort = 8443
  27. identityPath = "/api/v1/identity"
  28. provisionPath = "/api/v1/provision"
  29. )
  30. // Service 实现手工 IP 接入流程。所有设备身份都以 HTTPS 证书和永久 ID 为准。
  31. type Service struct{ repo *repository.Repository }
  32. func New() *Service { return &Service{repo: repository.New()} }
  33. // findProvisionReader finds the row to update for a provisioning request.
  34. // Unscoped is intentional: device_code remains unique after a soft delete.
  35. func findProvisionReader(db *gorm.DB, deviceID, deviceCode string) (dao.UHFReader, bool, error) {
  36. var existing dao.UHFReader
  37. var duplicate dao.UHFReader
  38. duplicateResult := db.Unscoped().Where("device_code = ?", deviceCode).Order("id DESC").First(&duplicate)
  39. if duplicateResult.Error != nil && !errors.Is(duplicateResult.Error, gorm.ErrRecordNotFound) {
  40. return existing, false, duplicateResult.Error
  41. }
  42. if duplicateResult.Error == nil {
  43. if duplicate.DeviceID != deviceID {
  44. return existing, false, errors.New("设备编码已被其他设备使用")
  45. }
  46. return duplicate, true, nil
  47. }
  48. result := db.Unscoped().Where("device_id = ?", deviceID).Order("id DESC").First(&existing)
  49. if result.Error != nil && !errors.Is(result.Error, gorm.ErrRecordNotFound) {
  50. return existing, false, result.Error
  51. }
  52. return existing, result.Error == nil, nil
  53. }
  54. type deviceIdentity struct {
  55. DeviceID string `json:"device_id"`
  56. DeviceCode string `json:"device_code"`
  57. DeviceType string `json:"device_type"`
  58. DeviceModel string `json:"device_model"`
  59. FirmwareVersion string `json:"firmware_version"`
  60. CertificateFingerprint string `json:"certificate_fingerprint"`
  61. PublicKeyFingerprint string `json:"public_key_fingerprint"`
  62. PairingRequired bool `json:"pairing_required"`
  63. }
  64. func normalizeFingerprint(value string) string {
  65. value = strings.ToLower(strings.TrimSpace(value))
  66. value = strings.TrimPrefix(value, "sha256:")
  67. value = strings.ReplaceAll(value, ":", "")
  68. value = strings.ReplaceAll(value, "-", "")
  69. return value
  70. }
  71. // ValidateAddress 只接受字面单播 IP,禁止回环、未指定、广播、组播和主机名。
  72. func ValidateAddress(rawIP string, port int) (net.IP, error) {
  73. value := strings.TrimSpace(rawIP)
  74. if value == "" || strings.EqualFold(value, "localhost") {
  75. return nil, errors.New("设备地址必须是合法 IP,不能使用 localhost")
  76. }
  77. if net.ParseIP(value) == nil {
  78. return nil, errors.New("设备地址必须是字面 IP,不能使用主机名")
  79. }
  80. ip := net.ParseIP(value)
  81. if ip.IsLoopback() || ip.IsUnspecified() || ip.IsMulticast() {
  82. return nil, errors.New("禁止使用回环、未指定或组播地址")
  83. }
  84. if port < 1 || port > 65535 {
  85. return nil, errors.New("HTTPS 端口必须是 1-65535 的整数")
  86. }
  87. if ip.To4() != nil {
  88. v4 := ip.To4()
  89. if v4[0] == 255 && v4[1] == 255 && v4[2] == 255 && v4[3] == 255 {
  90. return nil, errors.New("禁止使用广播地址")
  91. }
  92. if isDirectedBroadcast(v4) {
  93. return nil, errors.New("禁止使用当前网卡的定向广播地址")
  94. }
  95. }
  96. return ip, nil
  97. }
  98. func isDirectedBroadcast(ip net.IP) bool {
  99. interfaces, _ := net.Interfaces()
  100. for _, iface := range interfaces {
  101. addrs, _ := iface.Addrs()
  102. for _, addr := range addrs {
  103. var ipnet *net.IPNet
  104. switch value := addr.(type) {
  105. case *net.IPNet:
  106. ipnet = value
  107. case *net.IPAddr:
  108. ipnet = &net.IPNet{IP: value.IP, Mask: net.CIDRMask(32, 32)}
  109. }
  110. if ipnet == nil || ipnet.IP.To4() == nil || !ipnet.Contains(ip) {
  111. continue
  112. }
  113. network := ipnet.IP.To4()
  114. mask := ipnet.Mask
  115. return isDirectedBroadcastForNetwork(ip, network, mask)
  116. }
  117. }
  118. return false
  119. }
  120. func isDirectedBroadcastForNetwork(ip, network net.IP, mask net.IPMask) bool {
  121. input := ip.To4()
  122. network = network.To4()
  123. if input == nil || network == nil || len(mask) != net.IPv4len {
  124. return false
  125. }
  126. broadcast := make(net.IP, net.IPv4len)
  127. for i := 0; i < net.IPv4len; i++ {
  128. broadcast[i] = network[i] | ^mask[i]
  129. }
  130. return broadcast.Equal(input)
  131. }
  132. func (s *Service) httpClient(_ net.IP, fingerprint string) *http.Client {
  133. transport := &http.Transport{TLSClientConfig: &tls.Config{
  134. MinVersion: tls.VersionTLS12,
  135. InsecureSkipVerify: true, // 设备出厂可使用自签名证书;VerifyConnection 负责固定指纹。
  136. VerifyConnection: func(state tls.ConnectionState) error {
  137. if len(state.PeerCertificates) == 0 {
  138. return errors.New("设备未提供 TLS 证书")
  139. }
  140. cert := state.PeerCertificates[0]
  141. got := sha256.Sum256(cert.Raw)
  142. actual := hex.EncodeToString(got[:])
  143. if fingerprint != "" && normalizeFingerprint(fingerprint) != actual {
  144. return fmt.Errorf("证书指纹不匹配")
  145. }
  146. return nil
  147. },
  148. }}
  149. transport.DialContext = (&net.Dialer{Timeout: 5 * time.Second}).DialContext
  150. return &http.Client{Transport: transport, Timeout: 10 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
  151. return errors.New("禁止 HTTPS 重定向到其他主机")
  152. }}
  153. }
  154. func endpoint(ip net.IP, port int, path string) string {
  155. return (&url.URL{Scheme: "https", Host: net.JoinHostPort(ip.String(), strconv.Itoa(port)), Path: path}).String()
  156. }
  157. func decodeResponse(body []byte, target interface{}) error {
  158. if len(body) > 64*1024 {
  159. return errors.New("设备响应过大")
  160. }
  161. if err := json.Unmarshal(body, target); err != nil {
  162. return fmt.Errorf("设备响应不是合法 JSON: %w", err)
  163. }
  164. return nil
  165. }
  166. // ReadIdentity 连接设备并保存 10 分钟有效的身份记录。
  167. func (s *Service) ReadIdentity(req request.ManualIdentityRequest) (*responseModel.IdentityResponse, error) {
  168. port := req.Port
  169. if port == 0 {
  170. port = defaultConfigPort
  171. }
  172. ip, err := ValidateAddress(req.IP, port)
  173. if err != nil {
  174. return nil, err
  175. }
  176. client := s.httpClient(ip, "")
  177. httpReq, _ := http.NewRequest(http.MethodGet, endpoint(ip, port, identityPath), nil)
  178. httpReq.Header.Set("Accept", "application/json")
  179. resp, err := client.Do(httpReq)
  180. if err != nil {
  181. return nil, fmt.Errorf("HTTPS 身份读取失败: %w", err)
  182. }
  183. defer resp.Body.Close()
  184. body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024+1))
  185. if err != nil {
  186. return nil, fmt.Errorf("读取设备身份失败: %w", err)
  187. }
  188. if resp.StatusCode < 200 || resp.StatusCode >= 300 {
  189. return nil, fmt.Errorf("设备身份接口返回 HTTP %d", resp.StatusCode)
  190. }
  191. var identity deviceIdentity
  192. if err := decodeResponse(body, &identity); err != nil {
  193. return nil, err
  194. }
  195. if strings.TrimSpace(identity.DeviceID) == "" {
  196. return nil, errors.New("设备未返回永久设备 ID")
  197. }
  198. certFingerprint := ""
  199. if len(resp.TLS.PeerCertificates) > 0 {
  200. hash := sha256.Sum256(resp.TLS.PeerCertificates[0].Raw)
  201. certFingerprint = "SHA256:" + strings.ToUpper(hex.EncodeToString(hash[:]))
  202. }
  203. if identity.CertificateFingerprint != "" && normalizeFingerprint(identity.CertificateFingerprint) != normalizeFingerprint(certFingerprint) {
  204. return nil, fmt.Errorf("设备返回的证书指纹与 HTTPS 证书不匹配: received=%q expected=%s", identity.CertificateFingerprint, certFingerprint)
  205. }
  206. requestID := uuid.Must(uuid.NewV4()).String()
  207. expiredAt := time.Now().Add(10 * time.Minute)
  208. payload, _ := json.Marshal(identity)
  209. record := &dao.DeviceDiscovery{
  210. RequestID: requestID, DeviceID: identity.DeviceID, DeviceCode: identity.DeviceCode,
  211. DeviceType: identity.DeviceType, DeviceModel: identity.DeviceModel, FirmwareVersion: identity.FirmwareVersion,
  212. SourceIP: ip.String(), ConfigPort: port,
  213. Payload: string(payload), CertificateFingerprint: certFingerprint,
  214. PublicKeyFingerprint: identity.PublicKeyFingerprint,
  215. PairingRequired: identity.PairingRequired, ExpiredAt: expiredAt, ProvisionStatus: "identity_unverified",
  216. ProvisionMethod: req.ProvisionMethod,
  217. }
  218. if record.ProvisionMethod == "" {
  219. record.ProvisionMethod = "manual_ip"
  220. }
  221. if record.ProvisionMethod == "auto_udp" {
  222. record.SignatureVerified = true
  223. }
  224. if err := s.repo.CreateDiscovery(record); err != nil {
  225. return nil, err
  226. }
  227. return &responseModel.IdentityResponse{
  228. RequestID: requestID, DeviceID: identity.DeviceID, DeviceCode: identity.DeviceCode,
  229. DeviceType: identity.DeviceType, DeviceModel: identity.DeviceModel, FirmwareVersion: identity.FirmwareVersion,
  230. IP: ip.String(), ConfigPort: port, CertificateFingerprint: certFingerprint,
  231. PublicKeyFingerprint: identity.PublicKeyFingerprint, PairingRequired: identity.PairingRequired,
  232. ExpiredAt: expiredAt, ProvisionStatus: "identity_unverified",
  233. }, nil
  234. }
  235. // VerifyIdentity 固定设备证书指纹并通过设备配对接口确认一次性配对码。
  236. func (s *Service) VerifyIdentity(deviceID string, req request.VerifyRequest) (*responseModel.IdentityResponse, error) {
  237. record, err := s.repo.LatestDiscovery(deviceID)
  238. if err != nil {
  239. return nil, err
  240. }
  241. if normalizeFingerprint(req.Fingerprint) != normalizeFingerprint(record.CertificateFingerprint) {
  242. return nil, errors.New("证书指纹不匹配")
  243. }
  244. if record.PairingRequired && strings.TrimSpace(req.PairingCode) == "" {
  245. return nil, errors.New("设备要求输入一次性配对码")
  246. }
  247. if record.PairingRequired {
  248. ip := net.ParseIP(record.SourceIP)
  249. body, _ := json.Marshal(map[string]string{"pairing_code": req.PairingCode})
  250. client := s.httpClient(ip, record.CertificateFingerprint)
  251. httpReq, _ := http.NewRequest(http.MethodPost, endpoint(ip, record.ConfigPort, "/api/v1/pair"), bytes.NewReader(body))
  252. httpReq.Header.Set("Content-Type", "application/json")
  253. resp, callErr := client.Do(httpReq)
  254. if callErr != nil {
  255. return nil, fmt.Errorf("配对码校验失败: %w", callErr)
  256. }
  257. defer resp.Body.Close()
  258. if resp.StatusCode < 200 || resp.StatusCode >= 300 {
  259. return nil, fmt.Errorf("配对码校验失败,设备返回 HTTP %d", resp.StatusCode)
  260. }
  261. }
  262. now := time.Now()
  263. record.Verified = true
  264. record.VerifiedAt = &now
  265. record.ProvisionStatus = record.ProvisionMethod + "_verified"
  266. if err := s.repo.SaveDiscovery(record); err != nil {
  267. return nil, err
  268. }
  269. var identity deviceIdentity
  270. _ = json.Unmarshal([]byte(record.Payload), &identity)
  271. return &responseModel.IdentityResponse{
  272. RequestID: record.RequestID, DeviceID: record.DeviceID, DeviceCode: identity.DeviceCode,
  273. DeviceType: identity.DeviceType, DeviceModel: identity.DeviceModel, FirmwareVersion: identity.FirmwareVersion,
  274. IP: record.SourceIP, ConfigPort: record.ConfigPort, CertificateFingerprint: record.CertificateFingerprint,
  275. PublicKeyFingerprint: identity.PublicKeyFingerprint, PairingRequired: record.PairingRequired,
  276. Verified: true, ExpiredAt: record.ExpiredAt, ProvisionStatus: record.ProvisionStatus,
  277. }, nil
  278. }
  279. type provisionPayload struct {
  280. Schema string `json:"schema"`
  281. RequestID string `json:"request_id"`
  282. DeviceID string `json:"device_id"`
  283. DeviceCode string `json:"device_code"`
  284. MQTT struct {
  285. Host string `json:"host"`
  286. Port int `json:"port"`
  287. TLS bool `json:"tls"`
  288. ClientID string `json:"client_id"`
  289. } `json:"mqtt"`
  290. Route struct {
  291. ParkingLotID uint `json:"parking_lot_id"`
  292. BoothID uint `json:"booth_id"`
  293. ChannelID uint `json:"channel_id"`
  294. Direction string `json:"direction"`
  295. } `json:"route"`
  296. ImageUpload struct {
  297. URL string `json:"url"`
  298. MaxBytes int64 `json:"max_bytes"`
  299. } `json:"image_upload"`
  300. }
  301. const deviceImageUploadMaxBytes int64 = 8 * 1024 * 1024
  302. func deviceImageUploadURL(host string, port int) string {
  303. return "http://" + net.JoinHostPort(host, strconv.Itoa(port)) + "/device-images/upload"
  304. }
  305. // Provision 校验业务绑定后下发配置,并创建/更新 MQTT 设备记录。
  306. func (s *Service) Provision(deviceID string, req request.ProvisionRequest) (*responseModel.ProvisionStatusResponse, error) {
  307. record, err := s.repo.LatestDiscovery(deviceID)
  308. if err != nil {
  309. return nil, err
  310. }
  311. if !record.Verified {
  312. return nil, errors.New("设备身份尚未确认")
  313. }
  314. if global.GVA_DB == nil {
  315. return nil, errors.New("数据库未初始化")
  316. }
  317. var channel dao.Channel
  318. if err := global.GVA_DB.Preload("Booth").Where("id = ?", req.ChannelID).First(&channel).Error; err != nil {
  319. return nil, errors.New("通道不存在")
  320. }
  321. if channel.BoothID != req.BoothID || channel.ParkingLotID != req.ParkingLotID || channel.Booth == nil || channel.Booth.ParkingLotID != req.ParkingLotID {
  322. return nil, errors.New("停车场、岗亭和通道归属不一致")
  323. }
  324. // device_code has a database-wide unique index. Include soft-deleted rows in
  325. // this lookup; otherwise a deleted record can pass the pre-check and fail
  326. // later with SQLite 2067 during INSERT.
  327. existing, existingFound, err := findProvisionReader(global.GVA_DB, deviceID, req.DeviceCode)
  328. if err != nil {
  329. return nil, err
  330. }
  331. cfg := global.GVA_CONFIG.Mqtt
  332. if strings.TrimSpace(cfg.AdvertisedHost) == "" || net.ParseIP(cfg.AdvertisedHost) == nil {
  333. return nil, errors.New("未配置可供边缘设备访问的 MQTT advertised-host")
  334. }
  335. if _, err := ValidateAddress(cfg.AdvertisedHost, cfg.AdvertisedPort); err != nil {
  336. return nil, fmt.Errorf("MQTT advertised 地址无效: %w", err)
  337. }
  338. requestID := uuid.Must(uuid.NewV4()).String()
  339. payload := provisionPayload{Schema: "provision.request.v1", RequestID: requestID, DeviceID: deviceID, DeviceCode: req.DeviceCode}
  340. payload.MQTT.Host, payload.MQTT.Port, payload.MQTT.TLS, payload.MQTT.ClientID = cfg.AdvertisedHost, cfg.AdvertisedPort, cfg.TLSEnabled, req.DeviceCode
  341. payload.Route.ParkingLotID, payload.Route.BoothID, payload.Route.ChannelID, payload.Route.Direction = req.ParkingLotID, req.BoothID, req.ChannelID, req.Direction
  342. payload.ImageUpload.URL, payload.ImageUpload.MaxBytes = deviceImageUploadURL(cfg.AdvertisedHost, global.GVA_CONFIG.System.Addr), deviceImageUploadMaxBytes
  343. body, _ := json.Marshal(payload)
  344. ip := net.ParseIP(record.SourceIP)
  345. client := s.httpClient(ip, record.CertificateFingerprint)
  346. httpReq, _ := http.NewRequest(http.MethodPost, endpoint(ip, record.ConfigPort, provisionPath), bytes.NewReader(body))
  347. httpReq.Header.Set("Content-Type", "application/json")
  348. resp, err := client.Do(httpReq)
  349. if err != nil {
  350. return nil, fmt.Errorf("HTTPS 配置下发失败: %w", err)
  351. }
  352. defer resp.Body.Close()
  353. responseBody, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024+1))
  354. if resp.StatusCode < 200 || resp.StatusCode >= 300 {
  355. return nil, fmt.Errorf("设备拒绝配置,HTTP %d", resp.StatusCode)
  356. }
  357. var accepted struct {
  358. Result string `json:"result"`
  359. }
  360. if err := decodeResponse(responseBody, &accepted); err != nil {
  361. return nil, fmt.Errorf("设备配置响应无效: %w", err)
  362. }
  363. if accepted.Result != "accepted" {
  364. return nil, errors.New("设备未接受 MQTT 配置")
  365. }
  366. now := time.Now()
  367. if !existingFound {
  368. existing = dao.UHFReader{DeviceID: deviceID, DeviceCode: req.DeviceCode, DeviceName: req.DeviceName, DeviceType: req.DeviceType, ConnectType: dao.ConnectTypeMQTT, ChannelID: req.ChannelID, ParkingLotID: req.ParkingLotID}
  369. }
  370. // Restore a soft-deleted row before saving the new binding. Save must be
  371. // Unscoped so GORM does not silently exclude the historical row.
  372. existing.DeletedAt = gorm.DeletedAt{}
  373. existing.DeviceCode, existing.DeviceName, existing.DeviceType = req.DeviceCode, req.DeviceName, req.DeviceType
  374. existing.ConnectType, existing.ChannelID, existing.ParkingLotID = dao.ConnectTypeMQTT, req.ChannelID, req.ParkingLotID
  375. existing.IPAddress, existing.ConfigPort = record.SourceIP, record.ConfigPort
  376. existing.DeviceModel, existing.FirmwareVersion = record.DeviceModel, record.FirmwareVersion
  377. existing.DevicePublicKey, existing.ProvisionStatus, existing.ProvisionMethod, existing.ProvisionError = record.CertificateFingerprint, "provisioned", record.ProvisionMethod, ""
  378. existing.IdentityVerifiedAt = record.VerifiedAt
  379. existing.ProvisionedAt = &now
  380. if !existingFound {
  381. if err := global.GVA_DB.Create(&existing).Error; err != nil {
  382. return nil, err
  383. }
  384. } else if err := global.GVA_DB.Unscoped().Save(&existing).Error; err != nil {
  385. return nil, err
  386. }
  387. return &responseModel.ProvisionStatusResponse{DeviceID: deviceID, DeviceCode: req.DeviceCode, ProvisionStatus: "provisioned", ProvisionMethod: record.ProvisionMethod, DeviceStatus: existing.Status, IP: record.SourceIP, ConfigPort: record.ConfigPort, CertificateFinger: record.CertificateFingerprint, VerifiedAt: record.VerifiedAt, ProvisionedAt: &now}, nil
  388. }
  389. func (s *Service) Status(deviceID string) (*responseModel.ProvisionStatusResponse, error) {
  390. reader, err := s.repo.ReaderByDeviceID(deviceID)
  391. if err != nil {
  392. return nil, err
  393. }
  394. if reader == nil {
  395. return nil, errors.New("设备尚未完成配置")
  396. }
  397. return &responseModel.ProvisionStatusResponse{DeviceID: reader.DeviceID, DeviceCode: reader.DeviceCode, ProvisionStatus: reader.ProvisionStatus, ProvisionMethod: reader.ProvisionMethod, DeviceStatus: reader.Status, ProvisionError: reader.ProvisionError, IP: reader.IPAddress, ConfigPort: reader.ConfigPort, CertificateFinger: reader.DevicePublicKey, VerifiedAt: reader.IdentityVerifiedAt, ProvisionedAt: reader.ProvisionedAt}, nil
  398. }