Compare commits
89
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
014085c8e3 | ||
|
|
e2d1618b46 | ||
|
|
6a00d25557 | ||
|
|
b2cf37cccb | ||
|
|
e18502ee67 | ||
|
|
949e50f1c5 | ||
|
|
b0ea018075 | ||
|
|
8cef743ed6 | ||
|
|
d71ef1cefd | ||
|
|
e8e1e80af6 | ||
|
|
e4dc68be2c | ||
|
|
236475d641 | ||
|
|
e4c6c646b6 | ||
|
|
c5a1e6bebe | ||
|
|
49af68dc6e | ||
|
|
c3443d04db | ||
|
|
8643c8f990 | ||
|
|
ad7d45009f | ||
|
|
3b0b4ce33e | ||
|
|
2639afad3d | ||
|
|
3cb324ef13 | ||
|
|
6ba731b61a | ||
|
|
a8a2b93b66 | ||
|
|
b7e9fdcb51 | ||
|
|
41a6d0826a | ||
|
|
abbc9a7093 | ||
|
|
b8d14dbdae | ||
|
|
88766242d6 | ||
|
|
0f0a534dd5 | ||
|
|
56181cba96 | ||
|
|
aed36eab99 | ||
|
|
ffacf7a682 | ||
|
|
d171f45a73 | ||
|
|
c8baa277c0 | ||
|
|
c2f3dbb660 | ||
|
|
9c91a36aba | ||
|
|
0cf9dc357e | ||
|
|
c6087a1c91 | ||
|
|
e2c2a8686f | ||
|
|
676586711e | ||
|
|
07b08f12b1 | ||
|
|
ea4940eba4 | ||
|
|
b9fce1160b | ||
|
|
e73b396897 | ||
|
|
774b3f8925 | ||
|
|
cc5b2cc8c6 | ||
|
|
5817199779 | ||
|
|
0d22da45c3 | ||
|
|
061d1a89a1 | ||
|
|
37c51db951 | ||
|
|
24db9fa6a5 | ||
|
|
04e2598be0 | ||
|
|
75e0360a43 | ||
|
|
8a40e85866 | ||
|
|
82672baf02 | ||
|
|
20020c0c2f | ||
|
|
c408868113 | ||
|
|
3e1b367147 | ||
|
|
b1b687d6a2 | ||
|
|
dde84c8687 | ||
|
|
64b9ea8462 | ||
|
|
15340507eb | ||
|
|
e83b359cc5 | ||
|
|
86b4dd7a07 | ||
|
|
f6fda99aa2 | ||
|
|
ea9a89f851 | ||
|
|
f07952cb98 | ||
|
|
be1b479f68 | ||
|
|
20066172c3 | ||
|
|
18a4b8379c | ||
|
|
3297937e53 | ||
|
|
a8a01d9a0a | ||
|
|
da5318f8f0 | ||
|
|
4042011345 | ||
|
|
136caf189e | ||
|
|
6075433402 | ||
|
|
65798d01b6 | ||
|
|
b383f0a8d8 | ||
|
|
74070aa0f9 | ||
|
|
d0e947ca02 | ||
|
|
c29ce45971 | ||
|
|
ff93592001 | ||
|
|
5b466b4ae6 | ||
|
|
c8cf53291f | ||
|
|
850ca4fead | ||
|
|
05100c4f37 | ||
|
|
523fe81fb2 | ||
|
|
2591517c76 | ||
|
|
440831fb4d |
@@ -20,6 +20,7 @@ type Source struct {
|
||||
}
|
||||
type PathStatus struct {
|
||||
Name string `json:"name"`
|
||||
Exists bool `json:"exists"`
|
||||
Ready bool `json:"ready"`
|
||||
Readers int `json:"readers"`
|
||||
BytesReceived int64 `json:"bytes_received"`
|
||||
@@ -46,7 +47,15 @@ func (c *HTTPController) Apply(ctx context.Context, source Source) error {
|
||||
}
|
||||
payload := map[string]any{"source": parsed.String(), "sourceOnDemand": true, "rtspTransport": "tcp"}
|
||||
data, _ := json.Marshal(payload)
|
||||
endpoint := c.base + "/v3/config/paths/replace/" + url.PathEscape(source.Path)
|
||||
configured, err := c.configured(ctx, source.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
action := "add"
|
||||
if configured {
|
||||
action = "replace"
|
||||
}
|
||||
endpoint := c.base + "/v3/config/paths/" + action + "/" + url.PathEscape(source.Path)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -63,6 +72,39 @@ func (c *HTTPController) Apply(ctx context.Context, source Source) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *HTTPController) configured(ctx context.Context, path string) (bool, error) {
|
||||
endpoint := c.base + "/v3/config/paths/get/" + url.PathEscape(path)
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
var res *http.Response
|
||||
for {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
res, err = c.client.Do(req)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if !time.Now().Before(deadline) {
|
||||
return false, fmt.Errorf("mediamtx control unavailable: %w", err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false, ctx.Err()
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
defer res.Body.Close()
|
||||
io.Copy(io.Discard, io.LimitReader(res.Body, 1<<20))
|
||||
if res.StatusCode == http.StatusNotFound {
|
||||
return false, nil
|
||||
}
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
return false, fmt.Errorf("mediamtx config lookup returned %d", res.StatusCode)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
func (c *HTTPController) Status(ctx context.Context, path string) (PathStatus, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+"/v3/paths/get/"+url.PathEscape(path), nil)
|
||||
if err != nil {
|
||||
@@ -74,7 +116,7 @@ func (c *HTTPController) Status(ctx context.Context, path string) (PathStatus, e
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode == http.StatusNotFound {
|
||||
return PathStatus{Name: path}, nil
|
||||
return PathStatus{Name: path, Exists: false}, nil
|
||||
}
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
return PathStatus{}, fmt.Errorf("mediamtx status returned %d", res.StatusCode)
|
||||
@@ -88,5 +130,6 @@ func (c *HTTPController) Status(ctx context.Context, path string) (PathStatus, e
|
||||
if err := json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(&raw); err != nil {
|
||||
return PathStatus{}, err
|
||||
}
|
||||
return PathStatus{Name: raw.Name, Ready: raw.Ready, Readers: len(raw.Readers), BytesReceived: raw.BytesReceived}, nil
|
||||
return PathStatus{Name: raw.Name, Exists: true, Ready: raw.Ready, Readers: len(raw.Readers), BytesReceived: raw.BytesReceived}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,18 +3,30 @@ package mediamtx
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) }
|
||||
|
||||
func TestApplyBuildsCredentialSourceOnlyInTransientBody(t *testing.T) {
|
||||
var body map[string]any
|
||||
var appliedPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.URL.String(), "password") {
|
||||
t.Fatal("credential leaked in control URL")
|
||||
}
|
||||
if r.Method == http.MethodGet {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
appliedPath = r.URL.Path
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -28,6 +40,50 @@ func TestApplyBuildsCredentialSourceOnlyInTransientBody(t *testing.T) {
|
||||
if body["source"] != "rtsp://fixture-user:fixture-password@camera.invalid/main" {
|
||||
t.Fatalf("body=%#v", body)
|
||||
}
|
||||
if appliedPath != "/v3/config/paths/add/sense_test" {
|
||||
t.Fatalf("applied path = %q", appliedPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyReplacesExistingPath(t *testing.T) {
|
||||
var appliedPath string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
appliedPath = r.URL.Path
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
if err := NewHTTPController(server.URL).Apply(context.Background(), Source{Path: "sense_test", URI: "rtsp://camera.invalid/main"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if appliedPath != "/v3/config/paths/replace/sense_test" {
|
||||
t.Fatalf("applied path = %q", appliedPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyWaitsForNewlyStartedControlAPI(t *testing.T) {
|
||||
attempts := 0
|
||||
client := NewHTTPController("http://127.0.0.1:9997")
|
||||
client.client.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
attempts++
|
||||
if attempts < 3 {
|
||||
return nil, errors.New("connection refused")
|
||||
}
|
||||
status := http.StatusNotFound
|
||||
if request.Method == http.MethodPost {
|
||||
status = http.StatusOK
|
||||
}
|
||||
return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil
|
||||
})
|
||||
if err := client.Apply(context.Background(), Source{Path: "sense_test", URI: "rtsp://camera.invalid/main"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if attempts != 4 {
|
||||
t.Fatalf("attempts = %d", attempts)
|
||||
}
|
||||
}
|
||||
func TestApplyRejectsCredentialURI(t *testing.T) {
|
||||
client := NewHTTPController("http://127.0.0.1")
|
||||
@@ -35,3 +91,4 @@ func TestApplyRejectsCredentialURI(t *testing.T) {
|
||||
t.Fatal("credential URI accepted")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,16 @@ package onvif
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -34,10 +39,28 @@ func NewHTTPClient(timeout time.Duration) *HTTPClient {
|
||||
if timeout <= 0 {
|
||||
timeout = 8 * time.Second
|
||||
}
|
||||
return &HTTPClient{client: &http.Client{Timeout: timeout}}
|
||||
return &HTTPClient{client: &http.Client{
|
||||
Timeout: timeout,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}}
|
||||
}
|
||||
func (c *HTTPClient) Profiles(ctx context.Context, address string, credential Credential) ([]Profile, error) {
|
||||
endpoint, err := validateEndpoint(address)
|
||||
deviceEndpoint, err := validateEndpoint(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
capabilitiesBody := `<?xml version="1.0"?><s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body><GetCapabilities xmlns="http://www.onvif.org/ver10/device/wsdl"><Category>All</Category></GetCapabilities></s:Body></s:Envelope>`
|
||||
capabilities, err := c.soap(ctx, deviceEndpoint, credential, capabilitiesBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mediaAddress, err := ParseMediaServiceAddress(capabilities)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endpoint, err := normalizeServiceEndpoint(deviceEndpoint, mediaAddress)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -60,16 +83,45 @@ func (c *HTTPClient) Profiles(ctx context.Context, address string, credential Cr
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profiles[i].StreamURI, err = normalizeStreamURI(deviceEndpoint, profiles[i].StreamURI)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return profiles, nil
|
||||
}
|
||||
|
||||
func normalizeStreamURI(deviceEndpoint, streamURI string) (string, error) {
|
||||
device, err := url.Parse(deviceEndpoint)
|
||||
if err != nil || device.Hostname() == "" {
|
||||
return "", fmt.Errorf("invalid ONVIF address")
|
||||
}
|
||||
stream, err := url.Parse(streamURI)
|
||||
if err != nil || stream.Scheme != "rtsp" || stream.Host == "" || stream.User != nil {
|
||||
return "", fmt.Errorf("invalid RTSP stream URI")
|
||||
}
|
||||
if !strings.EqualFold(stream.Hostname(), device.Hostname()) {
|
||||
port := stream.Port()
|
||||
stream.Host = device.Hostname()
|
||||
if port != "" {
|
||||
stream.Host = net.JoinHostPort(device.Hostname(), port)
|
||||
}
|
||||
}
|
||||
return stream.String(), nil
|
||||
}
|
||||
func (c *HTTPClient) soap(ctx context.Context, endpoint string, credential Credential, body string) ([]byte, error) {
|
||||
return c.soapAttempt(ctx, endpoint, credential, body, "")
|
||||
}
|
||||
|
||||
func (c *HTTPClient) soapAttempt(ctx context.Context, endpoint string, credential Credential, body, authorization 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 != "" {
|
||||
if authorization != "" {
|
||||
req.Header.Set("Authorization", authorization)
|
||||
} else if credential.Username != "" {
|
||||
req.SetBasicAuth(credential.Username, credential.Password)
|
||||
}
|
||||
res, err := c.client.Do(req)
|
||||
@@ -82,6 +134,16 @@ func (c *HTTPClient) soap(ctx context.Context, endpoint string, credential Crede
|
||||
return nil, err
|
||||
}
|
||||
if res.StatusCode == http.StatusUnauthorized {
|
||||
if authorization == "" && credential.Username != "" {
|
||||
challenge, challengeErr := parseDigestChallenge(res.Header.Values("WWW-Authenticate"))
|
||||
if challengeErr == nil {
|
||||
digest, digestErr := digestAuthorization(http.MethodPost, req.URL.RequestURI(), credential, challenge)
|
||||
if digestErr != nil {
|
||||
return nil, digestErr
|
||||
}
|
||||
return c.soapAttempt(ctx, endpoint, credential, body, digest)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("authentication_failed")
|
||||
}
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
@@ -89,6 +151,157 @@ func (c *HTTPClient) soap(ctx context.Context, endpoint string, credential Crede
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
type digestChallenge struct {
|
||||
realm string
|
||||
nonce string
|
||||
opaque string
|
||||
algorithm string
|
||||
qop string
|
||||
}
|
||||
|
||||
func parseDigestChallenge(values []string) (digestChallenge, error) {
|
||||
for _, value := range values {
|
||||
if !strings.EqualFold(strings.TrimSpace(strings.SplitN(value, " ", 2)[0]), "Digest") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(strings.TrimSpace(value), " ", 2)
|
||||
if len(parts) != 2 {
|
||||
break
|
||||
}
|
||||
params, err := parseAuthParameters(parts[1])
|
||||
if err != nil {
|
||||
return digestChallenge{}, err
|
||||
}
|
||||
challenge := digestChallenge{
|
||||
realm: strings.TrimSpace(params["realm"]), nonce: strings.TrimSpace(params["nonce"]),
|
||||
opaque: strings.TrimSpace(params["opaque"]), algorithm: strings.ToUpper(strings.TrimSpace(params["algorithm"])),
|
||||
}
|
||||
if challenge.realm == "" || challenge.nonce == "" {
|
||||
return digestChallenge{}, fmt.Errorf("invalid_digest_challenge")
|
||||
}
|
||||
if challenge.algorithm == "" {
|
||||
challenge.algorithm = "MD5"
|
||||
}
|
||||
if challenge.algorithm != "MD5" && challenge.algorithm != "SHA-256" {
|
||||
return digestChallenge{}, fmt.Errorf("unsupported_digest_algorithm")
|
||||
}
|
||||
qops := strings.Split(params["qop"], ",")
|
||||
for _, qop := range qops {
|
||||
if strings.EqualFold(strings.TrimSpace(qop), "auth") {
|
||||
challenge.qop = "auth"
|
||||
break
|
||||
}
|
||||
}
|
||||
if params["qop"] != "" && challenge.qop == "" {
|
||||
return digestChallenge{}, fmt.Errorf("unsupported_digest_qop")
|
||||
}
|
||||
return challenge, nil
|
||||
}
|
||||
return digestChallenge{}, fmt.Errorf("digest_challenge_not_found")
|
||||
}
|
||||
|
||||
func parseAuthParameters(value string) (map[string]string, error) {
|
||||
result := map[string]string{}
|
||||
for position := 0; position < len(value); {
|
||||
for position < len(value) && (value[position] == ' ' || value[position] == ',') {
|
||||
position++
|
||||
}
|
||||
start := position
|
||||
for position < len(value) && value[position] != '=' && value[position] != ',' {
|
||||
position++
|
||||
}
|
||||
if position == start || position >= len(value) || value[position] != '=' {
|
||||
return nil, fmt.Errorf("invalid_digest_challenge")
|
||||
}
|
||||
name := strings.ToLower(strings.TrimSpace(value[start:position]))
|
||||
position++
|
||||
var parameter string
|
||||
if position < len(value) && value[position] == '"' {
|
||||
position++
|
||||
var builder strings.Builder
|
||||
closed := false
|
||||
for position < len(value) {
|
||||
if value[position] == '"' {
|
||||
position++
|
||||
closed = true
|
||||
break
|
||||
}
|
||||
if value[position] == '\\' && position+1 < len(value) {
|
||||
position++
|
||||
}
|
||||
builder.WriteByte(value[position])
|
||||
position++
|
||||
}
|
||||
if !closed {
|
||||
return nil, fmt.Errorf("invalid_digest_challenge")
|
||||
}
|
||||
parameter = builder.String()
|
||||
} else {
|
||||
start = position
|
||||
for position < len(value) && value[position] != ',' {
|
||||
position++
|
||||
}
|
||||
parameter = strings.TrimSpace(value[start:position])
|
||||
}
|
||||
result[name] = parameter
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func digestAuthorization(method, uri string, credential Credential, challenge digestChallenge) (string, error) {
|
||||
cnonceBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(cnonceBytes); err != nil {
|
||||
return "", fmt.Errorf("generate_digest_cnonce: %w", err)
|
||||
}
|
||||
cnonce := fmt.Sprintf("%x", cnonceBytes)
|
||||
hash := func(value string) string {
|
||||
if challenge.algorithm == "SHA-256" {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return fmt.Sprintf("%x", sum)
|
||||
}
|
||||
sum := md5.Sum([]byte(value))
|
||||
return fmt.Sprintf("%x", sum)
|
||||
}
|
||||
ha1 := hash(credential.Username + ":" + challenge.realm + ":" + credential.Password)
|
||||
ha2 := hash(method + ":" + uri)
|
||||
nonceCount := "00000001"
|
||||
response := hash(ha1 + ":" + challenge.nonce + ":" + ha2)
|
||||
if challenge.qop != "" {
|
||||
response = hash(ha1 + ":" + challenge.nonce + ":" + nonceCount + ":" + cnonce + ":" + challenge.qop + ":" + ha2)
|
||||
}
|
||||
values := []string{
|
||||
`username=` + strconv.Quote(credential.Username), `realm=` + strconv.Quote(challenge.realm),
|
||||
`nonce=` + strconv.Quote(challenge.nonce), `uri=` + strconv.Quote(uri),
|
||||
`response=` + strconv.Quote(response), `algorithm=` + challenge.algorithm,
|
||||
}
|
||||
if challenge.opaque != "" {
|
||||
values = append(values, `opaque=`+strconv.Quote(challenge.opaque))
|
||||
}
|
||||
if challenge.qop != "" {
|
||||
values = append(values, `qop=`+challenge.qop, `nc=`+nonceCount, `cnonce=`+strconv.Quote(cnonce))
|
||||
}
|
||||
return "Digest " + strings.Join(values, ", "), nil
|
||||
}
|
||||
|
||||
func normalizeServiceEndpoint(deviceEndpoint, advertisedEndpoint string) (string, error) {
|
||||
device, err := url.Parse(deviceEndpoint)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid ONVIF address")
|
||||
}
|
||||
advertised, err := url.Parse(advertisedEndpoint)
|
||||
if err != nil || advertised.Scheme == "" || advertised.Host == "" || advertised.User != nil {
|
||||
return "", fmt.Errorf("invalid ONVIF media address")
|
||||
}
|
||||
if advertised.Scheme != "http" && advertised.Scheme != "https" {
|
||||
return "", fmt.Errorf("unsupported ONVIF media scheme")
|
||||
}
|
||||
if !strings.EqualFold(advertised.Hostname(), device.Hostname()) {
|
||||
advertised.Scheme = device.Scheme
|
||||
advertised.Host = device.Host
|
||||
}
|
||||
return advertised.String(), nil
|
||||
}
|
||||
func validateEndpoint(value string) (string, error) {
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
@@ -107,3 +320,4 @@ func xmlEscape(value string) string {
|
||||
_ = xml.EscapeText(&b, []byte(value))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package onvif
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestProfilesDiscoversMediaServiceAndUsesDigest(t *testing.T) {
|
||||
var digestRequests atomic.Int32
|
||||
var server *httptest.Server
|
||||
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/onvif/device_service":
|
||||
fmt.Fprintf(w, `<Envelope><Body><GetCapabilitiesResponse><Capabilities><Media><XAddr>%s/onvif/media_service</XAddr></Media></Capabilities></GetCapabilitiesResponse></Body></Envelope>`, server.URL)
|
||||
case "/onvif/media_service":
|
||||
authorization := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(authorization, "Digest ") {
|
||||
w.Header().Set("WWW-Authenticate", `Digest realm="camera", nonce="nonce-1", algorithm=MD5, qop="auth"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
digestRequests.Add(1)
|
||||
if strings.Contains(readRequestBody(t, r), "GetProfiles") {
|
||||
fmt.Fprint(w, `<Envelope><Body><GetProfilesResponse><Profiles token="main"><Name>Main</Name><VideoEncoderConfiguration><Encoding>H264</Encoding><Resolution><Width>1920</Width><Height>1080</Height></Resolution></VideoEncoderConfiguration></Profiles></GetProfilesResponse></Body></Envelope>`)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, `<Envelope><Body><GetStreamUriResponse><MediaUri><Uri>rtsp://camera.invalid/live</Uri></MediaUri></GetStreamUriResponse></Body></Envelope>`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
profiles, err := NewHTTPClient(2*time.Second).Profiles(context.Background(), server.URL+"/onvif/device_service", Credential{Username: "operator", Password: "secret"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
serverURL, _ := url.Parse(server.URL)
|
||||
if len(profiles) != 1 || profiles[0].Width != 1920 || profiles[0].StreamURI != "rtsp://"+serverURL.Hostname()+"/live" {
|
||||
t.Fatalf("profiles=%#v", profiles)
|
||||
}
|
||||
if digestRequests.Load() != 2 {
|
||||
t.Fatalf("digest requests=%d", digestRequests.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeServiceEndpoint(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
device string
|
||||
advertised string
|
||||
want string
|
||||
wantError bool
|
||||
}{
|
||||
{name: "same host keeps media port", device: "http://camera.local:80/device", advertised: "http://camera.local:8000/media", want: "http://camera.local:8000/media"},
|
||||
{name: "different host uses authorized origin", device: "http://192.0.2.10:8080/device", advertised: "http://unusable.local:9000/media?profile=1", want: "http://192.0.2.10:8080/media?profile=1"},
|
||||
{name: "reject credentials", device: "http://camera.local/device", advertised: "http://user:pass@camera.local/media", wantError: true},
|
||||
{name: "reject scheme", device: "http://camera.local/device", advertised: "ftp://camera.local/media", wantError: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := normalizeServiceEndpoint(test.device, test.advertised)
|
||||
if test.wantError {
|
||||
if err == nil {
|
||||
t.Fatalf("got=%q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || got != test.want {
|
||||
t.Fatalf("got=%q err=%v", got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeStreamURI(t *testing.T) {
|
||||
got, err := normalizeStreamURI("http://192.0.2.10:80/onvif/device_service", "rtsp://unusable.local:8554/live/main?channel=1")
|
||||
if err != nil || got != "rtsp://192.0.2.10:8554/live/main?channel=1" {
|
||||
t.Fatalf("got=%q err=%v", got, err)
|
||||
}
|
||||
if _, err := normalizeStreamURI("http://camera.local/onvif", "rtsp://user:pass@camera.local/live"); err == nil {
|
||||
t.Fatal("credential stream URI accepted")
|
||||
}
|
||||
if _, err := normalizeStreamURI("http://camera.local/onvif", "http://camera.local/live"); err == nil {
|
||||
t.Fatal("non-RTSP URI accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectUnsupportedDigestChallenge(t *testing.T) {
|
||||
for _, challenge := range []string{
|
||||
`Digest realm="camera", nonce="n", algorithm=SHA-512, qop="auth"`,
|
||||
`Digest realm="camera", nonce="n", algorithm=MD5, qop="auth-int"`,
|
||||
`Digest realm="camera"`,
|
||||
} {
|
||||
if _, err := parseDigestChallenge([]string{challenge}); err == nil {
|
||||
t.Fatalf("challenge accepted: %s", challenge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEndpointRejectsCredentials(t *testing.T) {
|
||||
if _, err := validateEndpoint("http://user:pass@camera.invalid/onvif"); err == nil {
|
||||
t.Fatal("credential endpoint accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSOAPDoesNotFollowRedirect(t *testing.T) {
|
||||
redirectTargetCalled := false
|
||||
target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
redirectTargetCalled = true
|
||||
}))
|
||||
defer target.Close()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, target.URL, http.StatusFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := NewHTTPClient(time.Second).soap(context.Background(), server.URL, Credential{Username: "operator", Password: "secret"}, "<Envelope />")
|
||||
if err == nil || redirectTargetCalled {
|
||||
t.Fatalf("err=%v redirect_target_called=%v", err, redirectTargetCalled)
|
||||
}
|
||||
}
|
||||
|
||||
func readRequestBody(t *testing.T, r *http.Request) string {
|
||||
t.Helper()
|
||||
defer r.Body.Close()
|
||||
data, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func TestDigestAuthorizationUsesRequestURI(t *testing.T) {
|
||||
header, err := digestAuthorization(http.MethodPost, "/media?profile=1", Credential{Username: "operator", Password: "secret"}, digestChallenge{realm: "camera", nonce: "n", algorithm: "SHA-256", qop: "auth"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := parseAuthParameters(strings.TrimPrefix(header, "Digest "))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if parsed["uri"] != "/media?profile=1" || parsed["username"] != "operator" || parsed["response"] == "" {
|
||||
t.Fatalf("invalid digest fields: %#v", parsed)
|
||||
}
|
||||
if _, err := url.Parse(parsed["uri"]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,3 +54,33 @@ func ParseStreamURI(data []byte) (string, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ParseMediaServiceAddress(data []byte) (string, error) {
|
||||
decoder := xml.NewDecoder(strings.NewReader(string(data)))
|
||||
mediaDepth := 0
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("media_service_not_found")
|
||||
}
|
||||
switch value := token.(type) {
|
||||
case xml.StartElement:
|
||||
if value.Name.Local == "Media" {
|
||||
mediaDepth++
|
||||
continue
|
||||
}
|
||||
if mediaDepth > 0 && value.Name.Local == "XAddr" {
|
||||
var address string
|
||||
if err := decoder.DecodeElement(&address, &value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(address), nil
|
||||
}
|
||||
case xml.EndElement:
|
||||
if value.Name.Local == "Media" && mediaDepth > 0 {
|
||||
mediaDepth--
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,3 +29,18 @@ func TestRejectCredentialInStreamURI(t *testing.T) {
|
||||
t.Fatal("credential URI accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMediaServiceAddress(t *testing.T) {
|
||||
data := []byte(`<Envelope><Body><GetCapabilitiesResponse><Capabilities><Media><XAddr>http://camera.invalid:8000/onvif/media_service</XAddr></Media></Capabilities></GetCapabilitiesResponse></Body></Envelope>`)
|
||||
address, err := ParseMediaServiceAddress(data)
|
||||
if err != nil || address != "http://camera.invalid:8000/onvif/media_service" {
|
||||
t.Fatalf("address=%q err=%v", address, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMediaServiceAddressRejectsMissingMedia(t *testing.T) {
|
||||
if _, err := ParseMediaServiceAddress([]byte(`<Envelope><Body /></Envelope>`)); err == nil {
|
||||
t.Fatal("missing media service accepted")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package admission
|
||||
|
||||
const MigrationSQL = `
|
||||
CREATE TABLE IF NOT EXISTS sense_admission_results (
|
||||
device_id TEXT PRIMARY KEY REFERENCES sense_devices(id),
|
||||
address TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
checked_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sense_admission_profiles (
|
||||
device_id TEXT NOT NULL REFERENCES sense_devices(id),
|
||||
token TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
width INTEGER NOT NULL,
|
||||
height INTEGER NOT NULL,
|
||||
encoding TEXT NOT NULL,
|
||||
stream_uri TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
verification_status TEXT NOT NULL,
|
||||
verification_latency_ms BIGINT NOT NULL DEFAULT 0,
|
||||
verification_detail TEXT NOT NULL DEFAULT '',
|
||||
PRIMARY KEY(device_id, token)
|
||||
);
|
||||
`
|
||||
|
||||
const MediaStatusMigrationSQL = `
|
||||
ALTER TABLE sense_admission_results ADD COLUMN IF NOT EXISTS media_status TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE sense_admission_results ADD COLUMN IF NOT EXISTS media_detail TEXT NOT NULL DEFAULT '';
|
||||
`
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"yovision.local/sense/app/sense/adapters/onvif"
|
||||
@@ -25,25 +24,41 @@ type Profile struct {
|
||||
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"`
|
||||
DeviceID string `json:"device_id"`
|
||||
Address string `json:"address"`
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail"`
|
||||
Profiles []Profile `json:"profiles"`
|
||||
MediaStatus string `json:"media_status,omitempty"`
|
||||
MediaDetail string `json:"media_detail,omitempty"`
|
||||
CheckedAt time.Time `json:"checked_at"`
|
||||
}
|
||||
|
||||
type MediaOutcome struct {
|
||||
Status string
|
||||
Detail string
|
||||
}
|
||||
|
||||
type ReadyHandler func(context.Context, identity.Principal, Result) MediaOutcome
|
||||
|
||||
type Service struct {
|
||||
onvif onvif.Client
|
||||
rtsp rtsp.Verifier
|
||||
discoveryIP string
|
||||
discoveryTimeout time.Duration
|
||||
mu sync.RWMutex
|
||||
results map[string]Result
|
||||
store Store
|
||||
readyHandler ReadyHandler
|
||||
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) SetReadyHandler(handler ReadyHandler) { s.readyHandler = handler }
|
||||
|
||||
func NewService(client onvif.Client, verifier rtsp.Verifier, discoveryIP string, stores ...Store) *Service {
|
||||
var store Store = NewMemoryStore()
|
||||
if len(stores) > 0 && stores[0] != nil {
|
||||
store = stores[0]
|
||||
}
|
||||
return &Service{onvif: client, rtsp: verifier, discoveryIP: discoveryIP, discoveryTimeout: 3 * time.Second, store: store, now: time.Now}
|
||||
}
|
||||
func (s *Service) Discover(ctx context.Context) ([]string, error) {
|
||||
if strings.TrimSpace(s.discoveryIP) == "" {
|
||||
@@ -52,21 +67,25 @@ func (s *Service) Discover(ctx context.Context) ([]string, error) {
|
||||
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)
|
||||
onvifCredential, err := device.ReadONVIFCredential(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})
|
||||
profiles, err := s.onvif.Profiles(ctx, address, onvif.Credential{Username: onvifCredential.Username, Password: onvifCredential.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)
|
||||
_ = s.store.Save(ctx, result)
|
||||
identity.RecordAudit(ctx, actor.UserID, "admission.probe", deviceID, "failure", map[string]any{"status": status})
|
||||
return result, nil
|
||||
}
|
||||
rtspCredential, err := device.ReadRTSPCredential(ctx, deviceID)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("rtsp_credential_required")
|
||||
}
|
||||
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})
|
||||
verification, verifyErr := s.rtsp.Verify(ctx, profile.StreamURI, rtsp.Credential{Username: rtspCredential.Username, Password: rtspCredential.Password})
|
||||
if verifyErr != nil {
|
||||
verification = rtsp.Result{Status: "failed", Detail: "视频地址格式不正确"}
|
||||
}
|
||||
@@ -89,20 +108,31 @@ func (s *Service) Probe(ctx context.Context, actor identity.Principal, deviceID,
|
||||
}
|
||||
}
|
||||
result := Result{DeviceID: deviceID, Address: address, Status: status, Detail: detail, Profiles: items, CheckedAt: s.now().UTC()}
|
||||
s.save(result)
|
||||
if err := s.store.Save(ctx, result); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.Verification.Status == "ready" {
|
||||
if err := device.MarkActive(ctx, deviceID); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if s.readyHandler != nil {
|
||||
outcome := s.readyHandler(ctx, actor, result)
|
||||
result.MediaStatus = outcome.Status
|
||||
result.MediaDetail = outcome.Detail
|
||||
if err := s.store.Save(ctx, result); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
}
|
||||
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
|
||||
value, err := s.store.Get(context.Background(), deviceID)
|
||||
return value, err == nil
|
||||
}
|
||||
func classify(err error) (string, string) {
|
||||
value := strings.ToLower(err.Error())
|
||||
@@ -117,3 +147,4 @@ func classify(err error) (string, string) {
|
||||
return "unreachable", "无法读取设备信息,请检查地址和网络"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,20 @@ type fakeRTSP struct{}
|
||||
func (fakeRTSP) Verify(context.Context, string, rtsp.Credential) (rtsp.Result, error) {
|
||||
return rtsp.Result{Status: "ready"}, nil
|
||||
}
|
||||
|
||||
type credentialCapturingONVIF struct{ got onvif.Credential }
|
||||
|
||||
func (f *credentialCapturingONVIF) Profiles(_ context.Context, _ string, credential onvif.Credential) ([]onvif.Profile, error) {
|
||||
f.got = credential
|
||||
return fakeONVIF{}.Profiles(context.Background(), "", credential)
|
||||
}
|
||||
|
||||
type credentialCapturingRTSP struct{ got rtsp.Credential }
|
||||
|
||||
func (f *credentialCapturingRTSP) Verify(_ context.Context, _ string, credential rtsp.Credential) (rtsp.Result, error) {
|
||||
f.got = credential
|
||||
return rtsp.Result{Status: "ready"}, nil
|
||||
}
|
||||
func TestProbeProfilesWithoutCredentialURI(t *testing.T) {
|
||||
vault, err := device.NewCredentialVault(base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")), false)
|
||||
if err != nil {
|
||||
@@ -44,3 +58,50 @@ func TestProbeProfilesWithoutCredentialURI(t *testing.T) {
|
||||
t.Fatalf("result=%#v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeUsesSeparateCredentialsPersistsProfilesAndActivatesDevice(t *testing.T) {
|
||||
vault, err := device.NewCredentialVault(base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deviceStore := device.NewMemoryStore()
|
||||
deviceService := device.NewService(deviceStore, vault)
|
||||
device.NewModule(deviceService).Register(platform.NewApp(platform.Config{DatabaseMode: platform.DatabaseModeMemory}, nil, nil))
|
||||
item, err := deviceService.Create(context.Background(), identity.Principal{}, "camera", "gate", device.ModalityVideo, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = deviceService.SetCredentials(context.Background(), identity.Principal{}, item.ID, "onvif-user", "onvif-password", false, "rtsp-user", "rtsp-password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
onvifClient := &credentialCapturingONVIF{}
|
||||
rtspVerifier := &credentialCapturingRTSP{}
|
||||
store := NewMemoryStore()
|
||||
service := NewService(onvifClient, rtspVerifier, "", store)
|
||||
service.SetReadyHandler(func(_ context.Context, _ identity.Principal, result Result) MediaOutcome {
|
||||
if len(result.Profiles) != 2 {
|
||||
t.Fatalf("ready handler profiles=%d", len(result.Profiles))
|
||||
}
|
||||
return MediaOutcome{Status: "needs_attention", Detail: "媒体服务未就绪"}
|
||||
})
|
||||
result, err := service.Probe(context.Background(), identity.Principal{}, item.ID, "http://camera.invalid/onvif/device_service")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if onvifClient.got.Username != "onvif-user" || rtspVerifier.got.Username != "rtsp-user" {
|
||||
t.Fatal("credentials were not separated")
|
||||
}
|
||||
if result.MediaStatus != "needs_attention" {
|
||||
t.Fatalf("media outcome=%#v", result)
|
||||
}
|
||||
restarted := NewService(onvifClient, rtspVerifier, "", store)
|
||||
persisted, ok := restarted.Get(item.ID)
|
||||
if !ok || len(persisted.Profiles) != len(result.Profiles) {
|
||||
t.Fatalf("persisted=%#v", persisted)
|
||||
}
|
||||
updated, err := deviceService.Get(context.Background(), item.ID)
|
||||
if err != nil || updated.Status != device.StatusActive {
|
||||
t.Fatalf("device=%#v err=%v", updated, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package admission
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("admission result not found")
|
||||
|
||||
type Store interface {
|
||||
Save(context.Context, Result) error
|
||||
Get(context.Context, string) (Result, error)
|
||||
}
|
||||
|
||||
type MemoryStore struct {
|
||||
mu sync.RWMutex
|
||||
results map[string]Result
|
||||
}
|
||||
|
||||
func NewMemoryStore() *MemoryStore { return &MemoryStore{results: map[string]Result{}} }
|
||||
func (s *MemoryStore) Save(_ context.Context, result Result) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.results[result.DeviceID] = result
|
||||
return nil
|
||||
}
|
||||
func (s *MemoryStore) Get(_ context.Context, deviceID string) (Result, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
result, ok := s.results[deviceID]
|
||||
if !ok {
|
||||
return Result{}, ErrNotFound
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type PostgresStore struct{ database *sql.DB }
|
||||
|
||||
func NewPostgresStore(database *sql.DB) *PostgresStore { return &PostgresStore{database: database} }
|
||||
func (s *PostgresStore) Save(ctx context.Context, result Result) error {
|
||||
tx, err := s.database.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO sense_admission_results(device_id,address,status,detail,media_status,media_detail,checked_at) VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT(device_id) DO UPDATE SET address=EXCLUDED.address,status=EXCLUDED.status,detail=EXCLUDED.detail,media_status=EXCLUDED.media_status,media_detail=EXCLUDED.media_detail,checked_at=EXCLUDED.checked_at`, result.DeviceID, result.Address, result.Status, result.Detail, result.MediaStatus, result.MediaDetail, result.CheckedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM sense_admission_profiles WHERE device_id=$1`, result.DeviceID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, profile := range result.Profiles {
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO sense_admission_profiles(device_id,token,name,width,height,encoding,stream_uri,kind,verification_status,verification_latency_ms,verification_detail) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, result.DeviceID, profile.Token, profile.Name, profile.Width, profile.Height, profile.Encoding, profile.StreamURI, profile.Kind, profile.Verification.Status, profile.Verification.LatencyMS, profile.Verification.Detail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
func (s *PostgresStore) Get(ctx context.Context, deviceID string) (Result, error) {
|
||||
var result Result
|
||||
err := s.database.QueryRowContext(ctx, `SELECT device_id,address,status,detail,media_status,media_detail,checked_at FROM sense_admission_results WHERE device_id=$1`, deviceID).Scan(&result.DeviceID, &result.Address, &result.Status, &result.Detail, &result.MediaStatus, &result.MediaDetail, &result.CheckedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Result{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
rows, err := s.database.QueryContext(ctx, `SELECT token,name,width,height,encoding,stream_uri,kind,verification_status,verification_latency_ms,verification_detail FROM sense_admission_profiles WHERE device_id=$1 ORDER BY width*height DESC`, deviceID)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var profile Profile
|
||||
if err := rows.Scan(&profile.Token, &profile.Name, &profile.Width, &profile.Height, &profile.Encoding, &profile.StreamURI, &profile.Kind, &profile.Verification.Status, &profile.Verification.LatencyMS, &profile.Verification.Detail); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
result.Profiles = append(result.Profiles, profile)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
@@ -11,11 +11,19 @@ type Credential struct {
|
||||
Password string
|
||||
}
|
||||
|
||||
type DisplayInfo struct {
|
||||
Name string
|
||||
Location 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) {
|
||||
return ReadONVIFCredential(ctx, id)
|
||||
}
|
||||
func ReadONVIFCredential(ctx context.Context, id string) (Credential, error) {
|
||||
service := activeService.Load()
|
||||
if service == nil {
|
||||
return Credential{}, fmt.Errorf("device service is not ready")
|
||||
@@ -30,3 +38,51 @@ func ReadCredential(ctx context.Context, id string) (Credential, error) {
|
||||
username, password, err := service.vault.Decrypt(item.CredentialCiphertext)
|
||||
return Credential{Username: username, Password: password}, err
|
||||
}
|
||||
|
||||
func ReadRTSPCredential(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.RTSPCredentialCiphertext) == 0 {
|
||||
return Credential{}, fmt.Errorf("RTSP credential is not configured")
|
||||
}
|
||||
username, password, err := service.vault.Decrypt(item.RTSPCredentialCiphertext)
|
||||
return Credential{Username: username, Password: password}, err
|
||||
}
|
||||
|
||||
func Describe(ctx context.Context, id string) (DisplayInfo, error) {
|
||||
service := activeService.Load()
|
||||
if service == nil {
|
||||
return DisplayInfo{}, fmt.Errorf("device service is not ready")
|
||||
}
|
||||
item, err := service.store.Get(ctx, id)
|
||||
if err != nil {
|
||||
return DisplayInfo{}, err
|
||||
}
|
||||
return DisplayInfo{Name: item.Name, Location: item.Location}, nil
|
||||
}
|
||||
|
||||
func MarkActive(ctx context.Context, id string) error {
|
||||
service := activeService.Load()
|
||||
if service == nil {
|
||||
return fmt.Errorf("device service is not ready")
|
||||
}
|
||||
item, err := service.store.Get(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if item.Status == StatusDisabled || item.Status == StatusActive {
|
||||
return nil
|
||||
}
|
||||
expected := item.Version
|
||||
item.Status = StatusActive
|
||||
item.Version++
|
||||
item.UpdatedAt = service.now().UTC()
|
||||
return service.store.Update(ctx, item, expected)
|
||||
}
|
||||
|
||||
|
||||
@@ -78,15 +78,30 @@ func (m *Module) update(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
func (m *Module) credential(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
ONVIFUsername string `json:"onvif_username"`
|
||||
ONVIFPassword string `json:"onvif_password"`
|
||||
RTSPSameAsONVIF *bool `json:"rtsp_same_as_onvif"`
|
||||
RTSPUsername string `json:"rtsp_username"`
|
||||
RTSPPassword string `json:"rtsp_password"`
|
||||
}
|
||||
if err := platform.DecodeJSON(r, &req); err != nil {
|
||||
platform.WriteError(w, err)
|
||||
return
|
||||
}
|
||||
actor, _ := identity.PrincipalFromContext(r.Context())
|
||||
item, err := m.service.SetCredential(r.Context(), actor, r.PathValue("id"), req.Username, req.Password)
|
||||
if req.ONVIFUsername == "" {
|
||||
req.ONVIFUsername = req.Username
|
||||
}
|
||||
if req.ONVIFPassword == "" {
|
||||
req.ONVIFPassword = req.Password
|
||||
}
|
||||
rtspSame := true
|
||||
if req.RTSPSameAsONVIF != nil {
|
||||
rtspSame = *req.RTSPSameAsONVIF
|
||||
}
|
||||
item, err := m.service.SetCredentials(r.Context(), actor, r.PathValue("id"), req.ONVIFUsername, req.ONVIFPassword, rtspSame, req.RTSPUsername, req.RTSPPassword)
|
||||
if err != nil {
|
||||
writeDeviceError(w, err)
|
||||
return
|
||||
@@ -122,3 +137,4 @@ func writeDeviceError(w http.ResponseWriter, err error) {
|
||||
}
|
||||
platform.WriteError(w, &platform.APIError{Status: status, Code: code, Message: err.Error()})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
package device
|
||||
|
||||
const MigrationSQL = `CREATE TABLE IF NOT EXISTS sense_devices(id TEXT PRIMARY KEY,name TEXT NOT NULL,location TEXT NOT NULL DEFAULT '',modality TEXT NOT NULL,capabilities TEXT NOT NULL DEFAULT '',status TEXT NOT NULL,adapter_status TEXT NOT NULL,credential_ciphertext BYTEA NULL,version BIGINT NOT NULL,created_at TIMESTAMPTZ NOT NULL,updated_at TIMESTAMPTZ NOT NULL);CREATE INDEX IF NOT EXISTS sense_devices_created_idx ON sense_devices(created_at DESC);`
|
||||
|
||||
const SplitCredentialMigrationSQL = `ALTER TABLE sense_devices ADD COLUMN IF NOT EXISTS rtsp_credential_ciphertext BYTEA NULL;ALTER TABLE sense_devices ADD COLUMN IF NOT EXISTS rtsp_credential_same_as_onvif BOOLEAN NOT NULL DEFAULT TRUE;UPDATE sense_devices SET rtsp_credential_ciphertext=credential_ciphertext WHERE rtsp_credential_ciphertext IS NULL AND credential_ciphertext IS NOT NULL AND rtsp_credential_same_as_onvif=TRUE;`
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ func (s *Service) Create(ctx context.Context, actor identity.Principal, name, lo
|
||||
adapter = AdapterReady
|
||||
}
|
||||
now := s.now().UTC()
|
||||
item := Device{ID: newID(), Name: name, Location: strings.TrimSpace(location), Modality: modality, Capabilities: capabilities, Status: StatusPending, AdapterStatus: adapter, Version: 1, CreatedAt: now, UpdatedAt: now}
|
||||
item := Device{ID: newID(), Name: name, Location: strings.TrimSpace(location), Modality: modality, Capabilities: capabilities, Status: StatusPending, AdapterStatus: adapter, RTSPCredentialSameAsONVIF: true, Version: 1, CreatedAt: now, UpdatedAt: now}
|
||||
if err := s.store.Create(ctx, item); err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
@@ -45,7 +45,9 @@ func (s *Service) Get(ctx context.Context, id string) (Device, error) {
|
||||
item, err := s.store.Get(ctx, id)
|
||||
if err == nil {
|
||||
item.CredentialConfigured = len(item.CredentialCiphertext) > 0
|
||||
item.RTSPCredentialConfigured = len(item.RTSPCredentialCiphertext) > 0
|
||||
item.CredentialCiphertext = nil
|
||||
item.RTSPCredentialCiphertext = nil
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
@@ -53,6 +55,7 @@ func (s *Service) List(ctx context.Context, filter ListFilter) (Page, error) {
|
||||
page, err := s.store.List(ctx, filter)
|
||||
for i := range page.Items {
|
||||
page.Items[i].CredentialCiphertext = nil
|
||||
page.Items[i].RTSPCredentialCiphertext = nil
|
||||
}
|
||||
return page, err
|
||||
}
|
||||
@@ -74,31 +77,50 @@ func (s *Service) Update(ctx context.Context, actor identity.Principal, id, name
|
||||
}
|
||||
identity.RecordAudit(ctx, actor.UserID, "device.update", id, "success", map[string]any{"version": item.Version})
|
||||
item.CredentialConfigured = len(item.CredentialCiphertext) > 0
|
||||
item.RTSPCredentialConfigured = len(item.RTSPCredentialCiphertext) > 0
|
||||
item.CredentialCiphertext = nil
|
||||
item.RTSPCredentialCiphertext = nil
|
||||
return item, nil
|
||||
}
|
||||
func (s *Service) SetCredential(ctx context.Context, actor identity.Principal, id, username, password string) (Device, error) {
|
||||
if strings.TrimSpace(username) == "" || password == "" {
|
||||
return s.SetCredentials(ctx, actor, id, username, password, true, "", "")
|
||||
}
|
||||
func (s *Service) SetCredentials(ctx context.Context, actor identity.Principal, id, onvifUsername, onvifPassword string, rtspSame bool, rtspUsername, rtspPassword string) (Device, error) {
|
||||
if strings.TrimSpace(onvifUsername) == "" || onvifPassword == "" {
|
||||
return Device{}, fmt.Errorf("用户名和密码不能为空")
|
||||
}
|
||||
if !rtspSame && (strings.TrimSpace(rtspUsername) == "" || rtspPassword == "") {
|
||||
return Device{}, fmt.Errorf("RTSP 用户名和密码不能为空")
|
||||
}
|
||||
item, err := s.store.Get(ctx, id)
|
||||
if err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
ciphertext, err := s.vault.Encrypt(username, password)
|
||||
ciphertext, err := s.vault.Encrypt(onvifUsername, onvifPassword)
|
||||
if err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
expected := item.Version
|
||||
item.CredentialCiphertext = ciphertext
|
||||
item.RTSPCredentialSameAsONVIF = rtspSame
|
||||
if rtspSame {
|
||||
item.RTSPCredentialCiphertext = append([]byte(nil), ciphertext...)
|
||||
} else {
|
||||
item.RTSPCredentialCiphertext, err = s.vault.Encrypt(rtspUsername, rtspPassword)
|
||||
if err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
}
|
||||
item.CredentialConfigured = true
|
||||
item.RTSPCredentialConfigured = true
|
||||
item.Version++
|
||||
item.UpdatedAt = s.now().UTC()
|
||||
if err := s.store.Update(ctx, item, expected); err != nil {
|
||||
return Device{}, err
|
||||
}
|
||||
identity.RecordAudit(ctx, actor.UserID, "device.credential.update", id, "success", map[string]any{"configured": true})
|
||||
identity.RecordAudit(ctx, actor.UserID, "device.credential.update", id, "success", map[string]any{"configured": true, "rtsp_same_as_onvif": rtspSame})
|
||||
item.CredentialCiphertext = nil
|
||||
item.RTSPCredentialCiphertext = nil
|
||||
return item, nil
|
||||
}
|
||||
func (s *Service) Disable(ctx context.Context, actor identity.Principal, id string, expected int64) (Device, error) {
|
||||
@@ -114,7 +136,9 @@ func (s *Service) Disable(ctx context.Context, actor identity.Principal, id stri
|
||||
}
|
||||
identity.RecordAudit(ctx, actor.UserID, "device.disable", id, "success", nil)
|
||||
item.CredentialConfigured = len(item.CredentialCiphertext) > 0
|
||||
item.RTSPCredentialConfigured = len(item.RTSPCredentialCiphertext) > 0
|
||||
item.CredentialCiphertext = nil
|
||||
item.RTSPCredentialCiphertext = nil
|
||||
return item, nil
|
||||
}
|
||||
func newID() string {
|
||||
@@ -124,3 +148,4 @@ func newID() string {
|
||||
}
|
||||
return "dev_" + hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
|
||||
@@ -89,22 +89,23 @@ type PostgresStore struct{ database *sql.DB }
|
||||
func NewPostgresStore(database *sql.DB) *PostgresStore { return &PostgresStore{database: database} }
|
||||
|
||||
func (s *PostgresStore) Create(ctx context.Context, item Device) error {
|
||||
_, err := s.database.ExecContext(ctx, `INSERT INTO sense_devices(id,name,location,modality,capabilities,status,adapter_status,credential_ciphertext,version,created_at,updated_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, item.ID, item.Name, item.Location, item.Modality, strings.Join(item.Capabilities, ","), item.Status, item.AdapterStatus, item.CredentialCiphertext, item.Version, item.CreatedAt, item.UpdatedAt)
|
||||
_, err := s.database.ExecContext(ctx, `INSERT INTO sense_devices(id,name,location,modality,capabilities,status,adapter_status,credential_ciphertext,rtsp_credential_ciphertext,rtsp_credential_same_as_onvif,version,created_at,updated_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`, item.ID, item.Name, item.Location, item.Modality, strings.Join(item.Capabilities, ","), item.Status, item.AdapterStatus, item.CredentialCiphertext, item.RTSPCredentialCiphertext, item.RTSPCredentialSameAsONVIF, item.Version, item.CreatedAt, item.UpdatedAt)
|
||||
return err
|
||||
}
|
||||
func (s *PostgresStore) Get(ctx context.Context, id string) (Device, error) {
|
||||
var item Device
|
||||
var capabilities string
|
||||
err := s.database.QueryRowContext(ctx, `SELECT id,name,location,modality,capabilities,status,adapter_status,credential_ciphertext,version,created_at,updated_at FROM sense_devices WHERE id=$1`, id).Scan(&item.ID, &item.Name, &item.Location, &item.Modality, &capabilities, &item.Status, &item.AdapterStatus, &item.CredentialCiphertext, &item.Version, &item.CreatedAt, &item.UpdatedAt)
|
||||
err := s.database.QueryRowContext(ctx, `SELECT id,name,location,modality,capabilities,status,adapter_status,credential_ciphertext,rtsp_credential_ciphertext,rtsp_credential_same_as_onvif,version,created_at,updated_at FROM sense_devices WHERE id=$1`, id).Scan(&item.ID, &item.Name, &item.Location, &item.Modality, &capabilities, &item.Status, &item.AdapterStatus, &item.CredentialCiphertext, &item.RTSPCredentialCiphertext, &item.RTSPCredentialSameAsONVIF, &item.Version, &item.CreatedAt, &item.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Device{}, ErrNotFound
|
||||
}
|
||||
item.Capabilities = splitCapabilities(capabilities)
|
||||
item.CredentialConfigured = len(item.CredentialCiphertext) > 0
|
||||
item.RTSPCredentialConfigured = len(item.RTSPCredentialCiphertext) > 0
|
||||
return item, err
|
||||
}
|
||||
func (s *PostgresStore) Update(ctx context.Context, item Device, expected int64) error {
|
||||
result, err := s.database.ExecContext(ctx, `UPDATE sense_devices SET name=$2,location=$3,modality=$4,capabilities=$5,status=$6,adapter_status=$7,credential_ciphertext=$8,version=$9,updated_at=$10 WHERE id=$1 AND version=$11`, item.ID, item.Name, item.Location, item.Modality, strings.Join(item.Capabilities, ","), item.Status, item.AdapterStatus, item.CredentialCiphertext, item.Version, item.UpdatedAt, expected)
|
||||
result, err := s.database.ExecContext(ctx, `UPDATE sense_devices SET name=$2,location=$3,modality=$4,capabilities=$5,status=$6,adapter_status=$7,credential_ciphertext=$8,rtsp_credential_ciphertext=$9,rtsp_credential_same_as_onvif=$10,version=$11,updated_at=$12 WHERE id=$1 AND version=$13`, item.ID, item.Name, item.Location, item.Modality, strings.Join(item.Capabilities, ","), item.Status, item.AdapterStatus, item.CredentialCiphertext, item.RTSPCredentialCiphertext, item.RTSPCredentialSameAsONVIF, item.Version, item.UpdatedAt, expected)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -121,7 +122,7 @@ func (s *PostgresStore) List(ctx context.Context, filter ListFilter) (Page, erro
|
||||
if err := s.database.QueryRowContext(ctx, `SELECT count(*) FROM sense_devices WHERE name ILIKE $1 OR location ILIKE $1`, keyword).Scan(&total); err != nil {
|
||||
return Page{}, err
|
||||
}
|
||||
rows, err := s.database.QueryContext(ctx, `SELECT id,name,location,modality,capabilities,status,adapter_status,(credential_ciphertext IS NOT NULL),version,created_at,updated_at FROM sense_devices WHERE name ILIKE $1 OR location ILIKE $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`, keyword, size, (page-1)*size)
|
||||
rows, err := s.database.QueryContext(ctx, `SELECT id,name,location,modality,capabilities,status,adapter_status,(credential_ciphertext IS NOT NULL),(rtsp_credential_ciphertext IS NOT NULL),rtsp_credential_same_as_onvif,version,created_at,updated_at FROM sense_devices WHERE name ILIKE $1 OR location ILIKE $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3`, keyword, size, (page-1)*size)
|
||||
if err != nil {
|
||||
return Page{}, err
|
||||
}
|
||||
@@ -130,7 +131,7 @@ func (s *PostgresStore) List(ctx context.Context, filter ListFilter) (Page, erro
|
||||
for rows.Next() {
|
||||
var item Device
|
||||
var caps string
|
||||
if err := rows.Scan(&item.ID, &item.Name, &item.Location, &item.Modality, &caps, &item.Status, &item.AdapterStatus, &item.CredentialConfigured, &item.Version, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
if err := rows.Scan(&item.ID, &item.Name, &item.Location, &item.Modality, &caps, &item.Status, &item.AdapterStatus, &item.CredentialConfigured, &item.RTSPCredentialConfigured, &item.RTSPCredentialSameAsONVIF, &item.Version, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
return Page{}, err
|
||||
}
|
||||
item.Capabilities = splitCapabilities(caps)
|
||||
@@ -158,3 +159,4 @@ func splitCapabilities(value string) []string {
|
||||
}
|
||||
|
||||
var _ = time.Time{}
|
||||
|
||||
|
||||
@@ -13,18 +13,21 @@ const (
|
||||
)
|
||||
|
||||
type Device struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Location string `json:"location"`
|
||||
Modality string `json:"modality"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Status string `json:"status"`
|
||||
AdapterStatus string `json:"adapter_status"`
|
||||
CredentialConfigured bool `json:"credential_configured"`
|
||||
CredentialCiphertext []byte `json:"-"`
|
||||
Version int64 `json:"version"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Location string `json:"location"`
|
||||
Modality string `json:"modality"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Status string `json:"status"`
|
||||
AdapterStatus string `json:"adapter_status"`
|
||||
CredentialConfigured bool `json:"credential_configured"`
|
||||
CredentialCiphertext []byte `json:"-"`
|
||||
RTSPCredentialConfigured bool `json:"rtsp_credential_configured"`
|
||||
RTSPCredentialSameAsONVIF bool `json:"rtsp_credential_same_as_onvif"`
|
||||
RTSPCredentialCiphertext []byte `json:"-"`
|
||||
Version int64 `json:"version"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ListFilter struct {
|
||||
@@ -39,3 +42,4 @@ type Page struct {
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,21 @@ func Require(permission string, next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// RequireSameOriginFrame authenticates browser iframe navigation that cannot
|
||||
// attach the X-Product header used by API clients. Fetch Metadata keeps this
|
||||
// narrow: only a same-origin iframe navigation with a valid Sense session is
|
||||
// accepted.
|
||||
func RequireSameOriginFrame(permission string, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
module := activeModule.Load()
|
||||
if module == nil {
|
||||
platform.WriteError(w, &platform.APIError{Status: http.StatusServiceUnavailable, Code: "identity_not_ready", Message: "身份服务尚未就绪"})
|
||||
return
|
||||
}
|
||||
module.RequireSameOriginFrame(permission, next).ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func PrincipalFromContext(ctx context.Context) (Principal, bool) {
|
||||
principal, ok := ctx.Value(principalContextKey{}).(Principal)
|
||||
return principal, ok
|
||||
@@ -84,6 +99,28 @@ func (m *Module) Require(permission string, next http.Handler) http.Handler {
|
||||
}))
|
||||
}
|
||||
|
||||
func (m *Module) RequireSameOriginFrame(permission string, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Sec-Fetch-Site") != "same-origin" ||
|
||||
r.Header.Get("Sec-Fetch-Mode") != "navigate" ||
|
||||
r.Header.Get("Sec-Fetch-Dest") != "iframe" {
|
||||
platform.WriteError(w, &platform.APIError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "登录状态无效"})
|
||||
return
|
||||
}
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
platform.WriteError(w, &platform.APIError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "登录状态无效"})
|
||||
return
|
||||
}
|
||||
principal, err := m.service.Authenticate(r.Context(), cookie.Value)
|
||||
if err != nil || !HasPermission(principal.Role, permission) {
|
||||
platform.WriteError(w, &platform.APIError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "登录状态无效"})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), principalContextKey{}, principal)))
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Module) bootstrap(w http.ResponseWriter, r *http.Request) {
|
||||
var request struct {
|
||||
Username string `json:"username"`
|
||||
@@ -220,3 +257,4 @@ func SecureCookieFromEnvironment(value string, memoryMode bool) bool {
|
||||
}
|
||||
return !strings.EqualFold(value, "false")
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ func TestHTTPLoginCookieAndProductBoundary(t *testing.T) {
|
||||
}
|
||||
app := platform.NewApp(platform.Config{DatabaseMode: platform.DatabaseModeMemory}, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
NewModule(service, cfg).Register(app)
|
||||
app.Handle("GET /same-origin-frame", RequireSameOriginFrame(PermissionMediaRead, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})))
|
||||
|
||||
bootstrap := httptest.NewRequest(http.MethodPost, "/api/v1/identity/bootstrap", bytes.NewBufferString(`{"username":"admin","display_name":"管理员","password":"StrongPass2026"}`))
|
||||
bootstrap.Header.Set("X-Sense-Bootstrap-Token", "bootstrap-test")
|
||||
@@ -56,4 +59,41 @@ func TestHTTPLoginCookieAndProductBoundary(t *testing.T) {
|
||||
if senseResult.Code != http.StatusOK {
|
||||
t.Fatalf("Sense product header status = %d", senseResult.Code)
|
||||
}
|
||||
|
||||
meWithoutProduct := httptest.NewRequest(http.MethodGet, "/api/v1/identity/me", nil)
|
||||
meWithoutProduct.AddCookie(cookies[0])
|
||||
missingProductResult := httptest.NewRecorder()
|
||||
app.Handler().ServeHTTP(missingProductResult, meWithoutProduct)
|
||||
if missingProductResult.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("missing product header status = %d", missingProductResult.Code)
|
||||
}
|
||||
|
||||
frame := httptest.NewRequest(http.MethodGet, "/same-origin-frame", nil)
|
||||
frame.AddCookie(cookies[0])
|
||||
frame.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
frame.Header.Set("Sec-Fetch-Mode", "navigate")
|
||||
frame.Header.Set("Sec-Fetch-Dest", "iframe")
|
||||
frameResult := httptest.NewRecorder()
|
||||
app.Handler().ServeHTTP(frameResult, frame)
|
||||
if frameResult.Code != http.StatusNoContent {
|
||||
t.Fatalf("same-origin frame status = %d, body = %s", frameResult.Code, frameResult.Body.String())
|
||||
}
|
||||
|
||||
frame.Header.Set("Sec-Fetch-Site", "cross-site")
|
||||
crossSiteResult := httptest.NewRecorder()
|
||||
app.Handler().ServeHTTP(crossSiteResult, frame)
|
||||
if crossSiteResult.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("cross-site frame status = %d", crossSiteResult.Code)
|
||||
}
|
||||
|
||||
missingCookie := httptest.NewRequest(http.MethodGet, "/same-origin-frame", nil)
|
||||
missingCookie.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
missingCookie.Header.Set("Sec-Fetch-Mode", "navigate")
|
||||
missingCookie.Header.Set("Sec-Fetch-Dest", "iframe")
|
||||
missingCookieResult := httptest.NewRecorder()
|
||||
app.Handler().ServeHTTP(missingCookieResult, missingCookie)
|
||||
if missingCookieResult.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("missing-cookie frame status = %d", missingCookieResult.Code)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ func (m *Module) Register(app *platform.App) {
|
||||
app.Handle("GET /api/v1/liveview/routes", identity.Require(identity.PermissionMediaRead, http.HandlerFunc(m.routes)))
|
||||
app.Handle("POST /api/v1/liveview/sessions", identity.Require(identity.PermissionMediaRead, http.HandlerFunc(m.create)))
|
||||
app.Handle("GET /api/v1/liveview/sessions/{id}", identity.Require(identity.PermissionMediaRead, http.HandlerFunc(m.get)))
|
||||
app.Handle("GET /api/v1/liveview/sessions/{id}/player", identity.Require(identity.PermissionMediaRead, http.HandlerFunc(m.player)))
|
||||
app.Handle("GET /api/v1/liveview/sessions/{id}/player", identity.RequireSameOriginFrame(identity.PermissionMediaRead, http.HandlerFunc(m.player)))
|
||||
}
|
||||
func (m *Module) routes(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := m.service.Routes(r.Context())
|
||||
@@ -61,6 +61,11 @@ func (m *Module) player(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-src http: https:; style-src 'unsafe-inline'")
|
||||
// This endpoint is the authenticated, same-origin wrapper loaded by the
|
||||
// live-view page. Keep the global DENY policy everywhere else, and allow
|
||||
// only Sense itself to embed this wrapper.
|
||||
w.Header().Set("X-Frame-Options", "SAMEORIGIN")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'self'; frame-src http: https:; style-src 'unsafe-inline'")
|
||||
_ = playerTemplate.Execute(w, target)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package liveview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"yovision.local/sense/app/sense/media"
|
||||
)
|
||||
|
||||
func TestPlayerAllowsOnlySameOriginEmbedding(t *testing.T) {
|
||||
service, err := NewService("http://127.0.0.1:8889", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
route := media.Route{ID: "device:main", Path: "sense_device_main", Desired: "running", Actual: "ready"}
|
||||
service.route = func(context.Context, string) (media.Route, error) { return route, nil }
|
||||
service.refresh = func(context.Context, string) (media.Route, error) { return route, nil }
|
||||
service.sessions["view_test"] = Session{ID: "view_test", RouteID: route.ID, ExpiresAt: time.Now().Add(time.Minute)}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/liveview/sessions/view_test/player", nil)
|
||||
req.SetPathValue("id", "view_test")
|
||||
res := httptest.NewRecorder()
|
||||
NewModule(service).player(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", res.Code)
|
||||
}
|
||||
if got := res.Header().Get("X-Frame-Options"); got != "SAMEORIGIN" {
|
||||
t.Fatalf("X-Frame-Options = %q", got)
|
||||
}
|
||||
csp := res.Header().Get("Content-Security-Policy")
|
||||
if !strings.Contains(csp, "frame-ancestors 'self'") {
|
||||
t.Fatalf("Content-Security-Policy = %q", csp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,15 +10,34 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"yovision.local/sense/app/sense/admission"
|
||||
"yovision.local/sense/app/sense/device"
|
||||
"yovision.local/sense/app/sense/media"
|
||||
)
|
||||
|
||||
type Route struct {
|
||||
ID string `json:"id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
DeviceLocation string `json:"device_location"`
|
||||
ProfileToken string `json:"profile_token"`
|
||||
ProfileName string `json:"profile_name"`
|
||||
ProfileKind string `json:"profile_kind"`
|
||||
Desired string `json:"desired"`
|
||||
Actual string `json:"actual"`
|
||||
Detail string `json:"detail"`
|
||||
Readers int `json:"readers"`
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
ID string `json:"id"`
|
||||
OwnerID string `json:"-"`
|
||||
RouteID string `json:"route_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
ProfileToken string `json:"profile_token"`
|
||||
ProfileName string `json:"profile_name"`
|
||||
ProfileKind string `json:"profile_kind"`
|
||||
PlayerURL string `json:"player_url"`
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail"`
|
||||
@@ -28,6 +47,7 @@ type Service struct {
|
||||
base *url.URL
|
||||
ttl time.Duration
|
||||
route func(context.Context, string) (media.Route, error)
|
||||
refresh func(context.Context, string) (media.Route, error)
|
||||
routes func(context.Context) ([]media.Route, error)
|
||||
mu sync.RWMutex
|
||||
sessions map[string]Session
|
||||
@@ -45,17 +65,26 @@ func NewService(rawBase string, ttl time.Duration) (*Service, error) {
|
||||
if ttl <= 0 || ttl > 10*time.Minute {
|
||||
ttl = 2 * time.Minute
|
||||
}
|
||||
return &Service{base: parsed, ttl: ttl, route: media.PlaybackRoute, routes: media.PlaybackRoutes, sessions: map[string]Session{}, now: time.Now}, nil
|
||||
return &Service{base: parsed, ttl: ttl, route: media.PlaybackRoute, refresh: media.RefreshPlaybackRoute, routes: media.PlaybackRoutes, sessions: map[string]Session{}, now: time.Now}, nil
|
||||
}
|
||||
func (s *Service) Routes(ctx context.Context) ([]media.Route, error) {
|
||||
func (s *Service) Routes(ctx context.Context) ([]Route, error) {
|
||||
items, err := s.routes(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := items[:0]
|
||||
result := make([]Route, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.Desired == "running" {
|
||||
result = append(result, item)
|
||||
view := Route{ID: item.ID, DeviceID: item.DeviceID, DeviceName: item.DeviceID, ProfileToken: item.ProfileToken, ProfileName: item.ProfileToken, Desired: item.Desired, Actual: item.Actual, Detail: item.Detail, Readers: item.Readers}
|
||||
if info, describeErr := device.Describe(ctx, item.DeviceID); describeErr == nil {
|
||||
view.DeviceName = info.Name
|
||||
view.DeviceLocation = info.Location
|
||||
}
|
||||
if profile, profileErr := admission.VerifiedProfile(item.DeviceID, item.ProfileToken); profileErr == nil {
|
||||
view.ProfileName = profile.Name
|
||||
view.ProfileKind = profile.Kind
|
||||
}
|
||||
result = append(result, view)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
@@ -69,7 +98,14 @@ func (s *Service) Create(ctx context.Context, owner, routeID string) (Session, e
|
||||
return Session{}, fmt.Errorf("media route is stopped")
|
||||
}
|
||||
id := newID()
|
||||
session := Session{ID: id, OwnerID: owner, RouteID: route.ID, DeviceID: route.DeviceID, ProfileToken: route.ProfileToken, PlayerURL: "/api/v1/liveview/sessions/" + id + "/player", Status: route.Actual, Detail: route.Detail, ExpiresAt: s.now().UTC().Add(s.ttl)}
|
||||
session := Session{ID: id, OwnerID: owner, RouteID: route.ID, DeviceID: route.DeviceID, DeviceName: route.DeviceID, ProfileToken: route.ProfileToken, ProfileName: route.ProfileToken, PlayerURL: "/api/v1/liveview/sessions/" + id + "/player", Status: route.Actual, Detail: route.Detail, ExpiresAt: s.now().UTC().Add(s.ttl)}
|
||||
if info, describeErr := device.Describe(ctx, route.DeviceID); describeErr == nil {
|
||||
session.DeviceName = info.Name
|
||||
}
|
||||
if profile, profileErr := admission.VerifiedProfile(route.DeviceID, route.ProfileToken); profileErr == nil {
|
||||
session.ProfileName = profile.Name
|
||||
session.ProfileKind = profile.Kind
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.sessions[id] = session
|
||||
s.mu.Unlock()
|
||||
@@ -82,7 +118,7 @@ func (s *Service) Get(ctx context.Context, owner, id string) (Session, error) {
|
||||
if !ok || session.OwnerID != owner || !s.now().Before(session.ExpiresAt) {
|
||||
return Session{}, fmt.Errorf("playback session expired")
|
||||
}
|
||||
route, err := s.route(ctx, session.RouteID)
|
||||
route, err := s.refresh(ctx, session.RouteID)
|
||||
if err != nil {
|
||||
return Session{}, err
|
||||
}
|
||||
@@ -110,3 +146,4 @@ func newID() string {
|
||||
}
|
||||
return "view_" + hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
|
||||
@@ -44,3 +44,28 @@ func TestTTLIsBounded(t *testing.T) {
|
||||
t.Fatalf("ttl=%v", service.ttl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionPollRefreshesOnDemandMediaState(t *testing.T) {
|
||||
service, err := NewService("http://127.0.0.1:8889", time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
route := media.Route{ID: "device:main", DeviceID: "device", ProfileToken: "main", Path: "sense_device_main", Desired: "running", Actual: "waiting"}
|
||||
service.route = func(context.Context, string) (media.Route, error) { return route, nil }
|
||||
service.refresh = func(context.Context, string) (media.Route, error) {
|
||||
updated := route
|
||||
updated.Actual = "ready"
|
||||
updated.Detail = "上游拉流正常"
|
||||
updated.Readers = 1
|
||||
return updated, nil
|
||||
}
|
||||
session, err := service.Create(context.Background(), "owner", route.ID)
|
||||
if err != nil || session.Status != "waiting" || session.PlayerURL == "" {
|
||||
t.Fatalf("session=%#v err=%v", session, err)
|
||||
}
|
||||
updated, err := service.Get(context.Background(), "owner", session.ID)
|
||||
if err != nil || updated.Status != "ready" || updated.Detail != "上游拉流正常" {
|
||||
t.Fatalf("updated=%#v err=%v", updated, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
|
||||
"yovision.local/sense/app/sense/admission"
|
||||
"yovision.local/sense/app/sense/identity"
|
||||
)
|
||||
|
||||
var activeService atomic.Pointer[Service]
|
||||
@@ -16,6 +19,13 @@ func PlaybackRoute(ctx context.Context, id string) (Route, error) {
|
||||
}
|
||||
return service.store.Get(ctx, id)
|
||||
}
|
||||
func RefreshPlaybackRoute(ctx context.Context, id string) (Route, error) {
|
||||
service := activeService.Load()
|
||||
if service == nil {
|
||||
return Route{}, fmt.Errorf("media service is not ready")
|
||||
}
|
||||
return service.Refresh(ctx, id)
|
||||
}
|
||||
func PlaybackRoutes(ctx context.Context) ([]Route, error) {
|
||||
service := activeService.Load()
|
||||
if service == nil {
|
||||
@@ -23,3 +33,12 @@ func PlaybackRoutes(ctx context.Context) ([]Route, error) {
|
||||
}
|
||||
return service.store.List(ctx)
|
||||
}
|
||||
|
||||
func ConfigureReadyProfiles(ctx context.Context, actor identity.Principal, result admission.Result) admission.MediaOutcome {
|
||||
service := activeService.Load()
|
||||
if service == nil {
|
||||
return admission.MediaOutcome{Status: "needs_attention", Detail: "摄像机已接入,媒体服务尚未就绪"}
|
||||
}
|
||||
return service.ConfigureProfiles(ctx, actor, result)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,59 @@ type Service struct {
|
||||
}
|
||||
|
||||
func NewService(store Store, process mediamtx.Process, controller mediamtx.Controller) *Service {
|
||||
return &Service{store: store, process: process, controller: controller, profile: admission.VerifiedProfile, credential: device.ReadCredential, now: time.Now}
|
||||
return &Service{store: store, process: process, controller: controller, profile: admission.VerifiedProfile, credential: device.ReadRTSPCredential, now: time.Now}
|
||||
}
|
||||
|
||||
// Restore recreates desired routes after MediaMTX starts with its base config.
|
||||
// Individual route failures are persisted as safe business states and do not
|
||||
// prevent the Sense management plane from starting.
|
||||
func (s *Service) Restore(ctx context.Context) error {
|
||||
items, err := s.store.List(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, route := range items {
|
||||
if route.Desired != "running" {
|
||||
continue
|
||||
}
|
||||
if _, reconcileErr := s.Reconcile(ctx, identity.Principal{}, route.ID); reconcileErr != nil {
|
||||
route.Actual = "apply_failed"
|
||||
route.Detail = "媒体路径恢复失败,请检查视频服务"
|
||||
route.Readers = 0
|
||||
route.Version++
|
||||
route.UpdatedAt = s.now().UTC()
|
||||
if saveErr := s.store.Save(ctx, route); saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ConfigureProfiles(ctx context.Context, actor identity.Principal, result admission.Result) admission.MediaOutcome {
|
||||
configured := 0
|
||||
ready := 0
|
||||
for _, profile := range result.Profiles {
|
||||
if profile.Verification.Status != "ready" {
|
||||
continue
|
||||
}
|
||||
configured++
|
||||
route, err := s.Configure(ctx, actor, result.DeviceID, profile.Token)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
route, err = s.Reconcile(ctx, actor, route.ID)
|
||||
if err == nil && route.Actual == "ready" {
|
||||
ready++
|
||||
}
|
||||
}
|
||||
if configured == 0 {
|
||||
return admission.MediaOutcome{Status: "needs_attention", Detail: "未找到可用码流,请检查接入结果"}
|
||||
}
|
||||
if ready == configured {
|
||||
return admission.MediaOutcome{Status: "ready", Detail: "视频已进入实时监看"}
|
||||
}
|
||||
return admission.MediaOutcome{Status: "needs_attention", Detail: "摄像机已接入,媒体服务未就绪,请到视频服务查看并重试"}
|
||||
}
|
||||
func (s *Service) Configure(ctx context.Context, actor identity.Principal, deviceID, profileToken string) (Route, error) {
|
||||
if _, err := s.profile(deviceID, profileToken); err != nil {
|
||||
@@ -55,7 +107,7 @@ func (s *Service) Reconcile(ctx context.Context, actor identity.Principal, id st
|
||||
}
|
||||
if err := s.process.Start(ctx); err != nil {
|
||||
route.Actual = "process_failed"
|
||||
route.Detail = err.Error()
|
||||
route.Detail = "媒体进程未启动,请检查视频服务配置"
|
||||
route.Version++
|
||||
route.UpdatedAt = s.now().UTC()
|
||||
_ = s.store.Save(ctx, route)
|
||||
@@ -72,7 +124,7 @@ func (s *Service) Reconcile(ctx context.Context, actor identity.Principal, id st
|
||||
source := mediamtx.Source{Path: route.Path, URI: profile.StreamURI, Username: credential.Username, Password: credential.Password}
|
||||
if err := s.controller.Apply(ctx, source); err != nil {
|
||||
route.Actual = "apply_failed"
|
||||
route.Detail = err.Error()
|
||||
route.Detail = "媒体路径配置失败,请检查视频服务"
|
||||
route.Version++
|
||||
route.UpdatedAt = s.now().UTC()
|
||||
_ = s.store.Save(ctx, route)
|
||||
@@ -82,6 +134,10 @@ func (s *Service) Reconcile(ctx context.Context, actor identity.Principal, id st
|
||||
if err != nil {
|
||||
route.Actual = "unconverged"
|
||||
route.Detail = "尚未取得媒体状态"
|
||||
} else if !status.Exists {
|
||||
route.Actual = "apply_failed"
|
||||
route.Detail = "媒体路径不存在,请重新对账"
|
||||
route.Readers = 0
|
||||
} else if status.Ready {
|
||||
route.Actual = "ready"
|
||||
route.Detail = "上游拉流正常"
|
||||
@@ -99,6 +155,43 @@ func (s *Service) Reconcile(ctx context.Context, actor identity.Principal, id st
|
||||
identity.RecordAudit(ctx, actor.UserID, "media.reconcile", id, "success", map[string]any{"actual": route.Actual})
|
||||
return route, nil
|
||||
}
|
||||
|
||||
// Refresh reads the MediaMTX runtime state without reapplying configuration or
|
||||
// exposing the source URI. It is safe to call while a playback session polls.
|
||||
func (s *Service) Refresh(ctx context.Context, id string) (Route, error) {
|
||||
route, err := s.store.Get(ctx, id)
|
||||
if err != nil {
|
||||
return Route{}, err
|
||||
}
|
||||
if route.Desired != "running" {
|
||||
return route, nil
|
||||
}
|
||||
status, err := s.controller.Status(ctx, route.Path)
|
||||
if err != nil {
|
||||
return route, nil
|
||||
}
|
||||
actual := "waiting"
|
||||
detail := "等待播放器连接并按需拉流"
|
||||
if !status.Exists {
|
||||
actual = "apply_failed"
|
||||
detail = "媒体路径不存在,请重新对账"
|
||||
} else if status.Ready {
|
||||
actual = "ready"
|
||||
detail = "上游拉流正常"
|
||||
}
|
||||
if route.Actual == actual && route.Detail == detail && route.Readers == status.Readers {
|
||||
return route, nil
|
||||
}
|
||||
route.Actual = actual
|
||||
route.Detail = detail
|
||||
route.Readers = status.Readers
|
||||
route.Version++
|
||||
route.UpdatedAt = s.now().UTC()
|
||||
if err := s.store.Save(ctx, route); err != nil {
|
||||
return Route{}, err
|
||||
}
|
||||
return route, nil
|
||||
}
|
||||
func (s *Service) Stop(ctx context.Context, actor identity.Principal, id string) (Route, error) {
|
||||
route, err := s.store.Get(ctx, id)
|
||||
if err != nil {
|
||||
@@ -138,3 +231,4 @@ func safe(value string) string {
|
||||
return '_'
|
||||
}, value)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@ package media
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"yovision.local/sense/app/sense/adapters/mediamtx"
|
||||
"yovision.local/sense/app/sense/adapters/rtsp"
|
||||
"yovision.local/sense/app/sense/admission"
|
||||
"yovision.local/sense/app/sense/device"
|
||||
"yovision.local/sense/app/sense/identity"
|
||||
@@ -38,14 +40,22 @@ type fakeController struct {
|
||||
status mediamtx.PathStatus
|
||||
}
|
||||
|
||||
type refreshController struct{ status mediamtx.PathStatus }
|
||||
|
||||
func (f refreshController) Apply(context.Context, mediamtx.Source) error { return nil }
|
||||
func (f refreshController) Status(context.Context, string) (mediamtx.PathStatus, error) {
|
||||
return f.status, nil
|
||||
}
|
||||
|
||||
func (f fakeController) Apply(context.Context, mediamtx.Source) error { return f.applyErr }
|
||||
func (f fakeController) Status(context.Context, string) (mediamtx.PathStatus, error) {
|
||||
if f.status.Name == "error" {
|
||||
return mediamtx.PathStatus{}, errors.New("timeout")
|
||||
}
|
||||
if !f.status.Ready {
|
||||
return mediamtx.PathStatus{Ready: true, Readers: 2}, nil
|
||||
return mediamtx.PathStatus{Exists: true, Ready: true, Readers: 2}, nil
|
||||
}
|
||||
f.status.Exists = true
|
||||
return f.status, nil
|
||||
}
|
||||
|
||||
@@ -108,3 +118,79 @@ func TestReconcileRecordsFailureStates(t *testing.T) {
|
||||
t.Fatalf("result=%#v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureProfilesIsIdempotentAndReportsMediaState(t *testing.T) {
|
||||
process := &fakeProcess{startErr: errors.New("binary missing")}
|
||||
service := NewService(NewMemoryStore(), process, fakeController{})
|
||||
service.profile = func(_ string, token string) (admission.Profile, error) {
|
||||
return admission.Profile{Token: token, StreamURI: "rtsp://camera.invalid/" + token}, nil
|
||||
}
|
||||
service.credential = func(context.Context, string) (device.Credential, error) {
|
||||
return device.Credential{Username: "fixture", Password: "fixture"}, nil
|
||||
}
|
||||
result := admission.Result{DeviceID: "device", Profiles: []admission.Profile{{Token: "main", Verification: rtspReady()}, {Token: "sub", Verification: rtspReady()}}}
|
||||
outcome := service.ConfigureProfiles(context.Background(), identity.Principal{}, result)
|
||||
if outcome.Status != "needs_attention" {
|
||||
t.Fatalf("outcome=%#v", outcome)
|
||||
}
|
||||
outcome = service.ConfigureProfiles(context.Background(), identity.Principal{}, result)
|
||||
items, err := service.List(context.Background())
|
||||
if err != nil || len(items) != 2 || outcome.Status != "needs_attention" {
|
||||
t.Fatalf("items=%#v outcome=%#v err=%v", items, outcome, err)
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.Actual != "process_failed" || strings.Contains(item.Detail, "binary missing") {
|
||||
t.Fatalf("unsafe route detail=%#v", item)
|
||||
}
|
||||
}
|
||||
process.startErr = nil
|
||||
outcome = service.ConfigureProfiles(context.Background(), identity.Principal{}, result)
|
||||
if outcome.Status != "ready" {
|
||||
t.Fatalf("ready outcome=%#v", outcome)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshTracksOnDemandReaderWithoutReapplyingRoute(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
route := Route{ID: "device:main", DeviceID: "device", ProfileToken: "main", Path: "sense_device_main", Desired: "running", Actual: "waiting", Detail: "等待上游拉流", Version: 2}
|
||||
if err := store.Save(context.Background(), route); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(store, &fakeProcess{}, refreshController{status: mediamtx.PathStatus{Name: route.Path, Exists: true, Ready: true, Readers: 1}})
|
||||
result, err := service.Refresh(context.Background(), route.ID)
|
||||
if err != nil || result.Actual != "ready" || result.Readers != 1 || result.Detail != "上游拉流正常" {
|
||||
t.Fatalf("result=%#v err=%v", result, err)
|
||||
}
|
||||
unchanged, err := service.Refresh(context.Background(), route.ID)
|
||||
if err != nil || unchanged.Version != result.Version {
|
||||
t.Fatalf("unchanged=%#v err=%v", unchanged, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshReportsMissingPathInsteadOfWaiting(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
route := Route{ID: "device:main", Path: "sense_device_main", Desired: "running", Actual: "waiting", Version: 1}
|
||||
if err := store.Save(context.Background(), route); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(store, &fakeProcess{}, refreshController{status: mediamtx.PathStatus{Name: route.Path, Exists: false}})
|
||||
result, err := service.Refresh(context.Background(), route.ID)
|
||||
if err != nil || result.Actual != "apply_failed" || !strings.Contains(result.Detail, "不存在") {
|
||||
t.Fatalf("result=%#v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreReconcilesDesiredRoutes(t *testing.T) {
|
||||
process := &fakeProcess{}
|
||||
service, route := preparedService(t, process, fakeController{})
|
||||
if err := service.Restore(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restored, err := service.store.Get(context.Background(), route.ID)
|
||||
if err != nil || !process.State().Running || restored.Actual != "ready" {
|
||||
t.Fatalf("restored=%#v process=%#v err=%v", restored, process.State(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func rtspReady() rtsp.Result { return rtsp.Result{Status: "ready"} }
|
||||
|
||||
|
||||
@@ -6,13 +6,24 @@ import (
|
||||
"yovision.local/sense/app/sense/adapters/onvif"
|
||||
"yovision.local/sense/app/sense/adapters/rtsp"
|
||||
"yovision.local/sense/app/sense/admission"
|
||||
"yovision.local/sense/app/sense/media"
|
||||
"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"))
|
||||
var store admission.Store
|
||||
if app.Config().DatabaseMode == platform.DatabaseModeMemory {
|
||||
store = admission.NewMemoryStore()
|
||||
} else {
|
||||
store = admission.NewPostgresStore(app.Database())
|
||||
}
|
||||
app.RegisterMigration(platform.Migration{Version: 2026081302, Name: "sense_admission_profiles", SQL: admission.MigrationSQL})
|
||||
app.RegisterMigration(platform.Migration{Version: 2026081303, Name: "sense_admission_media_status", SQL: admission.MediaStatusMigrationSQL})
|
||||
service := admission.NewService(onvif.NewHTTPClient(8*time.Second), rtsp.NetVerifier{Timeout: 5 * time.Second}, os.Getenv("SENSE_ONVIF_DISCOVERY_IP"), store)
|
||||
service.SetReadyHandler(media.ConfigureReadyProfiles)
|
||||
admission.NewModule(service).Register(app)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,9 @@ func init() {
|
||||
store = device.NewPostgresStore(app.Database())
|
||||
}
|
||||
app.RegisterMigration(platform.Migration{Version: 2026081202, Name: "sense_device", SQL: device.MigrationSQL})
|
||||
app.RegisterMigration(platform.Migration{Version: 2026081301, Name: "sense_device_split_credentials", SQL: device.SplitCredentialMigrationSQL})
|
||||
device.NewModule(device.NewService(store, vault)).Register(app)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@ func init() {
|
||||
process := mediamtx.NewSupervisor(os.Getenv("SENSE_MEDIAMTX_BINARY"), os.Getenv("SENSE_MEDIAMTX_CONFIG"), 3)
|
||||
controller := mediamtx.NewHTTPController(valueOr("SENSE_MEDIAMTX_API", "http://127.0.0.1:9997"))
|
||||
app.RegisterMigration(platform.Migration{Version: 2026081203, Name: "sense_media", SQL: media.MigrationSQL})
|
||||
media.NewModule(media.NewService(store, process, controller)).Register(app)
|
||||
service := media.NewService(store, process, controller)
|
||||
media.NewModule(service).Register(app)
|
||||
app.RegisterStartup(service.Restore)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -28,3 +30,4 @@ func valueOr(key, fallback string) string {
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,9 @@ func Run() error {
|
||||
if err := app.ApplyMigrations(context.Background()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := app.Start(context.Background()); err != nil {
|
||||
return fmt.Errorf("start Sense modules: %w", err)
|
||||
}
|
||||
|
||||
server := &http.Server{
|
||||
Addr: cfg.HTTPAddress,
|
||||
@@ -77,3 +80,4 @@ func Run() error {
|
||||
return serveErr
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
@@ -17,6 +18,20 @@ type App struct {
|
||||
logger *slog.Logger
|
||||
mux *http.ServeMux
|
||||
migrations []Migration
|
||||
startups []func(context.Context) error
|
||||
}
|
||||
|
||||
func (a *App) RegisterStartup(startup func(context.Context) error) {
|
||||
a.startups = append(a.startups, startup)
|
||||
}
|
||||
|
||||
func (a *App) Start(ctx context.Context) error {
|
||||
for _, startup := range a.startups {
|
||||
if err := startup(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewApp(cfg Config, database *sql.DB, logger *slog.Logger) *App {
|
||||
@@ -101,3 +116,4 @@ func requestSecurityHeaders(next http.Handler) http.Handler {
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"lint": "eslint \"src/**/*.{js,vue}\"",
|
||||
"test:navigation": "node --test tests/navigation.test.cjs"
|
||||
"test:navigation": "node --test tests/navigation.test.cjs",
|
||||
"test:device-payload": "node --test tests/devicePayload.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "2.3.2",
|
||||
@@ -51,3 +52,4 @@
|
||||
"not dead"
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<template>
|
||||
<div class="stream-player">
|
||||
<div v-if="state === 'loading'" class="player-state"><el-icon class="is-loading" size="42"><Loading /></el-icon><strong>正在打开视频</strong><span>通常需要几秒钟</span></div>
|
||||
<div v-else-if="state !== 'ready'" class="player-state"><el-icon size="46"><WarningFilled /></el-icon><strong>{{ title }}</strong><span>{{ detail }}</span><el-button type="primary" @click="$emit('retry')">重新连接</el-button></div>
|
||||
<iframe v-else :key="playerUrl" :src="playerUrl" title="Sense 单路实时视频" allow="autoplay; fullscreen" @load="$emit('loaded')" />
|
||||
<iframe v-if="playable" :key="playerUrl" :src="playerUrl" title="Sense 单路实时视频" allow="autoplay; fullscreen" @load="$emit('loaded')" />
|
||||
<div v-else-if="state === 'loading'" class="player-state"><el-icon class="is-loading" size="42"><Loading /></el-icon><strong>正在打开视频</strong><span>通常需要几秒钟</span></div>
|
||||
<div v-else class="player-state"><el-icon size="46"><WarningFilled /></el-icon><strong>{{ title }}</strong><span>{{ detail }}</span><el-button type="primary" @click="$emit('retry')">重新连接</el-button></div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import{computed}from'vue'
|
||||
const props=defineProps({state:{type:String,default:'loading'},detail:{type:String,default:''},playerUrl:{type:String,default:''}});defineEmits(['retry','loaded'])
|
||||
const playable=computed(()=>Boolean(props.playerUrl)&&['waiting','ready'].includes(props.state))
|
||||
const title=computed(()=>({stopped:'视频已停止',process_failed:'视频服务未启动',apply_failed:'视频配置失败',unconverged:'视频状态未同步',waiting:'正在等待视频',expired:'播放会话已过期',offline:'视频已断开'})[props.state]||'暂时无法播放')
|
||||
</script>
|
||||
<style scoped>.stream-player{position:relative;width:100%;aspect-ratio:16/9;overflow:hidden;border-radius:4px;background:#101419}.stream-player iframe{width:100%;height:100%;border:0}.player-state{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;color:#c9cdd4}.player-state strong{color:#fff;font-size:18px}.player-state span{max-width:70%;text-align:center;font-size:13px}</style>
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<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="设备" prop="device_id"><el-select v-model="form.device_id" filterable placeholder="选择已登记的视频设备" style="width: 100%" @change="loadSavedResult"><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>
|
||||
@@ -19,6 +19,7 @@
|
||||
<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-alert v-if="result.media_status" :title="result.media_detail" :type="result.media_status === 'ready' ? 'success' : 'warning'" show-icon :closable="false" class="media-result" />
|
||||
<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" />
|
||||
@@ -38,15 +39,17 @@
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { listDevices } from '../../../api/sense/device'
|
||||
import { discoverDevices, probeDevice } from '../../../api/sense/admission'
|
||||
import { admissionResult, 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 loadSavedResult(){Object.keys(result).forEach(key=>delete result[key]);if(!form.device_id)return;try{Object.assign(result,await admissionResult(form.device_id))}catch{/* 尚未接入时保持空状态 */}}
|
||||
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>
|
||||
<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}.media-result{margin-bottom:16px}</style>
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<el-table-column prop="location" label="安装位置" min-width="150" />
|
||||
<el-table-column label="类型" width="110"><template #default="scope">{{ scope.row.modality === 'video' ? '视频设备' : scope.row.modality }}</template></el-table-column>
|
||||
<el-table-column label="接入能力" width="140"><template #default="scope"><el-tag :type="scope.row.adapter_status === 'ready' ? 'success' : 'warning'">{{ scope.row.adapter_status === 'ready' ? '可接入' : '适配器未就绪' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="凭据" width="110"><template #default="scope"><el-tag :type="scope.row.credential_configured ? 'success' : 'info'">{{ scope.row.credential_configured ? '已配置' : '未配置' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="设备凭据" width="150"><template #default="scope"><el-tag :type="scope.row.credential_configured && scope.row.rtsp_credential_configured ? 'success' : 'info'">{{ scope.row.credential_configured && scope.row.rtsp_credential_configured ? '已配置' : '未完整配置' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="状态" width="100"><template #default="scope"><el-tag :type="scope.row.status === 'disabled' ? 'info' : 'primary'">{{ statusLabel(scope.row.status) }}</el-tag></template></el-table-column>
|
||||
<el-table-column v-if="canWrite" label="操作" width="210" fixed="right"><template #default="scope"><el-button link type="primary" @click="openEdit(scope.row)">编辑</el-button><el-button link type="primary" @click="openCredential(scope.row)">更新凭据</el-button><el-button v-if="scope.row.status !== 'disabled'" link type="danger" @click="disable(scope.row)">停用</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
@@ -33,8 +33,13 @@
|
||||
<el-dialog v-model="credentialDialog" title="更新设备凭据" width="520px" destroy-on-close>
|
||||
<el-alert title="凭据保存后不能查看,只能再次更新。请勿把密码写入设备地址。" type="warning" show-icon :closable="false" />
|
||||
<el-form ref="credentialFormRef" :model="credentialForm" :rules="credentialRules" label-width="82px" class="credential-form">
|
||||
<el-form-item label="用户名" prop="username"><el-input v-model="credentialForm.username" autocomplete="off" /></el-form-item>
|
||||
<el-form-item label="密码" prop="password"><el-input v-model="credentialForm.password" type="password" show-password autocomplete="new-password" /></el-form-item>
|
||||
<el-form-item label="ONVIF 用户名" prop="onvif_username"><el-input v-model="credentialForm.onvif_username" autocomplete="off" /></el-form-item>
|
||||
<el-form-item label="ONVIF 密码" prop="onvif_password"><el-input v-model="credentialForm.onvif_password" type="password" show-password autocomplete="new-password" /></el-form-item>
|
||||
<el-form-item label-width="0"><el-checkbox v-model="credentialForm.rtsp_same_as_onvif">RTSP 与 ONVIF 使用相同账号</el-checkbox></el-form-item>
|
||||
<template v-if="!credentialForm.rtsp_same_as_onvif">
|
||||
<el-form-item label="RTSP 用户名" prop="rtsp_username"><el-input v-model="credentialForm.rtsp_username" autocomplete="off" /></el-form-item>
|
||||
<el-form-item label="RTSP 密码" prop="rtsp_password"><el-input v-model="credentialForm.rtsp_password" type="password" show-password autocomplete="new-password" /></el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="credentialDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveCredential">安全保存</el-button></template>
|
||||
</el-dialog>
|
||||
@@ -47,6 +52,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Search } from '@element-plus/icons-vue'
|
||||
import { useStore } from 'vuex'
|
||||
import { createDevice, disableDevice, listDevices, updateCredential, updateDevice } from '../../../api/sense/device'
|
||||
import { createDevicePayload, updateDevicePayload } from './devicePayload.mjs'
|
||||
|
||||
const store = useStore()
|
||||
const loading = ref(false), saving = ref(false), deviceDialog = ref(false), credentialDialog = ref(false)
|
||||
@@ -54,18 +60,18 @@ const editing = ref(null), credentialTarget = ref(null), deviceFormRef = ref(),
|
||||
const query = reactive({ keyword: '', page: 1, page_size: 20 })
|
||||
const page = reactive({ items: [], total: 0 })
|
||||
const deviceForm = reactive({ name: '', location: '', modality: 'video', capabilities: ['video'] })
|
||||
const credentialForm = reactive({ username: '', password: '' })
|
||||
const credentialForm = reactive({ onvif_username: '', onvif_password: '', rtsp_same_as_onvif: true, rtsp_username: '', rtsp_password: '' })
|
||||
const deviceRules = { name: [{ required: true, message: '请输入设备名称', trigger: 'blur' }], modality: [{ required: true, message: '请选择类型', trigger: 'change' }] }
|
||||
const credentialRules = { username: [{ required: true, message: '请输入用户名', trigger: 'blur' }], password: [{ required: true, message: '请输入密码', trigger: 'blur' }] }
|
||||
const credentialRules = { onvif_username: [{ required: true, message: '请输入 ONVIF 用户名', trigger: 'blur' }], onvif_password: [{ required: true, message: '请输入 ONVIF 密码', trigger: 'blur' }], rtsp_username: [{ validator: (_r, value, done) => !credentialForm.rtsp_same_as_onvif && !value ? done(new Error('请输入 RTSP 用户名')) : done(), trigger: 'blur' }], rtsp_password: [{ validator: (_r, value, done) => !credentialForm.rtsp_same_as_onvif && !value ? done(new Error('请输入 RTSP 密码')) : done(), trigger: 'blur' }] }
|
||||
const canWrite = computed(() => store.getters['sense-identity/hasPermission']?.('device.write'))
|
||||
function statusLabel(value) { return ({ pending: '待接入', active: '正常', offline: '离线', disabled: '已停用' })[value] || value }
|
||||
async function load() { loading.value = true; try { Object.assign(page, await listDevices(query)) } finally { loading.value = false } }
|
||||
function reset() { Object.assign(query, { keyword: '', page: 1, page_size: 20 }); load() }
|
||||
function openCreate() { editing.value = null; Object.assign(deviceForm, { name: '', location: '', modality: 'video', capabilities: ['video'] }); deviceDialog.value = true }
|
||||
function openEdit(row) { editing.value = row; Object.assign(deviceForm, { name: row.name, location: row.location, modality: row.modality, capabilities: row.capabilities }); deviceDialog.value = true }
|
||||
function openCredential(row) { credentialTarget.value = row; Object.assign(credentialForm, { username: '', password: '' }); credentialDialog.value = true }
|
||||
async function saveDevice() { const valid = await deviceFormRef.value?.validate().catch(() => false); if (!valid) return; saving.value = true; try { if (editing.value) await updateDevice(editing.value.id, { ...deviceForm, version: editing.value.version }); else await createDevice(deviceForm); ElMessage.success('设备已保存'); deviceDialog.value = false; await load() } catch (error) { ElMessage.error(error.message || '保存失败') } finally { saving.value = false } }
|
||||
async function saveCredential() { const valid = await credentialFormRef.value?.validate().catch(() => false); if (!valid) return; saving.value = true; try { await updateCredential(credentialTarget.value.id, credentialForm); Object.assign(credentialForm, { username: '', password: '' }); ElMessage.success('凭据已安全更新'); credentialDialog.value = false; await load() } catch (error) { ElMessage.error(error.message || '更新失败') } finally { saving.value = false } }
|
||||
function openCredential(row) { credentialTarget.value = row; Object.assign(credentialForm, { onvif_username: '', onvif_password: '', rtsp_same_as_onvif: row.rtsp_credential_same_as_onvif !== false, rtsp_username: '', rtsp_password: '' }); credentialDialog.value = true }
|
||||
async function saveDevice() { const valid = await deviceFormRef.value?.validate().catch(() => false); if (!valid) return; saving.value = true; try { if (editing.value) await updateDevice(editing.value.id, updateDevicePayload(deviceForm, editing.value.version)); else await createDevice(createDevicePayload(deviceForm)); ElMessage.success('设备已保存'); deviceDialog.value = false; await load() } catch (error) { ElMessage.error(error.message || '保存失败') } finally { saving.value = false } }
|
||||
async function saveCredential() { const valid = await credentialFormRef.value?.validate().catch(() => false); if (!valid) return; saving.value = true; try { await updateCredential(credentialTarget.value.id, credentialForm); Object.assign(credentialForm, { onvif_username: '', onvif_password: '', rtsp_same_as_onvif: true, rtsp_username: '', rtsp_password: '' }); ElMessage.success('ONVIF 与 RTSP 凭据已安全更新'); credentialDialog.value = false; await load() } catch (error) { ElMessage.error(error.message || '更新失败') } finally { saving.value = false } }
|
||||
async function disable(row) { await ElMessageBox.confirm(`停用“${row.name}”后将停止后续接入,设备记录和审计仍保留。`, '确认停用', { type: 'warning' }); await disableDevice(row.id, row.version); ElMessage.success('设备已停用'); await load() }
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -73,3 +79,4 @@ onMounted(load)
|
||||
<style scoped>
|
||||
.credential-form { margin-top: 20px; }
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export function createDevicePayload(form) {
|
||||
return {
|
||||
name: form.name,
|
||||
location: form.location,
|
||||
modality: form.modality,
|
||||
capabilities: [...form.capabilities]
|
||||
}
|
||||
}
|
||||
|
||||
export function updateDevicePayload(form, version) {
|
||||
return {
|
||||
name: form.name,
|
||||
location: form.location,
|
||||
capabilities: [...form.capabilities],
|
||||
version
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
<section class="page-container">
|
||||
<div class="page-heading"><div><h1>实时监看</h1><p>一次打开一路视频,可在已验证的主、子码流之间切换。</p></div><el-tag v-if="session.expires_at" type="info">会话短期有效</el-tag></div>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="7"><el-card shadow="never"><template #header><strong>选择视频</strong></template><el-form label-position="top"><el-form-item label="设备与 Profile"><el-select v-model="selectedRoute" filterable placeholder="选择一路视频" style="width:100%" @change="open"><el-option v-for="item in routes" :key="item.id" :label="`${item.device_id} · ${profileLabel(item.profile_token)}`" :value="item.id"><span>{{ item.device_id }}</span><span class="option-detail">{{ profileLabel(item.profile_token) }} · {{ stateLabel(item.actual) }}</span></el-option></el-select></el-form-item></el-form><el-descriptions v-if="current" :column="1" border><el-descriptions-item label="设备">{{ current.device_id }}</el-descriptions-item><el-descriptions-item label="Profile">{{ profileLabel(current.profile_token) }}</el-descriptions-item><el-descriptions-item label="媒体状态"><el-tag :type="current.actual === 'ready' ? 'success' : 'warning'">{{ stateLabel(current.actual) }}</el-tag></el-descriptions-item><el-descriptions-item label="观看连接">{{ current.readers }}</el-descriptions-item></el-descriptions><el-alert v-else title="先在视频服务中建立并对账媒体路径" type="info" show-icon :closable="false" /></el-card></el-col>
|
||||
<el-col :span="7"><el-card shadow="never"><template #header><strong>选择视频</strong></template><el-form label-position="top"><el-form-item label="设备与码流"><el-select v-model="selectedRoute" filterable placeholder="选择一路视频" style="width:100%" @change="open"><el-option v-for="item in routes" :key="item.id" :label="`${item.device_name} · ${profileLabel(item)}`" :value="item.id"><span>{{ item.device_name }}</span><span class="option-detail">{{ profileLabel(item) }} · {{ stateLabel(item.actual) }}</span></el-option></el-select></el-form-item></el-form><el-descriptions v-if="current" :column="1" border><el-descriptions-item label="设备">{{ current.device_name }}</el-descriptions-item><el-descriptions-item label="位置">{{ current.device_location || '未填写' }}</el-descriptions-item><el-descriptions-item label="码流">{{ profileLabel(current) }}</el-descriptions-item><el-descriptions-item label="媒体状态"><el-tag :type="current.actual === 'ready' ? 'success' : 'warning'">{{ stateLabel(current.actual) }}</el-tag></el-descriptions-item><el-descriptions-item label="观看连接">{{ current.readers }}</el-descriptions-item></el-descriptions><el-alert v-else title="请先在视频接入完成检查;系统会自动建立媒体路径。若仍无视频,请到视频服务排错。" type="info" show-icon :closable="false" /></el-card></el-col>
|
||||
<el-col :span="17"><el-card shadow="never"><template #header><div class="player-header"><strong>单路画面</strong><el-button v-if="selectedRoute" :icon="Refresh" @click="retry">重新连接</el-button></div></template><StreamPlayer :state="playerState" :detail="playerDetail" :player-url="session.player_url" @retry="retry" /></el-card></el-col>
|
||||
</el-row>
|
||||
</section>
|
||||
</template>
|
||||
<script setup>
|
||||
import{computed,onBeforeUnmount,onMounted,reactive,ref}from'vue';import{ElMessage}from'element-plus';import{Refresh}from'@element-plus/icons-vue';import{createSession,getSession,playbackRoutes}from'../../../api/sense/liveview';import StreamPlayer from'../../../components/sense/liveview/StreamPlayer.vue'
|
||||
const routes=ref([]),selectedRoute=ref(''),session=reactive({}),playerState=ref('waiting'),playerDetail=ref('请选择一路视频'),timer=ref();const current=computed(()=>routes.value.find(item=>item.id===selectedRoute.value));function profileLabel(value){return value?.toLowerCase().includes('sub')?'子码流':value?.toLowerCase().includes('main')?'主码流':value}function stateLabel(value){return({ready:'正常',waiting:'等待视频',stopped:'已停止',process_failed:'服务未启动',apply_failed:'配置失败',unconverged:'状态未同步'})[value]||value}
|
||||
const routes=ref([]),selectedRoute=ref(''),session=reactive({}),playerState=ref('waiting'),playerDetail=ref('请选择一路视频'),timer=ref();const current=computed(()=>routes.value.find(item=>item.id===selectedRoute.value));function profileLabel(item){const kind=({main:'主码流',sub:'子码流',other:'其他码流'})[item?.profile_kind];return kind?`${kind}${item.profile_name?`(${item.profile_name})`:''}`:(item?.profile_name||item?.profile_token)}function stateLabel(value){return({ready:'正常',waiting:'等待视频',stopped:'已停止',process_failed:'服务未启动',apply_failed:'配置失败',unconverged:'状态未同步'})[value]||value}
|
||||
async function load(){routes.value=(await playbackRoutes()).items||[]}
|
||||
async function open(){if(!selectedRoute.value)return;playerState.value='loading';try{Object.assign(session,await createSession(selectedRoute.value));playerState.value=session.status==='ready'?'ready':session.status;playerDetail.value=session.detail;startPolling()}catch(error){playerState.value='offline';playerDetail.value=error.message||'无法创建播放会话'}}
|
||||
function startPolling(){clearInterval(timer.value);timer.value=setInterval(async()=>{if(!session.id)return;try{const latest=await getSession(session.id);Object.assign(session,latest);playerState.value=latest.status==='ready'?'ready':latest.status;playerDetail.value=latest.detail}catch{playerState.value='expired';playerDetail.value='播放会话已过期,请重新连接';clearInterval(timer.value)}},5000)}
|
||||
@@ -17,3 +17,4 @@ async function retry(){if(!selectedRoute.value){ElMessage.info('请先选择视
|
||||
onMounted(load);onBeforeUnmount(()=>clearInterval(timer.value))
|
||||
</script>
|
||||
<style scoped>.player-header{display:flex;align-items:center;justify-content:space-between}.option-detail{float:right;color:#86909c;font-size:12px}</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { createDevicePayload, updateDevicePayload } from '../src/views/sense/device/devicePayload.mjs'
|
||||
|
||||
const form = {
|
||||
name: '东门摄像机',
|
||||
location: '教学楼一楼东门',
|
||||
modality: 'video',
|
||||
capabilities: ['video'],
|
||||
id: 'read-only-id'
|
||||
}
|
||||
|
||||
test('create payload keeps Chinese values and includes modality', () => {
|
||||
assert.deepEqual(createDevicePayload(form), {
|
||||
name: '东门摄像机',
|
||||
location: '教学楼一楼东门',
|
||||
modality: 'video',
|
||||
capabilities: ['video']
|
||||
})
|
||||
})
|
||||
|
||||
test('update payload uses the PATCH allowlist and excludes modality', () => {
|
||||
const payload = updateDevicePayload(form, 9)
|
||||
assert.deepEqual(payload, {
|
||||
name: '东门摄像机',
|
||||
location: '教学楼一楼东门',
|
||||
capabilities: ['video'],
|
||||
version: 9
|
||||
})
|
||||
assert.equal('modality' in payload, false)
|
||||
assert.equal('id' in payload, false)
|
||||
})
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: 1712a8053f365781aef9cbf33c576c97491f31d4
|
||||
synchronized_at: 2026-08-12T10:21:55Z
|
||||
wiki_revision: e6bb3132a752cd8036cab29429cbf3f49e85bfbf
|
||||
synchronized_at: 2026-08-13T14:06:00Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -100,13 +100,13 @@ Sense/Brain 生成事件
|
||||
Sense 后端功能以 `Sense/server/app/sense/` 为根,并通过 `Sense/server/cmd/sense/modules_<feature>.go` 独立注册:
|
||||
|
||||
- `identity/`:Sense 独立账户、bcrypt 密码、会话、四角色 RBAC 与统一审计;签发者和受众只属于 Sense。
|
||||
- `device/`:Device 台账、状态、分页和 AES-256-GCM 凭据保险箱;读取模型只返回 `credential_configured`。
|
||||
- `adapters/onvif/`、`adapters/rtsp/`、`admission/`:获准网卡上的受控发现、手工 ONVIF 接入、Profile/StreamUri 读取和 RTSP 验证。
|
||||
- `adapters/mediamtx/`、`media/`:外部 MediaMTX 进程所有权、localhost Control API、媒体期望态与实际态对账。
|
||||
- `liveview/`:绑定当前用户、最长两分钟的单路播放会话;只投影媒体路径,不暴露源 URI 或摄像机秘密。
|
||||
- `device/`:Device 台账、状态、分页和 AES-256-GCM 凭据保险箱;ONVIF 与 RTSP 凭据可分离或显式复用,读取模型只返回两组凭据是否已配置。
|
||||
- `adapters/onvif/`、`adapters/rtsp/`、`admission/`:获准网卡上的受控发现、手工 ONVIF 接入、Media 服务发现、Basic/Digest 认证、Profile/StreamUri 读取和 RTSP 验证。跨主机 Media 地址固定回用户已授权的 Device Service origin;跨主机 RTSP URI 只替换为授权主机并保留报告端口与路径。接入结果和脱敏 Profile 持久化到 PostgreSQL,重启后可恢复。
|
||||
- `adapters/mediamtx/`、`media/`:外部 MediaMTX 进程所有权、localhost Control API、媒体期望态与实际态对账;接入验证出可用 Profile 后自动按 Device/Profile 幂等建立并对账媒体路径。首次路径使用 add、已有路径使用 replace,控制 API 启动竞态在限定时间内重试;Sense 完成数据库迁移后自动恢复 `desired=running` 路径。路径不存在必须报告配置失败,不能伪装为 waiting。
|
||||
- `liveview/`:绑定当前用户、最长两分钟的单路播放会话;列表投影设备名称、位置、码流名称与用途,只在内部保留 ID,不暴露源 URI 或摄像机秘密。`waiting` 会话立即加载播放器以触发 MediaMTX 按需拉流,会话轮询只读刷新媒体实际状态。播放器包装页仅允许被 Sense 同源页面嵌入(`SAMEORIGIN` 与 `frame-ancestors 'self'`),其他页面继续使用全局 `DENY`。iframe 导航使用有效 Sense 会话 Cookie、媒体读取权限及同源 iframe Fetch Metadata 认证,不依赖浏览器导航无法附加的 `X-Product` 请求头;普通 API 仍要求产品头。
|
||||
- `area/`:归一化多边形/方向警戒线、不可变版本、并发版本校验和分辨率变化后的重新校准。
|
||||
|
||||
前端在 `Sense/ui/src/{api,views,router/modules,components}/sense/` 使用对应模块;通用页面复用 Element Plus 表单、表格、分页、Dialog、Tag 和应用容器,只为播放器与区域画布新增局部业务组件。项目内 `identity.RecordAudit`、`device.ReadCredential`、`admission.VerifiedProfile`、`media.PlaybackRoute` 和 `area.ExportCurrent` 是窄适配端口,不是跨项目契约。
|
||||
前端在 `Sense/ui/src/{api,views,router/modules,components}/sense/` 使用对应模块;通用页面复用 Element Plus 表单、表格、分页、Dialog、Tag 和应用容器,只为播放器与区域画布新增局部业务组件。项目内 `identity.RecordAudit`、`device.ReadRTSPCredential`、`device.Describe`、`admission.VerifiedProfile`、`media.PlaybackRoute` 和 `area.ExportCurrent` 是窄适配端口,不是跨项目契约。
|
||||
<!-- sense-mvp:end -->
|
||||
|
||||
|
||||
@@ -124,3 +124,4 @@ Sense 后端功能以 `Sense/server/app/sense/` 为根,并通过 `Sense/server
|
||||
|
||||
Event 写入、幂等 Receipt、规则评估和 Alert 创建/关联处于同一数据库事务;规则或关联失败时不保留半成品 Event。一个 Event 可匹配多个规则,一个未关闭 Alert 可聚合相同规则与地点的多个 Event。
|
||||
<!-- bell-mvp:end -->
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: c9d47108e45f66875726e3eef79eedb2a5942514
|
||||
synchronized_at: 2026-08-13T01:15:01Z
|
||||
wiki_revision: f9c089d2b993478e586f4abdb1845952a3cd3c51
|
||||
synchronized_at: 2026-08-13T14:37:00Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -73,11 +73,12 @@ synchronized_at: 2026-08-13T01:15:01Z
|
||||
- **Sense 账户**:只登录 Sense;不得接受 Bell JWT、Cookie 或用户数据。角色为系统管理员、实施/运维、站点管理员和只读用户,后端权限是最终边界。
|
||||
- **安全初始化**:系统不提供默认账户、默认密码或默认签名密钥;首次管理员由仓库外一次性令牌创建。所有模式的密码仅要求至少 6 个字符,不限制字符种类并允许包含用户名。负责人已明确接受该生产密码策略的字典猜测与凭据填充风险。
|
||||
- **Device**:设备不可变逻辑 ID 是后续 Profile、媒体和区域的内部引用。非视频适配器未实现时必须显示 `adapter_not_ready`。
|
||||
- **摄像机凭据**:只写不读,使用仓库外 32 字节密钥加密;不得进入 URL、日志、审计、工单或响应。
|
||||
- **摄像机凭据**:ONVIF 与 RTSP 可使用不同账号,也可显式复用;两组均只写不读,使用仓库外 32 字节密钥分别加密。密码不得进入 URL、日志、审计、工单或响应。
|
||||
- **受控发现**:ONVIF Discovery 默认关闭,只有显式设置获准本机 IP 才能发送发现;不得扫描未授权网段。
|
||||
- **Profile**:主辅码流按分辨率分类并分别验证;认证失败、不可达、超时与校时问题使用可定位状态。
|
||||
- **Profile**:主辅码流按分辨率分类并使用 RTSP 凭据分别验证;脱敏 Stream URI、验证状态和时间持久化,重启后保留。至少一个 Profile 验证成功后 Device 进入 `active`;认证失败、不可达、超时与校时问题使用可定位状态。
|
||||
- **自动媒体路径**:接入检查中每个验证成功的 Profile 都按 Device/Profile 幂等建立并立即对账媒体路径;首次配置使用 MediaMTX add、已有配置使用 replace,Sense 启动时从数据库恢复所有 `desired=running` 路径;路径不存在属于配置失败,不属于等待拉流。MediaMTX 不可用不回滚摄像机接入,接入结果记录“需要处理”并引导到视频服务排错。
|
||||
- **MediaMTX**:保持外部进程。Sense 只停止自己启动并持有句柄的进程,最多自动重启三次;摄像机凭据只在 localhost 控制请求中瞬时组装,不持久化、不返回。
|
||||
- **播放会话**:由当前 Sense 用户创建,最长两分钟;设备分页和媒体路径不以 16 路作为硬上限,页面一次只打开一路流。
|
||||
- **播放会话**:由当前 Sense 用户创建,最长两分钟;实时监看以设备名称、位置和主/子码流等业务标签供用户选择,内部 ID 只用于系统关联;`waiting` 表示等待第一个播放器触发按需拉流,不是播放失败,播放器连接后会话轮询刷新为实际状态;播放器导航必须携带有效 Sense 会话 Cookie,且 Fetch Metadata 必须表明是同源 iframe,普通 API 的 `X-Product` 边界不变;设备分页和媒体路径不以 16 路作为硬上限,页面一次只打开一路流。
|
||||
- **区域版本**:坐标为 0..1 归一化值,并绑定 Device、Profile、宽高。每次发布或停用形成新版本;范围、点数、自交、退化、方向和期望版本由后端校验。Profile 分辨率变化后旧版本必须标记为需要重新校准。
|
||||
<!-- sense-mvp:end -->
|
||||
|
||||
@@ -96,3 +97,8 @@ synchronized_at: 2026-08-13T01:15:01Z
|
||||
- **close**:只有处置人或管理员可以完成;必须选择“确认有危险、误报、现场正常、无法确认”之一,可附备注。相同重复请求幂等,其他改变结果的请求被拒绝。
|
||||
- **审计事实**:成功生命周期事实只追加;失败、重复和拒绝尝试写入安全审计,但不记录令牌、密码或连接密钥。
|
||||
<!-- bell-mvp:end -->
|
||||
## 设备编辑请求边界
|
||||
|
||||
- 新建设备请求显式提交名称、安装位置、设备类型和能力;编辑设备请求只提交名称、安装位置、能力和版本,不提交新建专用的 `modality` 或响应中的只读字段。
|
||||
- 设备名称和安装位置允许 UTF-8 中文;后端继续严格拒绝未知 JSON 字段,前端必须使用字段白名单构造请求,不得直接展开整个表单或响应对象。
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Local-Development-and-Verification
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Local-Development-and-Verification.-
|
||||
wiki_revision: 4f3e1f218d29e17a3f8fabd43316d1a562c69d07
|
||||
synchronized_at: 2026-08-13T02:35:03Z
|
||||
wiki_revision: 426001a8c28bde9c336ecc8b7166924f14aa55ae
|
||||
synchronized_at: 2026-08-13T03:39:26Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -73,8 +73,8 @@ Sense/Bell 的 Go、Node 与 pnpm 基线已冻结并记录于下文;Brain 的
|
||||
|
||||
## 测试数据与日志
|
||||
|
||||
- 只使用合成或脱敏事件、合成 RTSP 和明确授权的实验室设备。
|
||||
- 不提交真实视频、客户名称、地址、手机号、摄像头密码或通知凭据。
|
||||
- 只使用合成或脱敏事件、合成 RTSP 和明确授权的实验室设备。ONVIF 自动化测试需覆盖 Basic、Digest challenge、Media 服务发现、跨主机地址归一化、拒绝地址凭据和禁止重定向。
|
||||
- 不提交真实视频、客户名称、地址、手机号、摄像头密码或通知凭据;真实设备验证只记录状态与 Profile 数量,不记录设备地址、Authorization 或 Stream URI。
|
||||
- 日志必须可按 request/event/alert ID 追踪,但不得记录 Authorization、Cookie 或连接密钥。
|
||||
|
||||
## 完成修改前
|
||||
@@ -171,3 +171,4 @@ Get-FileHash .\Sense\dist\sense-windows-amd64.zip -Algorithm SHA256
|
||||
|
||||
解压后运行 `start-sense.bat demo` 可做内存模式临时预览。生产配置可写入运行目录的 `config\sense.env`,也可通过 Windows 进程环境注入;非空进程环境变量优先,启动器只读取 `SENSE_*` 键且不打印配置值。运行 `start-sense.bat check` 可在不连接数据库、不启动服务的情况下检查必填配置,然后用 `start-sense.bat` 启动生产模式。包内不含 PostgreSQL、MediaMTX、系统服务、客户数据或秘密,真实 `sense.env` 不得提交或重新打入交付 ZIP。
|
||||
<!-- sense-windows-package:end -->
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Troubleshooting
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Troubleshooting
|
||||
wiki_revision: 84d5d336475613d77030a7c722ea9c4d18b307fe
|
||||
synchronized_at: 2026-08-13T02:35:08Z
|
||||
wiki_revision: 7deb549d8b0ae87cdca0c7494e36150d261248a1
|
||||
synchronized_at: 2026-08-13T14:37:00Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 故障排查
|
||||
@@ -50,11 +50,15 @@ synchronized_at: 2026-08-13T02:35:08Z
|
||||
| 管理员登录后侧栏没有模块 | 先确认 /api/v1/identity/me 返回角色与权限;若权限正常,检查前端是否从具有 children 的应用布局路由派生菜单,不得依赖重复 / 路由记录顺序。 |
|
||||
| `adapter_not_ready` | 当前设备类型尚无适配器,不代表网络故障;首期完整支持 video。 |
|
||||
| `discovery_unavailable` | 未设置获准的 `SENSE_ONVIF_DISCOVERY_IP`,或该 IP 不属于本机网卡。可改用手工 ONVIF 地址。 |
|
||||
| `authentication_failed` | 在设备管理中重新写入凭据后再次执行接入检查;不要把凭据写进地址。 |
|
||||
| `authentication_failed` | 在设备管理中分别检查 ONVIF 与 RTSP 凭据,只有设备确实共用账号时才勾选“RTSP 与 ONVIF 使用相同账号”;不要把凭据写进地址。Sense 支持 ONVIF Basic 与 Digest,并会安全归一化摄像机广播的跨主机 Media/RTSP 地址。 |
|
||||
| `clock_skew` | 校准摄像机时间后重新探测。 |
|
||||
| `process_failed` | 检查仓库外 `SENSE_MEDIAMTX_BINARY`、基础配置和进程退出原因;达到三次重启上限后需人工处理。 |
|
||||
| 接入成功但提示“媒体服务未就绪” / `process_failed` | 接入资料和 Profile 已保存,实时监看路径也已建立;检查仓库外 `SENSE_MEDIAMTX_BINARY`、基础配置和进程退出原因,修复后到视频服务重新对账。达到三次重启上限后需人工处理。 |
|
||||
| `apply_failed` / `unconverged` | 检查 localhost Control API 是否启用并为 v3;确认媒体路径和外部进程状态。 |
|
||||
| 画面等待、断开或会话过期 | 先在视频服务执行对账,再重新连接;播放会话最长两分钟。 |
|
||||
| 实时监看没有设备 | 先在视频接入完成一次检查;系统会自动建立媒体路径。若已接入仍为空,检查接入结果的媒体状态和视频服务对账。 |
|
||||
| 实时监看提示“127.0.0.1 拒绝了我们的连接请求” | 先确认播放器接口响应头;该接口必须是 `X-Frame-Options: SAMEORIGIN` 且 CSP 包含 `frame-ancestors 'self'`,普通页面仍应为 `DENY`。播放器接口还必须允许不带 `X-Product`、但带有效 Sense Cookie 和同源 iframe Fetch Metadata 的浏览器导航;普通 API 仍应拒绝缺少产品头的请求。旧运行包会在响应头或认证中间件处阻止 iframe,需重新打包并重启 Sense。 |
|
||||
| 播放器提示 `stream not found` | MediaMTX 中没有对应配置路径。检查 Control API 配置列表是否包含 Sense 路径;新版本会在 Sense 启动时自动恢复 `desired=running` 路径,并在控制端口尚未就绪时限时重试。路径缺失必须显示配置失败,不能仅显示等待拉流。 |
|
||||
| 实时监看持续“等待拉流” | `waiting` 会话应立即加载播放器以触发按需拉流。若连接后仍等待,检查 MediaMTX 路径 readers 和源状态;`401 Unauthorized` 表示摄像机拒绝当前 RTSP 凭据,应在设备管理修正凭据后重新执行视频接入,不能把凭据写入日志或地址。 |
|
||||
| 画面等待、断开或会话过期 | 查看实时监看中的业务设备名和码流状态;会话轮询会只读刷新 MediaMTX 实际状态,无需手工对账。播放会话最长两分钟。 |
|
||||
| 区域提示需要重新校准 | Profile 分辨率已变化,按新画面重新绘制并发布新版本,不能静默复用旧坐标。 |
|
||||
|
||||
自动测试不访问真实摄像头或未授权网络。PostgreSQL、MediaMTX、目标浏览器与实验室摄像机的联合验证必须在获准部署环境完成。
|
||||
@@ -72,3 +76,9 @@ synchronized_at: 2026-08-13T02:35:08Z
|
||||
|
||||
`demo` 只用于临时查看。生产数据持久性、真实设备和媒体链路不能用 demo 验证替代。
|
||||
<!-- sense-windows-package:end -->
|
||||
## 设备管理
|
||||
|
||||
| 现象 | 处理 |
|
||||
|---|---|
|
||||
| 编辑设备时提示“请求内容格式不正确” | 中文名称和安装位置本身受支持。检查 PATCH payload 是否误带只允许新建时提交的 `modality` 或其他未知字段;编辑请求应只包含 `name`、`location`、`capabilities`、`version`。不要通过放宽后端未知字段校验绕过前端契约错误。 |
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-48-支持ONVIF-Digest认证与安全Media地址归一化
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-48-%E6%94%AF%E6%8C%81ONVIF-Digest%E8%AE%A4%E8%AF%81%E4%B8%8E%E5%AE%89%E5%85%A8Media%E5%9C%B0%E5%9D%80%E5%BD%92%E4%B8%80%E5%8C%96.-
|
||||
wiki_revision: 16667fcc50dac28098a3c4b5b9018bac1ff249be
|
||||
synchronized_at: 2026-08-13T03:46:29Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 48 支持ONVIF Digest认证与安全Media地址归一化
|
||||
|
||||
- 类型:缺陷
|
||||
- 所属 Epic:#7
|
||||
- 所属 MVP / 版本:#8
|
||||
- 状态:待验收
|
||||
- 日期:2026-08-13
|
||||
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/48
|
||||
- Wiki 页面:Task-48-支持ONVIF-Digest认证与安全Media地址归一化
|
||||
- Wiki revision:见本地镜像头
|
||||
|
||||
## 背景与目标
|
||||
|
||||
真实摄像机的 Device Service 可以访问,但 Media Service 要求 Digest Authentication,并广播了当前主机无法访问的 Media 地址。旧版 Sense 只预发送 Basic,并把 GetProfiles 发往 Device Service,无法读取 Profile。本任务在不泄露设备信息的前提下兼容该设备。
|
||||
|
||||
## 最终方案
|
||||
|
||||
- 先向用户填写的 Device Service 请求 GetCapabilities,解析 Media XAddr,再向 Media Service请求 GetProfiles 与 GetStreamUri。
|
||||
- 收到 Digest challenge 时仅重试一次,支持 MD5、SHA-256 和 qop=auth;拒绝缺失必填参数、不支持的算法和 qop。
|
||||
- Media XAddr 与 Device Service 同主机时保留服务公布的端口;跨主机时固定回用户已授权的 Device Service scheme/host/port,仅保留 Media path/query。
|
||||
- HTTP 客户端不跟随重定向,继续拒绝地址或 Stream URI 中携带凭据,不记录 Authorization。
|
||||
- 保留 Basic 兼容路径。真实设备使用专用 ONVIF 凭据成功读取 2 个 Profile。
|
||||
- 当前真实设备 ONVIF 与 RTSP 使用不同账号,而 Sense 每台设备只保存一组凭据,因此 RTSP 验证仍为 profile_failed;不同凭据模型不在本工单范围。
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `Sense/server/app/sense/adapters/onvif/client.go`:Media 服务发现、Digest challenge-response、地址归一化和重定向禁止。
|
||||
- `Sense/server/app/sense/adapters/onvif/client_test.go`:Digest、Basic 流程、安全地址和重定向测试。
|
||||
- `Sense/server/app/sense/adapters/onvif/parser.go`:解析 GetCapabilities 中的 Media XAddr。
|
||||
- `Sense/server/app/sense/adapters/onvif/parser_test.go`:Media 地址解析测试。
|
||||
- `docs/02-architecture-and-code-map.md`、`docs/04-local-development-and-verification.md`、`docs/06-troubleshooting.md`:由 Wiki 同步的长期说明。
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 验收标准 | 结果 |
|
||||
|---|---|
|
||||
| Digest Media Service 可完成 GetProfiles 和 GetStreamUri | 通过 |
|
||||
| 用户只填写 Device Service,Sense 自动发现 Media Service | 通过 |
|
||||
| 跨主机 Media XAddr 不被直接访问 | 通过 |
|
||||
| 同源合法 Media XAddr 与 Basic 路径不回归 | 通过 |
|
||||
| 危险地址、错误 challenge、算法/qop、重定向被拒绝 | 通过 |
|
||||
| 真实设备能够读取 Profile | 通过,读取 2 个 Profile |
|
||||
| 不泄露设备地址、凭据、Authorization 或 Stream URI | 通过 |
|
||||
|
||||
## 测试
|
||||
|
||||
- 执行命令:`go test ./app/sense/adapters/onvif ./app/sense/admission`
|
||||
- 结果:通过。
|
||||
- 执行命令:`go test ./...`
|
||||
- 结果:Sense 全部 Go 测试通过。
|
||||
- 执行命令:`Sense/scripts/package-windows.bat`(Go 1.26.5、Node 22.22.1、pnpm 9.15.1)
|
||||
- 结果:前后端 Windows 包构建通过;只有既有 webpack 体积警告。
|
||||
- 真实设备验证:通过 Sense API 使用只读 `ip_camera.env`,不输出敏感值。
|
||||
- 结果:`profile_count=2`、`admission_status=profile_failed`;Profile 已读取,RTSP 因 ONVIF/RTSP 不同账号未通过。
|
||||
- Windows ZIP SHA-256:`628CC85B009628C062ADB6F5125A2F89CF7501727042152D29D13A7AE6D3F888`。
|
||||
- **未验证部分**:未完成真实 RTSP 播放与 MediaMTX 接入;需要每设备分离 ONVIF/RTSP 凭据后再验收。
|
||||
|
||||
## 遗留问题
|
||||
|
||||
- 真实摄像机的 ONVIF 与 RTSP 使用不同账号;Sense 当前单凭据模型无法同时验证两者,需要独立工单扩展凭据边界。
|
||||
- 严格 Harness 当前仍被既有工单 #44 归档缺少“最终方案”章节阻塞,不在本工单中混入修复。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `688080c` 支持 ONVIF Digest Media 服务与安全地址归一化。
|
||||
- `2a0b63c` 更新架构、验证与排错 Wiki 镜像。
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-50-ONVIF与RTSP分离凭据并持久化Profile
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-50-ONVIF%E4%B8%8ERTSP%E5%88%86%E7%A6%BB%E5%87%AD%E6%8D%AE%E5%B9%B6%E6%8C%81%E4%B9%85%E5%8C%96Profile.-
|
||||
wiki_revision: 27ac0fe8ef97f9ec835fa62713f3a8c23c077940
|
||||
synchronized_at: 2026-08-13T04:18:00Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 50 ONVIF与RTSP分离凭据并持久化Profile
|
||||
|
||||
- 类型:功能
|
||||
- 所属 Epic:#7
|
||||
- 所属 MVP / 版本:#8
|
||||
- 状态:待验收
|
||||
- 日期:2026-08-13
|
||||
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/50
|
||||
- Wiki 页面:Task-50-ONVIF与RTSP分离凭据并持久化Profile
|
||||
- Wiki revision:见本地镜像头
|
||||
|
||||
## 背景与目标
|
||||
|
||||
真实摄像机的 ONVIF 与 RTSP 使用不同账号,旧版 Sense 每台设备只有一组凭据;接入结果还只保存在内存中。目标是分别安全保存两组凭据、持久化脱敏 Profile,并让接入成功的设备进入 active。
|
||||
|
||||
## 最终方案
|
||||
|
||||
- Device 新增独立 RTSP 密文与“复用 ONVIF”标志,旧凭据通过版本化迁移安全回填;API 只返回配置状态。
|
||||
- 设备页面同时维护 ONVIF/RTSP 凭据,默认允许显式复用;审计只记录是否复用,不记录值。
|
||||
- Admission 使用 ONVIF 凭据读取 Profile、RTSP 凭据验证视频;结果与脱敏 Stream URI、主/子码流、验证状态写入 PostgreSQL。
|
||||
- 摄像机广播跨主机 RTSP URI 时,只把主机归一化到用户授权的 Device Service 主机,保留报告端口与路径。
|
||||
- 至少一个 Profile 验证成功后 Device 进入 active;接入页切换设备时读取持久化结果。
|
||||
- 真实设备使用分离凭据后 2 个 Profile 均 ready,重启 Sense 后仍可读取。
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `Sense/server/app/sense/device/**`:分离凭据模型、迁移、加密读写与内部端口。
|
||||
- `Sense/server/app/sense/admission/**`:Profile Store、迁移、持久化和设备状态更新。
|
||||
- `Sense/server/app/sense/adapters/onvif/**`:安全归一化跨主机 RTSP URI。
|
||||
- `Sense/server/cmd/sense/modules_device.go`、`modules_admission.go`:注册版本化迁移与 PostgreSQL Store。
|
||||
- `Sense/ui/src/views/sense/device/Devices.vue`、`admission/Admission.vue`:分离凭据表单与持久结果恢复。
|
||||
- Wiki 架构、业务规则和排错页面:记录新安全边界。
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 验收标准 | 结果 |
|
||||
|---|---|
|
||||
| 两组凭据分别加密且只写不可读 | 通过 |
|
||||
| 同凭据复用与分离凭据均受支持 | 通过 |
|
||||
| Profile 重启后存在 | 通过,2 个 Profile |
|
||||
| 至少一个 Profile ready 后设备 active | 通过 |
|
||||
| 真实摄像机 ONVIF 与 RTSP 验证 | 通过,2/2 ready |
|
||||
| 不泄露地址、密码、Authorization 或 Stream URI | 通过 |
|
||||
|
||||
## 测试
|
||||
|
||||
- `go test ./...`:通过。
|
||||
- `pnpm lint`:0 error,存在基线格式 warning。
|
||||
- `pnpm build`:通过,存在既有 webpack 体积 warning。
|
||||
- Go 1.26.5 Windows 打包:通过。
|
||||
- 真实设备脱敏验证:`admission_status=ready`、`profile_count=2`、`ready_profile_count=2`、`device_status=active`。
|
||||
- 重启验证:`persisted_status=ready`、`persisted_profile_count=2`。
|
||||
- **未验证部分**:MediaMTX 自动路由与实时监看属于后续工单 #51。
|
||||
|
||||
## 遗留问题
|
||||
|
||||
- 无本工单范围内遗留;自动媒体路由在 #51 实施。
|
||||
- 严格 Harness 仍受既有 #44 归档缺少“最终方案”章节影响。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `1a63264` 分离凭据、持久化 Profile 和真实地址归一化。
|
||||
- `032f8c5` 更新长期 Wiki 镜像。
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-51-接入成功后自动建立媒体路由并进入实时监看
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-51-%E6%8E%A5%E5%85%A5%E6%88%90%E5%8A%9F%E5%90%8E%E8%87%AA%E5%8A%A8%E5%BB%BA%E7%AB%8B%E5%AA%92%E4%BD%93%E8%B7%AF%E7%94%B1%E5%B9%B6%E8%BF%9B%E5%85%A5%E5%AE%9E%E6%97%B6%E7%9B%91%E7%9C%8B.-
|
||||
wiki_revision: c4a8ab61dd224498d859f76befe7b9f4bf031fe3
|
||||
synchronized_at: 2026-08-13T04:40:00Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 51 接入成功后自动建立媒体路由并进入实时监看
|
||||
|
||||
- 类型:功能
|
||||
- 所属 Epic:#7
|
||||
- 所属 MVP / 版本:#8
|
||||
- 状态:待验收
|
||||
- 日期:2026-08-13
|
||||
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/51
|
||||
- Wiki 页面:Task-51-接入成功后自动建立媒体路由并进入实时监看
|
||||
- Wiki revision:见本地镜像头
|
||||
|
||||
## 背景与目标
|
||||
|
||||
摄像机已经能够完成 ONVIF/RTSP 接入并持久化 Profile,但用户仍需手工到视频服务创建路径,实时监看显示的也是内部 ID。目标是接入成功后自动建立路径,并用设备名称、位置和码流用途供非技术用户选择。
|
||||
|
||||
## 最终方案
|
||||
|
||||
- Admission 在至少一个 Profile 验证成功后调用窄媒体端口,对每个 ready Profile 按 Device/Profile 幂等配置并立即对账。
|
||||
- 自动媒体结果持久化到接入记录。MediaMTX 不可用时不回滚设备接入,返回需要处理的业务提示。
|
||||
- Media 使用独立 RTSP 凭据;进程或控制接口错误只向界面返回安全业务说明,不暴露底层路径和错误。
|
||||
- 实时监看 API 投影设备名称、位置、Profile 名称和主/子码流用途,内部 ID 仅用于关联。
|
||||
- 接入页显示媒体自动配置结果;实时监看空状态引导用户先完成视频接入,再到视频服务排错。
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `Sense/server/app/sense/admission/**`:媒体结果、回调端口、持久化和迁移。
|
||||
- `Sense/server/app/sense/media/**`:批量幂等配置、自动对账与安全降级。
|
||||
- `Sense/server/app/sense/device/credential_port.go`:只读业务展示投影。
|
||||
- `Sense/server/app/sense/liveview/**`:业务标签投影。
|
||||
- `Sense/ui/src/views/sense/{admission,liveview}/`:自动配置结果与业务化选择。
|
||||
- Wiki 架构、业务规则和排错页面。
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 验收标准 | 结果 |
|
||||
|---|---|
|
||||
| ready Profile 自动建立媒体路径 | 通过,真实设备 2 个 ready Profile 自动生成 2 条路径 |
|
||||
| 重复接入不产生重复路径 | 通过,单元测试验证按 Device/Profile 幂等 |
|
||||
| MediaMTX 不可用不回滚接入 | 通过,接入为 ready,媒体状态为 needs_attention |
|
||||
| 实时监看使用业务标签 | 通过,真实 API 返回设备名称且所有路径有 Profile 用途 |
|
||||
| 不泄露凭据、源 URI 或底层进程错误 | 通过 |
|
||||
|
||||
## 测试
|
||||
|
||||
- `go test ./...`:通过。
|
||||
- `corepack pnpm lint`:0 error,806 个既有格式 warning。
|
||||
- `corepack pnpm build`:通过,存在既有 webpack 体积 warning。
|
||||
- Go 1.26.5 Windows 打包:通过。
|
||||
- 真实设备脱敏验证:`admission_status=ready`、`ready_profiles=2`、`media_status=needs_attention`、`media_route_count=2`;所有路径使用业务设备名并包含码流用途。
|
||||
- **未验证部分**:当前环境没有可用 MediaMTX 二进制,因此未验证真实 WebRTC 画面;配置 MediaMTX 后可在视频服务重新对账。
|
||||
|
||||
## 遗留问题
|
||||
|
||||
- MediaMTX 是独立运行依赖,当前 Windows 包不包含该二进制。
|
||||
- 严格 Harness 仍受既有 #44 归档缺少“最终方案”章节影响。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `b9f92dd` 自动媒体路径和业务化实时监看。
|
||||
- `0f459f0` 更新长期 Wiki 镜像。
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-54-修复实时监看按需拉流循环等待
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-54-%E4%BF%AE%E5%A4%8D%E5%AE%9E%E6%97%B6%E7%9B%91%E7%9C%8B%E6%8C%89%E9%9C%80%E6%8B%89%E6%B5%81%E5%BE%AA%E7%8E%AF%E7%AD%89%E5%BE%85.-
|
||||
wiki_revision: ff79f9dbc718ae570b01dace25304379124a99b4
|
||||
synchronized_at: 2026-08-13T14:05:00Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 54 修复实时监看按需拉流循环等待
|
||||
|
||||
- 类型:缺陷
|
||||
- 所属 Epic:#7
|
||||
- 所属 MVP / 版本:#8
|
||||
- 状态:待验收
|
||||
- 日期:2026-08-13
|
||||
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/54
|
||||
- Wiki 页面:Task-54-修复实时监看按需拉流循环等待
|
||||
- Wiki revision:见本地镜像头
|
||||
|
||||
## 背景与目标
|
||||
|
||||
MediaMTX 使用按需拉流时,必须先有播放器读取路径才会连接摄像机。原实时监看页面只在状态变为“可播放”后才加载播放器,导致“等待拉流 → 没有读取者 → 始终等待”的循环。目标是在保持按需拉流和安全边界的前提下,让监看页主动成为读取者,并让会话状态收敛到真实媒体状态。
|
||||
|
||||
## 最终方案
|
||||
|
||||
- 实时监看收到有效播放地址且状态为“等待拉流”或“可播放”时都加载播放器;“等待拉流”保留明确状态提示,同时触发 MediaMTX 按需连接。
|
||||
- 会话查询通过只读媒体端口刷新控制器状态,把等待状态安全收敛为可播放或需要处理,不重新应用路径配置。
|
||||
- 播放器包装接口覆盖全局嵌入策略为 `SAMEORIGIN`,并用 CSP `frame-ancestors 'self'` 限制为 Sense 同源页面;其他接口继续保持 `X-Frame-Options: DENY`。
|
||||
- iframe 导航无法附加 API 客户端的 `X-Product` 头,因此播放器路由改用窄导航认证:有效 Sense 会话 Cookie、媒体读取权限、`Sec-Fetch-Site: same-origin`、`Sec-Fetch-Mode: navigate` 和 `Sec-Fetch-Dest: iframe` 必须同时满足。普通 API 的产品头要求保持不变。
|
||||
- MediaMTX 路径首次配置使用 add,已存在时使用 replace;控制 API 刚启动尚未监听时限时等待。
|
||||
- Sense 完成数据库迁移后自动恢复所有 `desired=running` 路径;路径不存在显式标记为配置失败,不再误报 waiting。
|
||||
- 继续保持 `sourceOnDemand`,无人观看时不占用摄像机连接与转码资源。
|
||||
- 真实环境验证过程中发现已保存的 RTSP 凭据被摄像机拒绝(401);已通过现有 Sense 接口使用本地已获授权配置修正运行数据,未把凭据写入仓库、工单、Wiki 或日志证据。
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `Sense/server/app/sense/media/service.go`、`route_port.go`:增加只读状态刷新、缺失状态和启动恢复能力。
|
||||
- `Sense/server/app/sense/adapters/mediamtx/client.go`、`client_test.go`:实现 add/replace 幂等语义并等待控制 API 就绪。
|
||||
- `Sense/server/cmd/sense/{modules_media.go,root.go}`、`internal/platform/app.go`:在迁移完成后执行媒体路径恢复。
|
||||
- `Sense/server/app/sense/media/service_test.go`:验证刷新状态且不重复配置路径。
|
||||
- `Sense/server/app/sense/liveview/service.go`:查询会话时刷新媒体状态。
|
||||
- `Sense/server/app/sense/liveview/http.go`、`http_test.go`:允许并验证播放器包装页仅同源嵌入。
|
||||
- `Sense/server/app/sense/identity/http.go`、`http_test.go`:增加并验证仅限同源 iframe 的 Cookie 导航认证,保留普通 API 产品边界。
|
||||
- `Sense/server/app/sense/liveview/service_test.go`:验证等待状态收敛到可播放。
|
||||
- `Sense/ui/src/components/sense/liveview/StreamPlayer.vue`:等待拉流时即加载播放器。
|
||||
- Wiki 架构、业务规则与排错页面。
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 验收标准 | 结果 |
|
||||
|---|---|
|
||||
| 等待拉流时主动创建播放器读取者 | 通过,真实 MediaMTX 路径观察到 1 个读取者 |
|
||||
| 会话状态从 waiting 收敛为 ready | 通过,真实 API 轮询由 waiting 变为 ready |
|
||||
| 实际媒体源可解码 | 通过,探测到 H.264 1920×1080 视频和 AAC 音频 |
|
||||
| 不重复应用路径配置 | 通过,单元测试验证刷新仅调用状态查询 |
|
||||
| 不泄露摄像机凭据和源地址 | 通过 |
|
||||
| 播放器可被 Sense 同源嵌入且其他页面仍禁止嵌入 | 通过,运行包接口响应头验证通过 |
|
||||
| iframe 不带 `X-Product` 时仍能安全认证 | 通过,同源 iframe + 有效 Cookie 返回 200;跨站返回 401;普通 API 缺少产品头返回 401 |
|
||||
| MediaMTX 冷启动后恢复 Sense 路径 | 通过,配置列表自动出现 2 条 `sourceOnDemand` 路径,无需手工对账 |
|
||||
| 两条摄像机路径可实际解码 | 通过,主码流 H.264 1920×1080 + AAC,子码流 H.264,均收到媒体字节 |
|
||||
|
||||
## 测试
|
||||
|
||||
- `go test ./...`:通过。
|
||||
- `corepack pnpm lint`:0 error,806 个既有格式 warning。
|
||||
- `corepack pnpm build`:通过,存在 3 个既有 webpack 体积 warning。
|
||||
- Go 1.26.5 Windows 打包:通过。
|
||||
- 运行包响应头:播放器接口为 `SAMEORIGIN` 且包含 `frame-ancestors 'self'`;首页仍为 `DENY`。
|
||||
- 运行包浏览器式认证:不带 `X-Product`、带有效 Cookie 和同源 iframe Fetch Metadata 的播放器请求返回 200;跨站播放器与缺少产品头的普通 API 均返回 401。
|
||||
- 冷启动恢复:MediaMTX 配置列表自动包含两条 Sense `sourceOnDemand` 路径;运行态列表也存在两条路径。
|
||||
- 真实媒体链路:主码流 H.264 1920×1080 + AAC,子码流 H.264;两条路径分别通过本机 RTSP 读取并收到媒体字节。
|
||||
- 单元测试覆盖路径首次 add、已有 replace、控制 API 启动等待、启动恢复及缺失路径不误报 waiting。
|
||||
- **未验证部分**:最终浏览器中的可视画面需要用户在当前浏览器验收;自动化已验证媒体源、读取者与会话状态收敛。
|
||||
|
||||
## 遗留问题
|
||||
|
||||
- 严格 Harness 仍受既有 #44 归档缺少“最终方案”章节影响。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `8d4e4c2` 修复实时监看按需拉流循环等待。
|
||||
- `b3dfd23` 更新按需拉流长期 Wiki 镜像。
|
||||
- `3696442` 允许播放器包装页仅被 Sense 同源嵌入。
|
||||
- `8bf9a6d` 支持同源播放器导航认证并保持普通 API 产品边界。
|
||||
- `d7cd3a5` 恢复 MediaMTX 按需路径并处理启动竞态。
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-56-修复设备编辑请求包含新建专用字段
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-56-%E4%BF%AE%E5%A4%8D%E8%AE%BE%E5%A4%87%E7%BC%96%E8%BE%91%E8%AF%B7%E6%B1%82%E5%8C%85%E5%90%AB%E6%96%B0%E5%BB%BA%E4%B8%93%E7%94%A8%E5%AD%97%E6%AE%B5.-
|
||||
wiki_revision: 212cf0a998afb41adfb1d671df07a1362580ea9e
|
||||
synchronized_at: 2026-08-13T14:37:31Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 56 修复设备编辑请求包含新建专用字段
|
||||
|
||||
- 类型:缺陷
|
||||
- 所属 Epic:#7
|
||||
- 所属 MVP / 版本:#8
|
||||
- 状态:待验收
|
||||
- 日期:2026-08-13
|
||||
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/56
|
||||
- Wiki 页面:Task-56-修复设备编辑请求包含新建专用字段
|
||||
- Wiki revision:见本地镜像头
|
||||
|
||||
## 背景与目标
|
||||
|
||||
用户在设备管理编辑中文安装位置时收到“请求内容格式不正确”。中文 UTF-8 并非根因;前端编辑请求展开了整个表单,把只允许新建时提交的 `modality` 发送到严格拒绝未知字段的 PATCH 接口。目标是让新建和编辑请求分别遵循明确字段白名单,并用可运行测试固定契约。
|
||||
|
||||
## 最终方案
|
||||
|
||||
- 提取纯 payload 构造函数,避免页面直接展开表单对象。
|
||||
- 新建请求显式包含 `name`、`location`、`modality`、`capabilities`。
|
||||
- 编辑请求显式仅包含 `name`、`location`、`capabilities`、`version`。
|
||||
- 使用 Node 内置测试运行器验证中文值原样保留、新建包含设备类型、编辑排除设备类型和只读 ID。
|
||||
- 后端 `DisallowUnknownFields` 保持不变,继续把未知字段视为契约错误。
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `Sense/ui/src/views/sense/device/Devices.vue`:分别构建设备新建和编辑请求。
|
||||
- `Sense/ui/src/views/sense/device/devicePayload.mjs`:设备请求字段白名单。
|
||||
- `Sense/ui/tests/devicePayload.test.mjs`:中文值和字段边界测试。
|
||||
- `Sense/ui/package.json`:增加设备 payload 测试命令。
|
||||
- Wiki 业务规则与故障排查页面。
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 验收标准 | 结果 |
|
||||
|---|---|
|
||||
| 编辑设备保存中文安装位置 | 通过,PATCH 返回 200 |
|
||||
| 保存后回读中文安装位置 | 通过,回读为“教学楼一楼东门” |
|
||||
| 编辑请求排除 modality 和只读字段 | 通过,单元测试验证 |
|
||||
| 新建请求保留 modality | 通过,单元测试验证 |
|
||||
| 后端严格未知字段策略保持不变 | 通过,未修改后端解析逻辑 |
|
||||
|
||||
## 测试
|
||||
|
||||
- `corepack pnpm@9.15.1 test:device-payload`:2 项通过。
|
||||
- `corepack pnpm@9.15.1 lint`:0 error,806 个既有 warning。
|
||||
- `corepack pnpm@9.15.1 build`:通过,3 个既有体积 warning。
|
||||
- `go test ./...`:通过。
|
||||
- Go 1.26.5 Windows 重新打包并启动:通过。
|
||||
- 真实 PostgreSQL API:白名单 PATCH 保存成功,GET 回读中文位置成功。
|
||||
- **未验证部分**:最终浏览器点击“保存”的视觉交互由用户验收;请求构造、真实 API 保存和回读已自动验证。
|
||||
|
||||
## 遗留问题
|
||||
|
||||
- 严格 Harness 仍受既有 #44 归档缺少“最终方案”章节影响。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `8e3a25d` 修复设备编辑请求字段并增加契约测试。
|
||||
|
||||
@@ -131,6 +131,27 @@
|
||||
{
|
||||
"page": "Task-46-Sense-Windows包内配置加载",
|
||||
"path": "docs/task/46-Sense-Windows包内配置加载.md"
|
||||
},
|
||||
{
|
||||
"page": "Task-48-支持ONVIF-Digest认证与安全Media地址归一化",
|
||||
"path": "docs/task/48-支持ONVIF-Digest认证与安全Media地址归一化.md"
|
||||
},
|
||||
{
|
||||
"page": "Task-50-ONVIF与RTSP分离凭据并持久化Profile",
|
||||
"path": "docs/task/50-ONVIF与RTSP分离凭据并持久化Profile.md"
|
||||
},
|
||||
{
|
||||
"page": "Task-51-接入成功后自动建立媒体路由并进入实时监看",
|
||||
"path": "docs/task/51-接入成功后自动建立媒体路由并进入实时监看.md"
|
||||
},
|
||||
{
|
||||
"page": "Task-54-修复实时监看按需拉流循环等待",
|
||||
"path": "docs/task/54-修复实时监看按需拉流循环等待.md"
|
||||
},
|
||||
{
|
||||
"page": "Task-56-修复设备编辑请求包含新建专用字段",
|
||||
"path": "docs/task/56-修复设备编辑请求包含新建专用字段.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user