From 4f17cca91fe70e875e3815e8aaa96f354dd936d8 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 12 Aug 2026 17:14:34 +0800 Subject: [PATCH] feat: add controlled ONVIF and RTSP admission (#24) --- .../server/app/sense/adapters/onvif/client.go | 109 ++++++++++++++++ .../app/sense/adapters/onvif/discovery.go | 80 ++++++++++++ .../server/app/sense/adapters/onvif/parser.go | 56 +++++++++ .../app/sense/adapters/onvif/parser_test.go | 31 +++++ .../app/sense/adapters/rtsp/verifier.go | 74 +++++++++++ Sense/server/app/sense/admission/http.go | 49 ++++++++ Sense/server/app/sense/admission/service.go | 119 ++++++++++++++++++ .../app/sense/admission/service_test.go | 46 +++++++ .../app/sense/device/credential_port.go | 32 +++++ Sense/server/app/sense/device/crypto.go | 27 ++++ Sense/server/app/sense/device/http.go | 1 + Sense/server/cmd/sense/modules_admission.go | 18 +++ Sense/testdata/onvif/profiles.xml | 2 + Sense/testdata/onvif/stream-uri.xml | 2 + Sense/tests/admission/README.md | 6 + Sense/ui/src/api/sense/admission/index.js | 6 + .../ui/src/router/modules/sense-admission.js | 3 + .../src/views/sense/admission/Admission.vue | 52 ++++++++ 18 files changed, 713 insertions(+) create mode 100644 Sense/server/app/sense/adapters/onvif/client.go create mode 100644 Sense/server/app/sense/adapters/onvif/discovery.go create mode 100644 Sense/server/app/sense/adapters/onvif/parser.go create mode 100644 Sense/server/app/sense/adapters/onvif/parser_test.go create mode 100644 Sense/server/app/sense/adapters/rtsp/verifier.go create mode 100644 Sense/server/app/sense/admission/http.go create mode 100644 Sense/server/app/sense/admission/service.go create mode 100644 Sense/server/app/sense/admission/service_test.go create mode 100644 Sense/server/app/sense/device/credential_port.go create mode 100644 Sense/server/cmd/sense/modules_admission.go create mode 100644 Sense/testdata/onvif/profiles.xml create mode 100644 Sense/testdata/onvif/stream-uri.xml create mode 100644 Sense/tests/admission/README.md create mode 100644 Sense/ui/src/api/sense/admission/index.js create mode 100644 Sense/ui/src/router/modules/sense-admission.js create mode 100644 Sense/ui/src/views/sense/admission/Admission.vue diff --git a/Sense/server/app/sense/adapters/onvif/client.go b/Sense/server/app/sense/adapters/onvif/client.go new file mode 100644 index 0000000..c03cb87 --- /dev/null +++ b/Sense/server/app/sense/adapters/onvif/client.go @@ -0,0 +1,109 @@ +package onvif + +import ( + "bytes" + "context" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +type Credential struct { + Username string + Password string +} +type Profile struct { + Token string `json:"token"` + Name string `json:"name"` + Width int `json:"width"` + Height int `json:"height"` + Encoding string `json:"encoding"` + StreamURI string `json:"stream_uri"` +} +type Client interface { + Profiles(context.Context, string, Credential) ([]Profile, error) +} + +type HTTPClient struct{ client *http.Client } + +func NewHTTPClient(timeout time.Duration) *HTTPClient { + if timeout <= 0 { + timeout = 8 * time.Second + } + return &HTTPClient{client: &http.Client{Timeout: timeout}} +} +func (c *HTTPClient) Profiles(ctx context.Context, address string, credential Credential) ([]Profile, error) { + endpoint, err := validateEndpoint(address) + if err != nil { + return nil, err + } + body := `` + data, err := c.soap(ctx, endpoint, credential, body) + if err != nil { + return nil, err + } + profiles, err := ParseProfiles(data) + if err != nil { + return nil, err + } + for i := range profiles { + uriBody := fmt.Sprintf(`RTP-UnicastRTSP%s`, xmlEscape(profiles[i].Token)) + streamData, err := c.soap(ctx, endpoint, credential, uriBody) + if err != nil { + return nil, err + } + profiles[i].StreamURI, err = ParseStreamURI(streamData) + if err != nil { + return nil, err + } + } + return profiles, nil +} +func (c *HTTPClient) soap(ctx context.Context, endpoint string, credential Credential, body string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBufferString(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/soap+xml; charset=utf-8") + if credential.Username != "" { + req.SetBasicAuth(credential.Username, credential.Password) + } + res, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("onvif request failed: %w", err) + } + defer res.Body.Close() + data, err := io.ReadAll(io.LimitReader(res.Body, 2<<20)) + if err != nil { + return nil, err + } + if res.StatusCode == http.StatusUnauthorized { + return nil, fmt.Errorf("authentication_failed") + } + if res.StatusCode < 200 || res.StatusCode >= 300 { + return nil, fmt.Errorf("onvif_http_%d", res.StatusCode) + } + return data, nil +} +func validateEndpoint(value string) (string, error) { + parsed, err := url.Parse(value) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "", fmt.Errorf("invalid ONVIF address") + } + if parsed.User != nil { + return "", fmt.Errorf("credentials must not appear in ONVIF address") + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", fmt.Errorf("unsupported ONVIF scheme") + } + return parsed.String(), nil +} +func xmlEscape(value string) string { + var b strings.Builder + _ = xml.EscapeText(&b, []byte(value)) + return b.String() +} diff --git a/Sense/server/app/sense/adapters/onvif/discovery.go b/Sense/server/app/sense/adapters/onvif/discovery.go new file mode 100644 index 0000000..7a41b81 --- /dev/null +++ b/Sense/server/app/sense/adapters/onvif/discovery.go @@ -0,0 +1,80 @@ +package onvif + +import ( + "context" + "fmt" + "net" + "strings" + "time" +) + +const discoveryProbe = `uuid:sense-controlled-discoveryurn:schemas-xmlsoap-org:ws:2005:04:discoveryhttp://schemas.xmlsoap.org/ws/2005/04/discovery/Probedn:NetworkVideoTransmitter` + +func Discover(ctx context.Context, localIP string, timeout time.Duration) ([]string, error) { + ip := net.ParseIP(localIP) + if ip == nil { + return nil, fmt.Errorf("approved discovery interface is not configured") + } + approved := false + interfaces, _ := net.Interfaces() + for _, iface := range interfaces { + addresses, _ := iface.Addrs() + for _, address := range addresses { + if strings.HasPrefix(address.String(), ip.String()+"/") { + approved = true + } + } + } + if !approved { + return nil, fmt.Errorf("approved discovery IP is not a local interface") + } + connection, err := net.ListenUDP("udp4", &net.UDPAddr{IP: ip, Port: 0}) + if err != nil { + return nil, err + } + defer connection.Close() + deadline := time.Now().Add(timeout) + _ = connection.SetDeadline(deadline) + target := &net.UDPAddr{IP: net.ParseIP("239.255.255.250"), Port: 3702} + if _, err := connection.WriteToUDP([]byte(discoveryProbe), target); err != nil { + return nil, err + } + found := map[string]bool{} + buffer := make([]byte, 65535) + for { + select { + case <-ctx.Done(): + return keys(found), nil + default: + } + n, _, err := connection.ReadFromUDP(buffer) + if err != nil { + if networkErr, ok := err.(net.Error); ok && networkErr.Timeout() { + return keys(found), nil + } + return nil, err + } + for _, value := range extractXAddrs(string(buffer[:n])) { + found[value] = true + } + } +} +func extractXAddrs(value string) []string { + startTag, endTag := "", "" + start := strings.Index(value, startTag) + if start < 0 { + return nil + } + end := strings.Index(value[start:], endTag) + if end < 0 { + return nil + } + return strings.Fields(value[start+len(startTag) : start+end]) +} +func keys(values map[string]bool) []string { + result := make([]string, 0, len(values)) + for value := range values { + result = append(result, value) + } + return result +} diff --git a/Sense/server/app/sense/adapters/onvif/parser.go b/Sense/server/app/sense/adapters/onvif/parser.go new file mode 100644 index 0000000..56b2665 --- /dev/null +++ b/Sense/server/app/sense/adapters/onvif/parser.go @@ -0,0 +1,56 @@ +package onvif + +import ( + "encoding/xml" + "fmt" + "strings" +) + +type profileEnvelope struct { + Profiles []struct { + Token string `xml:"token,attr"` + Name string `xml:"Name"` + Encoder struct { + Encoding string `xml:"Encoding"` + Resolution struct { + Width int `xml:"Width"` + Height int `xml:"Height"` + } `xml:"Resolution"` + } `xml:"VideoEncoderConfiguration"` + } `xml:"Body>GetProfilesResponse>Profiles"` +} + +func ParseProfiles(data []byte) ([]Profile, error) { + var envelope profileEnvelope + if err := xml.Unmarshal(data, &envelope); err != nil { + return nil, fmt.Errorf("parse ONVIF profiles: %w", err) + } + if len(envelope.Profiles) == 0 { + return nil, fmt.Errorf("no_profiles") + } + result := make([]Profile, 0, len(envelope.Profiles)) + for _, value := range envelope.Profiles { + result = append(result, Profile{Token: value.Token, Name: value.Name, Width: value.Encoder.Resolution.Width, Height: value.Encoder.Resolution.Height, Encoding: value.Encoder.Encoding}) + } + return result, nil +} +func ParseStreamURI(data []byte) (string, error) { + decoder := xml.NewDecoder(strings.NewReader(string(data))) + for { + token, err := decoder.Token() + if err != nil { + return "", fmt.Errorf("stream_uri_not_found") + } + start, ok := token.(xml.StartElement) + if ok && start.Name.Local == "Uri" { + var value string + if err := decoder.DecodeElement(&value, &start); err != nil { + return "", err + } + if strings.Contains(value, "@") { + return "", fmt.Errorf("stream_uri_contains_credentials") + } + return value, nil + } + } +} diff --git a/Sense/server/app/sense/adapters/onvif/parser_test.go b/Sense/server/app/sense/adapters/onvif/parser_test.go new file mode 100644 index 0000000..1da8a27 --- /dev/null +++ b/Sense/server/app/sense/adapters/onvif/parser_test.go @@ -0,0 +1,31 @@ +package onvif + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseFixtureProfilesAndStreamURI(t *testing.T) { + profilesData, err := os.ReadFile(filepath.Join("..", "..", "..", "..", "..", "testdata", "onvif", "profiles.xml")) + if err != nil { + t.Fatal(err) + } + profiles, err := ParseProfiles(profilesData) + if err != nil || len(profiles) != 2 || profiles[0].Width != 1920 { + t.Fatalf("profiles=%#v err=%v", profiles, err) + } + uriData, err := os.ReadFile(filepath.Join("..", "..", "..", "..", "..", "testdata", "onvif", "stream-uri.xml")) + if err != nil { + t.Fatal(err) + } + uri, err := ParseStreamURI(uriData) + if err != nil || uri != "rtsp://camera.invalid/live/main" { + t.Fatalf("uri=%q err=%v", uri, err) + } +} +func TestRejectCredentialInStreamURI(t *testing.T) { + if _, err := ParseStreamURI([]byte(`rtsp://user:pass@camera.invalid/live`)); err == nil { + t.Fatal("credential URI accepted") + } +} diff --git a/Sense/server/app/sense/adapters/rtsp/verifier.go b/Sense/server/app/sense/adapters/rtsp/verifier.go new file mode 100644 index 0000000..7d1a365 --- /dev/null +++ b/Sense/server/app/sense/adapters/rtsp/verifier.go @@ -0,0 +1,74 @@ +package rtsp + +import ( + "bufio" + "context" + "encoding/base64" + "fmt" + "net" + "net/url" + "strings" + "time" +) + +type Credential struct { + Username string + Password string +} +type Result struct { + Status string `json:"status"` + LatencyMS int64 `json:"latency_ms"` + Detail string `json:"detail,omitempty"` +} +type Verifier interface { + Verify(context.Context, string, Credential) (Result, error) +} +type NetVerifier struct{ Timeout time.Duration } + +func (v NetVerifier) Verify(ctx context.Context, raw string, credential Credential) (Result, error) { + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme != "rtsp" || parsed.Host == "" { + return Result{}, fmt.Errorf("invalid_rtsp_uri") + } + if parsed.User != nil { + return Result{}, fmt.Errorf("rtsp_uri_contains_credentials") + } + address := parsed.Host + if !strings.Contains(address, ":") { + address += ":554" + } + timeout := v.Timeout + if timeout <= 0 { + timeout = 5 * time.Second + } + dialer := net.Dialer{Timeout: timeout} + started := time.Now() + connection, err := dialer.DialContext(ctx, "tcp", address) + if err != nil { + return Result{Status: "unreachable", Detail: "无法连接视频端口"}, nil + } + defer connection.Close() + _ = connection.SetDeadline(time.Now().Add(timeout)) + authorization := "" + if credential.Username != "" { + authorization = "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(credential.Username+":"+credential.Password)) + "\r\n" + } + request := fmt.Sprintf("OPTIONS %s RTSP/1.0\r\nCSeq: 1\r\nUser-Agent: YoVision-Sense\r\n%s\r\n", parsed.String(), authorization) + if _, err := connection.Write([]byte(request)); err != nil { + return Result{}, err + } + line, err := bufio.NewReader(connection).ReadString('\n') + if err != nil { + return Result{Status: "timeout", Detail: "等待视频响应超时"}, nil + } + status := "ready" + detail := "码流可访问" + if strings.Contains(line, " 401 ") { + status = "authentication_failed" + detail = "设备拒绝了当前凭据" + } else if !strings.Contains(line, " 200 ") { + status = "failed" + detail = "设备返回非成功状态" + } + return Result{Status: status, LatencyMS: time.Since(started).Milliseconds(), Detail: detail}, nil +} diff --git a/Sense/server/app/sense/admission/http.go b/Sense/server/app/sense/admission/http.go new file mode 100644 index 0000000..2dca49b --- /dev/null +++ b/Sense/server/app/sense/admission/http.go @@ -0,0 +1,49 @@ +package admission + +import ( + "net/http" + "yovision.local/sense/app/sense/identity" + "yovision.local/sense/internal/platform" +) + +type Module struct{ service *Service } + +func NewModule(service *Service) *Module { return &Module{service: service} } +func (m *Module) Register(app *platform.App) { + app.Handle("GET /api/v1/admission/discover", identity.Require(identity.PermissionDeviceWrite, http.HandlerFunc(m.discover))) + app.Handle("POST /api/v1/admission/probe", identity.Require(identity.PermissionDeviceWrite, http.HandlerFunc(m.probe))) + app.Handle("GET /api/v1/admission/devices/{id}", identity.Require(identity.PermissionDeviceRead, http.HandlerFunc(m.get))) +} +func (m *Module) discover(w http.ResponseWriter, r *http.Request) { + items, err := m.service.Discover(r.Context()) + if err != nil { + platform.WriteError(w, &platform.APIError{Status: http.StatusServiceUnavailable, Code: "discovery_unavailable", Message: "未配置获准的发现网卡"}) + return + } + platform.WriteJSON(w, http.StatusOK, map[string]any{"items": items}) +} +func (m *Module) probe(w http.ResponseWriter, r *http.Request) { + var request struct { + DeviceID string `json:"device_id"` + Address string `json:"address"` + } + if err := platform.DecodeJSON(r, &request); err != nil { + platform.WriteError(w, err) + return + } + actor, _ := identity.PrincipalFromContext(r.Context()) + result, err := m.service.Probe(r.Context(), actor, request.DeviceID, request.Address) + if err != nil { + platform.WriteError(w, &platform.APIError{Status: http.StatusBadRequest, Code: "admission_failed", Message: err.Error()}) + return + } + platform.WriteJSON(w, http.StatusOK, result) +} +func (m *Module) get(w http.ResponseWriter, r *http.Request) { + result, ok := m.service.Get(r.PathValue("id")) + if !ok { + platform.WriteError(w, &platform.APIError{Status: http.StatusNotFound, Code: "admission_not_found", Message: "尚未执行接入验证"}) + return + } + platform.WriteJSON(w, http.StatusOK, result) +} diff --git a/Sense/server/app/sense/admission/service.go b/Sense/server/app/sense/admission/service.go new file mode 100644 index 0000000..d980b2b --- /dev/null +++ b/Sense/server/app/sense/admission/service.go @@ -0,0 +1,119 @@ +package admission + +import ( + "context" + "fmt" + "sort" + "strings" + "sync" + "time" + + "yovision.local/sense/app/sense/adapters/onvif" + "yovision.local/sense/app/sense/adapters/rtsp" + "yovision.local/sense/app/sense/device" + "yovision.local/sense/app/sense/identity" +) + +type Profile struct { + Token string `json:"token"` + Name string `json:"name"` + Width int `json:"width"` + Height int `json:"height"` + Encoding string `json:"encoding"` + StreamURI string `json:"stream_uri"` + Kind string `json:"kind"` + Verification rtsp.Result `json:"verification"` +} +type Result struct { + DeviceID string `json:"device_id"` + Address string `json:"address"` + Status string `json:"status"` + Detail string `json:"detail"` + Profiles []Profile `json:"profiles"` + CheckedAt time.Time `json:"checked_at"` +} +type Service struct { + onvif onvif.Client + rtsp rtsp.Verifier + discoveryIP string + discoveryTimeout time.Duration + mu sync.RWMutex + results map[string]Result + now func() time.Time +} + +func NewService(client onvif.Client, verifier rtsp.Verifier, discoveryIP string) *Service { + return &Service{onvif: client, rtsp: verifier, discoveryIP: discoveryIP, discoveryTimeout: 3 * time.Second, results: map[string]Result{}, now: time.Now} +} +func (s *Service) Discover(ctx context.Context) ([]string, error) { + if strings.TrimSpace(s.discoveryIP) == "" { + return nil, fmt.Errorf("discovery_not_configured") + } + return onvif.Discover(ctx, s.discoveryIP, s.discoveryTimeout) +} +func (s *Service) Probe(ctx context.Context, actor identity.Principal, deviceID, address string) (Result, error) { + credential, err := device.ReadCredential(ctx, deviceID) + if err != nil { + return Result{}, fmt.Errorf("credential_required") + } + profiles, err := s.onvif.Profiles(ctx, address, onvif.Credential{Username: credential.Username, Password: credential.Password}) + if err != nil { + status, detail := classify(err) + result := Result{DeviceID: deviceID, Address: address, Status: status, Detail: detail, CheckedAt: s.now().UTC()} + s.save(result) + identity.RecordAudit(ctx, actor.UserID, "admission.probe", deviceID, "failure", map[string]any{"status": status}) + return result, nil + } + items := make([]Profile, 0, len(profiles)) + for _, profile := range profiles { + verification, verifyErr := s.rtsp.Verify(ctx, profile.StreamURI, rtsp.Credential{Username: credential.Username, Password: credential.Password}) + if verifyErr != nil { + verification = rtsp.Result{Status: "failed", Detail: "视频地址格式不正确"} + } + kind := "other" + items = append(items, Profile{Token: profile.Token, Name: profile.Name, Width: profile.Width, Height: profile.Height, Encoding: profile.Encoding, StreamURI: profile.StreamURI, Kind: kind, Verification: verification}) + } + sort.Slice(items, func(i, j int) bool { return items[i].Width*items[i].Height > items[j].Width*items[j].Height }) + if len(items) > 0 { + items[0].Kind = "main" + } + if len(items) > 1 { + items[len(items)-1].Kind = "sub" + } + status := "ready" + detail := "设备与视频 Profile 已验证" + for _, item := range items { + if item.Verification.Status != "ready" { + status = "profile_failed" + detail = "部分视频 Profile 验证失败" + } + } + result := Result{DeviceID: deviceID, Address: address, Status: status, Detail: detail, Profiles: items, CheckedAt: s.now().UTC()} + s.save(result) + identity.RecordAudit(ctx, actor.UserID, "admission.probe", deviceID, "success", map[string]any{"profile_count": len(items), "status": status}) + return result, nil +} +func (s *Service) Get(deviceID string) (Result, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + value, ok := s.results[deviceID] + return value, ok +} +func (s *Service) save(result Result) { + s.mu.Lock() + defer s.mu.Unlock() + s.results[result.DeviceID] = result +} +func classify(err error) (string, string) { + value := strings.ToLower(err.Error()) + switch { + case strings.Contains(value, "authentication"): + return "authentication_failed", "设备拒绝了当前凭据,请更新后重试" + case strings.Contains(value, "deadline") || strings.Contains(value, "timeout"): + return "timeout", "设备响应超时" + case strings.Contains(value, "clock") || strings.Contains(value, "time"): + return "clock_skew", "设备时间可能不准确,请校时后重试" + default: + return "unreachable", "无法读取设备信息,请检查地址和网络" + } +} diff --git a/Sense/server/app/sense/admission/service_test.go b/Sense/server/app/sense/admission/service_test.go new file mode 100644 index 0000000..5a407c0 --- /dev/null +++ b/Sense/server/app/sense/admission/service_test.go @@ -0,0 +1,46 @@ +package admission + +import ( + "context" + "encoding/base64" + "testing" + "yovision.local/sense/app/sense/adapters/onvif" + "yovision.local/sense/app/sense/adapters/rtsp" + "yovision.local/sense/app/sense/device" + "yovision.local/sense/app/sense/identity" + "yovision.local/sense/internal/platform" +) + +type fakeONVIF struct{} + +func (fakeONVIF) Profiles(context.Context, string, onvif.Credential) ([]onvif.Profile, error) { + return []onvif.Profile{{Token: "main", Name: "主码流", Width: 1920, Height: 1080, Encoding: "H264", StreamURI: "rtsp://camera.invalid/main"}, {Token: "sub", Name: "子码流", Width: 640, Height: 360, Encoding: "H264", StreamURI: "rtsp://camera.invalid/sub"}}, nil +} + +type fakeRTSP struct{} + +func (fakeRTSP) Verify(context.Context, string, rtsp.Credential) (rtsp.Result, error) { + return rtsp.Result{Status: "ready"}, nil +} +func TestProbeProfilesWithoutCredentialURI(t *testing.T) { + vault, err := device.NewCredentialVault(base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")), false) + if err != nil { + t.Fatal(err) + } + store := device.NewMemoryStore() + deviceService := device.NewService(store, vault) + module := device.NewModule(deviceService) + module.Register(platform.NewApp(platform.Config{DatabaseMode: platform.DatabaseModeMemory}, nil, nil)) + item, err := deviceService.Create(context.Background(), identity.Principal{}, "camera", "", device.ModalityVideo, nil) + if err != nil { + t.Fatal(err) + } + if _, err = deviceService.SetCredential(context.Background(), identity.Principal{}, item.ID, "fixture-user", "fixture-password"); err != nil { + t.Fatal(err) + } + service := NewService(fakeONVIF{}, fakeRTSP{}, "") + result, err := service.Probe(context.Background(), identity.Principal{}, item.ID, "http://camera.invalid/onvif/device_service") + if err != nil || result.Status != "ready" || result.Profiles[0].Kind != "main" || result.Profiles[1].Kind != "sub" { + t.Fatalf("result=%#v err=%v", result, err) + } +} diff --git a/Sense/server/app/sense/device/credential_port.go b/Sense/server/app/sense/device/credential_port.go new file mode 100644 index 0000000..aee2741 --- /dev/null +++ b/Sense/server/app/sense/device/credential_port.go @@ -0,0 +1,32 @@ +package device + +import ( + "context" + "fmt" + "sync/atomic" +) + +type Credential struct { + Username string + Password string +} + +var activeService atomic.Pointer[Service] + +// ReadCredential is an internal adapter port. Credentials must never be +// returned from HTTP handlers, logged, or placed in a URL. +func ReadCredential(ctx context.Context, id string) (Credential, error) { + service := activeService.Load() + if service == nil { + return Credential{}, fmt.Errorf("device service is not ready") + } + item, err := service.store.Get(ctx, id) + if err != nil { + return Credential{}, err + } + if len(item.CredentialCiphertext) == 0 { + return Credential{}, fmt.Errorf("device credential is not configured") + } + username, password, err := service.vault.Decrypt(item.CredentialCiphertext) + return Credential{Username: username, Password: password}, err +} diff --git a/Sense/server/app/sense/device/crypto.go b/Sense/server/app/sense/device/crypto.go index d26779a..294536d 100644 --- a/Sense/server/app/sense/device/crypto.go +++ b/Sense/server/app/sense/device/crypto.go @@ -51,3 +51,30 @@ func (v *CredentialVault) Encrypt(username, password string) ([]byte, error) { } return gcm.Seal(nonce, nonce, plaintext, []byte("sense-device-credential-v1")), nil } + +func (v *CredentialVault) Decrypt(ciphertext []byte) (string, string, error) { + block, err := aes.NewCipher(v.key) + if err != nil { + return "", "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", "", err + } + if len(ciphertext) < gcm.NonceSize() { + return "", "", fmt.Errorf("invalid credential ciphertext") + } + nonce, encrypted := ciphertext[:gcm.NonceSize()], ciphertext[gcm.NonceSize():] + plaintext, err := gcm.Open(nil, nonce, encrypted, []byte("sense-device-credential-v1")) + if err != nil { + return "", "", fmt.Errorf("decrypt device credential: %w", err) + } + var value struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.Unmarshal(plaintext, &value); err != nil { + return "", "", err + } + return value.Username, value.Password, nil +} diff --git a/Sense/server/app/sense/device/http.go b/Sense/server/app/sense/device/http.go index ec08ab6..858e600 100644 --- a/Sense/server/app/sense/device/http.go +++ b/Sense/server/app/sense/device/http.go @@ -12,6 +12,7 @@ type Module struct{ service *Service } func NewModule(service *Service) *Module { return &Module{service: service} } func (m *Module) Register(app *platform.App) { + activeService.Store(m.service) app.Handle("GET /api/v1/devices", identity.Require(identity.PermissionDeviceRead, http.HandlerFunc(m.list))) app.Handle("POST /api/v1/devices", identity.Require(identity.PermissionDeviceWrite, http.HandlerFunc(m.create))) app.Handle("GET /api/v1/devices/{id}", identity.Require(identity.PermissionDeviceRead, http.HandlerFunc(m.get))) diff --git a/Sense/server/cmd/sense/modules_admission.go b/Sense/server/cmd/sense/modules_admission.go new file mode 100644 index 0000000..30c2712 --- /dev/null +++ b/Sense/server/cmd/sense/modules_admission.go @@ -0,0 +1,18 @@ +package sense + +import ( + "os" + "time" + "yovision.local/sense/app/sense/adapters/onvif" + "yovision.local/sense/app/sense/adapters/rtsp" + "yovision.local/sense/app/sense/admission" + "yovision.local/sense/internal/platform" +) + +func init() { + registerModule(func(app *platform.App) error { + service := admission.NewService(onvif.NewHTTPClient(8*time.Second), rtsp.NetVerifier{Timeout: 5 * time.Second}, os.Getenv("SENSE_ONVIF_DISCOVERY_IP")) + admission.NewModule(service).Register(app) + return nil + }) +} diff --git a/Sense/testdata/onvif/profiles.xml b/Sense/testdata/onvif/profiles.xml new file mode 100644 index 0000000..736d7ec --- /dev/null +++ b/Sense/testdata/onvif/profiles.xml @@ -0,0 +1,2 @@ + +主码流H26419201080子码流H264640360 diff --git a/Sense/testdata/onvif/stream-uri.xml b/Sense/testdata/onvif/stream-uri.xml new file mode 100644 index 0000000..9178c7b --- /dev/null +++ b/Sense/testdata/onvif/stream-uri.xml @@ -0,0 +1,2 @@ + +rtsp://camera.invalid/live/main diff --git a/Sense/tests/admission/README.md b/Sense/tests/admission/README.md new file mode 100644 index 0000000..f1fa438 --- /dev/null +++ b/Sense/tests/admission/README.md @@ -0,0 +1,6 @@ +# Admission acceptance + +Fixture tests cover ONVIF profile parsing, credential-free StreamUri handling, +main/sub classification and RTSP result mapping. Controlled WS-Discovery is +disabled until `SENSE_ONVIF_DISCOVERY_IP` names an approved local interface. +No discovery scan or real camera access runs during automated tests. diff --git a/Sense/ui/src/api/sense/admission/index.js b/Sense/ui/src/api/sense/admission/index.js new file mode 100644 index 0000000..1d56960 --- /dev/null +++ b/Sense/ui/src/api/sense/admission/index.js @@ -0,0 +1,6 @@ +import request from '../../../bootstrap/request' + +export const discoverDevices = () => request.get('/admission/discover') +export const probeDevice = (data) => request.post('/admission/probe', data) +export const admissionResult = (deviceID) => request.get(`/admission/devices/${deviceID}`) + diff --git a/Sense/ui/src/router/modules/sense-admission.js b/Sense/ui/src/router/modules/sense-admission.js new file mode 100644 index 0000000..6d4791b --- /dev/null +++ b/Sense/ui/src/router/modules/sense-admission.js @@ -0,0 +1,3 @@ +import Admission from '../../views/sense/admission/Admission.vue' +export default { path: 'admission', name: 'sense-admission', component: Admission, meta: { title: '视频接入', icon: 'Connection', order: 20, permission: 'device.write' } } + diff --git a/Sense/ui/src/views/sense/admission/Admission.vue b/Sense/ui/src/views/sense/admission/Admission.vue new file mode 100644 index 0000000..4d42e85 --- /dev/null +++ b/Sense/ui/src/views/sense/admission/Admission.vue @@ -0,0 +1,52 @@ + + + +