| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420 |
- package service
- import (
- "bytes"
- "crypto/sha256"
- "crypto/tls"
- "encoding/hex"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net"
- "net/http"
- "net/url"
- "strconv"
- "strings"
- "time"
- "github.com/gofrs/uuid/v5"
- "gorm.io/gorm"
- "wails-app/internal/dao"
- "wails-app/internal/global"
- "wails-app/internal/modules/deviceprovisioning/model/request"
- responseModel "wails-app/internal/modules/deviceprovisioning/model/response"
- "wails-app/internal/modules/deviceprovisioning/repository"
- )
- const (
- defaultConfigPort = 8443
- identityPath = "/api/v1/identity"
- provisionPath = "/api/v1/provision"
- )
- // Service 实现手工 IP 接入流程。所有设备身份都以 HTTPS 证书和永久 ID 为准。
- type Service struct{ repo *repository.Repository }
- func New() *Service { return &Service{repo: repository.New()} }
- // findProvisionReader finds the row to update for a provisioning request.
- // Unscoped is intentional: device_code remains unique after a soft delete.
- func findProvisionReader(db *gorm.DB, deviceID, deviceCode string) (dao.UHFReader, bool, error) {
- var existing dao.UHFReader
- var duplicate dao.UHFReader
- duplicateResult := db.Unscoped().Where("device_code = ?", deviceCode).Order("id DESC").First(&duplicate)
- if duplicateResult.Error != nil && !errors.Is(duplicateResult.Error, gorm.ErrRecordNotFound) {
- return existing, false, duplicateResult.Error
- }
- if duplicateResult.Error == nil {
- if duplicate.DeviceID != deviceID {
- return existing, false, errors.New("设备编码已被其他设备使用")
- }
- return duplicate, true, nil
- }
- result := db.Unscoped().Where("device_id = ?", deviceID).Order("id DESC").First(&existing)
- if result.Error != nil && !errors.Is(result.Error, gorm.ErrRecordNotFound) {
- return existing, false, result.Error
- }
- return existing, result.Error == nil, nil
- }
- type deviceIdentity struct {
- DeviceID string `json:"device_id"`
- DeviceCode string `json:"device_code"`
- DeviceType string `json:"device_type"`
- DeviceModel string `json:"device_model"`
- FirmwareVersion string `json:"firmware_version"`
- CertificateFingerprint string `json:"certificate_fingerprint"`
- PublicKeyFingerprint string `json:"public_key_fingerprint"`
- PairingRequired bool `json:"pairing_required"`
- }
- func normalizeFingerprint(value string) string {
- value = strings.ToLower(strings.TrimSpace(value))
- value = strings.TrimPrefix(value, "sha256:")
- value = strings.ReplaceAll(value, ":", "")
- value = strings.ReplaceAll(value, "-", "")
- return value
- }
- // ValidateAddress 只接受字面单播 IP,禁止回环、未指定、广播、组播和主机名。
- func ValidateAddress(rawIP string, port int) (net.IP, error) {
- value := strings.TrimSpace(rawIP)
- if value == "" || strings.EqualFold(value, "localhost") {
- return nil, errors.New("设备地址必须是合法 IP,不能使用 localhost")
- }
- if net.ParseIP(value) == nil {
- return nil, errors.New("设备地址必须是字面 IP,不能使用主机名")
- }
- ip := net.ParseIP(value)
- if ip.IsLoopback() || ip.IsUnspecified() || ip.IsMulticast() {
- return nil, errors.New("禁止使用回环、未指定或组播地址")
- }
- if port < 1 || port > 65535 {
- return nil, errors.New("HTTPS 端口必须是 1-65535 的整数")
- }
- if ip.To4() != nil {
- v4 := ip.To4()
- if v4[0] == 255 && v4[1] == 255 && v4[2] == 255 && v4[3] == 255 {
- return nil, errors.New("禁止使用广播地址")
- }
- if isDirectedBroadcast(v4) {
- return nil, errors.New("禁止使用当前网卡的定向广播地址")
- }
- }
- return ip, nil
- }
- func isDirectedBroadcast(ip net.IP) bool {
- interfaces, _ := net.Interfaces()
- for _, iface := range interfaces {
- addrs, _ := iface.Addrs()
- for _, addr := range addrs {
- var ipnet *net.IPNet
- switch value := addr.(type) {
- case *net.IPNet:
- ipnet = value
- case *net.IPAddr:
- ipnet = &net.IPNet{IP: value.IP, Mask: net.CIDRMask(32, 32)}
- }
- if ipnet == nil || ipnet.IP.To4() == nil || !ipnet.Contains(ip) {
- continue
- }
- network := ipnet.IP.To4()
- mask := ipnet.Mask
- return isDirectedBroadcastForNetwork(ip, network, mask)
- }
- }
- return false
- }
- func isDirectedBroadcastForNetwork(ip, network net.IP, mask net.IPMask) bool {
- input := ip.To4()
- network = network.To4()
- if input == nil || network == nil || len(mask) != net.IPv4len {
- return false
- }
- broadcast := make(net.IP, net.IPv4len)
- for i := 0; i < net.IPv4len; i++ {
- broadcast[i] = network[i] | ^mask[i]
- }
- return broadcast.Equal(input)
- }
- func (s *Service) httpClient(_ net.IP, fingerprint string) *http.Client {
- transport := &http.Transport{TLSClientConfig: &tls.Config{
- MinVersion: tls.VersionTLS12,
- InsecureSkipVerify: true, // 设备出厂可使用自签名证书;VerifyConnection 负责固定指纹。
- VerifyConnection: func(state tls.ConnectionState) error {
- if len(state.PeerCertificates) == 0 {
- return errors.New("设备未提供 TLS 证书")
- }
- cert := state.PeerCertificates[0]
- got := sha256.Sum256(cert.Raw)
- actual := hex.EncodeToString(got[:])
- if fingerprint != "" && normalizeFingerprint(fingerprint) != actual {
- return fmt.Errorf("证书指纹不匹配")
- }
- return nil
- },
- }}
- transport.DialContext = (&net.Dialer{Timeout: 5 * time.Second}).DialContext
- return &http.Client{Transport: transport, Timeout: 10 * time.Second, CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
- return errors.New("禁止 HTTPS 重定向到其他主机")
- }}
- }
- func endpoint(ip net.IP, port int, path string) string {
- return (&url.URL{Scheme: "https", Host: net.JoinHostPort(ip.String(), strconv.Itoa(port)), Path: path}).String()
- }
- func decodeResponse(body []byte, target interface{}) error {
- if len(body) > 64*1024 {
- return errors.New("设备响应过大")
- }
- if err := json.Unmarshal(body, target); err != nil {
- return fmt.Errorf("设备响应不是合法 JSON: %w", err)
- }
- return nil
- }
- // ReadIdentity 连接设备并保存 10 分钟有效的身份记录。
- func (s *Service) ReadIdentity(req request.ManualIdentityRequest) (*responseModel.IdentityResponse, error) {
- port := req.Port
- if port == 0 {
- port = defaultConfigPort
- }
- ip, err := ValidateAddress(req.IP, port)
- if err != nil {
- return nil, err
- }
- client := s.httpClient(ip, "")
- httpReq, _ := http.NewRequest(http.MethodGet, endpoint(ip, port, identityPath), nil)
- httpReq.Header.Set("Accept", "application/json")
- resp, err := client.Do(httpReq)
- if err != nil {
- return nil, fmt.Errorf("HTTPS 身份读取失败: %w", err)
- }
- defer resp.Body.Close()
- body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024+1))
- if err != nil {
- return nil, fmt.Errorf("读取设备身份失败: %w", err)
- }
- if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- return nil, fmt.Errorf("设备身份接口返回 HTTP %d", resp.StatusCode)
- }
- var identity deviceIdentity
- if err := decodeResponse(body, &identity); err != nil {
- return nil, err
- }
- if strings.TrimSpace(identity.DeviceID) == "" {
- return nil, errors.New("设备未返回永久设备 ID")
- }
- certFingerprint := ""
- if len(resp.TLS.PeerCertificates) > 0 {
- hash := sha256.Sum256(resp.TLS.PeerCertificates[0].Raw)
- certFingerprint = "SHA256:" + strings.ToUpper(hex.EncodeToString(hash[:]))
- }
- if identity.CertificateFingerprint != "" && normalizeFingerprint(identity.CertificateFingerprint) != normalizeFingerprint(certFingerprint) {
- return nil, fmt.Errorf("设备返回的证书指纹与 HTTPS 证书不匹配: received=%q expected=%s", identity.CertificateFingerprint, certFingerprint)
- }
- requestID := uuid.Must(uuid.NewV4()).String()
- expiredAt := time.Now().Add(10 * time.Minute)
- payload, _ := json.Marshal(identity)
- record := &dao.DeviceDiscovery{
- RequestID: requestID, DeviceID: identity.DeviceID, DeviceCode: identity.DeviceCode,
- DeviceType: identity.DeviceType, DeviceModel: identity.DeviceModel, FirmwareVersion: identity.FirmwareVersion,
- SourceIP: ip.String(), ConfigPort: port,
- Payload: string(payload), CertificateFingerprint: certFingerprint,
- PublicKeyFingerprint: identity.PublicKeyFingerprint,
- PairingRequired: identity.PairingRequired, ExpiredAt: expiredAt, ProvisionStatus: "identity_unverified",
- ProvisionMethod: req.ProvisionMethod,
- }
- if record.ProvisionMethod == "" {
- record.ProvisionMethod = "manual_ip"
- }
- if record.ProvisionMethod == "auto_udp" {
- record.SignatureVerified = true
- }
- if err := s.repo.CreateDiscovery(record); err != nil {
- return nil, err
- }
- return &responseModel.IdentityResponse{
- RequestID: requestID, DeviceID: identity.DeviceID, DeviceCode: identity.DeviceCode,
- DeviceType: identity.DeviceType, DeviceModel: identity.DeviceModel, FirmwareVersion: identity.FirmwareVersion,
- IP: ip.String(), ConfigPort: port, CertificateFingerprint: certFingerprint,
- PublicKeyFingerprint: identity.PublicKeyFingerprint, PairingRequired: identity.PairingRequired,
- ExpiredAt: expiredAt, ProvisionStatus: "identity_unverified",
- }, nil
- }
- // VerifyIdentity 固定设备证书指纹并通过设备配对接口确认一次性配对码。
- func (s *Service) VerifyIdentity(deviceID string, req request.VerifyRequest) (*responseModel.IdentityResponse, error) {
- record, err := s.repo.LatestDiscovery(deviceID)
- if err != nil {
- return nil, err
- }
- if normalizeFingerprint(req.Fingerprint) != normalizeFingerprint(record.CertificateFingerprint) {
- return nil, errors.New("证书指纹不匹配")
- }
- if record.PairingRequired && strings.TrimSpace(req.PairingCode) == "" {
- return nil, errors.New("设备要求输入一次性配对码")
- }
- if record.PairingRequired {
- ip := net.ParseIP(record.SourceIP)
- body, _ := json.Marshal(map[string]string{"pairing_code": req.PairingCode})
- client := s.httpClient(ip, record.CertificateFingerprint)
- httpReq, _ := http.NewRequest(http.MethodPost, endpoint(ip, record.ConfigPort, "/api/v1/pair"), bytes.NewReader(body))
- httpReq.Header.Set("Content-Type", "application/json")
- resp, callErr := client.Do(httpReq)
- if callErr != nil {
- return nil, fmt.Errorf("配对码校验失败: %w", callErr)
- }
- defer resp.Body.Close()
- if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- return nil, fmt.Errorf("配对码校验失败,设备返回 HTTP %d", resp.StatusCode)
- }
- }
- now := time.Now()
- record.Verified = true
- record.VerifiedAt = &now
- record.ProvisionStatus = record.ProvisionMethod + "_verified"
- if err := s.repo.SaveDiscovery(record); err != nil {
- return nil, err
- }
- var identity deviceIdentity
- _ = json.Unmarshal([]byte(record.Payload), &identity)
- return &responseModel.IdentityResponse{
- RequestID: record.RequestID, DeviceID: record.DeviceID, DeviceCode: identity.DeviceCode,
- DeviceType: identity.DeviceType, DeviceModel: identity.DeviceModel, FirmwareVersion: identity.FirmwareVersion,
- IP: record.SourceIP, ConfigPort: record.ConfigPort, CertificateFingerprint: record.CertificateFingerprint,
- PublicKeyFingerprint: identity.PublicKeyFingerprint, PairingRequired: record.PairingRequired,
- Verified: true, ExpiredAt: record.ExpiredAt, ProvisionStatus: record.ProvisionStatus,
- }, nil
- }
- type provisionPayload struct {
- Schema string `json:"schema"`
- RequestID string `json:"request_id"`
- DeviceID string `json:"device_id"`
- DeviceCode string `json:"device_code"`
- MQTT struct {
- Host string `json:"host"`
- Port int `json:"port"`
- TLS bool `json:"tls"`
- ClientID string `json:"client_id"`
- } `json:"mqtt"`
- Route struct {
- ParkingLotID uint `json:"parking_lot_id"`
- BoothID uint `json:"booth_id"`
- ChannelID uint `json:"channel_id"`
- Direction string `json:"direction"`
- } `json:"route"`
- ImageUpload struct {
- URL string `json:"url"`
- MaxBytes int64 `json:"max_bytes"`
- } `json:"image_upload"`
- }
- const deviceImageUploadMaxBytes int64 = 8 * 1024 * 1024
- func deviceImageUploadURL(host string, port int) string {
- return "http://" + net.JoinHostPort(host, strconv.Itoa(port)) + "/device-images/upload"
- }
- // Provision 校验业务绑定后下发配置,并创建/更新 MQTT 设备记录。
- func (s *Service) Provision(deviceID string, req request.ProvisionRequest) (*responseModel.ProvisionStatusResponse, error) {
- record, err := s.repo.LatestDiscovery(deviceID)
- if err != nil {
- return nil, err
- }
- if !record.Verified {
- return nil, errors.New("设备身份尚未确认")
- }
- if global.GVA_DB == nil {
- return nil, errors.New("数据库未初始化")
- }
- var channel dao.Channel
- if err := global.GVA_DB.Preload("Booth").Where("id = ?", req.ChannelID).First(&channel).Error; err != nil {
- return nil, errors.New("通道不存在")
- }
- if channel.BoothID != req.BoothID || channel.ParkingLotID != req.ParkingLotID || channel.Booth == nil || channel.Booth.ParkingLotID != req.ParkingLotID {
- return nil, errors.New("停车场、岗亭和通道归属不一致")
- }
- // device_code has a database-wide unique index. Include soft-deleted rows in
- // this lookup; otherwise a deleted record can pass the pre-check and fail
- // later with SQLite 2067 during INSERT.
- existing, existingFound, err := findProvisionReader(global.GVA_DB, deviceID, req.DeviceCode)
- if err != nil {
- return nil, err
- }
- cfg := global.GVA_CONFIG.Mqtt
- if strings.TrimSpace(cfg.AdvertisedHost) == "" || net.ParseIP(cfg.AdvertisedHost) == nil {
- return nil, errors.New("未配置可供边缘设备访问的 MQTT advertised-host")
- }
- if _, err := ValidateAddress(cfg.AdvertisedHost, cfg.AdvertisedPort); err != nil {
- return nil, fmt.Errorf("MQTT advertised 地址无效: %w", err)
- }
- requestID := uuid.Must(uuid.NewV4()).String()
- payload := provisionPayload{Schema: "provision.request.v1", RequestID: requestID, DeviceID: deviceID, DeviceCode: req.DeviceCode}
- payload.MQTT.Host, payload.MQTT.Port, payload.MQTT.TLS, payload.MQTT.ClientID = cfg.AdvertisedHost, cfg.AdvertisedPort, cfg.TLSEnabled, req.DeviceCode
- payload.Route.ParkingLotID, payload.Route.BoothID, payload.Route.ChannelID, payload.Route.Direction = req.ParkingLotID, req.BoothID, req.ChannelID, req.Direction
- payload.ImageUpload.URL, payload.ImageUpload.MaxBytes = deviceImageUploadURL(cfg.AdvertisedHost, global.GVA_CONFIG.System.Addr), deviceImageUploadMaxBytes
- body, _ := json.Marshal(payload)
- ip := net.ParseIP(record.SourceIP)
- client := s.httpClient(ip, record.CertificateFingerprint)
- httpReq, _ := http.NewRequest(http.MethodPost, endpoint(ip, record.ConfigPort, provisionPath), bytes.NewReader(body))
- httpReq.Header.Set("Content-Type", "application/json")
- resp, err := client.Do(httpReq)
- if err != nil {
- return nil, fmt.Errorf("HTTPS 配置下发失败: %w", err)
- }
- defer resp.Body.Close()
- responseBody, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024+1))
- if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- return nil, fmt.Errorf("设备拒绝配置,HTTP %d", resp.StatusCode)
- }
- var accepted struct {
- Result string `json:"result"`
- }
- if err := decodeResponse(responseBody, &accepted); err != nil {
- return nil, fmt.Errorf("设备配置响应无效: %w", err)
- }
- if accepted.Result != "accepted" {
- return nil, errors.New("设备未接受 MQTT 配置")
- }
- now := time.Now()
- if !existingFound {
- existing = dao.UHFReader{DeviceID: deviceID, DeviceCode: req.DeviceCode, DeviceName: req.DeviceName, DeviceType: req.DeviceType, ConnectType: dao.ConnectTypeMQTT, ChannelID: req.ChannelID, ParkingLotID: req.ParkingLotID}
- }
- // Restore a soft-deleted row before saving the new binding. Save must be
- // Unscoped so GORM does not silently exclude the historical row.
- existing.DeletedAt = gorm.DeletedAt{}
- existing.DeviceCode, existing.DeviceName, existing.DeviceType = req.DeviceCode, req.DeviceName, req.DeviceType
- existing.ConnectType, existing.ChannelID, existing.ParkingLotID = dao.ConnectTypeMQTT, req.ChannelID, req.ParkingLotID
- existing.IPAddress, existing.ConfigPort = record.SourceIP, record.ConfigPort
- existing.DeviceModel, existing.FirmwareVersion = record.DeviceModel, record.FirmwareVersion
- existing.DevicePublicKey, existing.ProvisionStatus, existing.ProvisionMethod, existing.ProvisionError = record.CertificateFingerprint, "provisioned", record.ProvisionMethod, ""
- existing.IdentityVerifiedAt = record.VerifiedAt
- existing.ProvisionedAt = &now
- if !existingFound {
- if err := global.GVA_DB.Create(&existing).Error; err != nil {
- return nil, err
- }
- } else if err := global.GVA_DB.Unscoped().Save(&existing).Error; err != nil {
- return nil, err
- }
- 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
- }
- func (s *Service) Status(deviceID string) (*responseModel.ProvisionStatusResponse, error) {
- reader, err := s.repo.ReaderByDeviceID(deviceID)
- if err != nil {
- return nil, err
- }
- if reader == nil {
- return nil, errors.New("设备尚未完成配置")
- }
- 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
- }
|