Compare commits

..
Author SHA1 Message Date
ila c29ce45971 docs: archive ONVIF Digest compatibility (#48) 2026-08-13 11:52:35 +08:00
ila ff93592001 docs: register ONVIF Digest task archive (#48) 2026-08-13 11:52:34 +08:00
ila 5b466b4ae6 docs: document ONVIF credential troubleshooting (#48) 2026-08-13 11:52:33 +08:00
ila c8cf53291f docs: document ONVIF Digest verification (#48) 2026-08-13 11:52:31 +08:00
ila 850ca4fead docs: document ONVIF Digest architecture (#48) 2026-08-13 11:52:30 +08:00
ila 05100c4f37 test: cover ONVIF media address parsing (#48) 2026-08-13 11:52:28 +08:00
ila 523fe81fb2 fix: parse ONVIF media service address (#48) 2026-08-13 11:52:26 +08:00
ila 2591517c76 test: cover ONVIF Digest compatibility (#48) 2026-08-13 11:52:25 +08:00
ila 440831fb4d fix: support ONVIF Digest media services (#48) 2026-08-13 11:52:24 +08:00
QiuSW ef85943ef4 docs: archive Sense package environment loading (#46) 2026-08-13 10:42:12 +08:00
QiuSW 6bbaa07962 docs: document Sense package environment loading (#46) 2026-08-13 10:38:26 +08:00
QiuSW 5a178986e5 fix: load Sense package environment file (#46) 2026-08-13 10:32:13 +08:00
QiuSW 5e5aa48cf9 docs: complete task 44 acceptance archive (#44) 2026-08-13 09:32:37 +08:00
QiuSW 21624f6c55 docs: create task 44 acceptance archive (#44) 2026-08-13 09:30:07 +08:00
QiuSW c4a799c99c test: cover Sense administrator navigation (#44) 2026-08-13 09:29:00 +08:00
QiuSW 62ed5cc1ce docs: add Sense empty navigation troubleshooting (#44) 2026-08-13 09:27:45 +08:00
QiuSW 048a6ec969 fix: restore Sense permission navigation (#44) 2026-08-13 09:25:26 +08:00
QiuSW cb3015a5b1 docs: complete task 42 acceptance archive (#42) 2026-08-13 09:21:17 +08:00
QiuSW 245d38d471 docs: create task 42 acceptance archive (#42) 2026-08-13 09:19:03 +08:00
QiuSW 88dcebc3fb docs: record length-only Sense password policy (#42) 2026-08-13 09:16:42 +08:00
QiuSW 8091fbb89c feat: simplify Sense password policy (#42) 2026-08-13 09:14:14 +08:00
25 changed files with 921 additions and 101 deletions
+17
View File
@@ -5,6 +5,7 @@ for %%I in ("%~dp0..") do set "SENSE_ROOT=%%~fI"
for %%I in ("%SENSE_ROOT%\dist") do set "OUTPUT_ROOT=%%~fI"
for %%I in ("%OUTPUT_ROOT%\sense-windows-amd64") do set "PACKAGE_DIR=%%~fI"
for %%I in ("%OUTPUT_ROOT%\sense-windows-amd64.zip") do set "ZIP_PATH=%%~fI"
for %%I in ("%OUTPUT_ROOT%\.sense.env.preserve") do set "PRESERVED_ENV=%%~fI"
for %%I in ("%SENSE_ROOT%\ui\dist") do set "UI_DIST=%%~fI"
if /I not "%PACKAGE_DIR%"=="%SENSE_ROOT%\dist\sense-windows-amd64" (
@@ -15,6 +16,10 @@ if /I not "%ZIP_PATH%"=="%SENSE_ROOT%\dist\sense-windows-amd64.zip" (
echo [ERROR] Unsafe ZIP path: %ZIP_PATH%
exit /b 1
)
if /I not "%PRESERVED_ENV%"=="%SENSE_ROOT%\dist\.sense.env.preserve" (
echo [ERROR] Unsafe preserved configuration path: %PRESERVED_ENV%
exit /b 1
)
where go >nul 2>nul || goto :missing_go
for /f "tokens=3" %%V in ('go version') do set "GO_VERSION=%%V"
@@ -45,6 +50,14 @@ echo [2/5] Building frontend...
call corepack pnpm@9.15.1 build || goto :failed_popd
popd
if exist "%PRESERVED_ENV%" (
echo [ERROR] Preserved configuration already exists: %PRESERVED_ENV%
echo Move it back to config\sense.env or remove it after confirming it is obsolete.
exit /b 1
)
if exist "%PACKAGE_DIR%\config\sense.env" (
copy /y "%PACKAGE_DIR%\config\sense.env" "%PRESERVED_ENV%" >nul || goto :failed
)
if exist "%PACKAGE_DIR%" rmdir /s /q "%PACKAGE_DIR%"
if exist "%ZIP_PATH%" del /q "%ZIP_PATH%"
mkdir "%PACKAGE_DIR%\config" || goto :failed
@@ -64,10 +77,14 @@ robocopy "%SENSE_ROOT%\LICENSES" "%PACKAGE_DIR%\LICENSES" /E /NFL /NDL /NJH /NJS
if errorlevel 8 goto :failed
copy /y "%SENSE_ROOT%\config\sense.env.example" "%PACKAGE_DIR%\config\sense.env.example" >nul || goto :failed
copy /y "%SENSE_ROOT%\scripts\runtime\start-sense.bat" "%PACKAGE_DIR%\start-sense.bat" >nul || goto :failed
copy /y "%SENSE_ROOT%\scripts\runtime\start-sense.ps1" "%PACKAGE_DIR%\start-sense.ps1" >nul || goto :failed
copy /y "%SENSE_ROOT%\scripts\runtime\README-WINDOWS.md" "%PACKAGE_DIR%\README-WINDOWS.md" >nul || goto :failed
echo [5/5] Creating ZIP...
powershell -NoProfile -ExecutionPolicy Bypass -Command "Compress-Archive -Path '%PACKAGE_DIR%' -DestinationPath '%ZIP_PATH%' -Force" || goto :failed
if exist "%PRESERVED_ENV%" (
move /y "%PRESERVED_ENV%" "%PACKAGE_DIR%\config\sense.env" >nul || goto :failed
)
echo.
echo Package directory: %PACKAGE_DIR%
+8 -2
View File
@@ -14,7 +14,7 @@ start-sense.bat demo
## 生产启动
生产环境必须先安装并准备独立 PostgreSQL,然后在仓库和运行包之外安全设置以下环境变量:
生产环境必须先安装并准备独立 PostgreSQL。把配置写入运行目录的 `config\sense.env`,或在 Windows 进程环境中提供;已存在的非空进程环境变量优先于文件:
- `SENSE_DATABASE_URL`:PostgreSQL 连接地址。
- `SENSE_IDENTITY_SIGNING_KEY`:至少 32 个字符的会话签名密钥。
@@ -27,6 +27,12 @@ start-sense.bat demo
start-sense.bat
```
完整变量名可参考 `config\sense.env.example`。示例文件只有空值和非秘密默认值,启动脚本不会自动读取它;请使用 Windows 环境变量或外部秘密管理工具注入真实值。
启动前可只检查配置,不连接数据库也不启动服务:
```bat
start-sense.bat check
```
完整变量名可参考 `config\sense.env.example`。复制为 `config\sense.env` 后填写真实值;该文件不会进入 ZIP 或 Git。本机在同一运行目录重新打包时会保留该文件,但交付 ZIP 始终不包含它。启动器只读取 `SENSE_*` 键,忽略空行与 `#` 注释,且不会打印配置值。生产环境仍建议使用 Windows 环境变量或外部秘密管理工具注入真实值。
健康检查为 `GET /healthz` 和 `GET /readyz`。MediaMTX 仍是独立程序,本运行包不会安装 PostgreSQL、MediaMTX 或 Windows 服务。
+1 -41
View File
@@ -1,45 +1,5 @@
@echo off
setlocal
cd /d "%~dp0"
set "SENSE_UI_STATIC_DIR=%~dp0ui"
if /I "%~1"=="demo" goto :demo
set "SENSE_DATABASE_MODE=postgres"
if "%SENSE_DATABASE_URL%"=="" goto :missing_database_url
if "%SENSE_IDENTITY_SIGNING_KEY%"=="" goto :missing_identity_key
if "%SENSE_BOOTSTRAP_TOKEN%"=="" goto :missing_bootstrap_token
if "%SENSE_CREDENTIAL_KEY%"=="" goto :missing_credential_key
echo Starting Sense in production mode at %SENSE_HTTP_ADDRESS%...
"%~dp0sense-server.exe"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0start-sense.ps1" %*
exit /b %ERRORLEVEL%
:demo
set "SENSE_DATABASE_MODE=memory"
if "%SENSE_HTTP_ADDRESS%"=="" set "SENSE_HTTP_ADDRESS=127.0.0.1:18080"
echo Starting Sense in temporary demo mode at http://%SENSE_HTTP_ADDRESS% ...
echo Demo data is discarded when the process stops. Do not use this mode in production.
"%~dp0sense-server.exe"
exit /b %ERRORLEVEL%
:missing_database_url
echo [ERROR] SENSE_DATABASE_URL is required in production mode.
goto :configuration_help
:missing_identity_key
echo [ERROR] SENSE_IDENTITY_SIGNING_KEY is required in production mode.
goto :configuration_help
:missing_bootstrap_token
echo [ERROR] SENSE_BOOTSTRAP_TOKEN is required in production mode.
goto :configuration_help
:missing_credential_key
echo [ERROR] SENSE_CREDENTIAL_KEY is required in production mode.
:configuration_help
echo Set the required environment variables outside this directory.
echo See README-WINDOWS.md and config\sense.env.example.
exit /b 1
+91
View File
@@ -0,0 +1,91 @@
param(
[ValidateSet('production', 'demo', 'check')]
[string]$Mode = 'production'
)
$ErrorActionPreference = 'Stop'
function Import-SenseEnvironment {
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
return
}
$lineNumber = 0
foreach ($line in Get-Content -LiteralPath $Path -Encoding UTF8) {
$lineNumber++
$trimmed = $line.Trim()
if ($trimmed.Length -eq 0 -or $trimmed.StartsWith('#')) {
continue
}
if ($line -notmatch '^\s*(SENSE_[A-Z0-9_]+)\s*=(.*)$') {
throw "config\sense.env line $lineNumber must use SENSE_NAME=value format"
}
$name = $Matches[1]
$value = $Matches[2].Trim()
if ($value.Length -ge 2) {
$first = $value[0]
$last = $value[$value.Length - 1]
if (($first -eq '"' -and $last -eq '"') -or ($first -eq "'" -and $last -eq "'")) {
$value = $value.Substring(1, $value.Length - 2)
}
}
$current = [Environment]::GetEnvironmentVariable($name, 'Process')
if ([string]::IsNullOrEmpty($current)) {
[Environment]::SetEnvironmentVariable($name, $value, 'Process')
}
}
}
function Assert-RequiredEnvironment {
param([string[]]$Names)
foreach ($name in $Names) {
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name, 'Process'))) {
throw "$name is required in production mode"
}
}
}
try {
$runtimeRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
Import-SenseEnvironment -Path (Join-Path $runtimeRoot 'config\sense.env')
[Environment]::SetEnvironmentVariable('SENSE_UI_STATIC_DIR', (Join-Path $runtimeRoot 'ui'), 'Process')
if ([string]::IsNullOrWhiteSpace($env:SENSE_HTTP_ADDRESS)) {
$env:SENSE_HTTP_ADDRESS = '127.0.0.1:18080'
}
if ($Mode -eq 'demo') {
$env:SENSE_DATABASE_MODE = 'memory'
Write-Host "Starting Sense in temporary demo mode at http://$($env:SENSE_HTTP_ADDRESS) ..."
Write-Host 'Demo data is discarded when the process stops. Do not use this mode in production.'
} else {
$env:SENSE_DATABASE_MODE = 'postgres'
Assert-RequiredEnvironment -Names @(
'SENSE_DATABASE_URL',
'SENSE_IDENTITY_SIGNING_KEY',
'SENSE_BOOTSTRAP_TOKEN',
'SENSE_CREDENTIAL_KEY'
)
if ($Mode -eq 'check') {
Write-Host 'Sense production configuration check passed.'
exit 0
}
Write-Host "Starting Sense in production mode at $($env:SENSE_HTTP_ADDRESS) ..."
}
$server = Join-Path $runtimeRoot 'sense-server.exe'
if (-not (Test-Path -LiteralPath $server -PathType Leaf)) {
throw 'sense-server.exe was not found beside the start script'
}
& $server
exit $LASTEXITCODE
} catch {
Write-Host "[ERROR] $($_.Exception.Message)"
Write-Host 'See README-WINDOWS.md and config\sense.env.example.'
exit 1
}
+193 -3
View File
@@ -3,11 +3,15 @@ package onvif
import (
"bytes"
"context"
"crypto/md5"
"crypto/rand"
"crypto/sha256"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
@@ -34,10 +38,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
}
@@ -64,12 +86,18 @@ func (c *HTTPClient) Profiles(ctx context.Context, address string, credential Cr
return profiles, 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 +110,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 +127,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 +296,4 @@ func xmlEscape(value string) string {
_ = xml.EscapeText(&b, []byte(value))
return b.String()
}
@@ -0,0 +1,144 @@
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)
}
if len(profiles) != 1 || profiles[0].Width != 1920 || profiles[0].StreamURI != "rtsp://camera.invalid/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 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")
}
}
+1 -15
View File
@@ -2,28 +2,14 @@ package identity
import (
"fmt"
"strings"
"unicode"
"golang.org/x/crypto/bcrypt"
)
func ValidatePassword(username, password string) error {
func ValidatePassword(_ string, password string) error {
if len([]rune(password)) < 6 {
return fmt.Errorf("密码至少需要 6 个字符")
}
var upper, lower, digit bool
for _, value := range password {
upper = upper || unicode.IsUpper(value)
lower = lower || unicode.IsLower(value)
digit = digit || unicode.IsDigit(value)
}
if !upper || !lower || !digit {
return fmt.Errorf("密码必须同时包含大写字母、小写字母和数字")
}
if username != "" && strings.Contains(strings.ToLower(password), strings.ToLower(username)) {
return fmt.Errorf("密码不能包含用户名")
}
return nil
}
@@ -12,12 +12,10 @@ func TestValidatePasswordLengthAndComplexity(t *testing.T) {
password string
want string
}{
{name: "six characters", username: "operator", password: "Abc123"},
{name: "six lowercase characters", username: "operator", password: "abcdef"},
{name: "contains username", username: "admin", password: "admin123"},
{name: "six unicode characters", username: "operator", password: "密码密码密码"},
{name: "five characters", username: "operator", password: "Ab123", want: "至少需要 6 个字符"},
{name: "missing uppercase", username: "operator", password: "abc123", want: "大写字母、小写字母和数字"},
{name: "missing lowercase", username: "operator", password: "ABC123", want: "大写字母、小写字母和数字"},
{name: "missing digit", username: "operator", password: "Abcdef", want: "大写字母、小写字母和数字"},
{name: "contains username", username: "admin", password: "MyAdmin1", want: "不能包含用户名"},
}
for _, test := range tests {
+2 -2
View File
@@ -10,7 +10,8 @@
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "eslint \"src/**/*.{js,vue}\""
"lint": "eslint \"src/**/*.{js,vue}\"",
"test:navigation": "node --test tests/navigation.test.cjs"
},
"dependencies": {
"@element-plus/icons-vue": "2.3.2",
@@ -50,4 +51,3 @@
"not dead"
]
}
+3 -11
View File
@@ -58,22 +58,14 @@ import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useStore } from 'vuex'
import { buildNavigation } from './navigation'
const router = useRouter()
const store = useStore()
const collapsed = ref(false)
const navigation = computed(() => {
const root = router.getRoutes().find((route) => route.path === '/')
const permissions = store.getters['sense-identity/permissions'] || []
return (root?.children || [])
.filter((route) => route.meta?.title && !route.meta?.hidden)
.filter((route) => !route.meta?.permission || permissions.includes(route.meta.permission))
.map((route) => ({
path: route.path ? `/${route.path}`.replace('//', '/') : '/',
title: route.meta.title,
icon: route.meta.icon,
order: route.meta.order || 0
}))
.sort((left, right) => left.order - right.order)
return buildNavigation(router.getRoutes(), permissions)
})
async function logout() {
+18
View File
@@ -0,0 +1,18 @@
function buildNavigation(routes, permissions = []) {
const layout = routes
.filter((route) => route.path === '/' && Array.isArray(route.children) && route.children.length > 0)
.sort((left, right) => right.children.length - left.children.length)[0]
return (layout?.children || [])
.filter((route) => route.meta?.title && !route.meta?.hidden)
.filter((route) => !route.meta?.permission || permissions.includes(route.meta.permission))
.map((route) => ({
path: route.path ? `/${route.path}`.replace('//', '/') : '/',
title: route.meta.title,
icon: route.meta.icon,
order: route.meta.order || 0
}))
.sort((left, right) => left.order - right.order)
}
module.exports = { buildNavigation }
+1 -1
View File
@@ -30,7 +30,7 @@
<el-form-item label="用户名" prop="username"><el-input v-model="form.username" :disabled="Boolean(editingID)" /></el-form-item>
<el-form-item label="姓名" prop="display_name"><el-input v-model="form.display_name" /></el-form-item>
<el-form-item label="角色" prop="role"><el-select v-model="form.role" style="width: 100%"><el-option v-for="role in roles" :key="role.value" :label="role.label" :value="role.value" /></el-select></el-form-item>
<el-form-item v-if="!editingID" label="初始密码" prop="password"><el-input v-model="form.password" type="password" show-password /><div class="field-hint">至少 6 位,包含大小写字母和数字,且不含用户名。</div></el-form-item>
<el-form-item v-if="!editingID" label="初始密码" prop="password"><el-input v-model="form.password" type="password" show-password /><div class="field-hint">至少 6 位字符。</div></el-form-item>
<el-form-item v-else label="状态"><el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" /></el-form-item>
</el-form>
<template #footer><el-button @click="dialogVisible = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template>
+65
View File
@@ -0,0 +1,65 @@
const assert = require('node:assert/strict')
const test = require('node:test')
const { buildNavigation } = require('../src/layout/navigation.js')
test('selects the layout record when normalized routes contain duplicate root paths', () => {
const routes = [
{ path: '/', name: 'sense-dashboard', children: [], meta: { title: '工作台' } },
{ path: '/devices', name: 'sense-devices', children: [], meta: { title: '设备管理' } },
{
path: '/',
children: [
{ path: 'devices', meta: { title: '设备管理', permission: 'device.read', order: 10 } },
{ path: '', meta: { title: '工作台', order: 0 } },
{ path: 'users', meta: { title: '用户与权限', permission: 'identity.users.read', order: 90 } }
]
}
]
assert.deepEqual(buildNavigation(routes, ['device.read']), [
{ path: '/', title: '工作台', icon: undefined, order: 0 },
{ path: '/devices', title: '设备管理', icon: undefined, order: 10 }
])
})
test('returns no unauthorized or hidden entries', () => {
const routes = [{
path: '/',
children: [
{ path: '', meta: { title: '工作台' } },
{ path: 'users', meta: { title: '用户与权限', permission: 'identity.users.read' } },
{ path: 'internal', meta: { title: '内部页', hidden: true } }
]
}]
assert.deepEqual(buildNavigation(routes), [
{ path: '/', title: '工作台', icon: undefined, order: 0 }
])
})
test('administrator receives every Sense MVP navigation entry', () => {
const entries = [
['', '工作台', null, 0],
['devices', '设备管理', 'device.read', 10],
['admission', '视频接入', 'device.write', 20],
['media', '视频服务', 'media.read', 30],
['liveview', '实时监看', 'media.read', 40],
['areas', '区域与警戒线', 'area.read', 50],
['users', '用户与权限', 'identity.users.read', 90],
['audit', '操作记录', 'identity.audit.read', 91]
]
const routes = [{ path: '/', children: entries.map(([path, title, permission, order]) => ({
path,
meta: { title, permission, order }
})) }]
const permissions = [
'device.read', 'device.write', 'media.read', 'area.read',
'identity.users.read', 'identity.audit.read'
]
assert.deepEqual(buildNavigation(routes, permissions).map((item) => item.title), [
'工作台', '设备管理', '视频接入', '视频服务', '实时监看',
'区域与警戒线', '用户与权限', '操作记录'
])
})
+4 -3
View File
@@ -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: fbb20f3bcffe5c632236108e7883ac9a0061d830
synchronized_at: 2026-08-13T03:39:20Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -101,7 +101,7 @@ Sense 后端功能以 `Sense/server/app/sense/` 为根,并通过 `Sense/server
- `identity/`:Sense 独立账户、bcrypt 密码、会话、四角色 RBAC 与统一审计;签发者和受众只属于 Sense。
- `device/`:Device 台账、状态、分页和 AES-256-GCM 凭据保险箱;读取模型只返回 `credential_configured`。
- `adapters/onvif/`、`adapters/rtsp/`、`admission/`:获准网卡上的受控发现、手工 ONVIF 接入、Profile/StreamUri 读取和 RTSP 验证。
- `adapters/onvif/`、`adapters/rtsp/`、`admission/`:获准网卡上的受控发现、手工 ONVIF 接入、Media 服务发现、Basic/Digest 认证、Profile/StreamUri 读取和 RTSP 验证。摄像机广播跨主机 Media 地址时固定回用户已授权的 Device Service origin,仅保留服务路径和查询参数。
- `adapters/mediamtx/`、`media/`:外部 MediaMTX 进程所有权、localhost Control API、媒体期望态与实际态对账。
- `liveview/`:绑定当前用户、最长两分钟的单路播放会话;只投影媒体路径,不暴露源 URI 或摄像机秘密。
- `area/`:归一化多边形/方向警戒线、不可变版本、并发版本校验和分辨率变化后的重新校准。
@@ -124,3 +124,4 @@ Sense 后端功能以 `Sense/server/app/sense/` 为根,并通过 `Sense/server
Event 写入、幂等 Receipt、规则评估和 Alert 创建/关联处于同一数据库事务;规则或关联失败时不保留半成品 Event。一个 Event 可匹配多个规则,一个未关闭 Alert 可聚合相同规则与地点的多个 Event。
<!-- bell-mvp:end -->
+3 -3
View File
@@ -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: fbb85952429813bb3da7e3cd8526ec03bc0dac0d
synchronized_at: 2026-08-13T00:52:39Z
wiki_revision: c9d47108e45f66875726e3eef79eedb2a5942514
synchronized_at: 2026-08-13T01:15:01Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -71,7 +71,7 @@ synchronized_at: 2026-08-13T00:52:39Z
## Sense MVP 业务与安全规则
- **Sense 账户**:只登录 Sense;不得接受 Bell JWT、Cookie 或用户数据。角色为系统管理员、实施/运维、站点管理员和只读用户,后端权限是最终边界。
- **安全初始化**:系统不提供默认账户、默认密码或默认签名密钥;首次管理员由仓库外一次性令牌创建。所有模式的密码至少 6 个字符,必须同时包含大写字母、小写字母和数字,且不得包含用户名。该最短长度由负责人在已知生产安全风险后明确确认。
- **安全初始化**:系统不提供默认账户、默认密码或默认签名密钥;首次管理员由仓库外一次性令牌创建。所有模式的密码仅要求至少 6 个字符,不限制字符种类并允许包含用户名。负责人已明确接受该生产密码策略的字典猜测与凭据填充风险。
- **Device**:设备不可变逻辑 ID 是后续 Profile、媒体和区域的内部引用。非视频适配器未实现时必须显示 `adapter_not_ready`。
- **摄像机凭据**:只写不读,使用仓库外 32 字节密钥加密;不得进入 URL、日志、审计、工单或响应。
- **受控发现**:ONVIF Discovery 默认关闭,只有显式设置获准本机 IP 才能发送发现;不得扫描未授权网段。
@@ -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: 3bf0987ec9d7ddf543602e465faa088aead46531
synchronized_at: 2026-08-12T10:22:02Z
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 或连接密钥。
## 完成修改前
@@ -167,7 +167,8 @@ cmd /c .\Sense\scripts\package-windows.bat
Get-FileHash .\Sense\dist\sense-windows-amd64.zip -Algorithm SHA256
```
脚本精确校验 Node 22.22.1 和 pnpm 9.15.1,冻结安装并构建前端,以 Windows amd64/CGO 关闭方式编译后端,然后生成被 Git 忽略的 `Sense\dist\sense-windows-amd64\` 和 ZIP。可重复运行只会覆盖这两个固定产物。
脚本精确校验 Node 22.22.1 和 pnpm 9.15.1,冻结安装并构建前端,以 Windows amd64/CGO 关闭方式编译后端,然后生成被 Git 忽略的 `Sense\dist\sense-windows-amd64\` 和 ZIP。同一运行目录重新打包时会保留已有 `config\sense.env`,但该文件不会进入 ZIP。
解压后运行 `start-sense.bat demo` 可做内存模式临时预览;生产运行 `start-sense.bat` 前必须从仓库和运行包外注入 PostgreSQL URL、签名密钥、引导令牌和摄像头凭据加密密钥。包内不含 PostgreSQL、MediaMTX、系统服务、客户数据或秘密。
解压后运行 `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 -->
+6 -4
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Troubleshooting
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Troubleshooting
wiki_revision: a076590915040eae0d165be6254c6302f308fc5a
synchronized_at: 2026-08-12T10:22:09Z
wiki_revision: 832eace3bb0083168cc9ff11f689180e2d13bdea
synchronized_at: 2026-08-13T03:39:34Z
<!-- gitea-wiki-mirror:end -->
# 故障排查
@@ -47,9 +47,10 @@ synchronized_at: 2026-08-12T10:22:09Z
|---|---|
| 启动提示缺少 `SENSE_DATABASE_URL` | 正式模式必须提供独立 PostgreSQL URL;只有测试/smoke 可显式使用 `memory`。 |
| 登录统一提示用户名或密码错误 | 为避免枚举账户,失败响应不区分用户不存在、密码错误或账户停用;由管理员查看审计。 |
| 管理员登录后侧栏没有模块 | 先确认 /api/v1/identity/me 返回角色与权限;若权限正常,检查前端是否从具有 children 的应用布局路由派生菜单,不得依赖重复 / 路由记录顺序。 |
| `adapter_not_ready` | 当前设备类型尚无适配器,不代表网络故障;首期完整支持 video。 |
| `discovery_unavailable` | 未设置获准的 `SENSE_ONVIF_DISCOVERY_IP`,或该 IP 不属于本机网卡。可改用手工 ONVIF 地址。 |
| `authentication_failed` | 在设备管理中重新写入凭据后再次执行接入检查;不要把凭据写进地址。 |
| `authentication_failed` | 在设备管理中重新写入凭据后再次执行接入检查;不要把凭据写进地址。Sense 支持 ONVIF Basic 与 Digest;若 Profile 能读取但视频验证失败,确认 ONVIF 与 RTSP 是否使用同一组账号。当前每台设备只保存一组凭据,不同账号需后续凭据模型支持。 |
| `clock_skew` | 校准摄像机时间后重新探测。 |
| `process_failed` | 检查仓库外 `SENSE_MEDIAMTX_BINARY`、基础配置和进程退出原因;达到三次重启上限后需人工处理。 |
| `apply_failed` / `unconverged` | 检查 localhost Control API 是否启用并为 v3;确认媒体路径和外部进程状态。 |
@@ -65,9 +66,10 @@ synchronized_at: 2026-08-12T10:22:09Z
| 现象 | 检查与处理 |
|---|---|
| 打包提示 Go/Node/pnpm 版本不符 | 对照根 `goadmin-baseline.json` 安装精确版本,并把 Go 1.26.5 放到 `PATH` 首位;不要修改脚本绕过版本检查。 |
| 生产启动提示缺少环境变量 | 在运行包外设置 `SENSE_DATABASE_URL`、`SENSE_IDENTITY_SIGNING_KEY`、`SENSE_BOOTSTRAP_TOKEN`、`SENSE_CREDENTIAL_KEY`;启动脚本不会读取示例文件。 |
| 生产启动提示缺少环境变量 | 确认运行目录中存在 `config\sense.env`(不是只保留 `sense.env.example`),并填写 `SENSE_DATABASE_URL`、`SENSE_IDENTITY_SIGNING_KEY`、`SENSE_BOOTSTRAP_TOKEN`、`SENSE_CREDENTIAL_KEY`;也可在进程环境中设置,非空进程环境变量优先。运行 `start-sense.bat check` 定位缺失的变量名,脚本不会打印变量值。 |
| 本机健康检查被代理返回空响应 | localhost 可能被 `HTTP_PROXY` 接管;测试工具应对 `127.0.0.1` 使用 no-proxy,再检查 `SENSE_HTTP_ADDRESS` 端口占用。 |
| 页面可打开但视频不可用 | Windows 运行包不包含 MediaMTX;按包内说明配置独立 MediaMTX 二进制、配置和 Control API。 |
`demo` 只用于临时查看。生产数据持久性、真实设备和媒体链路不能用 demo 验证替代。
<!-- sense-windows-package:end -->
+5 -5
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Delivery-Documentation-Guide
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Delivery-Documentation-Guide.-
wiki_revision: 2cf0a0f2f261c002866c86c4e70b5f6ed89f9c1c
synchronized_at: 2026-08-13T00:53:04Z
wiki_revision: d4f2bfcef46493361ba019839b36957ed9947003
synchronized_at: 2026-08-13T02:35:26Z
<!-- gitea-wiki-mirror:end -->
# 交付文档指南
@@ -84,7 +84,7 @@ synchronized_at: 2026-08-13T00:53:04Z
Sense 面向网管、实施人员和非技术现场人员,菜单按日常任务组织:工作台 → 设备管理 → 视频接入 → 视频服务 → 实时监看 → 区域与警戒线。普通操作优先展示中文状态与下一步,不要求用户理解 ONVIF、RTSP 或 MediaMTX 内部模型。
- 系统管理员:完成一次性安全初始化,维护 Sense 用户与角色,查看操作记录。首次管理员和新用户密码至少 6 个字符,必须同时包含大写字母、小写字母和数字,且不得包含用户名。
- 系统管理员:完成一次性安全初始化,维护 Sense 用户与角色,查看操作记录。首次管理员和新用户密码仅要求至少 6 个字符,不限制字符种类并允许包含用户名;仍应由管理员选择难猜且不复用的密码。
- 实施/运维:添加设备、只写更新凭据、在获准网卡发现或手工接入、检查主辅码流、处理媒体进程与对账。
- 站点管理员:查看/维护站点设备,实时监看,绘制和发布区域版本;无权创建系统管理员。
- 只读用户:查看设备、媒体状态、实时画面和区域,不能修改。
@@ -98,7 +98,7 @@ Sense 面向网管、实施人员和非技术现场人员,菜单按日常任
交付对象为实施和运维人员。`sense-windows-amd64.zip` 包含后端程序、已构建前端、空值示例配置、上游许可证、启动脚本与包内说明;不包含 PostgreSQL、MediaMTX、Windows 服务、生产数据或秘密。
- 临时查看必须显式运行 `start-sense.bat demo`,其内存数据在进程结束后丢失,不能当作生产部署。
- 生产启动前由运维在运行包外安全注入数据库、身份签名、初始化令牌和凭据加密配置,再运行 `start-sense.bat`。
- 生产配置可由运维写入解压目录的 `config\sense.env`,或通过 Windows 进程环境安全注入;进程环境优先。先运行 `start-sense.bat check` 检查必填项,再运行 `start-sense.bat`。
- 交付时记录 ZIP SHA-256,并至少验证 `/healthz` 与首页;真实 PostgreSQL、MediaMTX、摄像机和目标浏览器仍需在获准环境验收。
- 包内 `README-WINDOWS.md` 是现场操作入口;不得把真实连接串、密码或令牌回填进示例文件后重新分发。
- 包内 `README-WINDOWS.md` 是现场操作入口;真实 `config\sense.env` 只留在具体部署目录,不得提交 Git 或重新打入交付 ZIP,交付 ZIP 只保留 `sense.env.example`。
<!-- sense-windows-package:end -->
@@ -0,0 +1,71 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-42-Sense密码策略仅限制最短6位
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-42-Sense%E5%AF%86%E7%A0%81%E7%AD%96%E7%95%A5%E4%BB%85%E9%99%90%E5%88%B6%E6%9C%80%E7%9F%AD6%E4%BD%8D.-
wiki_revision: 53d52a17ae17072e1335b8bdff30fa19ad50f20f
synchronized_at: 2026-08-13T01:20:24Z
<!-- gitea-wiki-mirror:end -->
# 42 Sense密码策略仅限制最短6位
- 类型:需求变更
- 所属 Epic:#7
- 所属 MVP / 版本:#8
- 状态:待验收
- 日期:2026-08-13
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/42
- Wiki 页面:Task-42-Sense密码策略仅限制最短6位
- Wiki revision:见本地镜像头
## 背景与目标
负责人确认 Sense demo 与生产密码仅要求至少 6 个字符,取消字符种类和用户名包含限制,以支持用户指定的简单管理员凭据。实际账号和密码属于运行时秘密,不进入本归档。
## 最终方案
- 后端 `ValidatePassword` 只按 Unicode 字符数检查至少 6 个字符;bootstrap 与创建用户继续共享该入口。
- 测试覆盖 6 位全小写、包含用户名和 6 个 Unicode 字符通过,以及 5 字符拒绝。
- 前端新用户表单只提示“至少 6 位字符”。
- Wiki 记录全模式规则和明确接受的风险,交付说明仍建议选择难猜且不复用的密码。
- 重新生成 Windows 包,使用运行时注入的脱敏凭据完成 demo 管理员初始化和登录,服务保持运行。
## 修改文件
- `Sense/server/app/sense/identity/password.go`
- `Sense/server/app/sense/identity/password_test.go`
- `Sense/ui/src/views/sense/identity/Users.vue`
- `docs/03-business-rules-and-glossary.md`
- `docs/delivery/README.md`
- `wiki-docs.json`
- `docs/task/42-Sense密码策略仅限制最短6位.md`
## 验收结果
| 验收标准 | 结果 |
|---|---|
| 6 位全小写密码通过,5 位拒绝 | 通过 |
| 包含用户名密码通过 | 通过 |
| bootstrap 与创建用户共享规则 | 通过 |
| 前端只提示至少 6 位 | 通过 |
| Windows ZIP 重建并完成管理员初始化/登录 | 通过;bootstrap 201,login 200 |
| 凭据不进入仓库、Wiki、工单或日志 | 通过;工单需求已脱敏 |
## 测试
- `go test ./app/sense/identity/...`、`go test ./...`:通过。
- `corepack pnpm@9.15.1 lint`:0 error,存在既有格式 warning。
- `cmd /c Sense\scripts\package-windows.bat`:通过;前端仅有既有 vendor bundle 体积警告。
- ZIP:7,280,519 bytes;SHA-256 `7A1DC3378ECAD90DC7521F1E49C7A279E4C31B38D6EBC8CC30E123528BECDEE4`。
- 包内 demo:管理员初始化 201、登录 200;服务监听 `127.0.0.1:18080`。
- Harness:31 项测试、strict 和 Wiki 镜像检查通过。
- **未验证部分**:未在真实 PostgreSQL 环境验证;未由目标管理员人工页面登录验收。生产弱密码风险为负责人明确接受的产品决策。
## 遗留问题
- 本策略显著增加字典猜测和凭据填充风险;当前未增加 MFA、登录限速或外部身份提供者。
## 相关提交
- `8091fbb` feat: simplify Sense password policy (#42)
- `88dcebc` docs: record length-only Sense password policy (#42)
- `245d38d` docs: create task 42 acceptance archive (#42)
@@ -0,0 +1,69 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-44-Sense管理员侧栏模块为空修复
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-44-Sense%E7%AE%A1%E7%90%86%E5%91%98%E4%BE%A7%E6%A0%8F%E6%A8%A1%E5%9D%97%E4%B8%BA%E7%A9%BA%E4%BF%AE%E5%A4%8D.-
wiki_revision: 9647fffe7d8b0ede13c53746e8966fffb3555820
synchronized_at: 2026-08-13T01:31:35Z
<!-- gitea-wiki-mirror:end -->
# 44 Sense管理员侧栏模块为空修复
- 类型:缺陷
- 所属 Epic:#7
- 所属 MVP / 版本:#8
- 状态:待验收
- 日期:2026-08-13
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/44
- Wiki 页面:Task-44-Sense管理员侧栏模块为空修复
- Wiki revision:见本地镜像头
## 背景与目标
最新 Windows 包中管理员可登录,但侧栏不显示任何模块。目标是在不放宽 RBAC 的前提下恢复 Sense MVP 导航,并防止 Vue Router 规范化记录顺序再次导致空菜单。
## 根因与最终方案
后端管理员会话正确返回 9 项权限。Vue Router `getRoutes()` 会同时返回父布局 `/` 和路径也为 `/` 的工作台子记录;原代码用首个 `path === '/'` 记录派生菜单,误选无 children 的工作台记录。
修复后只选择路径为 `/` 且具有 children 的布局记录,并在多个候选中优先 children 最完整者。权限过滤、隐藏入口过滤和排序逻辑保持不变,并提取为独立可测试函数。
## 修改文件
- `Sense/ui/src/layout/AppLayout.vue`:调用稳定的菜单派生函数。
- `Sense/ui/src/layout/navigation.js`:定位布局、按权限过滤并排序。
- `Sense/ui/tests/navigation.test.cjs`:重复根路径、权限/隐藏过滤和管理员八项导航测试。
- `Sense/ui/package.json`:增加 `test:navigation`。
- `docs/06-troubleshooting.md`:Wiki-first 排错说明。
- `wiki-docs.json`、`docs/task/44-Sense管理员侧栏模块为空修复.md`:任务归档。
## 验收结果
| 验收标准 | 结果 |
|---|---|
| 管理员显示八项 Sense MVP 导航 | 自动回归通过 |
| 不依赖重复 `/` 记录顺序 | 通过 |
| 权限不足和隐藏入口仍被过滤 | 通过 |
| 前端 test/lint/build | 通过;lint 0 error,存在既有 warning |
| Windows ZIP 重建并以管理员登录 | 通过;角色 administrator,9 项权限,新前端资源已加载 |
## 测试
- `corepack pnpm@9.15.1 test:navigation`:3 项通过。
- Vue Router 实际规范化记录探针:正确派生工作台和设备管理。
- `corepack pnpm@9.15.1 lint`:0 error,既有 warning。
- Windows 打包:通过;前端仅有既有 vendor bundle 体积警告。
- 包内运行:登录角色 administrator、权限数 9,首页引用新脚本 `js/index.25411ed5.js`。
- Harness:31 项、strict 与 Wiki 镜像检查通过。
- ZIP:7,280,825 bytes;SHA-256 `0703B397D20EAAE54BCB6B5A83170EFF6816246A8C163276495CD93EEEB72B4F`。
- **未验证部分**:未通过自动化真实浏览器截图断言侧栏 DOM;已要求用户 Ctrl+F5 后人工查看。
## 遗留问题
- 浏览器可能缓存旧前端资源,更新包后需要强制刷新。
## 相关提交
- `048a6ec` fix: restore Sense permission navigation (#44)
- `62ed5cc` docs: add Sense empty navigation troubleshooting (#44)
- `c4a799c` test: cover Sense administrator navigation (#44)
- `21624f6` docs: create task 44 acceptance archive (#44)
@@ -0,0 +1,72 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-46-Sense-Windows包内配置加载
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-46-Sense-Windows%E5%8C%85%E5%86%85%E9%85%8D%E7%BD%AE%E5%8A%A0%E8%BD%BD.-
wiki_revision: eab38da7cae8089d9cf7ed38e48d3e52962b68b4
synchronized_at: 2026-08-13T02:42:05Z
<!-- gitea-wiki-mirror:end -->
# 46 Sense Windows包内配置加载
- 类型:缺陷
- 所属 Epic:#7
- 所属 MVP / 版本:#8
- 状态:待验收
- 日期:2026-08-13
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/46
- Wiki 页面:Task-46-Sense-Windows包内配置加载
- Wiki revision:见本地镜像头
## 背景与目标
Windows 打包目录已有 `config\sense.env`,但旧版 `start-sense.bat` 只读取进程环境,导致生产启动误报缺少 `SENSE_DATABASE_URL`。本工单让启动入口安全读取包内配置,同时确保真实配置不进入 Git 或交付 ZIP。
## 最终方案
- `start-sense.bat` 保持现场入口,委托同目录 `start-sense.ps1` 加载配置并启动服务。
- 加载器只接受 `SENSE_*` 键,忽略空行和 `#` 注释;不执行配置内容、不输出配置值,外部非空进程环境变量优先。
- 默认生产模式强制 PostgreSQL,验证数据库连接、身份签名、初始化令牌和凭据加密四个必填配置;`check` 模式只验证配置,不连接数据库、不启动服务。
- `demo` 仍强制内存模式;UI 路径固定为包内 `ui`。
- 重打包会安全暂存并恢复已有部署目录的 `config\sense.env`;交付 ZIP 只包含 `sense.env.example`,不包含真实配置。
## 修改文件
- `Sense/scripts/runtime/start-sense.bat`:委托 PowerShell 启动器并传递退出码。
- `Sense/scripts/runtime/start-sense.ps1`:加载、校验配置并处理 production、demo、check 模式。
- `Sense/scripts/runtime/README-WINDOWS.md`:补充包内配置、优先级和检查命令。
- `Sense/scripts/package-windows.bat`:复制 PowerShell 启动器,重打包时保留本机配置且从 ZIP 排除。
- `docs/04-local-development-and-verification.md`、`docs/06-troubleshooting.md`、`docs/delivery/README.md`:由 Gitea Wiki 同步生成的长期说明。
## 验收结果
| 验收标准 | 结果 |
|---|---|
| 包内已有 `config\sense.env` 时无需手工 `set` 即可通过生产配置检查 | 通过 |
| `check` 不连接数据库、不启动服务且不输出配置值 | 通过 |
| 外部非空进程环境优先,加载器只接受 `SENSE_*` 且不执行配置内容 | 通过(代码检查) |
| demo 强制内存模式,production 强制 PostgreSQL 和包内 UI | 通过(代码检查) |
| 重打包后原配置不变 | 通过;重建前后 SHA-256 一致 |
| ZIP 含 BAT、PowerShell 启动器、示例与说明,不含真实 `sense.env` | 通过 |
## 测试
- 执行命令:`cmd /c Sense\scripts\package-windows.bat`
- 结果:前后端打包成功,生成 Windows amd64 目录和 ZIP。
- 执行命令:`cmd /c Sense\dist\sense-windows-amd64\start-sense.bat check`
- 结果:输出 `Sense production configuration check passed.`,退出码 0。
- 执行命令:检查 ZIP 条目和 SHA-256。
- 结果:`start-sense.ps1` 与 `sense.env.example` 存在,真实 `config/sense.env` 不存在;ZIP SHA-256 为 `6311B29F1899691AF793A4758CD59096C5388D3C85214209B9EAFBCAD232BA8A`。
- 执行命令:`python dev_scripts/sync_wiki_docs.py --check`
- 结果:Wiki 镜像检查通过。
- 执行命令:`python dev_scripts/check_harness.py --strict`
- 结果:未通过;被基线中工单 #44 归档缺少“最终方案”章节阻塞,与本工单修改无关。
- **未验证部分**:未连接用户 PostgreSQL 启动生产服务;未使用真实摄像机或 MediaMTX;因本工单不修改业务服务逻辑,这些留待部署验收。
## 遗留问题
- 基线工单 #44 的任务归档需在其自身验收闭环中补齐“最终方案”章节,之后再运行严格 Harness。
## 相关提交
- `5a17898` 修复 Windows 包内配置加载。
- `6bbaa07` 更新 Wiki 镜像中的启动、排错和交付说明。
@@ -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 镜像。
+17
View File
@@ -119,6 +119,23 @@
{
"page": "Task-40-Sense全模式密码最短6位",
"path": "docs/task/40-Sense全模式密码最短6位.md"
},
{
"page": "Task-42-Sense密码策略仅限制最短6位",
"path": "docs/task/42-Sense密码策略仅限制最短6位.md"
},
{
"page": "Task-44-Sense管理员侧栏模块为空修复",
"path": "docs/task/44-Sense管理员侧栏模块为空修复.md"
},
{
"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"
}
]
}