feat: add controlled ONVIF and RTSP admission (#24)
This commit is contained in:
@@ -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 := `<?xml version="1.0"?><s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body><GetProfiles xmlns="http://www.onvif.org/ver10/media/wsdl"/></s:Body></s:Envelope>`
|
||||
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(`<?xml version="1.0"?><s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body><GetStreamUri xmlns="http://www.onvif.org/ver10/media/wsdl"><StreamSetup><Stream xmlns="http://www.onvif.org/ver10/schema">RTP-Unicast</Stream><Transport xmlns="http://www.onvif.org/ver10/schema"><Protocol>RTSP</Protocol></Transport></StreamSetup><ProfileToken>%s</ProfileToken></GetStreamUri></s:Body></s:Envelope>`, 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()
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const discoveryProbe = `<?xml version="1.0"?><e:Envelope xmlns:e="http://www.w3.org/2003/05/soap-envelope" xmlns:w="http://schemas.xmlsoap.org/ws/2004/08/addressing" xmlns:d="http://schemas.xmlsoap.org/ws/2005/04/discovery" xmlns:dn="http://www.onvif.org/ver10/network/wsdl"><e:Header><w:MessageID>uuid:sense-controlled-discovery</w:MessageID><w:To e:mustUnderstand="true">urn:schemas-xmlsoap-org:ws:2005:04:discovery</w:To><w:Action e:mustUnderstand="true">http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe</w:Action></e:Header><e:Body><d:Probe><d:Types>dn:NetworkVideoTransmitter</d:Types></d:Probe></e:Body></e:Envelope>`
|
||||
|
||||
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 := "<d:XAddrs>", "</d:XAddrs>"
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(`<Uri>rtsp://user:pass@camera.invalid/live</Uri>`)); err == nil {
|
||||
t.Fatal("credential URI accepted")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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", "无法读取设备信息,请检查地址和网络"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body><GetProfilesResponse xmlns="http://www.onvif.org/ver10/media/wsdl"><Profiles token="main"><Name>主码流</Name><VideoEncoderConfiguration><Encoding>H264</Encoding><Resolution><Width>1920</Width><Height>1080</Height></Resolution></VideoEncoderConfiguration></Profiles><Profiles token="sub"><Name>子码流</Name><VideoEncoderConfiguration><Encoding>H264</Encoding><Resolution><Width>640</Width><Height>360</Height></Resolution></VideoEncoderConfiguration></Profiles></GetProfilesResponse></s:Body></s:Envelope>
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0"?>
|
||||
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body><GetStreamUriResponse xmlns="http://www.onvif.org/ver10/media/wsdl"><MediaUri><Uri>rtsp://camera.invalid/live/main</Uri></MediaUri></GetStreamUriResponse></s:Body></s:Envelope>
|
||||
@@ -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.
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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' } }
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<section class="page-container">
|
||||
<div class="page-heading"><div><h1>视频接入</h1><p>选择已登记设备,填写 ONVIF 服务地址并检查主、子码流。</p></div></div>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="9">
|
||||
<el-card shadow="never">
|
||||
<template #header><strong>接入检查</strong></template>
|
||||
<el-alert title="发现功能只在实施人员配置获准网卡后启用,不会扫描其他网络。" type="info" show-icon :closable="false" />
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="admission-form">
|
||||
<el-form-item label="设备" prop="device_id"><el-select v-model="form.device_id" filterable placeholder="选择已登记的视频设备" style="width: 100%"><el-option v-for="item in devices" :key="item.id" :label="`${item.name} · ${item.location || '未填写位置'}`" :value="item.id" /></el-select></el-form-item>
|
||||
<el-form-item label="ONVIF 服务地址" prop="address"><el-input v-model="form.address" placeholder="例如:http://设备地址/onvif/device_service" /><div class="field-hint">地址中不能包含用户名或密码;凭据来自设备管理中的安全配置。</div></el-form-item>
|
||||
<el-form-item><el-button type="primary" :loading="probing" @click="probe">检查设备与视频</el-button><el-button :loading="discovering" @click="discover">发现设备</el-button></el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="15">
|
||||
<el-card shadow="never">
|
||||
<template #header><div class="result-header"><strong>检查结果</strong><el-tag v-if="result.status" :type="result.status === 'ready' ? 'success' : 'warning'">{{ statusLabel(result.status) }}</el-tag></div></template>
|
||||
<el-empty v-if="!result.status" description="选择设备并开始检查" />
|
||||
<template v-else>
|
||||
<el-result :icon="result.status === 'ready' ? 'success' : 'warning'" :title="statusLabel(result.status)" :sub-title="result.detail" />
|
||||
<el-table v-if="result.profiles?.length" :data="result.profiles" border>
|
||||
<el-table-column label="用途" width="90"><template #default="scope"><el-tag>{{ scope.row.kind === 'main' ? '主码流' : scope.row.kind === 'sub' ? '子码流' : '其他' }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="name" label="Profile" min-width="120" />
|
||||
<el-table-column label="分辨率" width="120"><template #default="scope">{{ scope.row.width }} × {{ scope.row.height }}</template></el-table-column>
|
||||
<el-table-column prop="encoding" label="编码" width="90" />
|
||||
<el-table-column label="视频验证" min-width="150"><template #default="scope"><el-tag :type="scope.row.verification.status === 'ready' ? 'success' : 'danger'">{{ scope.row.verification.detail || scope.row.verification.status }}</el-tag></template></el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-dialog v-model="discoveryDialog" title="发现结果" width="680px"><el-empty v-if="!discovered.length" description="当前获准网卡未发现设备" /><el-table v-else :data="discovered.map(address => ({ address }))" border><el-table-column prop="address" label="ONVIF 地址" /><el-table-column label="操作" width="90"><template #default="scope"><el-button link type="primary" @click="useAddress(scope.row.address)">使用</el-button></template></el-table-column></el-table></el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { listDevices } from '../../../api/sense/device'
|
||||
import { discoverDevices, probeDevice } from '../../../api/sense/admission'
|
||||
const devices=ref([]),probing=ref(false),discovering=ref(false),discoveryDialog=ref(false),discovered=ref([]),formRef=ref()
|
||||
const form=reactive({device_id:'',address:''}),result=reactive({})
|
||||
const rules={device_id:[{required:true,message:'请选择设备',trigger:'change'}],address:[{required:true,message:'请输入 ONVIF 服务地址',trigger:'blur'},{validator:(_r,v,done)=>v.includes('@')?done(new Error('地址中不能包含凭据')):done(),trigger:'blur'}]}
|
||||
function statusLabel(value){return({ready:'接入正常',profile_failed:'部分码流失败',authentication_failed:'认证失败',timeout:'响应超时',clock_skew:'需要校时',unreachable:'设备不可达'})[value]||value}
|
||||
async function loadDevices(){devices.value=(await listDevices({page:1,page_size:100})).items.filter(item=>item.modality==='video'&&item.status!=='disabled')}
|
||||
async function probe(){const valid=await formRef.value?.validate().catch(()=>false);if(!valid)return;probing.value=true;try{Object.assign(result,await probeDevice(form))}catch(error){ElMessage.error(error.message||'检查失败')}finally{probing.value=false}}
|
||||
async function discover(){discovering.value=true;try{discovered.value=(await discoverDevices()).items||[];discoveryDialog.value=true}catch(error){ElMessage.warning(error.message||'未配置获准发现网卡')}finally{discovering.value=false}}
|
||||
function useAddress(value){form.address=value;discoveryDialog.value=false}
|
||||
onMounted(loadDevices)
|
||||
</script>
|
||||
<style scoped>.admission-form{margin-top:18px}.field-hint{color:#86909c;font-size:12px;line-height:1.5}.result-header{display:flex;align-items:center;justify-content:space-between}</style>
|
||||
Reference in New Issue
Block a user