Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae9bd015c2 | ||
|
|
06e0790f00 | ||
|
|
009dc3cca0 | ||
|
|
573113eb3b | ||
|
|
b548b05874 | ||
|
|
23a85278cb | ||
|
|
96777a948f | ||
|
|
c2b023c9fe | ||
|
|
4c35da9ef6 | ||
|
|
30c43aa8d7 | ||
|
|
a22d3ce0f1 | ||
|
|
359c553452 | ||
|
|
2e61167500 | ||
|
|
54c58551ae | ||
|
|
67391acb16 | ||
|
|
2a395aa126 | ||
|
|
e4fed702c4 | ||
|
|
49aa79f3b9 | ||
|
|
64e20e6aed | ||
|
|
19c0868c5d | ||
|
|
1c6b30fac0 | ||
|
|
6702b8a5b9 | ||
|
|
a35f1d6770 | ||
|
|
6194b664ee | ||
|
|
1caa429cad | ||
|
|
f99fe8d4f7 | ||
|
|
b4a8e0e1f1 | ||
|
|
86c3e79121 |
@@ -0,0 +1,59 @@
|
||||
# Bell 独立纵切验收
|
||||
|
||||
本验收只使用 Bell 自身、临时 PostgreSQL 和项目内合成事件,不启动或调用 Sense、Brain,不连接默认 5432、生产数据库或客户数据。
|
||||
|
||||
## 固定工具链
|
||||
|
||||
```powershell
|
||||
$env:GOTOOLCHAIN='go1.26.5'
|
||||
go version
|
||||
node --version
|
||||
corepack pnpm@9.15.1 --version
|
||||
```
|
||||
|
||||
预期分别为 Go 1.26.5、Node 22.22.1、pnpm 9.15.1。
|
||||
|
||||
## 源码验证
|
||||
|
||||
```powershell
|
||||
Set-Location Bell\server
|
||||
$env:GOTOOLCHAIN='go1.26.5'
|
||||
go test ./... -count=1
|
||||
go vet ./...
|
||||
go build ./...
|
||||
|
||||
Set-Location ..\ui
|
||||
corepack pnpm@9.15.1 install --frozen-lockfile
|
||||
corepack pnpm@9.15.1 lint
|
||||
corepack pnpm@9.15.1 test:unit --runInBand
|
||||
corepack pnpm@9.15.1 build:prod
|
||||
```
|
||||
|
||||
## Windows 包和隔离 E2E
|
||||
|
||||
```powershell
|
||||
Set-Location <仓库根目录>
|
||||
Bell\scripts\build\build-windows.bat
|
||||
pwsh -NoProfile -File Bell\scripts\build\test-package.ps1 -PackageRoot Bell\dist\bell-windows-amd64
|
||||
pwsh -NoProfile -File Bell\scripts\test-independent-e2e.ps1 -PreparedPackageRoot Bell\dist\bell-windows-amd64
|
||||
```
|
||||
|
||||
E2E 自动完成并清理:临时 PostgreSQL、随机数据库/HTTP 端口、随机管理员/处置员凭据、迁移、健康检查、登录/RBAC、最小 Bell 菜单、规则、合成 Event/Receipt 幂等、Alert、20 路并发 ack、越权/缺参拒绝、close 重放幂等、两条生命周期时间线、冷重启、Windows stop 和日志泄密检查。原始包保持生产配置并先通过审计;业务自动化只把临时包副本切换为 `dev` 测试模式。生产验证码的获取、正确登录、错误及重放拒绝由 #138 的 `Bell/server/tests/bell_production_login/run-postgres.ps1` 覆盖,不暴露或识别验证码答案。
|
||||
|
||||
浏览器验收打开脚本输出的临时 `base_url`,检查:
|
||||
|
||||
- 匿名访问跳转登录页,并显示验证码输入;测试模式可填写任意非空验证码,生产验证码行为由 #138 回归覆盖;
|
||||
- 登录后保留 GoAdmin 侧栏、顶部导航和标签页;
|
||||
- 管理员显示 Bell 必要业务菜单,包括预警管理、事件查询、规则配置;处置员仅显示预警处理所需入口;
|
||||
- 预警详情可显示关联事件、处理人、现场结果和两条处理时间线;
|
||||
- 不显示开发工具、定时任务、系统监控等无关入口。
|
||||
|
||||
## 仓库闭环
|
||||
|
||||
```powershell
|
||||
python dev_scripts/harness.py check --strict
|
||||
git diff --check
|
||||
git status --short --branch
|
||||
```
|
||||
|
||||
浏览器人工/工具检查、真实生产数据库、客户网络和长期负载不由 API 单测替代;未执行的项目必须在工单证据中明确说明。
|
||||
@@ -0,0 +1,59 @@
|
||||
# Bell Windows 运行说明
|
||||
|
||||
Bell Windows 包包含独立后端、GoAdmin 管理端静态资源和启动、停止、检查脚本。正式运行需要独立 PostgreSQL;包内不提供默认账号、密码、JWT secret 或数据库。
|
||||
|
||||
## 配置
|
||||
|
||||
编辑 `config\bell.env`:
|
||||
|
||||
```text
|
||||
BELL_HOST=127.0.0.1
|
||||
BELL_PORT=18090
|
||||
BELL_WEB_HOST=127.0.0.1
|
||||
BELL_WEB_PORT=18091
|
||||
BELL_DATABASE_URL=host=127.0.0.1 port=5432 user=bell dbname=bell sslmode=disable
|
||||
BELL_JWT_SECRET=<至少 32 字符的独立随机值>
|
||||
BELL_BOOTSTRAP_USERNAME=<仅首次迁移使用>
|
||||
BELL_BOOTSTRAP_PASSWORD=<仅首次迁移使用,至少 8 字符>
|
||||
BELL_AUTO_MIGRATE=true
|
||||
BELL_SYNTHETIC_EVENTS_ENABLED=false
|
||||
```
|
||||
|
||||
不要把真实配置提交到 Git。首次迁移成功后,建议从进程环境中移除 `BELL_BOOTSTRAP_PASSWORD`;它不会写入明文数据库。
|
||||
|
||||
## 启动、检查和停止
|
||||
|
||||
```bat
|
||||
check-bell.bat
|
||||
start-bell.bat
|
||||
check-bell.bat -Running
|
||||
stop-bell.bat
|
||||
```
|
||||
|
||||
浏览器访问 `http://127.0.0.1:18091/`。`BELL_PORT` 是仅供本机 Web 网关访问的后端端口;`BELL_WEB_PORT` 是用户访问入口。启动脚本默认先执行幂等数据库迁移,再启动后端和 Web 网关;任一步失败都会返回非零退出码。
|
||||
|
||||
`stop-bell.bat` 只按包内 PID 文件和启动命令行核对后停止本包进程树,不按端口终止未知进程。运行日志位于 `runtime\logs`,不得包含密码、JWT 或登录 token。
|
||||
|
||||
## 构建和包审计
|
||||
|
||||
从仓库根目录运行:
|
||||
|
||||
```powershell
|
||||
Bell\scripts\build\build-windows.bat
|
||||
pwsh -NoProfile -File Bell\scripts\build\test-package.ps1 -PackageRoot Bell\dist\bell-windows-amd64
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
- `Bell\dist\bell-windows-amd64\`
|
||||
- `Bell\dist\bell-windows-amd64.zip`
|
||||
|
||||
包内 `VERSION.txt`、`MANIFEST.sha256` 和 `LICENSES\` 分别记录源码提交、工具链、文件摘要、GoAdmin 来源及 MIT 许可证。
|
||||
|
||||
## 常见错误
|
||||
|
||||
- `BELL_DATABASE_URL is required`:设置独立 PostgreSQL 连接串。
|
||||
- `PostgreSQL is unreachable`:启动 PostgreSQL,并检查地址和端口。
|
||||
- `BELL_JWT_SECRET must contain...`:生成至少 32 字符、只供 Bell 使用的随机值。
|
||||
- `port ... is already in use`:停止已有 Bell,或修改后端/Web 端口。
|
||||
- `Bell database migration failed`:检查数据库是否存在、用户权限及迁移日志;不要删除已有 Event、Alert 或生命周期事实。
|
||||
@@ -37,3 +37,12 @@ corepack pnpm@9.15.1 dev
|
||||
```
|
||||
|
||||
生产构建使用 `corepack pnpm@9.15.1 build:prod`。生产环境不会生成或接受仓库默认管理员、默认 JWT secret 或默认数据库连接串。
|
||||
|
||||
## Windows 交付与独立验收
|
||||
|
||||
- Windows 构建:`Bell\scripts\build\build-windows.bat`
|
||||
- 包审计:`pwsh -NoProfile -File Bell\scripts\build\test-package.ps1 -PackageRoot Bell\dist\bell-windows-amd64`
|
||||
- 隔离 E2E:`pwsh -NoProfile -File Bell\scripts\test-independent-e2e.ps1 -PreparedPackageRoot Bell\dist\bell-windows-amd64`
|
||||
- 包内启动、检查和停止:`start-bell.bat`、`check-bell.bat -Running`、`stop-bell.bat`
|
||||
|
||||
完整配置、排错和验收标准见 `README-WINDOWS.md` 与 `ACCEPTANCE.md`。隔离 E2E 使用临时 PostgreSQL、随机端口和随机凭据,不启动或调用 Sense、Brain。
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Bell production environment. Copy values into process environment or this file.
|
||||
BELL_HOST=127.0.0.1
|
||||
BELL_PORT=18090
|
||||
BELL_WEB_HOST=127.0.0.1
|
||||
BELL_WEB_PORT=18091
|
||||
BELL_DATABASE_URL=
|
||||
BELL_JWT_SECRET=
|
||||
BELL_BOOTSTRAP_USERNAME=
|
||||
BELL_BOOTSTRAP_PASSWORD=
|
||||
BELL_AUTO_MIGRATE=true
|
||||
BELL_SYNTHETIC_EVENTS_ENABLED=false
|
||||
@@ -0,0 +1,13 @@
|
||||
param([Parameter(Mandatory = $true)][string]$WebRoot)
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = [IO.Path]::GetFullPath($WebRoot)
|
||||
$index = Join-Path $root 'index.html'
|
||||
if (-not (Test-Path -LiteralPath $index -PathType Leaf)) { throw 'web/index.html is missing.' }
|
||||
$html = Get-Content -LiteralPath $index -Raw -Encoding UTF8
|
||||
$references = [regex]::Matches($html, '(?:src|href)=["''](?<path>/[^"''?#]+)') | ForEach-Object { $_.Groups['path'].Value.TrimStart('/').Replace('/', '\') }
|
||||
foreach ($relative in $references | Sort-Object -Unique) {
|
||||
if ($relative -match '^https?:') { continue }
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $root $relative) -PathType Leaf)) { throw "web asset referenced by index.html is missing: $relative" }
|
||||
}
|
||||
Write-Host "Bell web asset check passed: $root"
|
||||
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
setlocal
|
||||
where pwsh.exe >nul 2>nul
|
||||
if %errorlevel% equ 0 (pwsh.exe -NoProfile -File "%~dp0build-windows.ps1" %*) else (powershell.exe -NoProfile -File "%~dp0build-windows.ps1" %*)
|
||||
exit /b %errorlevel%
|
||||
@@ -0,0 +1,88 @@
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$bellRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
|
||||
$repositoryRoot = Split-Path $bellRoot -Parent
|
||||
$serverRoot = Join-Path $bellRoot 'server'
|
||||
$uiRoot = Join-Path $bellRoot 'ui'
|
||||
$distRoot = Join-Path $bellRoot 'dist'
|
||||
$target = Join-Path $distRoot 'bell-windows-amd64'
|
||||
$archive = Join-Path $distRoot 'bell-windows-amd64.zip'
|
||||
$staging = Join-Path $distRoot ('.bell-windows-amd64.staging-' + $PID)
|
||||
|
||||
function Assert-ChildPath([string]$Parent,[string]$Child) {
|
||||
$parentPath = [IO.Path]::GetFullPath($Parent).TrimEnd('\') + '\'
|
||||
$childPath = [IO.Path]::GetFullPath($Child)
|
||||
if (-not $childPath.StartsWith($parentPath,[StringComparison]::OrdinalIgnoreCase)) { throw "Unsafe build path outside $Parent`: $Child" }
|
||||
}
|
||||
function Get-FileSha256([string]$Path) {
|
||||
$sha = [Security.Cryptography.SHA256]::Create(); $stream = [IO.File]::OpenRead($Path)
|
||||
try { return ([BitConverter]::ToString($sha.ComputeHash($stream))).Replace('-','') } finally { $stream.Dispose(); $sha.Dispose() }
|
||||
}
|
||||
Assert-ChildPath $bellRoot $distRoot; Assert-ChildPath $distRoot $target; Assert-ChildPath $distRoot $archive; Assert-ChildPath $distRoot $staging
|
||||
|
||||
$savedToolchain = $env:GOTOOLCHAIN
|
||||
$env:GOTOOLCHAIN = 'go1.26.5'
|
||||
try {
|
||||
Push-Location $serverRoot
|
||||
try { $goVersion = (& go env GOVERSION).Trim() } finally { Pop-Location }
|
||||
$nodeVersion = (& node --version).Trim().TrimStart('v')
|
||||
$pnpmVersion = (& corepack pnpm@9.15.1 --version).Trim()
|
||||
if ($goVersion -ne 'go1.26.5') { throw "Go 1.26.5 is required; found $goVersion." }
|
||||
if ($nodeVersion -ne '22.22.1') { throw "Node 22.22.1 is required; found $nodeVersion." }
|
||||
if ($pnpmVersion -ne '9.15.1') { throw "pnpm 9.15.1 is required; found $pnpmVersion." }
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $distRoot | Out-Null
|
||||
if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force }
|
||||
New-Item -ItemType Directory -Path $staging | Out-Null
|
||||
Push-Location $uiRoot
|
||||
try {
|
||||
& corepack pnpm@9.15.1 install --frozen-lockfile
|
||||
if ($LASTEXITCODE -ne 0) { throw 'pnpm install failed.' }
|
||||
& corepack pnpm@9.15.1 run build:prod
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Bell UI production build failed.' }
|
||||
# The frozen Vue CLI differential build references a module runtime
|
||||
# that ScriptExt removes from disk. The complete legacy bundle is
|
||||
# present, so make that reproducible bundle the package entry point.
|
||||
$builtIndex = Join-Path $uiRoot 'dist\index.html'
|
||||
$html = Get-Content -LiteralPath $builtIndex -Raw -Encoding UTF8
|
||||
$html = [regex]::Replace($html, '<script[^>]+type="module"[^>]*></script>', '')
|
||||
$html = $html.Replace(' nomodule', '')
|
||||
[IO.File]::WriteAllText($builtIndex, $html, (New-Object Text.UTF8Encoding($false)))
|
||||
} finally { Pop-Location }
|
||||
|
||||
$oldGOOS,$oldGOARCH,$oldCGO = $env:GOOS,$env:GOARCH,$env:CGO_ENABLED
|
||||
try {
|
||||
$env:GOOS='windows'; $env:GOARCH='amd64'; $env:CGO_ENABLED='0'
|
||||
Push-Location $serverRoot
|
||||
try { & go build -trimpath -ldflags '-s -w' -o (Join-Path $staging 'bell.exe') .; if ($LASTEXITCODE -ne 0) { throw 'Bell server Windows build failed.' } } finally { Pop-Location }
|
||||
} finally { $env:GOOS,$env:GOARCH,$env:CGO_ENABLED=$oldGOOS,$oldGOARCH,$oldCGO }
|
||||
|
||||
Copy-Item -LiteralPath (Join-Path $uiRoot 'dist') -Destination (Join-Path $staging 'web') -Recurse
|
||||
New-Item -ItemType Directory -Path (Join-Path $staging 'scripts\runtime'),(Join-Path $staging 'config'),(Join-Path $staging 'LICENSES') | Out-Null
|
||||
Copy-Item -Path (Join-Path $bellRoot 'scripts\runtime\*.ps1') -Destination (Join-Path $staging 'scripts\runtime')
|
||||
foreach ($name in @('start-bell','stop-bell','check-bell')) { Copy-Item -LiteralPath (Join-Path $bellRoot "scripts\runtime\$name.bat") -Destination (Join-Path $staging "$name.bat") }
|
||||
Copy-Item -LiteralPath (Join-Path $bellRoot 'config\bell.env.example') -Destination (Join-Path $staging 'config\bell.env.example')
|
||||
Copy-Item -LiteralPath (Join-Path $bellRoot 'config\bell.env.example') -Destination (Join-Path $staging 'config\bell.env')
|
||||
Copy-Item -LiteralPath (Join-Path $serverRoot 'config\settings.yml') -Destination (Join-Path $staging 'config\settings.yml')
|
||||
Copy-Item -LiteralPath (Join-Path $serverRoot 'config\db.sql') -Destination (Join-Path $staging 'config\db.sql')
|
||||
Copy-Item -LiteralPath (Join-Path $serverRoot 'config\pg.sql') -Destination (Join-Path $staging 'config\pg.sql')
|
||||
Copy-Item -LiteralPath (Join-Path $bellRoot 'README-WINDOWS.md') -Destination (Join-Path $staging 'README-WINDOWS.md')
|
||||
Copy-Item -LiteralPath (Join-Path $bellRoot 'LICENSES') -Destination $staging -Recurse -Force
|
||||
Copy-Item -LiteralPath (Join-Path $serverRoot 'LICENSE.md') -Destination (Join-Path $staging 'LICENSES\Bell-server-LICENSE.md')
|
||||
Copy-Item -LiteralPath (Join-Path $uiRoot 'LICENSE') -Destination (Join-Path $staging 'LICENSES\Bell-ui-LICENSE')
|
||||
$commit = (& git -C $repositoryRoot rev-parse HEAD).Trim()
|
||||
[IO.File]::WriteAllLines((Join-Path $staging 'VERSION.txt'),@("source_commit=$commit",'go=1.26.5','node=22.22.1','pnpm=9.15.1'),(New-Object Text.UTF8Encoding($false)))
|
||||
& (Join-Path $PSScriptRoot 'test-package.ps1') -PackageRoot $staging
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Bell package audit failed.' }
|
||||
$manifest = foreach ($file in Get-ChildItem -LiteralPath $staging -Recurse -File | Sort-Object FullName) { "$(Get-FileSha256 $file.FullName) $($file.FullName.Substring($staging.Length+1).Replace('\','/'))" }
|
||||
[IO.File]::WriteAllLines((Join-Path $staging 'MANIFEST.sha256'),$manifest,(New-Object Text.UTF8Encoding($false)))
|
||||
if (Test-Path -LiteralPath $target) { Remove-Item -LiteralPath $target -Recurse -Force }
|
||||
Move-Item -LiteralPath $staging -Destination $target
|
||||
if (Test-Path -LiteralPath $archive) { Remove-Item -LiteralPath $archive -Force }
|
||||
Compress-Archive -LiteralPath $target -DestinationPath $archive -CompressionLevel Optimal
|
||||
Write-Host "Bell Windows package: $target"
|
||||
Write-Host "Bell Windows archive: $archive"
|
||||
} finally {
|
||||
$env:GOTOOLCHAIN = $savedToolchain
|
||||
if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
param([Parameter(Mandatory = $true)][string]$PackageRoot)
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$root = [IO.Path]::GetFullPath($PackageRoot)
|
||||
if (-not (Test-Path -LiteralPath $root -PathType Container)) { throw "Package directory not found: $root" }
|
||||
$required = @(
|
||||
'bell.exe','start-bell.bat','stop-bell.bat','check-bell.bat','README-WINDOWS.md',
|
||||
'config\bell.env','config\bell.env.example','config\settings.yml','config\db.sql','config\pg.sql','web\index.html',
|
||||
'scripts\runtime\bell-common.ps1','scripts\runtime\bell-web.ps1',
|
||||
'LICENSES\SOURCES.md','LICENSES\go-admin-LICENSE.md','LICENSES\go-admin-ui-LICENSE',
|
||||
'VERSION.txt'
|
||||
)
|
||||
foreach ($relative in $required) { if (-not (Test-Path -LiteralPath (Join-Path $root $relative))) { throw "Package is missing required path: $relative" } }
|
||||
& (Join-Path $PSScriptRoot 'assert-web-assets.ps1') -WebRoot (Join-Path $root 'web')
|
||||
$forbiddenDirectories = Get-ChildItem -LiteralPath $root -Recurse -Directory | Where-Object { $_.Name -in @('node_modules','.git','dist','.cache') }
|
||||
if ($forbiddenDirectories) { throw "Package contains forbidden build directory: $($forbiddenDirectories[0].FullName)" }
|
||||
$forbiddenFiles = Get-ChildItem -LiteralPath $root -Recurse -File | Where-Object { $_.Extension -in @('.db','.sqlite','.sqlite3','.dump','.bak') }
|
||||
if ($forbiddenFiles) { throw "Package contains database or backup data: $($forbiddenFiles[0].FullName)" }
|
||||
$config = Get-Content -LiteralPath (Join-Path $root 'config\bell.env') -Raw -Encoding UTF8
|
||||
foreach ($secret in @('BELL_DATABASE_URL','BELL_JWT_SECRET','BELL_BOOTSTRAP_USERNAME','BELL_BOOTSTRAP_PASSWORD')) {
|
||||
if ($config -match "(?m)^$secret[ \t]*=[ \t]*[^ \t\r\n]") { throw "Package contains a non-empty credential field: $secret" }
|
||||
}
|
||||
$sources = Get-Content -LiteralPath (Join-Path $root 'LICENSES\SOURCES.md') -Raw -Encoding UTF8
|
||||
foreach ($commit in @('f06540883b41d03782bb6b2c4150f298f328c6b6','67d393d713877572fab0b897296a4c1d525fc81d','424855aacf6905f3fde860c3331385cb25529a0d')) {
|
||||
if (-not $sources.Contains($commit)) { throw "Package source evidence is missing commit $commit" }
|
||||
}
|
||||
$version = Get-Content -LiteralPath (Join-Path $root 'VERSION.txt') -Raw -Encoding UTF8
|
||||
foreach ($entry in @('go=1.26.5','node=22.22.1','pnpm=9.15.1')) { if (-not $version.Contains($entry)) { throw "Package version evidence is missing $entry" } }
|
||||
$textExtensions = @('.md','.txt','.env','.example','.ps1','.bat','.yml','.yaml','.json','.html','.js','.css')
|
||||
foreach ($file in Get-ChildItem -LiteralPath $root -Recurse -File | Where-Object { $textExtensions -contains $_.Extension.ToLowerInvariant() }) {
|
||||
$content = [string](Get-Content -LiteralPath $file.FullName -Raw -ErrorAction SilentlyContinue)
|
||||
if ($content -match '(?i)(admin123|password123|BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY)') { throw "Package contains a forbidden default credential or private key marker: $($file.FullName)" }
|
||||
}
|
||||
Write-Host "Bell package audit passed: $root"
|
||||
@@ -0,0 +1,117 @@
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$script:BellAllowedEnvironment = @(
|
||||
'BELL_HOST', 'BELL_PORT', 'BELL_WEB_HOST', 'BELL_WEB_PORT',
|
||||
'BELL_DATABASE_URL', 'BELL_JWT_SECRET', 'BELL_BOOTSTRAP_USERNAME',
|
||||
'BELL_BOOTSTRAP_PASSWORD', 'BELL_AUTO_MIGRATE',
|
||||
'BELL_SYNTHETIC_EVENTS_ENABLED'
|
||||
)
|
||||
|
||||
function Get-BellPackageRoot {
|
||||
param([string]$ScriptDirectory = $PSScriptRoot)
|
||||
return [IO.Path]::GetFullPath((Join-Path $ScriptDirectory '..\..'))
|
||||
}
|
||||
function Import-BellEnvironment {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "Bell configuration file not found: $Path" }
|
||||
$lineNumber = 0
|
||||
foreach ($rawLine in Get-Content -LiteralPath $Path -Encoding UTF8) {
|
||||
$lineNumber++
|
||||
$line = $rawLine.Trim()
|
||||
if ($line.Length -eq 0 -or $line.StartsWith('#')) { continue }
|
||||
$separator = $line.IndexOf('=')
|
||||
if ($separator -lt 1) { throw "Invalid Bell configuration at line $lineNumber. Expected NAME=value." }
|
||||
$name = $line.Substring(0, $separator).Trim()
|
||||
if ($script:BellAllowedEnvironment -notcontains $name) { throw "Unsupported Bell configuration key at line ${lineNumber}: $name" }
|
||||
$value = $line.Substring($separator + 1)
|
||||
if ($value.Length -ge 2) {
|
||||
$first, $last = $value[0], $value[$value.Length - 1]
|
||||
if (($first -eq '"' -and $last -eq '"') -or ($first -eq "'" -and $last -eq "'")) { $value = $value.Substring(1, $value.Length - 2) }
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name, 'Process'))) {
|
||||
[Environment]::SetEnvironmentVariable($name, $value, 'Process')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-BellEnvironmentValue {
|
||||
param([Parameter(Mandatory = $true)][string]$Name, [string]$Default = '')
|
||||
$value = [Environment]::GetEnvironmentVariable($Name, 'Process')
|
||||
if ([string]::IsNullOrWhiteSpace($value)) { return $Default }
|
||||
return $value
|
||||
}
|
||||
|
||||
function Test-BellTcpEndpoint {
|
||||
param([Parameter(Mandatory = $true)][string]$HostName, [Parameter(Mandatory = $true)][int]$Port, [int]$TimeoutMilliseconds = 2000)
|
||||
$client = [Net.Sockets.TcpClient]::new()
|
||||
try { return $client.ConnectAsync($HostName, $Port).Wait($TimeoutMilliseconds) -and $client.Connected } catch { return $false } finally { $client.Dispose() }
|
||||
}
|
||||
|
||||
function Test-BellListenPortAvailable {
|
||||
param([Parameter(Mandatory = $true)][string]$HostName, [Parameter(Mandatory = $true)][int]$Port)
|
||||
$ip = if ($HostName -eq '0.0.0.0') { [Net.IPAddress]::Any } elseif ($HostName -in @('127.0.0.1', 'localhost')) { [Net.IPAddress]::Loopback } else { [Net.IPAddress]::Parse($HostName) }
|
||||
$listener = [Net.Sockets.TcpListener]::new($ip, $Port)
|
||||
try { $listener.Start(); return $true } catch { return $false } finally { try { $listener.Stop() } catch {} }
|
||||
}
|
||||
|
||||
function Get-BellDatabaseEndpoint {
|
||||
param([Parameter(Mandatory = $true)][string]$Connection)
|
||||
if ($Connection -match '^postgres(?:ql)?://') {
|
||||
$uri = [Uri]$Connection
|
||||
return [pscustomobject]@{ Host = $uri.Host; Port = $(if ($uri.IsDefaultPort) { 5432 } else { $uri.Port }); Database = $uri.AbsolutePath.TrimStart('/') }
|
||||
}
|
||||
$values = @{}
|
||||
foreach ($match in [regex]::Matches($Connection, '(?:^|\s)(?<key>[A-Za-z_][A-Za-z0-9_]*)=(?<value>''(?:[^'']|'''')*''|"(?:[^"]|"")*"|[^\s]+)')) {
|
||||
$value = $match.Groups['value'].Value.Trim("'", '"')
|
||||
$values[$match.Groups['key'].Value.ToLowerInvariant()] = $value
|
||||
}
|
||||
if ($values.Count -eq 0) { throw 'BELL_DATABASE_URL must be a PostgreSQL URI or keyword connection string.' }
|
||||
return [pscustomobject]@{ Host = $(if ($values.host) { $values.host } else { '127.0.0.1' }); Port = $(if ($values.port) { [int]$values.port } else { 5432 }); Database = [string]$values.dbname }
|
||||
}
|
||||
|
||||
function Get-BellPort {
|
||||
param([string]$Name, [int]$Default)
|
||||
$text = Get-BellEnvironmentValue -Name $Name -Default $Default.ToString()
|
||||
$port = 0
|
||||
if (-not [int]::TryParse($text, [ref]$port) -or $port -lt 1 -or $port -gt 65535) { throw "$Name must be an integer between 1 and 65535." }
|
||||
return $port
|
||||
}
|
||||
|
||||
function Initialize-BellRuntime {
|
||||
param([Parameter(Mandatory = $true)][string]$PackageRoot, [switch]$AllowOccupiedPorts)
|
||||
Import-BellEnvironment -Path (Join-Path $PackageRoot 'config\bell.env')
|
||||
$hostName = Get-BellEnvironmentValue -Name 'BELL_HOST' -Default '127.0.0.1'
|
||||
$webHost = Get-BellEnvironmentValue -Name 'BELL_WEB_HOST' -Default '127.0.0.1'
|
||||
if ($hostName -notin @('127.0.0.1', 'localhost') -or $webHost -notin @('127.0.0.1', 'localhost')) { throw 'BELL_HOST and BELL_WEB_HOST must be loopback addresses.' }
|
||||
$port = Get-BellPort -Name 'BELL_PORT' -Default 18090
|
||||
$webPort = Get-BellPort -Name 'BELL_WEB_PORT' -Default 18091
|
||||
if ($port -eq $webPort) { throw 'BELL_PORT and BELL_WEB_PORT must be different.' }
|
||||
if (-not $AllowOccupiedPorts) {
|
||||
if (-not (Test-BellListenPortAvailable -HostName $hostName -Port $port)) { throw "Bell backend port $hostName`:$port is already in use." }
|
||||
if (-not (Test-BellListenPortAvailable -HostName $webHost -Port $webPort)) { throw "Bell web port $webHost`:$webPort is already in use." }
|
||||
}
|
||||
$databaseURL = Get-BellEnvironmentValue -Name 'BELL_DATABASE_URL'
|
||||
if ([string]::IsNullOrWhiteSpace($databaseURL)) { throw 'BELL_DATABASE_URL is required.' }
|
||||
$database = Get-BellDatabaseEndpoint -Connection $databaseURL
|
||||
if ([string]::IsNullOrWhiteSpace($database.Database)) { throw 'BELL_DATABASE_URL must name a database.' }
|
||||
if (-not (Test-BellTcpEndpoint -HostName $database.Host -Port $database.Port)) { throw "PostgreSQL is unreachable at $($database.Host):$($database.Port)." }
|
||||
$jwt = Get-BellEnvironmentValue -Name 'BELL_JWT_SECRET'
|
||||
if ($jwt.Length -lt 32 -or $jwt.StartsWith('__BELL_')) { throw 'BELL_JWT_SECRET must contain at least 32 non-default characters.' }
|
||||
$webRoot = Join-Path $PackageRoot 'web'
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $webRoot 'index.html') -PathType Leaf)) { throw "Bell web assets are missing: $webRoot" }
|
||||
return [pscustomobject]@{
|
||||
Host = $hostName; Port = $port; WebHost = $webHost; WebPort = $webPort;
|
||||
BackendUrl = "http://$hostName`:$port"; WebUrl = "http://$webHost`:$webPort";
|
||||
SettingsPath = (Join-Path $PackageRoot 'config\settings.yml'); WebRoot = $webRoot
|
||||
}
|
||||
}
|
||||
|
||||
function Wait-BellHealth {
|
||||
param([Parameter(Mandatory = $true)][string]$BaseUrl, [int]$Attempts = 100)
|
||||
for ($attempt = 0; $attempt -lt $Attempts; $attempt++) {
|
||||
try { $health = Invoke-RestMethod -Uri "$BaseUrl/healthz" -TimeoutSec 2 -NoProxy; if ($health.status -eq 'ok' -and $health.service -eq 'bell') { return } } catch {}
|
||||
Start-Sleep -Milliseconds 300
|
||||
}
|
||||
throw "Bell health check timed out: $BaseUrl/healthz"
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$WebRoot,
|
||||
[Parameter(Mandatory = $true)][string]$ListenHost,
|
||||
[Parameter(Mandatory = $true)][int]$ListenPort,
|
||||
[Parameter(Mandatory = $true)][string]$BackendUrl
|
||||
)
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$root = [IO.Path]::GetFullPath($WebRoot).TrimEnd('\') + '\'
|
||||
$listener = [Net.HttpListener]::new()
|
||||
$listener.Prefixes.Add("http://$ListenHost`:$ListenPort/")
|
||||
$handler = [Net.Http.HttpClientHandler]::new()
|
||||
$handler.UseProxy = $false
|
||||
$client = [Net.Http.HttpClient]::new($handler)
|
||||
$mime = @{ '.html'='text/html; charset=utf-8'; '.js'='application/javascript; charset=utf-8'; '.css'='text/css; charset=utf-8'; '.json'='application/json; charset=utf-8'; '.svg'='image/svg+xml'; '.png'='image/png'; '.jpg'='image/jpeg'; '.jpeg'='image/jpeg'; '.gif'='image/gif'; '.ico'='image/x-icon'; '.woff'='font/woff'; '.woff2'='font/woff2'; '.ttf'='font/ttf'; '.eot'='application/vnd.ms-fontobject' }
|
||||
|
||||
try {
|
||||
$listener.Start()
|
||||
Write-Host "Bell web listening at http://$ListenHost`:$ListenPort/"
|
||||
while ($listener.IsListening) {
|
||||
$context = $listener.GetContext()
|
||||
try {
|
||||
$request = $context.Request
|
||||
$response = $context.Response
|
||||
$path = $request.Url.AbsolutePath
|
||||
if ($path -eq '/healthz' -or $path.StartsWith('/api/')) {
|
||||
$target = "$BackendUrl$($request.Url.PathAndQuery)"
|
||||
$message = [Net.Http.HttpRequestMessage]::new([Net.Http.HttpMethod]::new($request.HttpMethod), $target)
|
||||
if ($request.HasEntityBody) {
|
||||
$memory = [IO.MemoryStream]::new()
|
||||
$request.InputStream.CopyTo($memory)
|
||||
$message.Content = [Net.Http.ByteArrayContent]::new($memory.ToArray())
|
||||
$memory.Dispose()
|
||||
}
|
||||
foreach ($key in $request.Headers.AllKeys) {
|
||||
if ($key -in @('Host','Content-Length')) { continue }
|
||||
$values = $request.Headers.GetValues($key)
|
||||
if (-not $message.Headers.TryAddWithoutValidation($key, $values) -and $null -ne $message.Content) { [void]$message.Content.Headers.TryAddWithoutValidation($key, $values) }
|
||||
}
|
||||
$upstream = $client.SendAsync($message).GetAwaiter().GetResult()
|
||||
$bytes = $upstream.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult()
|
||||
$response.StatusCode = [int]$upstream.StatusCode
|
||||
if ($upstream.Content.Headers.ContentType) { $response.ContentType = $upstream.Content.Headers.ContentType.ToString() }
|
||||
$response.ContentLength64 = $bytes.Length
|
||||
$response.OutputStream.Write($bytes, 0, $bytes.Length)
|
||||
$message.Dispose(); $upstream.Dispose()
|
||||
} else {
|
||||
$relative = [Uri]::UnescapeDataString($path.TrimStart('/')).Replace('/', '\')
|
||||
if ([string]::IsNullOrWhiteSpace($relative)) { $relative = 'index.html' }
|
||||
$file = [IO.Path]::GetFullPath((Join-Path $root $relative))
|
||||
if (-not $file.StartsWith($root, [StringComparison]::OrdinalIgnoreCase)) { $response.StatusCode = 403 }
|
||||
elseif (-not (Test-Path -LiteralPath $file -PathType Leaf)) {
|
||||
$file = Join-Path $root 'index.html'
|
||||
}
|
||||
if ($response.StatusCode -ne 403) {
|
||||
$bytes = [IO.File]::ReadAllBytes($file)
|
||||
$extension = [IO.Path]::GetExtension($file).ToLowerInvariant()
|
||||
$response.ContentType = $(if ($mime.ContainsKey($extension)) { $mime[$extension] } else { 'application/octet-stream' })
|
||||
$response.ContentLength64 = $bytes.Length
|
||||
$response.OutputStream.Write($bytes, 0, $bytes.Length)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
try { $context.Response.StatusCode = 502; $bytes = [Text.Encoding]::UTF8.GetBytes('Bell web gateway error'); $context.Response.ContentLength64 = $bytes.Length; $context.Response.OutputStream.Write($bytes,0,$bytes.Length) } catch {}
|
||||
} finally {
|
||||
try { $context.Response.OutputStream.Close() } catch {}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
$client.Dispose(); $handler.Dispose(); try { $listener.Stop() } catch {}; $listener.Close()
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
setlocal
|
||||
where pwsh.exe >nul 2>nul
|
||||
if %errorlevel% equ 0 (pwsh.exe -NoProfile -File "%~dp0scripts\runtime\check-bell.ps1" %*) else (powershell.exe -NoProfile -File "%~dp0scripts\runtime\check-bell.ps1" %*)
|
||||
exit /b %errorlevel%
|
||||
@@ -0,0 +1,12 @@
|
||||
param([switch]$Running)
|
||||
. (Join-Path $PSScriptRoot 'bell-common.ps1')
|
||||
try {
|
||||
$root = Get-BellPackageRoot
|
||||
$state = Initialize-BellRuntime -PackageRoot $root -AllowOccupiedPorts:$Running
|
||||
if ($Running) {
|
||||
Wait-BellHealth -BaseUrl $state.BackendUrl -Attempts 2
|
||||
Wait-BellHealth -BaseUrl $state.WebUrl -Attempts 2
|
||||
}
|
||||
Write-Host "Bell configuration check passed. PostgreSQL reachable; backend=$($state.BackendUrl); web=$($state.WebUrl)."
|
||||
exit 0
|
||||
} catch { Write-Error $_.Exception.Message; exit 1 }
|
||||
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
setlocal
|
||||
where pwsh.exe >nul 2>nul
|
||||
if %errorlevel% equ 0 (pwsh.exe -NoProfile -File "%~dp0scripts\runtime\start-bell.ps1" %*) else (powershell.exe -NoProfile -File "%~dp0scripts\runtime\start-bell.ps1" %*)
|
||||
exit /b %errorlevel%
|
||||
@@ -0,0 +1,41 @@
|
||||
param([switch]$SkipMigration)
|
||||
. (Join-Path $PSScriptRoot 'bell-common.ps1')
|
||||
|
||||
$backend = $null
|
||||
$pidFile = $null
|
||||
try {
|
||||
$root = Get-BellPackageRoot
|
||||
$state = Initialize-BellRuntime -PackageRoot $root
|
||||
$bell = Join-Path $root 'bell.exe'
|
||||
if (-not (Test-Path -LiteralPath $bell -PathType Leaf)) { throw "Bell executable not found: $bell" }
|
||||
$runtime = Join-Path $root 'runtime'
|
||||
$logs = Join-Path $runtime 'logs'
|
||||
New-Item -ItemType Directory -Force -Path $logs,(Join-Path $root 'temp\logs') | Out-Null
|
||||
$pidFile = Join-Path $runtime 'bell.pid'
|
||||
if (Test-Path -LiteralPath $pidFile) {
|
||||
$oldPid = 0
|
||||
if ([int]::TryParse((Get-Content -LiteralPath $pidFile -Raw).Trim(), [ref]$oldPid) -and (Get-Process -Id $oldPid -ErrorAction SilentlyContinue)) { throw "Bell appears to be running with process id $oldPid." }
|
||||
Remove-Item -LiteralPath $pidFile -Force
|
||||
}
|
||||
[IO.File]::WriteAllText($pidFile, "$PID", (New-Object Text.UTF8Encoding($false)))
|
||||
Push-Location $root
|
||||
try {
|
||||
$autoMigrate = (Get-BellEnvironmentValue -Name 'BELL_AUTO_MIGRATE' -Default 'true').ToLowerInvariant()
|
||||
if (-not $SkipMigration -and $autoMigrate -notin @('false','0','no')) {
|
||||
Write-Host 'Applying pending Bell database migrations...'
|
||||
& $bell migrate -c $state.SettingsPath
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Bell database migration failed.' }
|
||||
}
|
||||
$backend = Start-Process -FilePath $bell -ArgumentList @('server','-c',$state.SettingsPath) -WorkingDirectory $root -RedirectStandardOutput (Join-Path $logs 'bell.out.log') -RedirectStandardError (Join-Path $logs 'bell.err.log') -WindowStyle Hidden -PassThru
|
||||
Wait-BellHealth -BaseUrl $state.BackendUrl
|
||||
Write-Host "Bell is available at $($state.WebUrl)/"
|
||||
Write-Host 'Press Ctrl+C in this window or run stop-bell.bat to stop Bell.'
|
||||
& (Join-Path $PSScriptRoot 'bell-web.ps1') -WebRoot $state.WebRoot -ListenHost $state.WebHost -ListenPort $state.WebPort -BackendUrl $state.BackendUrl
|
||||
} finally { Pop-Location }
|
||||
} catch {
|
||||
Write-Error $_.Exception.Message
|
||||
exit 1
|
||||
} finally {
|
||||
if ($backend -and -not $backend.HasExited) { & taskkill.exe /PID $backend.Id /T /F 2>$null | Out-Null }
|
||||
if ($pidFile -and (Test-Path -LiteralPath $pidFile)) { Remove-Item -LiteralPath $pidFile -Force }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
setlocal
|
||||
where pwsh.exe >nul 2>nul
|
||||
if %errorlevel% equ 0 (pwsh.exe -NoProfile -File "%~dp0scripts\runtime\stop-bell.ps1" %*) else (powershell.exe -NoProfile -File "%~dp0scripts\runtime\stop-bell.ps1" %*)
|
||||
exit /b %errorlevel%
|
||||
@@ -0,0 +1,17 @@
|
||||
. (Join-Path $PSScriptRoot 'bell-common.ps1')
|
||||
try {
|
||||
$root = Get-BellPackageRoot
|
||||
$pidFile = Join-Path $root 'runtime\bell.pid'
|
||||
if (-not (Test-Path -LiteralPath $pidFile -PathType Leaf)) { Write-Host 'Bell is not running (no pid file).'; exit 0 }
|
||||
$processId = 0
|
||||
if (-not [int]::TryParse((Get-Content -LiteralPath $pidFile -Raw).Trim(), [ref]$processId)) { throw 'Bell pid file is invalid.' }
|
||||
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $processId" -ErrorAction SilentlyContinue
|
||||
if (-not $process) { Remove-Item -LiteralPath $pidFile -Force; Write-Host 'Removed stale Bell pid file.'; exit 0 }
|
||||
$rootPattern = [regex]::Escape($root)
|
||||
if ($process.Name -notmatch '^(pwsh|powershell)\.exe$' -or $process.CommandLine -notmatch 'start-bell\.ps1' -or $process.CommandLine -notmatch $rootPattern) { throw "Process $processId is not the Bell package launcher; it was not stopped." }
|
||||
& taskkill.exe /PID $processId /T /F | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Failed to stop the Bell process tree.' }
|
||||
Remove-Item -LiteralPath $pidFile -Force -ErrorAction SilentlyContinue
|
||||
Write-Host 'Bell backend and web process tree stopped.'
|
||||
exit 0
|
||||
} catch { Write-Error $_.Exception.Message; exit 1 }
|
||||
@@ -0,0 +1,7 @@
|
||||
param([string]$PostgresBin='D:\pgsql17\bin',[string]$PreparedPackageRoot='',[switch]$KeepTemporary,[switch]$BrowserHold)
|
||||
$arguments=@('-NoProfile','-File',(Join-Path $PSScriptRoot '..\tests\e2e\run-isolated-e2e.ps1'),'-PostgresBin',$PostgresBin)
|
||||
if(-not[string]::IsNullOrWhiteSpace($PreparedPackageRoot)){$arguments+=@('-PreparedPackageRoot',$PreparedPackageRoot)}
|
||||
if($KeepTemporary){$arguments+='-KeepTemporary'}
|
||||
if($BrowserHold){$arguments+='-BrowserHold'}
|
||||
& pwsh.exe @arguments
|
||||
exit $LASTEXITCODE
|
||||
@@ -0,0 +1,68 @@
|
||||
package machine_identity
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type registryDocument struct {
|
||||
Version string `json:"version"`
|
||||
Audience string `json:"audience"`
|
||||
Principals []registryPrincipal `json:"principals"`
|
||||
}
|
||||
|
||||
type registryPrincipal struct {
|
||||
PrincipalID string `json:"principal_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Keys []registryKey `json:"keys"`
|
||||
}
|
||||
|
||||
type registryKey struct {
|
||||
KeyID string `json:"kid"`
|
||||
PublicKey string `json:"public_key_base64url"`
|
||||
Status string `json:"status"`
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
|
||||
func LoadRegistry(filePath, expectedAudience string) (*Registry, error) {
|
||||
if strings.TrimSpace(filePath) == "" || !validAudiences[expectedAudience] {
|
||||
return nil, errors.New("machine principal registry path and audience are required")
|
||||
}
|
||||
raw, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, errors.New("read machine principal registry")
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
var document registryDocument
|
||||
if err = decoder.Decode(&document); err != nil {
|
||||
return nil, errors.New("invalid machine principal registry")
|
||||
}
|
||||
if err = decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
return nil, errors.New("invalid machine principal registry")
|
||||
}
|
||||
if document.Version != "yovision.machine-principal-registry/v1" || document.Audience != expectedAudience || len(document.Principals) == 0 {
|
||||
return nil, errors.New("invalid machine principal registry")
|
||||
}
|
||||
records := make([]KeyRecord, 0)
|
||||
for _, principal := range document.Principals {
|
||||
if len(principal.Keys) == 0 {
|
||||
return nil, errors.New("invalid machine principal registry")
|
||||
}
|
||||
for _, key := range principal.Keys {
|
||||
publicKey, decodeErr := base64.RawURLEncoding.Strict().DecodeString(key.PublicKey)
|
||||
if decodeErr != nil || len(publicKey) != ed25519.PublicKeySize || (key.Status != "active" && key.Status != "revoked") {
|
||||
return nil, errors.New("invalid machine principal registry")
|
||||
}
|
||||
records = append(records, KeyRecord{Principal: principal.PrincipalID, KeyID: key.KeyID, PublicKey: ed25519.PublicKey(publicKey), Audience: document.Audience,
|
||||
Scopes: key.Scopes, Enabled: principal.Enabled, Revoked: key.Status == "revoked"})
|
||||
}
|
||||
}
|
||||
return NewRegistry(records...)
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package machine_identity
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
principalPattern = regexp.MustCompile(`^yv:(sense|brain|bell):[a-z0-9][a-z0-9.-]{0,62}$`)
|
||||
keyIDPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{8,64}$`)
|
||||
tokenIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{22,64}$`)
|
||||
validAudiences = map[string]bool{"yovision-sense": true, "yovision-brain": true, "yovision-bell": true}
|
||||
validScopes = map[string]bool{"source-config:write": true, "runtime-status:write": true, "events:ingest": true, "evidence:read": true}
|
||||
)
|
||||
|
||||
const (
|
||||
Version = "yovision.machine-identity/v1"
|
||||
TokenType = "YOVISION-MACHINE+JWT"
|
||||
MaxLifetime = 5 * time.Minute
|
||||
AllowedSkew = 30 * time.Second
|
||||
MaxKeyOverlap = 24 * time.Hour
|
||||
)
|
||||
|
||||
type Error struct{ Code string }
|
||||
|
||||
func (e *Error) Error() string { return e.Code }
|
||||
|
||||
func codeError(code string) error { return &Error{Code: code} }
|
||||
|
||||
// BearerToken deliberately has no cookie or query fallback.
|
||||
func BearerToken(authorization string) (string, error) {
|
||||
parts := strings.Split(authorization, " ")
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] == "" || strings.ContainsAny(parts[1], " \t\r\n,") {
|
||||
return "", codeError("machine_token_missing")
|
||||
}
|
||||
return parts[1], nil
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
Version string `json:"ver"`
|
||||
Issuer string `json:"iss"`
|
||||
Subject string `json:"sub"`
|
||||
Audience string `json:"aud"`
|
||||
Scopes []string `json:"scope"`
|
||||
IssuedAt int64 `json:"iat"`
|
||||
NotBefore int64 `json:"nbf"`
|
||||
ExpiresAt int64 `json:"exp"`
|
||||
TokenID string `json:"jti"`
|
||||
Method string `json:"htm"`
|
||||
Path string `json:"htu"`
|
||||
BodySHA256 string `json:"body_sha256"`
|
||||
}
|
||||
|
||||
type protectedHeader struct {
|
||||
Algorithm string `json:"alg"`
|
||||
Type string `json:"typ"`
|
||||
KeyID string `json:"kid"`
|
||||
Version string `json:"ver"`
|
||||
}
|
||||
|
||||
type KeyRecord struct {
|
||||
Principal string
|
||||
KeyID string
|
||||
PublicKey ed25519.PublicKey
|
||||
Audience string
|
||||
Scopes []string
|
||||
Enabled bool
|
||||
Revoked bool
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
keys map[string]KeyRecord
|
||||
}
|
||||
|
||||
func NewRegistry(records ...KeyRecord) (*Registry, error) {
|
||||
r := &Registry{keys: make(map[string]KeyRecord, len(records))}
|
||||
for _, record := range records {
|
||||
if !keyIDPattern.MatchString(record.KeyID) || !principalPattern.MatchString(record.Principal) || !validAudiences[record.Audience] || len(record.PublicKey) != ed25519.PublicKeySize || !validScopeList(record.Scopes) {
|
||||
return nil, errors.New("invalid machine key record")
|
||||
}
|
||||
if _, exists := r.keys[record.KeyID]; exists {
|
||||
return nil, errors.New("duplicate machine key id")
|
||||
}
|
||||
record.PublicKey = slices.Clone(record.PublicKey)
|
||||
record.Scopes = slices.Clone(record.Scopes)
|
||||
r.keys[record.KeyID] = record
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (r *Registry) Lookup(keyID string) (KeyRecord, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
record, ok := r.keys[keyID]
|
||||
record.PublicKey = slices.Clone(record.PublicKey)
|
||||
record.Scopes = slices.Clone(record.Scopes)
|
||||
return record, ok
|
||||
}
|
||||
|
||||
func (r *Registry) Revoke(keyID string) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
record, ok := r.keys[keyID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
record.Revoked = true
|
||||
r.keys[keyID] = record
|
||||
return true
|
||||
}
|
||||
|
||||
type ReplayStore struct {
|
||||
mu sync.Mutex
|
||||
used map[string]time.Time
|
||||
}
|
||||
|
||||
// ReplayCache must atomically persist accepted (principal, jti) pairs until
|
||||
// expiry. ReplayStore is process-local and intended for tests or a single
|
||||
// uninterrupted process; connector implementations inject a durable store.
|
||||
type ReplayCache interface {
|
||||
Consume(principal, tokenID string, expiresAt, now time.Time) bool
|
||||
}
|
||||
|
||||
func NewReplayStore() *ReplayStore { return &ReplayStore{used: map[string]time.Time{}} }
|
||||
|
||||
func (s *ReplayStore) Consume(principal, tokenID string, expiresAt, now time.Time) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for key, expiry := range s.used {
|
||||
if !expiry.After(now) {
|
||||
delete(s.used, key)
|
||||
}
|
||||
}
|
||||
key := principal + "\x00" + tokenID
|
||||
if _, exists := s.used[key]; exists {
|
||||
return false
|
||||
}
|
||||
s.used[key] = expiresAt
|
||||
return true
|
||||
}
|
||||
|
||||
type Signer struct {
|
||||
Principal string
|
||||
KeyID string
|
||||
PrivateKey ed25519.PrivateKey
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func LoadPrivateKey(path string) (ed25519.PrivateKey, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil, errors.New("machine private key path is required")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, errors.New("read machine private key")
|
||||
}
|
||||
block, rest := pem.Decode(raw)
|
||||
if block == nil || len(bytes.TrimSpace(rest)) != 0 || block.Type != "PRIVATE KEY" {
|
||||
return nil, errors.New("machine private key must be one PKCS#8 PEM block")
|
||||
}
|
||||
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, errors.New("parse machine private key")
|
||||
}
|
||||
key, ok := parsed.(ed25519.PrivateKey)
|
||||
if !ok || len(key) != ed25519.PrivateKeySize {
|
||||
return nil, errors.New("machine private key is not Ed25519")
|
||||
}
|
||||
return slices.Clone(key), nil
|
||||
}
|
||||
|
||||
func (s Signer) Mint(audience string, scopes []string, method, requestPath string, body []byte) (string, error) {
|
||||
if !principalPattern.MatchString(s.Principal) || !keyIDPattern.MatchString(s.KeyID) || len(s.PrivateKey) != ed25519.PrivateKeySize || !validAudiences[audience] || !validScopeList(scopes) {
|
||||
return "", errors.New("incomplete machine signer configuration")
|
||||
}
|
||||
normalizedPath, err := normalizePath(requestPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
method = strings.ToUpper(method)
|
||||
if !allowedMethod(method) {
|
||||
return "", errors.New("unsupported machine request method")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if s.Now != nil {
|
||||
now = s.Now().UTC()
|
||||
}
|
||||
tokenID, err := randomTokenID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
digest := sha256.Sum256(body)
|
||||
claims := Claims{Version: Version, Issuer: s.Principal, Subject: s.Principal, Audience: audience,
|
||||
Scopes: slices.Clone(scopes), IssuedAt: now.Unix(), NotBefore: now.Unix(), ExpiresAt: now.Add(MaxLifetime).Unix(),
|
||||
TokenID: tokenID, Method: method, Path: normalizedPath, BodySHA256: hex.EncodeToString(digest[:])}
|
||||
header := protectedHeader{Algorithm: "EdDSA", Type: TokenType, KeyID: s.KeyID, Version: Version}
|
||||
headerJSON, _ := json.Marshal(header)
|
||||
claimsJSON, _ := json.Marshal(claims)
|
||||
signingInput := rawBase64(headerJSON) + "." + rawBase64(claimsJSON)
|
||||
signature := ed25519.Sign(s.PrivateKey, []byte(signingInput))
|
||||
return signingInput + "." + rawBase64(signature), nil
|
||||
}
|
||||
|
||||
type Verifier struct {
|
||||
Registry *Registry
|
||||
Replay ReplayCache
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (v Verifier) Verify(token, audience, requiredScope, method, requestPath string, body []byte) (Claims, error) {
|
||||
if v.Registry == nil || v.Replay == nil {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 || strings.Contains(token, "=") {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
headerBytes, err := decodeRaw(parts[0])
|
||||
if err != nil {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
var header protectedHeader
|
||||
if err = decodeClosed(headerBytes, &header); err != nil || header.Algorithm != "EdDSA" || header.Type != TokenType || header.Version != Version || !keyIDPattern.MatchString(header.KeyID) {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
record, ok := v.Registry.Lookup(header.KeyID)
|
||||
if !ok {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
signature, err := decodeRaw(parts[2])
|
||||
if err != nil || len(signature) != ed25519.SignatureSize || !ed25519.Verify(record.PublicKey, []byte(parts[0]+"."+parts[1]), signature) {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
if !record.Enabled || record.Revoked {
|
||||
return Claims{}, codeError("machine_identity_revoked")
|
||||
}
|
||||
claimsBytes, err := decodeRaw(parts[1])
|
||||
if err != nil {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
var claims Claims
|
||||
if err = decodeClosed(claimsBytes, &claims); err != nil || !validClaimsShape(claims) || claims.Issuer != record.Principal || claims.Subject != record.Principal {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if v.Now != nil {
|
||||
now = v.Now().UTC()
|
||||
}
|
||||
nowUnix := now.Unix()
|
||||
if claims.ExpiresAt-claims.IssuedAt <= 0 || claims.ExpiresAt-claims.IssuedAt > int64(MaxLifetime/time.Second) ||
|
||||
claims.NotBefore < claims.IssuedAt || claims.NotBefore > claims.ExpiresAt || claims.IssuedAt > nowUnix+int64(AllowedSkew/time.Second) {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
if claims.NotBefore > nowUnix+int64(AllowedSkew/time.Second) || claims.ExpiresAt < nowUnix-int64(AllowedSkew/time.Second) {
|
||||
return Claims{}, codeError("machine_token_expired")
|
||||
}
|
||||
if claims.Audience != audience || record.Audience != audience {
|
||||
return Claims{}, codeError("machine_audience_denied")
|
||||
}
|
||||
if !slices.Contains(claims.Scopes, requiredScope) || !slices.Contains(record.Scopes, requiredScope) {
|
||||
return Claims{}, codeError("machine_scope_denied")
|
||||
}
|
||||
normalizedPath, err := normalizePath(requestPath)
|
||||
digest := sha256.Sum256(body)
|
||||
if err != nil || claims.Method != strings.ToUpper(method) || claims.Path != normalizedPath || claims.BodySHA256 != hex.EncodeToString(digest[:]) {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
if !v.Replay.Consume(claims.Issuer, claims.TokenID, time.Unix(claims.ExpiresAt, 0).Add(AllowedSkew), now) {
|
||||
return Claims{}, codeError("machine_token_replayed")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func decodeClosed(raw []byte, target any) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
return errors.New("trailing JSON value")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validClaimsShape(claims Claims) bool {
|
||||
if claims.Version != Version || !principalPattern.MatchString(claims.Issuer) || claims.Subject != claims.Issuer || !validAudiences[claims.Audience] || !tokenIDPattern.MatchString(claims.TokenID) ||
|
||||
len(claims.Scopes) == 0 || len(claims.Scopes) > 4 || !allowedMethod(claims.Method) || claims.Path == "" || len(claims.BodySHA256) != 64 {
|
||||
return false
|
||||
}
|
||||
if !validScopeList(claims.Scopes) {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(claims.BodySHA256)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func validScopeList(scopes []string) bool {
|
||||
if len(scopes) == 0 || len(scopes) > 4 {
|
||||
return false
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, scope := range scopes {
|
||||
if !validScopes[scope] || seen[scope] {
|
||||
return false
|
||||
}
|
||||
seen[scope] = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizePath(value string) (string, error) {
|
||||
parsed, err := url.ParseRequestURI(value)
|
||||
if err != nil || parsed.IsAbs() || parsed.Host != "" || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Path == "" || !strings.HasPrefix(parsed.Path, "/") || strings.Contains(parsed.Path, "\\") || strings.Contains(parsed.Path, "//") || path.Clean(parsed.Path) != parsed.Path {
|
||||
return "", errors.New("machine request path must be a normalized absolute path without query or fragment")
|
||||
}
|
||||
return parsed.EscapedPath(), nil
|
||||
}
|
||||
|
||||
func allowedMethod(method string) bool {
|
||||
switch method {
|
||||
case "GET", "POST", "PUT", "PATCH", "DELETE":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func randomTokenID() (string, error) {
|
||||
raw := make([]byte, 16)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("generate machine token id: %w", err)
|
||||
}
|
||||
return rawBase64(raw), nil
|
||||
}
|
||||
|
||||
func rawBase64(value []byte) string { return base64.RawURLEncoding.EncodeToString(value) }
|
||||
|
||||
func decodeRaw(value string) ([]byte, error) {
|
||||
return base64.RawURLEncoding.Strict().DecodeString(value)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package machine_identity
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type crossLanguageVector struct {
|
||||
PublicKey string `json:"public_key_base64url"`
|
||||
Token string `json:"token"`
|
||||
Now int64 `json:"now"`
|
||||
Audience string `json:"audience"`
|
||||
Scope string `json:"required_scope"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Body string `json:"body_base64"`
|
||||
}
|
||||
|
||||
func testIdentity(t *testing.T) (Signer, *Registry, time.Time) {
|
||||
t.Helper()
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Unix(1_800_000_000, 0).UTC()
|
||||
registry, err := NewRegistry(KeyRecord{Principal: "yv:sense:site-a", KeyID: "sense-key-0001", PublicKey: publicKey,
|
||||
Audience: "yovision-brain", Scopes: []string{"source-config:write"}, Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Signer{Principal: "yv:sense:site-a", KeyID: "sense-key-0001", PrivateKey: privateKey, Now: func() time.Time { return now }}, registry, now
|
||||
}
|
||||
|
||||
func errorCode(t *testing.T, err error) string {
|
||||
t.Helper()
|
||||
var coded *Error
|
||||
if !errors.As(err, &coded) {
|
||||
t.Fatalf("expected coded error, got %v", err)
|
||||
}
|
||||
return coded.Code
|
||||
}
|
||||
|
||||
func TestMintAndVerifyRequestBoundToken(t *testing.T) {
|
||||
signer, registry, now := testIdentity(t)
|
||||
body := []byte(`{"revision":7}`)
|
||||
token, err := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now }}
|
||||
claims, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if claims.Issuer != signer.Principal || claims.Subject != signer.Principal || claims.ExpiresAt-claims.IssuedAt != 300 {
|
||||
t.Fatalf("unexpected claims: %+v", claims)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerTokenHasNoCookieOrQueryFallback(t *testing.T) {
|
||||
if token, err := BearerToken("Bearer compact.token.value"); err != nil || token != "compact.token.value" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, value := range []string{"", "compact.token.value", "Bearer", "Bearer one two", "Cookie compact.token.value"} {
|
||||
if _, err := BearerToken(value); errorCode(t, err) != "machine_token_missing" {
|
||||
t.Fatalf("accepted %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsReplayWrongAudienceScopeAndRequest(t *testing.T) {
|
||||
signer, registry, now := testIdentity(t)
|
||||
body := []byte(`{"revision":7}`)
|
||||
mint := func() string {
|
||||
token, err := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now }}
|
||||
token := mint()
|
||||
if _, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_token_replayed" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := verifier.Verify(mint(), "yovision-bell", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_audience_denied" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := verifier.Verify(mint(), "yovision-brain", "events:ingest", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_scope_denied" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := verifier.Verify(mint(), "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", []byte("changed")); errorCode(t, err) != "machine_token_invalid" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiryRevocationAndRotation(t *testing.T) {
|
||||
signer, registry, now := testIdentity(t)
|
||||
body := []byte("{}")
|
||||
token, _ := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
|
||||
expired := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now.Add(6 * time.Minute) }}
|
||||
if _, err := expired.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_token_expired" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oldPublic, oldPrivate, _ := ed25519.GenerateKey(rand.Reader)
|
||||
newPublic, newPrivate, _ := ed25519.GenerateKey(rand.Reader)
|
||||
rotation, err := NewRegistry(
|
||||
KeyRecord{Principal: "yv:brain:node-a", KeyID: "brain-old-0001", PublicKey: oldPublic, Audience: "yovision-sense", Scopes: []string{"runtime-status:write"}, Enabled: true},
|
||||
KeyRecord{Principal: "yv:brain:node-a", KeyID: "brain-new-0002", PublicKey: newPublic, Audience: "yovision-sense", Scopes: []string{"runtime-status:write"}, Enabled: true},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldSigner := Signer{Principal: "yv:brain:node-a", KeyID: "brain-old-0001", PrivateKey: oldPrivate, Now: func() time.Time { return now }}
|
||||
newSigner := Signer{Principal: "yv:brain:node-a", KeyID: "brain-new-0002", PrivateKey: newPrivate, Now: func() time.Time { return now }}
|
||||
oldToken, _ := oldSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
|
||||
newToken, _ := newSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
|
||||
verify := Verifier{Registry: rotation, Replay: NewReplayStore(), Now: func() time.Time { return now }}
|
||||
if _, err = verify.Verify(oldToken, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = verify.Verify(newToken, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !rotation.Revoke("brain-old-0001") {
|
||||
t.Fatal("old key was not revoked")
|
||||
}
|
||||
oldAfterRevoke, _ := oldSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
|
||||
if _, err = verify.Verify(oldAfterRevoke, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); errorCode(t, err) != "machine_identity_revoked" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportPolicyRejectsUnsafeTLS(t *testing.T) {
|
||||
safe := TransportPolicy{TLSMinVersion: tls.VersionTLS12, VerifyCertificate: true, VerifyHostname: true,
|
||||
ConnectTimeout: time.Second, ResponseHeaderTimeout: time.Second, RequestTimeout: 2 * time.Second, MaxRequestBytes: 1024}
|
||||
if err := safe.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unsafe := safe
|
||||
unsafe.VerifyHostname = false
|
||||
if err := unsafe.Validate(); err == nil {
|
||||
t.Fatal("unsafe hostname policy accepted")
|
||||
}
|
||||
unsafe = safe
|
||||
unsafe.TLSMinVersion = tls.VersionTLS11
|
||||
if err := unsafe.Validate(); err == nil {
|
||||
t.Fatal("TLS 1.1 accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifiesCrossLanguageVector(t *testing.T) {
|
||||
vectorPath := filepath.Join("..", "..", "..", "..", "..", "..", "contracts", "tests", "machine-identity-v1", "cross-language-vector.json")
|
||||
raw, err := os.ReadFile(vectorPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var vector crossLanguageVector
|
||||
if err = json.Unmarshal(raw, &vector); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
publicKey, err := base64.RawURLEncoding.DecodeString(vector.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := base64.StdEncoding.DecodeString(vector.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry, err := NewRegistry(KeyRecord{Principal: "yv:brain:vector", KeyID: "brain-vector-0001", PublicKey: ed25519.PublicKey(publicKey), Audience: vector.Audience, Scopes: []string{vector.Scope}, Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return time.Unix(vector.Now, 0) }}
|
||||
claims, err := verifier.Verify(vector.Token, vector.Audience, vector.Scope, vector.Method, vector.Path, body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if claims.Issuer != "yv:brain:vector" {
|
||||
t.Fatalf("unexpected issuer: %s", claims.Issuer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadsExternalPublicRegistryAndRejectsWrongAudience(t *testing.T) {
|
||||
publicKey, _, _ := ed25519.GenerateKey(rand.Reader)
|
||||
document := map[string]any{
|
||||
"version": "yovision.machine-principal-registry/v1", "audience": "yovision-bell",
|
||||
"principals": []any{map[string]any{"principal_id": "yv:sense:site-a", "enabled": true, "keys": []any{map[string]any{
|
||||
"kid": "sense-key-0001", "public_key_base64url": base64.RawURLEncoding.EncodeToString(publicKey), "status": "active", "scopes": []string{"events:ingest"},
|
||||
}}}},
|
||||
}
|
||||
raw, _ := json.Marshal(document)
|
||||
file := filepath.Join(t.TempDir(), "principals.json")
|
||||
if err := os.WriteFile(file, raw, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry, err := LoadRegistry(file, "yovision-bell")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record, ok := registry.Lookup("sense-key-0001"); !ok || record.Principal != "yv:sense:site-a" {
|
||||
t.Fatal("registry record missing")
|
||||
}
|
||||
if _, err = LoadRegistry(file, "yovision-sense"); err == nil {
|
||||
t.Fatal("wrong registry audience accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package machine_identity
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TransportPolicy struct {
|
||||
TLSMinVersion uint16
|
||||
VerifyCertificate bool
|
||||
VerifyHostname bool
|
||||
ConnectTimeout time.Duration
|
||||
ResponseHeaderTimeout time.Duration
|
||||
RequestTimeout time.Duration
|
||||
MaxRequestBytes int64
|
||||
}
|
||||
|
||||
func (p TransportPolicy) Validate() error {
|
||||
if p.TLSMinVersion < tls.VersionTLS12 || !p.VerifyCertificate || !p.VerifyHostname || p.ConnectTimeout < 100*time.Millisecond || p.ConnectTimeout > 30*time.Second ||
|
||||
p.ResponseHeaderTimeout < 100*time.Millisecond || p.ResponseHeaderTimeout > 30*time.Second || p.RequestTimeout < 100*time.Millisecond || p.RequestTimeout > 60*time.Second ||
|
||||
p.MaxRequestBytes < 1 || p.MaxRequestBytes > 10*1024*1024 {
|
||||
return errors.New("machine transport policy is unsafe")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p TransportPolicy) HTTPClient() (*http.Client, error) {
|
||||
if err := p.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{MinVersion: p.TLSMinVersion},
|
||||
TLSHandshakeTimeout: p.ConnectTimeout,
|
||||
ResponseHeaderTimeout: p.ResponseHeaderTimeout,
|
||||
}
|
||||
return &http.Client{Transport: transport, Timeout: p.RequestTimeout}, nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateBellMinimalMenu)
|
||||
}
|
||||
|
||||
func migrateBellMinimalMenu(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := ApplyBellMinimalMenuVisibility(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// ApplyBellMinimalMenuVisibility keeps the imported GoAdmin menu records for
|
||||
// rollback and upgrades, but exposes only Bell product entries and the three
|
||||
// RBAC administration pages required to maintain local accounts.
|
||||
func ApplyBellMinimalMenuVisibility(tx *gorm.DB) error {
|
||||
if err := tx.Exec(`
|
||||
UPDATE sys_menu
|
||||
SET visible = '1', updated_at = now()
|
||||
WHERE menu_type IN ('M', 'C')
|
||||
AND deleted_at IS NULL
|
||||
AND visible IS DISTINCT FROM '1'
|
||||
`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Exec(`
|
||||
UPDATE sys_menu
|
||||
SET visible = '0', updated_at = now()
|
||||
WHERE menu_type IN ('M', 'C')
|
||||
AND deleted_at IS NULL
|
||||
AND (
|
||||
path IN ('/admin', '/admin/sys-user', '/admin/sys-menu', '/admin/sys-role', '/bell')
|
||||
OR permission IN ('admin:sysUser:list', 'admin:sysMenu:list', 'admin:sysRole:list',
|
||||
'bell:alert:list', 'bell:event:list', 'bell:rule:list')
|
||||
)
|
||||
AND visible IS DISTINCT FROM '0'
|
||||
`).Error
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package bell_minimal_menu_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
|
||||
versionlocal "go-admin/cmd/migrate/migration/version-local"
|
||||
)
|
||||
|
||||
var expectedVisibleMenus = []string{
|
||||
"事件查询",
|
||||
"用户管理",
|
||||
"系统管理",
|
||||
"菜单管理",
|
||||
"角色管理",
|
||||
"规则配置",
|
||||
"预警中心",
|
||||
"预警管理",
|
||||
}
|
||||
|
||||
func TestBellMinimalMenuMigration(t *testing.T) {
|
||||
databaseURL := os.Getenv("BELL_MINIMAL_MENU_TEST_DATABASE_URL")
|
||||
if databaseURL == "" {
|
||||
t.Skip("minimal menu database is not configured")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assertVisibleMenus(t, db)
|
||||
assertUnusedMenusRetainedAndHidden(t, db)
|
||||
assertOperatorHasNoDefaultMenus(t, db)
|
||||
|
||||
var rowCountBefore int64
|
||||
if err = db.Table("sys_menu").Count(&rowCountBefore).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`UPDATE sys_menu SET visible = '0' WHERE title IN ('开发工具','定时任务','系统工具')`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec(`UPDATE sys_menu SET visible = '1' WHERE title IN ('系统管理','预警中心')`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Transaction(versionlocal.ApplyBellMinimalMenuVisibility); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Transaction(versionlocal.ApplyBellMinimalMenuVisibility); err != nil {
|
||||
t.Fatalf("reapplying minimal menu policy failed: %v", err)
|
||||
}
|
||||
var rowCountAfter int64
|
||||
if err = db.Table("sys_menu").Count(&rowCountAfter).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rowCountAfter != rowCountBefore {
|
||||
t.Fatalf("menu records changed during visibility migration: before=%d after=%d", rowCountBefore, rowCountAfter)
|
||||
}
|
||||
assertVisibleMenus(t, db)
|
||||
assertUnusedMenusRetainedAndHidden(t, db)
|
||||
}
|
||||
|
||||
func assertVisibleMenus(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
var titles []string
|
||||
if err := db.Table("sys_menu").
|
||||
Where("menu_type IN ? AND deleted_at IS NULL AND visible = ?", []string{"M", "C"}, "0").
|
||||
Order("title").Pluck("title", &titles).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sort.Strings(titles)
|
||||
expected := append([]string(nil), expectedVisibleMenus...)
|
||||
sort.Strings(expected)
|
||||
if !reflect.DeepEqual(titles, expected) {
|
||||
t.Fatalf("visible menu mismatch\nwant: %v\n got: %v", expected, titles)
|
||||
}
|
||||
}
|
||||
|
||||
func assertUnusedMenusRetainedAndHidden(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
for _, title := range []string{"开发工具", "定时任务", "系统工具"} {
|
||||
var values []string
|
||||
if err := db.Table("sys_menu").Where("title = ? AND deleted_at IS NULL", title).Pluck("visible", &values).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(values) == 0 {
|
||||
t.Fatalf("unused upstream menu %q was deleted", title)
|
||||
}
|
||||
for _, visible := range values {
|
||||
if visible != "1" {
|
||||
t.Fatalf("unused upstream menu %q remains visible=%q", title, visible)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertOperatorHasNoDefaultMenus(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
var titles []string
|
||||
err := db.Raw(`
|
||||
SELECT DISTINCT m.title
|
||||
FROM sys_role r
|
||||
JOIN sys_role_menu rm ON rm.role_id = r.role_id
|
||||
JOIN sys_menu m ON m.menu_id = rm.menu_id
|
||||
WHERE r.role_key = 'operator'
|
||||
AND m.menu_type IN ('M', 'C')
|
||||
AND m.deleted_at IS NULL
|
||||
AND (m.path LIKE '/admin%' OR m.permission LIKE 'admin:%')
|
||||
`).Scan(&titles).Error
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(titles) != 0 {
|
||||
t.Fatalf("operator retains default administration menus: %v", titles)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
[CmdletBinding()]
|
||||
param([string]$PostgresBin = 'D:\pgsql17\bin')
|
||||
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$started = $false
|
||||
$root = Join-Path ([IO.Path]::GetTempPath()) ('yovision-bell-140-' + [guid]::NewGuid().ToString('N'))
|
||||
$data = Join-Path $root 'postgres'
|
||||
$log = Join-Path $root 'postgres.log'
|
||||
$serverRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
|
||||
function Get-FreePort {
|
||||
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
|
||||
try {
|
||||
$listener.Start()
|
||||
return ([Net.IPEndPoint]$listener.LocalEndpoint).Port
|
||||
} finally {
|
||||
$listener.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
function Wait-ForPort([int]$Port) {
|
||||
for ($attempt = 0; $attempt -lt 120; $attempt++) {
|
||||
try {
|
||||
$client = [Net.Sockets.TcpClient]::new()
|
||||
$connected = $client.ConnectAsync('127.0.0.1', $Port).Wait(250) -and $client.Connected
|
||||
$client.Dispose()
|
||||
if ($connected) { return }
|
||||
} catch {
|
||||
}
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
throw 'PostgreSQL did not start'
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $root | Out-Null
|
||||
$port = Get-FreePort
|
||||
try {
|
||||
foreach ($name in @('initdb.exe', 'pg_ctl.exe', 'createdb.exe')) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $PostgresBin $name))) { throw "Missing $name" }
|
||||
}
|
||||
& (Join-Path $PostgresBin 'initdb.exe') -D $data -U postgres -A trust --encoding=UTF8 --no-locale | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw 'initdb failed' }
|
||||
$arguments = "-D `"$data`" -l `"$log`" -o `"-p $port -h 127.0.0.1`" start"
|
||||
Start-Process (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $arguments -WindowStyle Hidden | Out-Null
|
||||
Wait-ForPort $port
|
||||
$started = $true
|
||||
& (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $port -U postgres bell_140
|
||||
if ($LASTEXITCODE -ne 0) { throw 'createdb failed' }
|
||||
|
||||
$env:GOTOOLCHAIN = 'go1.26.5'
|
||||
$env:BELL_DATABASE_URL = "host=127.0.0.1 port=$port user=postgres dbname=bell_140 sslmode=disable"
|
||||
$env:BELL_MINIMAL_MENU_TEST_DATABASE_URL = $env:BELL_DATABASE_URL
|
||||
$env:BELL_JWT_SECRET = [guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N')
|
||||
$env:BELL_BOOTSTRAP_USERNAME = 'bell_140_admin'
|
||||
$env:BELL_BOOTSTRAP_PASSWORD = [guid]::NewGuid().ToString('N')
|
||||
$env:BELL_HOST = '127.0.0.1'
|
||||
$env:BELL_PORT = (Get-FreePort).ToString()
|
||||
|
||||
Push-Location $serverRoot
|
||||
try {
|
||||
go run . migrate -c config/settings.yml *> (Join-Path $root 'migrate.log')
|
||||
if ($LASTEXITCODE -ne 0) { throw "migration failed; evidence: $root" }
|
||||
go test ./tests/bell_minimal_menu -count=1 -v
|
||||
if ($LASTEXITCODE -ne 0) { throw 'minimal menu test failed' }
|
||||
go run . migrate -c config/settings.yml *> (Join-Path $root 'migrate-repeat.log')
|
||||
if ($LASTEXITCODE -ne 0) { throw "repeat migration failed; evidence: $root" }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
Write-Output 'BELL_140_MINIMAL_MENU fresh=true upgrade=true repeat=true admin_whitelist=true operator_default_menu=false'
|
||||
} finally {
|
||||
if ($started) {
|
||||
& (Join-Path $PostgresBin 'pg_ctl.exe') -D $data -m fast stop *> (Join-Path $root 'stop.log')
|
||||
}
|
||||
foreach ($name in @('BELL_DATABASE_URL', 'BELL_MINIMAL_MENU_TEST_DATABASE_URL', 'BELL_JWT_SECRET', 'BELL_BOOTSTRAP_USERNAME', 'BELL_BOOTSTRAP_PASSWORD', 'BELL_HOST', 'BELL_PORT')) {
|
||||
Remove-Item "Env:$name" -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
param([Parameter(Mandatory = $true)][string]$PackageRoot)
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$web = Join-Path ([IO.Path]::GetFullPath($PackageRoot)) 'web'
|
||||
& (Join-Path $PSScriptRoot '..\..\scripts\build\assert-web-assets.ps1') -WebRoot $web
|
||||
$index = Get-Content -LiteralPath (Join-Path $web 'index.html') -Raw -Encoding UTF8
|
||||
if ($index -notmatch 'id=["'']app["'']') { throw 'Bell package does not contain the GoAdmin Vue application mount.' }
|
||||
Write-Host 'Bell GoAdmin shell compatibility check passed.'
|
||||
@@ -0,0 +1,204 @@
|
||||
param(
|
||||
[string]$PostgresBin = 'D:\pgsql17\bin',
|
||||
[string]$PreparedPackageRoot = '',
|
||||
[switch]$KeepTemporary,
|
||||
[switch]$BrowserHold
|
||||
)
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..\..'))
|
||||
$bellRoot = Join-Path $repositoryRoot 'Bell'
|
||||
$temporary = Join-Path ([IO.Path]::GetTempPath()) ('bell-e2e-' + [guid]::NewGuid().ToString('N'))
|
||||
$pgData = Join-Path $temporary 'postgres'
|
||||
$pgLog = Join-Path $temporary 'postgres.log'
|
||||
$runtimeOut = Join-Path $temporary 'bell-launcher.out.log'
|
||||
$runtimeErr = Join-Path $temporary 'bell-launcher.err.log'
|
||||
$launcher = $null
|
||||
$pgStarted = $false
|
||||
$savedEnvironment = @{}
|
||||
|
||||
function Get-FreeTcpPort {
|
||||
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback,0)
|
||||
try { $listener.Start(); return ([Net.IPEndPoint]$listener.LocalEndpoint).Port } finally { $listener.Stop() }
|
||||
}
|
||||
function Get-UniqueFreePorts([int]$Count) {
|
||||
$ports = [Collections.Generic.List[int]]::new()
|
||||
while ($ports.Count -lt $Count) { $port=Get-FreeTcpPort; if (-not $ports.Contains($port)) { $ports.Add($port) } }
|
||||
return $ports.ToArray()
|
||||
}
|
||||
function New-RandomText([int]$Bytes=32) {
|
||||
$buffer=New-Object byte[] $Bytes; $generator=[Security.Cryptography.RandomNumberGenerator]::Create()
|
||||
try { $generator.GetBytes($buffer) } finally { $generator.Dispose() }
|
||||
return [Convert]::ToBase64String($buffer).TrimEnd('=').Replace('+','A').Replace('/','B')
|
||||
}
|
||||
function Set-TestEnvironment([string]$Name,[string]$Value) {
|
||||
if (-not $script:savedEnvironment.ContainsKey($Name)) { $script:savedEnvironment[$Name]=[Environment]::GetEnvironmentVariable($Name,'Process') }
|
||||
[Environment]::SetEnvironmentVariable($Name,$Value,'Process')
|
||||
}
|
||||
function Wait-Tcp([int]$Port,[bool]$Open,[int]$Attempts=120) {
|
||||
for($i=0;$i -lt $Attempts;$i++) {
|
||||
$client=[Net.Sockets.TcpClient]::new()
|
||||
try { $connected=$client.ConnectAsync('127.0.0.1',$Port).Wait(250)-and$client.Connected } catch { $connected=$false } finally { $client.Dispose() }
|
||||
if($connected -eq $Open){return}; Start-Sleep -Milliseconds 250
|
||||
}
|
||||
throw "TCP port $Port did not reach open=$Open"
|
||||
}
|
||||
function Wait-Health([string]$BaseUrl) {
|
||||
for($i=0;$i -lt 120;$i++){try{$health=Invoke-RestMethod -Uri "$BaseUrl/healthz" -TimeoutSec 2 -NoProxy;if($health.status-eq'ok'-and$health.service-eq'bell'){return}}catch{};Start-Sleep -Milliseconds 300}
|
||||
throw "Bell health endpoint did not become ready: $BaseUrl"
|
||||
}
|
||||
function Invoke-BellJson {
|
||||
param([string]$Method,[string]$Path,$Body=$null,[string]$Token='',[int]$ExpectedCode=200)
|
||||
$headers=@{};if($Token){$headers.Authorization="Bearer $Token"}
|
||||
$arguments=@{Method=$Method;Uri="$script:baseUrl$Path";Headers=$headers;TimeoutSec=20;NoProxy=$true}
|
||||
if($null-ne$Body){$arguments.ContentType='application/json; charset=utf-8';$arguments.Body=$Body|ConvertTo-Json -Depth 12 -Compress}
|
||||
try{$response=Invoke-RestMethod @arguments}catch{throw "Bell request failed for $Method $Path`: $($_.Exception.Message)"}
|
||||
if([int]$response.code-ne$ExpectedCode){throw "Unexpected Bell code for $Method $Path`: expected $ExpectedCode, got $($response.code), message=$($response.msg)"}
|
||||
return $response
|
||||
}
|
||||
function Login([string]$Username,[string]$Password){$response=Invoke-BellJson POST '/api/v1/login' @{username=$Username;password=$Password;code='0';uuid='0'};if([string]::IsNullOrWhiteSpace($response.token)){throw "Login did not return a token for $Username"};return [string]$response.token}
|
||||
function Get-VisibleMenuTitles($Menus,[bool]$AncestorsVisible=$true) {
|
||||
foreach($menu in @($Menus)) {
|
||||
if($null-eq$menu){continue}
|
||||
$visible=$AncestorsVisible-and([string]$menu.visible-eq'0')
|
||||
if($visible-and-not[string]::IsNullOrWhiteSpace([string]$menu.title)){[string]$menu.title}
|
||||
if($menu.PSObject.Properties.Name-contains'children'){
|
||||
Get-VisibleMenuTitles -Menus $menu.children -AncestorsVisible $visible
|
||||
}
|
||||
}
|
||||
}
|
||||
function Start-Package([string]$Root){
|
||||
$script:launcher=Start-Process -FilePath 'cmd.exe' -ArgumentList @('/d','/c',"`"$(Join-Path $Root 'start-bell.bat')`"") -WorkingDirectory $Root -RedirectStandardOutput $runtimeOut -RedirectStandardError $runtimeErr -WindowStyle Hidden -PassThru
|
||||
Wait-Health $script:baseUrl
|
||||
}
|
||||
function Stop-Package([string]$Root){
|
||||
& (Join-Path $Root 'stop-bell.bat') | Out-Host
|
||||
if($LASTEXITCODE-ne 0){throw 'Bell package stop failed'}
|
||||
Wait-Tcp -Port $script:webPort -Open $false -Attempts 40
|
||||
Wait-Tcp -Port $script:backendPort -Open $false -Attempts 40
|
||||
if($script:launcher-and-not$script:launcher.HasExited){$script:launcher.WaitForExit(5000)|Out-Null}
|
||||
$script:launcher=$null
|
||||
}
|
||||
function Stop-ProcessTree($Process){if($Process-and-not$Process.HasExited){& taskkill.exe /PID $Process.Id /T /F 2>$null|Out-Null}}
|
||||
|
||||
New-Item -ItemType Directory -Path $temporary | Out-Null
|
||||
try {
|
||||
foreach($name in @('initdb.exe','pg_ctl.exe','createdb.exe','psql.exe')){$path=Join-Path $PostgresBin $name;if(-not(Test-Path -LiteralPath $path -PathType Leaf)){throw "Required PostgreSQL tool not found: $path"}}
|
||||
if([string]::IsNullOrWhiteSpace($PreparedPackageRoot)){
|
||||
& (Join-Path $bellRoot 'scripts\build\build-windows.ps1')
|
||||
if($LASTEXITCODE-ne 0){throw 'Bell Windows package build failed'}
|
||||
$preparedPackageRoot=Join-Path $bellRoot 'dist\bell-windows-amd64'
|
||||
}else{$preparedPackageRoot=[IO.Path]::GetFullPath($PreparedPackageRoot)}
|
||||
& (Join-Path $bellRoot 'scripts\build\test-package.ps1') -PackageRoot $preparedPackageRoot
|
||||
& (Join-Path $bellRoot 'tests\compatibility\assert-go-admin-shell.ps1') -PackageRoot $preparedPackageRoot
|
||||
$packageRoot=Join-Path $temporary 'package'
|
||||
Copy-Item -LiteralPath $preparedPackageRoot -Destination $packageRoot -Recurse
|
||||
# Production captcha behavior is covered by #138. The isolated business
|
||||
# E2E uses a disposable package copy in dev mode so it never needs to
|
||||
# expose or OCR a captcha answer.
|
||||
$settingsPath=Join-Path $packageRoot 'config\settings.yml'
|
||||
$settings=Get-Content -LiteralPath $settingsPath -Raw -Encoding UTF8
|
||||
$testSettings=[regex]::Replace($settings,'(?m)^(\s*mode:\s*)prod\s*$','$1dev')
|
||||
if($testSettings-eq$settings){throw 'Packaged settings did not contain the expected production mode'}
|
||||
[IO.File]::WriteAllText($settingsPath,$testSettings,(New-Object Text.UTF8Encoding($false)))
|
||||
|
||||
$pgPort,$script:backendPort,$script:webPort=Get-UniqueFreePorts 3
|
||||
if($pgPort-eq 5432){throw 'E2E must not use the default PostgreSQL port'}
|
||||
$script:baseUrl="http://127.0.0.1:$script:webPort"
|
||||
& (Join-Path $PostgresBin 'initdb.exe') -D $pgData -U bell_e2e -A trust --encoding=UTF8 --no-locale|Out-Null
|
||||
if($LASTEXITCODE-ne 0){throw 'isolated PostgreSQL initdb failed'}
|
||||
$pgArguments="-D `"$pgData`" -l `"$pgLog`" -o `"-p $pgPort -h 127.0.0.1`" start"
|
||||
Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $pgArguments -RedirectStandardOutput (Join-Path $temporary 'pg-ctl.out.log') -RedirectStandardError (Join-Path $temporary 'pg-ctl.err.log') -WindowStyle Hidden|Out-Null
|
||||
Wait-Tcp -Port $pgPort -Open $true;$pgStarted=$true
|
||||
& (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $pgPort -U bell_e2e bell_e2e
|
||||
if($LASTEXITCODE-ne 0){throw 'isolated Bell database creation failed'}
|
||||
|
||||
$adminName='bell_e2e_admin_'+(New-RandomText 5).ToLowerInvariant();$adminPassword=New-RandomText 20
|
||||
$operatorPassword=New-RandomText 20;$jwt=New-RandomText 48
|
||||
$environment=@{
|
||||
BELL_HOST='127.0.0.1';BELL_PORT="$script:backendPort";BELL_WEB_HOST='127.0.0.1';BELL_WEB_PORT="$script:webPort";
|
||||
BELL_DATABASE_URL="host=127.0.0.1 port=$pgPort user=bell_e2e dbname=bell_e2e sslmode=disable";
|
||||
BELL_JWT_SECRET=$jwt;BELL_BOOTSTRAP_USERNAME=$adminName;BELL_BOOTSTRAP_PASSWORD=$adminPassword;
|
||||
BELL_AUTO_MIGRATE='true';BELL_SYNTHETIC_EVENTS_ENABLED='true'
|
||||
}
|
||||
foreach($item in $environment.GetEnumerator()){Set-TestEnvironment $item.Key $item.Value}
|
||||
Start-Package $packageRoot
|
||||
$anonymous=Invoke-BellJson GET '/api/v1/bell/alerts' $null '' 401
|
||||
$adminToken=Login $adminName $adminPassword
|
||||
$psql=Join-Path $PostgresBin 'psql.exe'
|
||||
$operatorRole=[int]((&$psql -X -h 127.0.0.1 -p $pgPort -U bell_e2e -d bell_e2e -tAc "select role_id from sys_role where role_key='operator';").Trim())
|
||||
if($operatorRole-lt 1){throw 'operator role was not migrated'}
|
||||
$operators=@(
|
||||
@{username='bell_e2e_operator_a';nickName='处置员A'},
|
||||
@{username='bell_e2e_operator_b';nickName='处置员B'}
|
||||
)
|
||||
foreach($operator in $operators){[void](Invoke-BellJson POST '/api/v1/sys-user' @{username=$operator.username;password=$operatorPassword;nickName=$operator.nickName;phone='13800000000';roleId=$operatorRole;sex='1';email="$($operator.username)@invalid.local";deptId=1;postId=1;status='2'} $adminToken)}
|
||||
$tokenA=Login $operators[0].username $operatorPassword;$tokenB=Login $operators[1].username $operatorPassword
|
||||
$adminMenu=Invoke-BellJson GET '/api/v1/menurole' $null $adminToken
|
||||
$operatorMenu=Invoke-BellJson GET '/api/v1/menurole' $null $tokenA
|
||||
$adminVisible=@(Get-VisibleMenuTitles $adminMenu.data)
|
||||
$operatorVisible=@(Get-VisibleMenuTitles $operatorMenu.data)
|
||||
foreach($label in @('预警管理','事件查询','规则配置')){if($adminVisible-notcontains$label){throw "administrator menu is missing $label; visible=$($adminVisible-join',')"}}
|
||||
foreach($label in @('预警管理','事件查询')){if($operatorVisible-notcontains$label){throw "operator menu is missing $label; visible=$($operatorVisible-join',')"}}
|
||||
foreach($label in @('开发工具','定时任务','系统监控')){if($adminVisible-contains$label-or$operatorVisible-contains$label){throw "unrelated menu is visible: $label"}}
|
||||
|
||||
$eventType='bell_e2e_danger';$ruleBody=@{code='bell-e2e-danger';name='E2E危险区域规则';eventType=$eventType;minimumSeverity='medium';locationContains='东门'}
|
||||
[void](Invoke-BellJson POST '/api/v1/bell/rules' $ruleBody $tokenA 403)
|
||||
[void](Invoke-BellJson POST '/api/v1/bell/rules' $ruleBody $adminToken)
|
||||
$eventBody=Get-Content -LiteralPath (Join-Path $bellRoot 'tests\fixtures\synthetic-danger-event.json') -Raw -Encoding UTF8|ConvertFrom-Json
|
||||
$eventBody.eventType=$eventType
|
||||
$created=Invoke-BellJson POST '/api/v1/bell/synthetic-events' $eventBody $adminToken
|
||||
$replay=Invoke-BellJson POST '/api/v1/bell/synthetic-events' $eventBody $adminToken
|
||||
if($created.data.duplicate-ne$false-or$replay.data.duplicate-ne$true-or$created.data.event.id-ne$replay.data.event.id){throw 'synthetic Event idempotency failed'}
|
||||
$eventId=[string]$created.data.event.id
|
||||
$alerts=Invoke-BellJson GET '/api/v1/bell/alerts?status=open&pageIndex=1&pageSize=20' $null $tokenA
|
||||
$alert=@($alerts.data.list)[0]
|
||||
if(-not$alert){throw 'rule evaluation did not create an open Alert'}
|
||||
$alertId=[string]$alert.id
|
||||
$alertDetail=(Invoke-BellJson GET "/api/v1/bell/alerts/$alertId" $null $tokenA).data
|
||||
if(@($alertDetail.events.id)-notcontains$eventId){throw 'created Alert is not linked to the synthetic Event'}
|
||||
|
||||
$requests=for($i=0;$i-lt 20;$i++){[pscustomobject]@{Token=$(if($i%2-eq0){$tokenA}else{$tokenB})}}
|
||||
$acks=$requests|ForEach-Object -Parallel {
|
||||
$headers=@{Authorization="Bearer $($_.Token)"}
|
||||
$response=Invoke-RestMethod -Method Post -Uri "$using:baseUrl/api/v1/bell/alerts/$using:alertId/ack" -Headers $headers -ContentType 'application/json' -Body '{}' -TimeoutSec 20 -NoProxy
|
||||
[pscustomobject]@{Token=$_.Token;Response=$response}
|
||||
} -ThrottleLimit 20
|
||||
$winners=@($acks|Where-Object{$_.Response.data.won-eq$true})
|
||||
if($winners.Count-ne 1){throw "concurrent ack winners=$($winners.Count)"}
|
||||
$lifecycle=(Invoke-BellJson GET "/api/v1/bell/alerts/$alertId/lifecycle" $null $tokenA).data.detail
|
||||
if($lifecycle.timeline.Count-ne 1-or$lifecycle.projection.status-ne'acknowledged'){throw 'ack lifecycle projection is inconsistent'}
|
||||
$winnerToken=[string]$winners[0].Token
|
||||
$loserToken=$(if($winnerToken-eq$tokenA){$tokenB}else{$tokenA})
|
||||
[void](Invoke-BellJson POST "/api/v1/bell/alerts/$alertId/close" @{outcome='site_normal'} $loserToken 403)
|
||||
[void](Invoke-BellJson POST "/api/v1/bell/alerts/$alertId/close" @{} $winnerToken 400)
|
||||
$closed=Invoke-BellJson POST "/api/v1/bell/alerts/$alertId/close" @{outcome='site_normal';note='现场检查正常'} $winnerToken
|
||||
$closeReplay=Invoke-BellJson POST "/api/v1/bell/alerts/$alertId/close" @{outcome='site_normal';note='现场检查正常'} $winnerToken
|
||||
if($closed.data.won-ne$true-or$closeReplay.data.idempotent-ne$true){throw 'close or idempotent replay failed'}
|
||||
$final=(Invoke-BellJson GET "/api/v1/bell/alerts/$alertId/lifecycle" $null $winnerToken).data.detail
|
||||
if($final.timeline.Count-ne 2-or$final.projection.status-ne'closed'){throw 'closed timeline is incomplete'}
|
||||
$facts=(&$psql -X -h 127.0.0.1 -p $pgPort -U bell_e2e -d bell_e2e -tAc "select (select count(*) from bell_events),(select count(*) from bell_event_receipts),(select count(*) from bell_alert_lifecycle_facts where alert_id='$alertId');").Trim()
|
||||
if($facts-ne'1|1|2'){throw "unexpected persisted fact counts: $facts"}
|
||||
|
||||
Stop-Package $packageRoot
|
||||
Start-Package $packageRoot
|
||||
$after=(Invoke-BellJson GET "/api/v1/bell/alerts/$alertId/lifecycle" $null $winnerToken).data.detail
|
||||
if($after.timeline.Count-ne 2-or$after.projection.closeOutcome-ne'site_normal'){throw 'cold restart lost lifecycle state'}
|
||||
$rootPage=Invoke-WebRequest -Uri "$script:baseUrl/" -TimeoutSec 10 -NoProxy
|
||||
if($rootPage.StatusCode-ne 200-or$rootPage.Content-notmatch'id=["'']app["'']'){throw 'packaged GoAdmin web shell is not available'}
|
||||
if($BrowserHold){
|
||||
$browserSession=Join-Path $temporary 'browser-session.json';$browserDone=Join-Path $temporary 'browser-done'
|
||||
@{baseUrl=$script:baseUrl;username=$adminName;password=$adminPassword;alertId=$alertId}|ConvertTo-Json|Set-Content -LiteralPath $browserSession -Encoding UTF8
|
||||
Write-Host "Bell browser session ready: $browserSession"
|
||||
for($i=0;$i-lt 1200-and-not(Test-Path -LiteralPath $browserDone);$i++){Start-Sleep -Milliseconds 500}
|
||||
if(-not(Test-Path -LiteralPath $browserDone)){throw 'Browser verification did not signal completion within 10 minutes'}
|
||||
}
|
||||
Stop-Package $packageRoot
|
||||
foreach($log in @($runtimeOut,$runtimeErr,(Join-Path $packageRoot 'runtime\logs\bell.out.log'),(Join-Path $packageRoot 'runtime\logs\bell.err.log'))){if(Test-Path $log){$text=[string](Get-Content -LiteralPath $log -Raw -ErrorAction SilentlyContinue);foreach($secret in @($adminPassword,$operatorPassword,$jwt,$adminToken,$tokenA,$tokenB)){if($text.Contains($secret)){throw "runtime log exposed an E2E credential: $log"}}}}
|
||||
Write-Host "Bell isolated E2E passed: health/login/RBAC, minimal menu, Event/Receipt idempotency, Rule/Alert, 20 concurrent ack, close authorization/idempotency, timeline, cold restart, package start/stop. base_url=$script:baseUrl"
|
||||
} finally {
|
||||
try { if($launcher){Stop-Package $packageRoot} } catch { Stop-ProcessTree $launcher }
|
||||
if($pgStarted){Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList "-D `"$pgData`" -m fast stop" -RedirectStandardOutput (Join-Path $temporary 'pg-stop.out.log') -RedirectStandardError (Join-Path $temporary 'pg-stop.err.log') -WindowStyle Hidden|Out-Null;try{Wait-Tcp -Port $pgPort -Open $false -Attempts 40}catch{}}
|
||||
foreach($item in $savedEnvironment.GetEnumerator()){[Environment]::SetEnvironmentVariable($item.Key,$item.Value,'Process')}
|
||||
if(-not$KeepTemporary-and(Test-Path -LiteralPath $temporary)){$resolved=[IO.Path]::GetFullPath($temporary);if(-not$resolved.StartsWith([IO.Path]::GetTempPath(),[StringComparison]::OrdinalIgnoreCase)){throw "Unsafe temporary cleanup path: $resolved"};Remove-Item -LiteralPath $resolved -Recurse -Force}elseif($KeepTemporary){Write-Host "Kept Bell E2E directory: $temporary"}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"sourceEventId": "bell-e2e-danger-001",
|
||||
"eventType": "danger_area_entered",
|
||||
"occurredAt": "2026-08-29T00:00:00Z",
|
||||
"location": "东门危险区域",
|
||||
"severity": "high",
|
||||
"evidenceRef": "e2e/evidence/bell-e2e-danger-001",
|
||||
"attributes": {
|
||||
"target": "anonymous",
|
||||
"fixture": true
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
<script>
|
||||
|
||||
import variables from '@/styles/variables.scss'
|
||||
import variables from '@/styles/variables.scss?module'
|
||||
import { mapGetters } from 'vuex'
|
||||
|
||||
export default {
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
import { mapGetters } from 'vuex'
|
||||
import Logo from './Logo'
|
||||
import SidebarItem from './SidebarItem'
|
||||
import variables from '@/styles/variables.scss'
|
||||
import variables from '@/styles/variables.scss?module'
|
||||
|
||||
export default {
|
||||
components: { SidebarItem, Logo },
|
||||
|
||||
@@ -20,7 +20,7 @@ import RightPanel from '@/components/RightPanel'
|
||||
import { AppMain, Navbar, Settings, Sidebar, TagsView } from './components'
|
||||
import ResizeMixin from './mixin/ResizeHandler'
|
||||
import { mapState } from 'vuex'
|
||||
import variables from '@/styles/variables.scss'
|
||||
import variables from '@/styles/variables.scss?module'
|
||||
|
||||
export default {
|
||||
name: 'MainLayout',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import variables from '@/styles/element-variables.scss'
|
||||
import variables from '@/styles/element-variables.scss?module'
|
||||
import defaultSettings from '@/settings'
|
||||
|
||||
const { showSettings, topNav, tagsView, fixedHeader, sidebarLogo, themeStyle } = defaultSettings
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const valueImports = [
|
||||
['src/store/modules/settings.js', "@/styles/element-variables.scss?module"],
|
||||
['src/layout/index.vue', "@/styles/variables.scss?module"],
|
||||
['src/layout/components/Sidebar/Logo.vue', "@/styles/variables.scss?module"],
|
||||
['src/layout/components/Sidebar/index.vue', "@/styles/variables.scss?module"]
|
||||
]
|
||||
|
||||
describe('GoAdmin shell Sass value imports', () => {
|
||||
it.each(valueImports)('%s explicitly requests CSS Modules exports', (file, request) => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '../../..', file), 'utf8')
|
||||
expect(source).toContain(`from '${request}'`)
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,7 @@ version = "0.1.0"
|
||||
description = "Headless inference delivery unit for YoVision"
|
||||
readme = "README.md"
|
||||
requires-python = "==3.11.*"
|
||||
dependencies = []
|
||||
dependencies = ["cryptography==50.0.1"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
# The wheel backend is selected by the official PyTorch index documented in
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Independent service-to-service machine identity for Brain connectors."""
|
||||
|
||||
from .token import (
|
||||
ALLOWED_SKEW_SECONDS,
|
||||
MAX_KEY_OVERLAP_SECONDS,
|
||||
MAX_LIFETIME_SECONDS,
|
||||
VERSION,
|
||||
Claims,
|
||||
KeyRecord,
|
||||
MachineIdentityError,
|
||||
Registry,
|
||||
ReplayStore,
|
||||
Signer,
|
||||
Verifier,
|
||||
load_private_key,
|
||||
load_registry,
|
||||
bearer_token,
|
||||
)
|
||||
from .transport import TransportPolicy
|
||||
|
||||
__all__ = [
|
||||
"ALLOWED_SKEW_SECONDS",
|
||||
"MAX_KEY_OVERLAP_SECONDS",
|
||||
"MAX_LIFETIME_SECONDS",
|
||||
"VERSION",
|
||||
"Claims",
|
||||
"KeyRecord",
|
||||
"MachineIdentityError",
|
||||
"Registry",
|
||||
"ReplayStore",
|
||||
"Signer",
|
||||
"TransportPolicy",
|
||||
"Verifier",
|
||||
"load_private_key",
|
||||
"load_registry",
|
||||
"bearer_token",
|
||||
]
|
||||
@@ -0,0 +1,336 @@
|
||||
"""Ed25519 request-bound machine tokens.
|
||||
|
||||
This module never accepts browser cookies, GoAdmin JWTs, query tokens, or
|
||||
shared secrets. HTTP adapters must obtain the compact token exclusively from
|
||||
the Authorization bearer header and pass the request body unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import posixpath
|
||||
import re
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable, Protocol
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
||||
Ed25519PrivateKey,
|
||||
Ed25519PublicKey,
|
||||
)
|
||||
|
||||
VERSION = "yovision.machine-identity/v1"
|
||||
TOKEN_TYPE = "YOVISION-MACHINE+JWT"
|
||||
MAX_LIFETIME_SECONDS = 300
|
||||
ALLOWED_SKEW_SECONDS = 30
|
||||
MAX_KEY_OVERLAP_SECONDS = 24 * 60 * 60
|
||||
_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"})
|
||||
_AUDIENCES = frozenset({"yovision-sense", "yovision-brain", "yovision-bell"})
|
||||
_SCOPES = frozenset({"source-config:write", "runtime-status:write", "events:ingest", "evidence:read"})
|
||||
_PRINCIPAL = re.compile(r"^yv:(sense|brain|bell):[a-z0-9][a-z0-9.-]{0,62}$")
|
||||
_KEY_ID = re.compile(r"^[A-Za-z0-9._-]{8,64}$")
|
||||
_TOKEN_ID = re.compile(r"^[A-Za-z0-9_-]{22,64}$")
|
||||
|
||||
|
||||
class MachineIdentityError(ValueError):
|
||||
"""A stable, non-secret authentication failure."""
|
||||
|
||||
def __init__(self, code: str) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
def bearer_token(authorization: str) -> str:
|
||||
"""Extract only an Authorization bearer token; there is no cookie/query fallback."""
|
||||
parts = authorization.split(" ")
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer" or not parts[1] or any(character in parts[1] for character in " \t\r\n,"):
|
||||
raise MachineIdentityError("machine_token_missing")
|
||||
return parts[1]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Claims:
|
||||
ver: str
|
||||
iss: str
|
||||
sub: str
|
||||
aud: str
|
||||
scope: tuple[str, ...]
|
||||
iat: int
|
||||
nbf: int
|
||||
exp: int
|
||||
jti: str
|
||||
htm: str
|
||||
htu: str
|
||||
body_sha256: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KeyRecord:
|
||||
principal: str
|
||||
key_id: str
|
||||
public_key: Ed25519PublicKey
|
||||
audience: str
|
||||
scopes: frozenset[str]
|
||||
enabled: bool = True
|
||||
revoked: bool = False
|
||||
|
||||
|
||||
class Registry:
|
||||
def __init__(self, records: Iterable[KeyRecord]) -> None:
|
||||
self._lock = threading.RLock()
|
||||
self._records: dict[str, KeyRecord] = {}
|
||||
for record in records:
|
||||
if not _KEY_ID.fullmatch(record.key_id) or not _PRINCIPAL.fullmatch(record.principal) or record.audience not in _AUDIENCES or not _valid_scopes(record.scopes):
|
||||
raise ValueError("invalid machine key record")
|
||||
if record.key_id in self._records:
|
||||
raise ValueError("duplicate machine key id")
|
||||
self._records[record.key_id] = record
|
||||
|
||||
def lookup(self, key_id: str) -> KeyRecord | None:
|
||||
with self._lock:
|
||||
return self._records.get(key_id)
|
||||
|
||||
def revoke(self, key_id: str) -> bool:
|
||||
with self._lock:
|
||||
record = self._records.get(key_id)
|
||||
if record is None:
|
||||
return False
|
||||
self._records[key_id] = KeyRecord(
|
||||
principal=record.principal,
|
||||
key_id=record.key_id,
|
||||
public_key=record.public_key,
|
||||
audience=record.audience,
|
||||
scopes=record.scopes,
|
||||
enabled=record.enabled,
|
||||
revoked=True,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
class ReplayStore:
|
||||
"""Process-local replay cache for tests or one uninterrupted process."""
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._used: dict[tuple[str, str], int] = {}
|
||||
|
||||
def consume(self, principal: str, token_id: str, expires_at: int, now: int) -> bool:
|
||||
with self._lock:
|
||||
self._used = {key: expiry for key, expiry in self._used.items() if expiry > now}
|
||||
key = (principal, token_id)
|
||||
if key in self._used:
|
||||
return False
|
||||
self._used[key] = expires_at
|
||||
return True
|
||||
|
||||
|
||||
class ReplayCache(Protocol):
|
||||
"""Connector implementations provide an atomic durable implementation."""
|
||||
|
||||
def consume(self, principal: str, token_id: str, expires_at: int, now: int) -> bool: ...
|
||||
|
||||
|
||||
def load_private_key(path: str | Path) -> Ed25519PrivateKey:
|
||||
if not str(path).strip():
|
||||
raise ValueError("machine private key path is required")
|
||||
try:
|
||||
raw = Path(path).read_bytes()
|
||||
key = serialization.load_pem_private_key(raw, password=None)
|
||||
except (OSError, ValueError, TypeError) as exc:
|
||||
raise ValueError("invalid machine private key file") from exc
|
||||
if not isinstance(key, Ed25519PrivateKey):
|
||||
raise ValueError("machine private key is not Ed25519")
|
||||
return key
|
||||
|
||||
|
||||
def load_registry(path: str | Path, expected_audience: str) -> Registry:
|
||||
if not str(path).strip() or expected_audience not in _AUDIENCES:
|
||||
raise ValueError("machine principal registry path and audience are required")
|
||||
try:
|
||||
document = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ValueError("invalid machine principal registry") from exc
|
||||
if not isinstance(document, dict) or set(document) != {"version", "audience", "principals"} or document["version"] != "yovision.machine-principal-registry/v1" or document["audience"] != expected_audience or not isinstance(document["principals"], list) or not document["principals"]:
|
||||
raise ValueError("invalid machine principal registry")
|
||||
records: list[KeyRecord] = []
|
||||
try:
|
||||
for principal in document["principals"]:
|
||||
if not isinstance(principal, dict) or set(principal) != {"principal_id", "enabled", "keys"} or not isinstance(principal["enabled"], bool) or not isinstance(principal["keys"], list) or not principal["keys"]:
|
||||
raise ValueError
|
||||
for key in principal["keys"]:
|
||||
if not isinstance(key, dict) or set(key) != {"kid", "public_key_base64url", "status", "scopes"} or key["status"] not in {"active", "revoked"} or not isinstance(key["scopes"], list):
|
||||
raise ValueError
|
||||
public_key = Ed25519PublicKey.from_public_bytes(_b64decode(key["public_key_base64url"]))
|
||||
records.append(KeyRecord(principal["principal_id"], key["kid"], public_key, expected_audience, frozenset(key["scopes"]), principal["enabled"], key["status"] == "revoked"))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
raise ValueError("invalid machine principal registry") from None
|
||||
return Registry(records)
|
||||
|
||||
|
||||
class Signer:
|
||||
def __init__(
|
||||
self,
|
||||
principal: str,
|
||||
key_id: str,
|
||||
private_key: Ed25519PrivateKey,
|
||||
*,
|
||||
clock: Callable[[], int] | None = None,
|
||||
) -> None:
|
||||
if not _PRINCIPAL.fullmatch(principal) or not _KEY_ID.fullmatch(key_id) or not isinstance(private_key, Ed25519PrivateKey):
|
||||
raise ValueError("incomplete machine signer configuration")
|
||||
self._principal = principal
|
||||
self._key_id = key_id
|
||||
self._private_key = private_key
|
||||
self._clock = clock or (lambda: int(time.time()))
|
||||
|
||||
def mint(self, audience: str, scopes: Iterable[str], method: str, request_path: str, body: bytes) -> str:
|
||||
normalized_path = _normalize_path(request_path)
|
||||
normalized_method = method.upper()
|
||||
scope_values = tuple(scopes)
|
||||
if audience not in _AUDIENCES or not _valid_scopes(scope_values) or normalized_method not in _METHODS:
|
||||
raise ValueError("invalid machine token request")
|
||||
now = int(self._clock())
|
||||
header = {"alg": "EdDSA", "typ": TOKEN_TYPE, "kid": self._key_id, "ver": VERSION}
|
||||
claims = {
|
||||
"ver": VERSION,
|
||||
"iss": self._principal,
|
||||
"sub": self._principal,
|
||||
"aud": audience,
|
||||
"scope": list(scope_values),
|
||||
"iat": now,
|
||||
"nbf": now,
|
||||
"exp": now + MAX_LIFETIME_SECONDS,
|
||||
"jti": secrets.token_urlsafe(16),
|
||||
"htm": normalized_method,
|
||||
"htu": normalized_path,
|
||||
"body_sha256": hashlib.sha256(body).hexdigest(),
|
||||
}
|
||||
encoded_header = _encode_json(header)
|
||||
encoded_claims = _encode_json(claims)
|
||||
signing_input = f"{encoded_header}.{encoded_claims}".encode("ascii")
|
||||
signature = self._private_key.sign(signing_input)
|
||||
return f"{encoded_header}.{encoded_claims}.{_b64encode(signature)}"
|
||||
|
||||
|
||||
class Verifier:
|
||||
def __init__(
|
||||
self,
|
||||
registry: Registry,
|
||||
replay_store: ReplayCache,
|
||||
*,
|
||||
clock: Callable[[], int] | None = None,
|
||||
) -> None:
|
||||
self._registry = registry
|
||||
self._replay_store = replay_store
|
||||
self._clock = clock or (lambda: int(time.time()))
|
||||
|
||||
def verify(
|
||||
self,
|
||||
token: str,
|
||||
audience: str,
|
||||
required_scope: str,
|
||||
method: str,
|
||||
request_path: str,
|
||||
body: bytes,
|
||||
) -> Claims:
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3 or "=" in token:
|
||||
raise MachineIdentityError("machine_token_invalid")
|
||||
header = _decode_object(parts[0], {"alg", "typ", "kid", "ver"})
|
||||
if header.get("alg") != "EdDSA" or header.get("typ") != TOKEN_TYPE or header.get("ver") != VERSION or not isinstance(header.get("kid"), str) or not _KEY_ID.fullmatch(header["kid"]):
|
||||
raise MachineIdentityError("machine_token_invalid")
|
||||
record = self._registry.lookup(header["kid"])
|
||||
if record is None:
|
||||
raise MachineIdentityError("machine_token_invalid")
|
||||
try:
|
||||
record.public_key.verify(_b64decode(parts[2]), f"{parts[0]}.{parts[1]}".encode("ascii"))
|
||||
except (InvalidSignature, ValueError):
|
||||
raise MachineIdentityError("machine_token_invalid") from None
|
||||
if not record.enabled or record.revoked:
|
||||
raise MachineIdentityError("machine_identity_revoked")
|
||||
|
||||
raw = _decode_object(parts[1], {"ver", "iss", "sub", "aud", "scope", "iat", "nbf", "exp", "jti", "htm", "htu", "body_sha256"})
|
||||
claims = _claims_from_object(raw)
|
||||
if claims.iss != record.principal or claims.sub != record.principal:
|
||||
raise MachineIdentityError("machine_token_invalid")
|
||||
now = int(self._clock())
|
||||
if claims.exp - claims.iat <= 0 or claims.exp - claims.iat > MAX_LIFETIME_SECONDS or claims.nbf < claims.iat or claims.nbf > claims.exp or claims.iat > now + ALLOWED_SKEW_SECONDS:
|
||||
raise MachineIdentityError("machine_token_invalid")
|
||||
if claims.nbf > now + ALLOWED_SKEW_SECONDS or claims.exp < now - ALLOWED_SKEW_SECONDS:
|
||||
raise MachineIdentityError("machine_token_expired")
|
||||
if claims.aud != audience or record.audience != audience:
|
||||
raise MachineIdentityError("machine_audience_denied")
|
||||
if required_scope not in claims.scope or required_scope not in record.scopes:
|
||||
raise MachineIdentityError("machine_scope_denied")
|
||||
if claims.htm != method.upper() or claims.htu != _normalize_path(request_path) or claims.body_sha256 != hashlib.sha256(body).hexdigest():
|
||||
raise MachineIdentityError("machine_token_invalid")
|
||||
if not self._replay_store.consume(claims.iss, claims.jti, claims.exp + ALLOWED_SKEW_SECONDS, now):
|
||||
raise MachineIdentityError("machine_token_replayed")
|
||||
return claims
|
||||
|
||||
|
||||
def _claims_from_object(value: dict[str, object]) -> Claims:
|
||||
try:
|
||||
scope = value["scope"]
|
||||
if not isinstance(scope, list) or not _valid_scopes(scope):
|
||||
raise ValueError
|
||||
integer_fields = ("iat", "nbf", "exp")
|
||||
if any(not isinstance(value[field], int) or isinstance(value[field], bool) for field in integer_fields):
|
||||
raise ValueError
|
||||
string_fields = ("ver", "iss", "sub", "aud", "jti", "htm", "htu", "body_sha256")
|
||||
if any(not isinstance(value[field], str) for field in string_fields):
|
||||
raise ValueError
|
||||
claims = Claims(scope=tuple(scope), **{key: value[key] for key in string_fields + integer_fields})
|
||||
if claims.ver != VERSION or not _PRINCIPAL.fullmatch(claims.iss) or claims.iss != claims.sub or claims.aud not in _AUDIENCES or not _TOKEN_ID.fullmatch(claims.jti) or claims.htm not in _METHODS or len(claims.body_sha256) != 64:
|
||||
raise ValueError
|
||||
bytes.fromhex(claims.body_sha256)
|
||||
_normalize_path(claims.htu)
|
||||
return claims
|
||||
except (KeyError, TypeError, ValueError):
|
||||
raise MachineIdentityError("machine_token_invalid") from None
|
||||
|
||||
|
||||
def _normalize_path(value: str) -> str:
|
||||
split = urlsplit(value)
|
||||
if not value.startswith("/") or split.scheme or split.netloc or split.query or split.fragment or "\\" in split.path or "//" in split.path or posixpath.normpath(split.path) != split.path:
|
||||
raise ValueError("machine request path must be normalized and contain no query or fragment")
|
||||
return split.path
|
||||
|
||||
|
||||
def _valid_scopes(scopes: Iterable[str]) -> bool:
|
||||
values = tuple(scopes)
|
||||
return 1 <= len(values) <= 4 and len(set(values)) == len(values) and all(scope in _SCOPES for scope in values)
|
||||
|
||||
|
||||
def _encode_json(value: dict[str, object]) -> str:
|
||||
return _b64encode(json.dumps(value, ensure_ascii=True, separators=(",", ":"), sort_keys=True).encode("utf-8"))
|
||||
|
||||
|
||||
def _decode_object(value: str, expected_keys: set[str]) -> dict[str, object]:
|
||||
try:
|
||||
decoded = json.loads(_b64decode(value).decode("utf-8"))
|
||||
except (UnicodeDecodeError, ValueError, json.JSONDecodeError):
|
||||
raise MachineIdentityError("machine_token_invalid") from None
|
||||
if not isinstance(decoded, dict) or set(decoded) != expected_keys:
|
||||
raise MachineIdentityError("machine_token_invalid")
|
||||
return decoded
|
||||
|
||||
|
||||
def _b64encode(value: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
|
||||
|
||||
|
||||
def _b64decode(value: str) -> bytes:
|
||||
if not value or "=" in value:
|
||||
raise ValueError("invalid base64url")
|
||||
decoded = base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True)
|
||||
if _b64encode(decoded) != value:
|
||||
raise ValueError("non-canonical base64url")
|
||||
return decoded
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Fail-closed HTTPS transport policy for Brain connectors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ssl
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TransportPolicy:
|
||||
tls_min_version: str
|
||||
verify_certificate: bool
|
||||
verify_hostname: bool
|
||||
connect_timeout_ms: int
|
||||
response_header_timeout_ms: int
|
||||
request_timeout_ms: int
|
||||
max_request_bytes: int
|
||||
|
||||
def validate(self) -> None:
|
||||
if (
|
||||
self.tls_min_version not in {"1.2", "1.3"}
|
||||
or not self.verify_certificate
|
||||
or not self.verify_hostname
|
||||
or not 100 <= self.connect_timeout_ms <= 30_000
|
||||
or not 100 <= self.response_header_timeout_ms <= 30_000
|
||||
or not 100 <= self.request_timeout_ms <= 60_000
|
||||
or not 1 <= self.max_request_bytes <= 10 * 1024 * 1024
|
||||
):
|
||||
raise ValueError("machine transport policy is unsafe")
|
||||
|
||||
def ssl_context(self) -> ssl.SSLContext:
|
||||
self.validate()
|
||||
context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
|
||||
context.minimum_version = ssl.TLSVersion.TLSv1_3 if self.tls_min_version == "1.3" else ssl.TLSVersion.TLSv1_2
|
||||
context.check_hostname = True
|
||||
context.verify_mode = ssl.CERT_REQUIRED
|
||||
return context
|
||||
@@ -15,6 +15,14 @@
|
||||
|
||||
E2E 入口从 PowerShell 7 调用时会自动转入 Windows PowerShell 5.1 执行本地 HTTP 回归;源码打包仍显式使用冻结要求的 PowerShell 7。这样与 Windows 交付脚本的宿主一致,也避开当前机器 PowerShell 7 HTTP 客户端对本地 Go/MediaMTX 响应的兼容问题。
|
||||
|
||||
默认入口只复制 Git 已跟踪的 `Sense/` 源码到系统临时目录,因此正常开发工作区中已有的 `node_modules`、`dist`、本地配置、日志和其他未跟踪文件不会进入验收副本。`-PreparedPackageRoot` 也会先把指定包复制到本次临时目录,运行时配置、浏览器脚本、截图和日志不会写回原包或源码树。
|
||||
|
||||
Sense 进程提前退出或 HTTP 就绪超时时,脚本返回非零并输出阶段、退出状态、临时日志位置和经过过滤、截断的日志摘要;数据库连接、密码、token、Cookie、JWT 和 credential key 不得出现在诊断中。默认无论成功或失败都会清理所属进程和临时目录;`-KeepTemporary` 仅用于排错,仍会停止进程,但保留目录可能包含随机运行时秘密,必须限制访问并在排错后安全删除。
|
||||
|
||||
为避免 Windows 首次扫描临时复制的 MediaMTX 二进制占用产品固定的就绪窗口,E2E 会先在同一动态端口和临时配置上启动一次包内 MediaMTX,确认 Control API 可用并完全停止,再由 Sense 以 managed 模式启动并完成生命周期验收。预检失败会单独报告 `MediaMTX preflight` 阶段,不会被误报为 Sense HTTP 超时。
|
||||
|
||||
HTTP、RTSP、HLS、Control API 和 ONVIF 动态端口使用 TCP 绑定探测;WebRTC 本地 UDP 端口必须使用 UDP socket 实际绑定探测,不得用 TCP 空闲结果代替,避免落入 Windows 的 UDP 排除或占用范围。
|
||||
|
||||
## 回归矩阵
|
||||
|
||||
| 范围 | 自动化证据 | 判定 |
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package machine_identity
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type registryDocument struct {
|
||||
Version string `json:"version"`
|
||||
Audience string `json:"audience"`
|
||||
Principals []registryPrincipal `json:"principals"`
|
||||
}
|
||||
|
||||
type registryPrincipal struct {
|
||||
PrincipalID string `json:"principal_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Keys []registryKey `json:"keys"`
|
||||
}
|
||||
|
||||
type registryKey struct {
|
||||
KeyID string `json:"kid"`
|
||||
PublicKey string `json:"public_key_base64url"`
|
||||
Status string `json:"status"`
|
||||
Scopes []string `json:"scopes"`
|
||||
}
|
||||
|
||||
func LoadRegistry(filePath, expectedAudience string) (*Registry, error) {
|
||||
if strings.TrimSpace(filePath) == "" || !validAudiences[expectedAudience] {
|
||||
return nil, errors.New("machine principal registry path and audience are required")
|
||||
}
|
||||
raw, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, errors.New("read machine principal registry")
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
var document registryDocument
|
||||
if err = decoder.Decode(&document); err != nil {
|
||||
return nil, errors.New("invalid machine principal registry")
|
||||
}
|
||||
if err = decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
return nil, errors.New("invalid machine principal registry")
|
||||
}
|
||||
if document.Version != "yovision.machine-principal-registry/v1" || document.Audience != expectedAudience || len(document.Principals) == 0 {
|
||||
return nil, errors.New("invalid machine principal registry")
|
||||
}
|
||||
records := make([]KeyRecord, 0)
|
||||
for _, principal := range document.Principals {
|
||||
if len(principal.Keys) == 0 {
|
||||
return nil, errors.New("invalid machine principal registry")
|
||||
}
|
||||
for _, key := range principal.Keys {
|
||||
publicKey, decodeErr := base64.RawURLEncoding.Strict().DecodeString(key.PublicKey)
|
||||
if decodeErr != nil || len(publicKey) != ed25519.PublicKeySize || (key.Status != "active" && key.Status != "revoked") {
|
||||
return nil, errors.New("invalid machine principal registry")
|
||||
}
|
||||
records = append(records, KeyRecord{Principal: principal.PrincipalID, KeyID: key.KeyID, PublicKey: ed25519.PublicKey(publicKey), Audience: document.Audience,
|
||||
Scopes: key.Scopes, Enabled: principal.Enabled, Revoked: key.Status == "revoked"})
|
||||
}
|
||||
}
|
||||
return NewRegistry(records...)
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package machine_identity
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
principalPattern = regexp.MustCompile(`^yv:(sense|brain|bell):[a-z0-9][a-z0-9.-]{0,62}$`)
|
||||
keyIDPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{8,64}$`)
|
||||
tokenIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{22,64}$`)
|
||||
validAudiences = map[string]bool{"yovision-sense": true, "yovision-brain": true, "yovision-bell": true}
|
||||
validScopes = map[string]bool{"source-config:write": true, "runtime-status:write": true, "events:ingest": true, "evidence:read": true}
|
||||
)
|
||||
|
||||
const (
|
||||
Version = "yovision.machine-identity/v1"
|
||||
TokenType = "YOVISION-MACHINE+JWT"
|
||||
MaxLifetime = 5 * time.Minute
|
||||
AllowedSkew = 30 * time.Second
|
||||
MaxKeyOverlap = 24 * time.Hour
|
||||
)
|
||||
|
||||
type Error struct{ Code string }
|
||||
|
||||
func (e *Error) Error() string { return e.Code }
|
||||
|
||||
func codeError(code string) error { return &Error{Code: code} }
|
||||
|
||||
// BearerToken deliberately has no cookie or query fallback.
|
||||
func BearerToken(authorization string) (string, error) {
|
||||
parts := strings.Split(authorization, " ")
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] == "" || strings.ContainsAny(parts[1], " \t\r\n,") {
|
||||
return "", codeError("machine_token_missing")
|
||||
}
|
||||
return parts[1], nil
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
Version string `json:"ver"`
|
||||
Issuer string `json:"iss"`
|
||||
Subject string `json:"sub"`
|
||||
Audience string `json:"aud"`
|
||||
Scopes []string `json:"scope"`
|
||||
IssuedAt int64 `json:"iat"`
|
||||
NotBefore int64 `json:"nbf"`
|
||||
ExpiresAt int64 `json:"exp"`
|
||||
TokenID string `json:"jti"`
|
||||
Method string `json:"htm"`
|
||||
Path string `json:"htu"`
|
||||
BodySHA256 string `json:"body_sha256"`
|
||||
}
|
||||
|
||||
type protectedHeader struct {
|
||||
Algorithm string `json:"alg"`
|
||||
Type string `json:"typ"`
|
||||
KeyID string `json:"kid"`
|
||||
Version string `json:"ver"`
|
||||
}
|
||||
|
||||
type KeyRecord struct {
|
||||
Principal string
|
||||
KeyID string
|
||||
PublicKey ed25519.PublicKey
|
||||
Audience string
|
||||
Scopes []string
|
||||
Enabled bool
|
||||
Revoked bool
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
keys map[string]KeyRecord
|
||||
}
|
||||
|
||||
func NewRegistry(records ...KeyRecord) (*Registry, error) {
|
||||
r := &Registry{keys: make(map[string]KeyRecord, len(records))}
|
||||
for _, record := range records {
|
||||
if !keyIDPattern.MatchString(record.KeyID) || !principalPattern.MatchString(record.Principal) || !validAudiences[record.Audience] || len(record.PublicKey) != ed25519.PublicKeySize || !validScopeList(record.Scopes) {
|
||||
return nil, errors.New("invalid machine key record")
|
||||
}
|
||||
if _, exists := r.keys[record.KeyID]; exists {
|
||||
return nil, errors.New("duplicate machine key id")
|
||||
}
|
||||
record.PublicKey = slices.Clone(record.PublicKey)
|
||||
record.Scopes = slices.Clone(record.Scopes)
|
||||
r.keys[record.KeyID] = record
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (r *Registry) Lookup(keyID string) (KeyRecord, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
record, ok := r.keys[keyID]
|
||||
record.PublicKey = slices.Clone(record.PublicKey)
|
||||
record.Scopes = slices.Clone(record.Scopes)
|
||||
return record, ok
|
||||
}
|
||||
|
||||
func (r *Registry) Revoke(keyID string) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
record, ok := r.keys[keyID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
record.Revoked = true
|
||||
r.keys[keyID] = record
|
||||
return true
|
||||
}
|
||||
|
||||
type ReplayStore struct {
|
||||
mu sync.Mutex
|
||||
used map[string]time.Time
|
||||
}
|
||||
|
||||
// ReplayCache must atomically persist accepted (principal, jti) pairs until
|
||||
// expiry. ReplayStore is process-local and intended for tests or a single
|
||||
// uninterrupted process; connector implementations inject a durable store.
|
||||
type ReplayCache interface {
|
||||
Consume(principal, tokenID string, expiresAt, now time.Time) bool
|
||||
}
|
||||
|
||||
func NewReplayStore() *ReplayStore { return &ReplayStore{used: map[string]time.Time{}} }
|
||||
|
||||
func (s *ReplayStore) Consume(principal, tokenID string, expiresAt, now time.Time) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for key, expiry := range s.used {
|
||||
if !expiry.After(now) {
|
||||
delete(s.used, key)
|
||||
}
|
||||
}
|
||||
key := principal + "\x00" + tokenID
|
||||
if _, exists := s.used[key]; exists {
|
||||
return false
|
||||
}
|
||||
s.used[key] = expiresAt
|
||||
return true
|
||||
}
|
||||
|
||||
type Signer struct {
|
||||
Principal string
|
||||
KeyID string
|
||||
PrivateKey ed25519.PrivateKey
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func LoadPrivateKey(path string) (ed25519.PrivateKey, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil, errors.New("machine private key path is required")
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, errors.New("read machine private key")
|
||||
}
|
||||
block, rest := pem.Decode(raw)
|
||||
if block == nil || len(bytes.TrimSpace(rest)) != 0 || block.Type != "PRIVATE KEY" {
|
||||
return nil, errors.New("machine private key must be one PKCS#8 PEM block")
|
||||
}
|
||||
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, errors.New("parse machine private key")
|
||||
}
|
||||
key, ok := parsed.(ed25519.PrivateKey)
|
||||
if !ok || len(key) != ed25519.PrivateKeySize {
|
||||
return nil, errors.New("machine private key is not Ed25519")
|
||||
}
|
||||
return slices.Clone(key), nil
|
||||
}
|
||||
|
||||
func (s Signer) Mint(audience string, scopes []string, method, requestPath string, body []byte) (string, error) {
|
||||
if !principalPattern.MatchString(s.Principal) || !keyIDPattern.MatchString(s.KeyID) || len(s.PrivateKey) != ed25519.PrivateKeySize || !validAudiences[audience] || !validScopeList(scopes) {
|
||||
return "", errors.New("incomplete machine signer configuration")
|
||||
}
|
||||
normalizedPath, err := normalizePath(requestPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
method = strings.ToUpper(method)
|
||||
if !allowedMethod(method) {
|
||||
return "", errors.New("unsupported machine request method")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if s.Now != nil {
|
||||
now = s.Now().UTC()
|
||||
}
|
||||
tokenID, err := randomTokenID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
digest := sha256.Sum256(body)
|
||||
claims := Claims{Version: Version, Issuer: s.Principal, Subject: s.Principal, Audience: audience,
|
||||
Scopes: slices.Clone(scopes), IssuedAt: now.Unix(), NotBefore: now.Unix(), ExpiresAt: now.Add(MaxLifetime).Unix(),
|
||||
TokenID: tokenID, Method: method, Path: normalizedPath, BodySHA256: hex.EncodeToString(digest[:])}
|
||||
header := protectedHeader{Algorithm: "EdDSA", Type: TokenType, KeyID: s.KeyID, Version: Version}
|
||||
headerJSON, _ := json.Marshal(header)
|
||||
claimsJSON, _ := json.Marshal(claims)
|
||||
signingInput := rawBase64(headerJSON) + "." + rawBase64(claimsJSON)
|
||||
signature := ed25519.Sign(s.PrivateKey, []byte(signingInput))
|
||||
return signingInput + "." + rawBase64(signature), nil
|
||||
}
|
||||
|
||||
type Verifier struct {
|
||||
Registry *Registry
|
||||
Replay ReplayCache
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (v Verifier) Verify(token, audience, requiredScope, method, requestPath string, body []byte) (Claims, error) {
|
||||
if v.Registry == nil || v.Replay == nil {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 || strings.Contains(token, "=") {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
headerBytes, err := decodeRaw(parts[0])
|
||||
if err != nil {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
var header protectedHeader
|
||||
if err = decodeClosed(headerBytes, &header); err != nil || header.Algorithm != "EdDSA" || header.Type != TokenType || header.Version != Version || !keyIDPattern.MatchString(header.KeyID) {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
record, ok := v.Registry.Lookup(header.KeyID)
|
||||
if !ok {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
signature, err := decodeRaw(parts[2])
|
||||
if err != nil || len(signature) != ed25519.SignatureSize || !ed25519.Verify(record.PublicKey, []byte(parts[0]+"."+parts[1]), signature) {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
if !record.Enabled || record.Revoked {
|
||||
return Claims{}, codeError("machine_identity_revoked")
|
||||
}
|
||||
claimsBytes, err := decodeRaw(parts[1])
|
||||
if err != nil {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
var claims Claims
|
||||
if err = decodeClosed(claimsBytes, &claims); err != nil || !validClaimsShape(claims) || claims.Issuer != record.Principal || claims.Subject != record.Principal {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if v.Now != nil {
|
||||
now = v.Now().UTC()
|
||||
}
|
||||
nowUnix := now.Unix()
|
||||
if claims.ExpiresAt-claims.IssuedAt <= 0 || claims.ExpiresAt-claims.IssuedAt > int64(MaxLifetime/time.Second) ||
|
||||
claims.NotBefore < claims.IssuedAt || claims.NotBefore > claims.ExpiresAt || claims.IssuedAt > nowUnix+int64(AllowedSkew/time.Second) {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
if claims.NotBefore > nowUnix+int64(AllowedSkew/time.Second) || claims.ExpiresAt < nowUnix-int64(AllowedSkew/time.Second) {
|
||||
return Claims{}, codeError("machine_token_expired")
|
||||
}
|
||||
if claims.Audience != audience || record.Audience != audience {
|
||||
return Claims{}, codeError("machine_audience_denied")
|
||||
}
|
||||
if !slices.Contains(claims.Scopes, requiredScope) || !slices.Contains(record.Scopes, requiredScope) {
|
||||
return Claims{}, codeError("machine_scope_denied")
|
||||
}
|
||||
normalizedPath, err := normalizePath(requestPath)
|
||||
digest := sha256.Sum256(body)
|
||||
if err != nil || claims.Method != strings.ToUpper(method) || claims.Path != normalizedPath || claims.BodySHA256 != hex.EncodeToString(digest[:]) {
|
||||
return Claims{}, codeError("machine_token_invalid")
|
||||
}
|
||||
if !v.Replay.Consume(claims.Issuer, claims.TokenID, time.Unix(claims.ExpiresAt, 0).Add(AllowedSkew), now) {
|
||||
return Claims{}, codeError("machine_token_replayed")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func decodeClosed(raw []byte, target any) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
return errors.New("trailing JSON value")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validClaimsShape(claims Claims) bool {
|
||||
if claims.Version != Version || !principalPattern.MatchString(claims.Issuer) || claims.Subject != claims.Issuer || !validAudiences[claims.Audience] || !tokenIDPattern.MatchString(claims.TokenID) ||
|
||||
len(claims.Scopes) == 0 || len(claims.Scopes) > 4 || !allowedMethod(claims.Method) || claims.Path == "" || len(claims.BodySHA256) != 64 {
|
||||
return false
|
||||
}
|
||||
if !validScopeList(claims.Scopes) {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(claims.BodySHA256)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func validScopeList(scopes []string) bool {
|
||||
if len(scopes) == 0 || len(scopes) > 4 {
|
||||
return false
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, scope := range scopes {
|
||||
if !validScopes[scope] || seen[scope] {
|
||||
return false
|
||||
}
|
||||
seen[scope] = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizePath(value string) (string, error) {
|
||||
parsed, err := url.ParseRequestURI(value)
|
||||
if err != nil || parsed.IsAbs() || parsed.Host != "" || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Path == "" || !strings.HasPrefix(parsed.Path, "/") || strings.Contains(parsed.Path, "\\") || strings.Contains(parsed.Path, "//") || path.Clean(parsed.Path) != parsed.Path {
|
||||
return "", errors.New("machine request path must be a normalized absolute path without query or fragment")
|
||||
}
|
||||
return parsed.EscapedPath(), nil
|
||||
}
|
||||
|
||||
func allowedMethod(method string) bool {
|
||||
switch method {
|
||||
case "GET", "POST", "PUT", "PATCH", "DELETE":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func randomTokenID() (string, error) {
|
||||
raw := make([]byte, 16)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", fmt.Errorf("generate machine token id: %w", err)
|
||||
}
|
||||
return rawBase64(raw), nil
|
||||
}
|
||||
|
||||
func rawBase64(value []byte) string { return base64.RawURLEncoding.EncodeToString(value) }
|
||||
|
||||
func decodeRaw(value string) ([]byte, error) {
|
||||
return base64.RawURLEncoding.Strict().DecodeString(value)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package machine_identity
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type crossLanguageVector struct {
|
||||
PublicKey string `json:"public_key_base64url"`
|
||||
Token string `json:"token"`
|
||||
Now int64 `json:"now"`
|
||||
Audience string `json:"audience"`
|
||||
Scope string `json:"required_scope"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Body string `json:"body_base64"`
|
||||
}
|
||||
|
||||
func testIdentity(t *testing.T) (Signer, *Registry, time.Time) {
|
||||
t.Helper()
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Unix(1_800_000_000, 0).UTC()
|
||||
registry, err := NewRegistry(KeyRecord{Principal: "yv:sense:site-a", KeyID: "sense-key-0001", PublicKey: publicKey,
|
||||
Audience: "yovision-brain", Scopes: []string{"source-config:write"}, Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Signer{Principal: "yv:sense:site-a", KeyID: "sense-key-0001", PrivateKey: privateKey, Now: func() time.Time { return now }}, registry, now
|
||||
}
|
||||
|
||||
func errorCode(t *testing.T, err error) string {
|
||||
t.Helper()
|
||||
var coded *Error
|
||||
if !errors.As(err, &coded) {
|
||||
t.Fatalf("expected coded error, got %v", err)
|
||||
}
|
||||
return coded.Code
|
||||
}
|
||||
|
||||
func TestMintAndVerifyRequestBoundToken(t *testing.T) {
|
||||
signer, registry, now := testIdentity(t)
|
||||
body := []byte(`{"revision":7}`)
|
||||
token, err := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now }}
|
||||
claims, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if claims.Issuer != signer.Principal || claims.Subject != signer.Principal || claims.ExpiresAt-claims.IssuedAt != 300 {
|
||||
t.Fatalf("unexpected claims: %+v", claims)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBearerTokenHasNoCookieOrQueryFallback(t *testing.T) {
|
||||
if token, err := BearerToken("Bearer compact.token.value"); err != nil || token != "compact.token.value" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, value := range []string{"", "compact.token.value", "Bearer", "Bearer one two", "Cookie compact.token.value"} {
|
||||
if _, err := BearerToken(value); errorCode(t, err) != "machine_token_missing" {
|
||||
t.Fatalf("accepted %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectsReplayWrongAudienceScopeAndRequest(t *testing.T) {
|
||||
signer, registry, now := testIdentity(t)
|
||||
body := []byte(`{"revision":7}`)
|
||||
mint := func() string {
|
||||
token, err := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now }}
|
||||
token := mint()
|
||||
if _, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_token_replayed" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := verifier.Verify(mint(), "yovision-bell", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_audience_denied" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := verifier.Verify(mint(), "yovision-brain", "events:ingest", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_scope_denied" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := verifier.Verify(mint(), "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", []byte("changed")); errorCode(t, err) != "machine_token_invalid" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiryRevocationAndRotation(t *testing.T) {
|
||||
signer, registry, now := testIdentity(t)
|
||||
body := []byte("{}")
|
||||
token, _ := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
|
||||
expired := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now.Add(6 * time.Minute) }}
|
||||
if _, err := expired.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_token_expired" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oldPublic, oldPrivate, _ := ed25519.GenerateKey(rand.Reader)
|
||||
newPublic, newPrivate, _ := ed25519.GenerateKey(rand.Reader)
|
||||
rotation, err := NewRegistry(
|
||||
KeyRecord{Principal: "yv:brain:node-a", KeyID: "brain-old-0001", PublicKey: oldPublic, Audience: "yovision-sense", Scopes: []string{"runtime-status:write"}, Enabled: true},
|
||||
KeyRecord{Principal: "yv:brain:node-a", KeyID: "brain-new-0002", PublicKey: newPublic, Audience: "yovision-sense", Scopes: []string{"runtime-status:write"}, Enabled: true},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldSigner := Signer{Principal: "yv:brain:node-a", KeyID: "brain-old-0001", PrivateKey: oldPrivate, Now: func() time.Time { return now }}
|
||||
newSigner := Signer{Principal: "yv:brain:node-a", KeyID: "brain-new-0002", PrivateKey: newPrivate, Now: func() time.Time { return now }}
|
||||
oldToken, _ := oldSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
|
||||
newToken, _ := newSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
|
||||
verify := Verifier{Registry: rotation, Replay: NewReplayStore(), Now: func() time.Time { return now }}
|
||||
if _, err = verify.Verify(oldToken, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = verify.Verify(newToken, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !rotation.Revoke("brain-old-0001") {
|
||||
t.Fatal("old key was not revoked")
|
||||
}
|
||||
oldAfterRevoke, _ := oldSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
|
||||
if _, err = verify.Verify(oldAfterRevoke, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); errorCode(t, err) != "machine_identity_revoked" {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransportPolicyRejectsUnsafeTLS(t *testing.T) {
|
||||
safe := TransportPolicy{TLSMinVersion: tls.VersionTLS12, VerifyCertificate: true, VerifyHostname: true,
|
||||
ConnectTimeout: time.Second, ResponseHeaderTimeout: time.Second, RequestTimeout: 2 * time.Second, MaxRequestBytes: 1024}
|
||||
if err := safe.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unsafe := safe
|
||||
unsafe.VerifyHostname = false
|
||||
if err := unsafe.Validate(); err == nil {
|
||||
t.Fatal("unsafe hostname policy accepted")
|
||||
}
|
||||
unsafe = safe
|
||||
unsafe.TLSMinVersion = tls.VersionTLS11
|
||||
if err := unsafe.Validate(); err == nil {
|
||||
t.Fatal("TLS 1.1 accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifiesCrossLanguageVector(t *testing.T) {
|
||||
vectorPath := filepath.Join("..", "..", "..", "..", "..", "..", "contracts", "tests", "machine-identity-v1", "cross-language-vector.json")
|
||||
raw, err := os.ReadFile(vectorPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var vector crossLanguageVector
|
||||
if err = json.Unmarshal(raw, &vector); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
publicKey, err := base64.RawURLEncoding.DecodeString(vector.PublicKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := base64.StdEncoding.DecodeString(vector.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry, err := NewRegistry(KeyRecord{Principal: "yv:brain:vector", KeyID: "brain-vector-0001", PublicKey: ed25519.PublicKey(publicKey), Audience: vector.Audience, Scopes: []string{vector.Scope}, Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return time.Unix(vector.Now, 0) }}
|
||||
claims, err := verifier.Verify(vector.Token, vector.Audience, vector.Scope, vector.Method, vector.Path, body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if claims.Issuer != "yv:brain:vector" {
|
||||
t.Fatalf("unexpected issuer: %s", claims.Issuer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadsExternalPublicRegistryAndRejectsWrongAudience(t *testing.T) {
|
||||
publicKey, _, _ := ed25519.GenerateKey(rand.Reader)
|
||||
document := map[string]any{
|
||||
"version": "yovision.machine-principal-registry/v1", "audience": "yovision-bell",
|
||||
"principals": []any{map[string]any{"principal_id": "yv:sense:site-a", "enabled": true, "keys": []any{map[string]any{
|
||||
"kid": "sense-key-0001", "public_key_base64url": base64.RawURLEncoding.EncodeToString(publicKey), "status": "active", "scopes": []string{"events:ingest"},
|
||||
}}}},
|
||||
}
|
||||
raw, _ := json.Marshal(document)
|
||||
file := filepath.Join(t.TempDir(), "principals.json")
|
||||
if err := os.WriteFile(file, raw, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry, err := LoadRegistry(file, "yovision-bell")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record, ok := registry.Lookup("sense-key-0001"); !ok || record.Principal != "yv:sense:site-a" {
|
||||
t.Fatal("registry record missing")
|
||||
}
|
||||
if _, err = LoadRegistry(file, "yovision-sense"); err == nil {
|
||||
t.Fatal("wrong registry audience accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package machine_identity
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TransportPolicy struct {
|
||||
TLSMinVersion uint16
|
||||
VerifyCertificate bool
|
||||
VerifyHostname bool
|
||||
ConnectTimeout time.Duration
|
||||
ResponseHeaderTimeout time.Duration
|
||||
RequestTimeout time.Duration
|
||||
MaxRequestBytes int64
|
||||
}
|
||||
|
||||
func (p TransportPolicy) Validate() error {
|
||||
if p.TLSMinVersion < tls.VersionTLS12 || !p.VerifyCertificate || !p.VerifyHostname || p.ConnectTimeout < 100*time.Millisecond || p.ConnectTimeout > 30*time.Second ||
|
||||
p.ResponseHeaderTimeout < 100*time.Millisecond || p.ResponseHeaderTimeout > 30*time.Second || p.RequestTimeout < 100*time.Millisecond || p.RequestTimeout > 60*time.Second ||
|
||||
p.MaxRequestBytes < 1 || p.MaxRequestBytes > 10*1024*1024 {
|
||||
return errors.New("machine transport policy is unsafe")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p TransportPolicy) HTTPClient() (*http.Client, error) {
|
||||
if err := p.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{MinVersion: p.TLSMinVersion},
|
||||
TLSHandshakeTimeout: p.ConnectTimeout,
|
||||
ResponseHeaderTimeout: p.ResponseHeaderTimeout,
|
||||
}
|
||||
return &http.Client{Transport: transport, Timeout: p.RequestTimeout}, nil
|
||||
}
|
||||
@@ -49,4 +49,18 @@ foreach ($file in $fixtureFiles) {
|
||||
if ($file.Extension -eq '.json') { [void]($content | ConvertFrom-Json); $passed++ }
|
||||
}
|
||||
|
||||
$e2eScript = Read-Utf8 (Join-Path $senseRoot 'tests\e2e\run-isolated-e2e.ps1')
|
||||
Assert-True ($e2eScript.Contains('Copy-TrackedSenseSource $repositoryRoot $senseCopy')) 'Sense E2E no longer copies only tracked source'
|
||||
Assert-True (-not $e2eScript.Contains('Copy-Item -LiteralPath $sourceSense -Destination $senseCopy -Recurse')) 'Sense E2E regressed to recursive source-tree copying'
|
||||
Assert-True ($e2eScript.Contains("`$browserScript = Join-Path `$PSScriptRoot 'browser-smoke.cjs'")) 'browser smoke script no longer runs from its tracked location'
|
||||
Assert-True ($e2eScript.Contains("`$packageRoot = Join-Path `$temporary 'prepared-package'")) 'prepared package no longer runs from an isolated temporary copy'
|
||||
Assert-True ($e2eScript.Contains("-Process `$process -Stage 'Sense HTTP'")) 'Sense HTTP readiness no longer observes the package process'
|
||||
Assert-True ($e2eScript.Contains('Get-SafeLogSummary')) 'Sense readiness diagnostics no longer use log redaction'
|
||||
Assert-True ($e2eScript.Contains("-Stage 'MediaMTX preflight'")) 'Sense E2E no longer preflights the copied MediaMTX package and config'
|
||||
Assert-True ($e2eScript.Contains('function Get-FreeUdpPort')) 'Sense E2E no longer probes WebRTC UDP ports with the UDP protocol'
|
||||
Assert-True ($e2eScript.Contains('do { $webrtcUDPort = Get-FreeUdpPort }')) 'Sense E2E WebRTC UDP port regressed to TCP-only discovery'
|
||||
|
||||
& powershell.exe -NoProfile -File (Join-Path $senseRoot 'tests\e2e\run-isolated-e2e.ps1') -HarnessSelfTest
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Sense E2E harness self-test failed' }
|
||||
|
||||
Write-Host "Sense compatibility regression passed: $passed assertions."
|
||||
|
||||
@@ -3,11 +3,13 @@ param(
|
||||
[string]$MediaMTX = 'C:\Users\ila20\Desktop\mediamtx\mediamtx.exe',
|
||||
[string]$Browser = 'C:\Program Files\Google\Chrome\Application\chrome.exe',
|
||||
[string]$PreparedPackageRoot = '',
|
||||
[switch]$HarnessSelfTest,
|
||||
[switch]$KeepTemporary
|
||||
)
|
||||
if ($PSVersionTable.PSEdition -eq 'Core') {
|
||||
$legacyArguments = @('-NoProfile', '-File', $PSCommandPath, '-PostgresBin', $PostgresBin, '-MediaMTX', $MediaMTX, '-Browser', $Browser)
|
||||
if (-not [string]::IsNullOrWhiteSpace($PreparedPackageRoot)) { $legacyArguments += @('-PreparedPackageRoot', $PreparedPackageRoot) }
|
||||
if ($HarnessSelfTest) { $legacyArguments += '-HarnessSelfTest' }
|
||||
if ($KeepTemporary) { $legacyArguments += '-KeepTemporary' }
|
||||
& powershell.exe @legacyArguments
|
||||
exit $LASTEXITCODE
|
||||
@@ -34,8 +36,10 @@ $fixtureStatus = Join-Path $temporary 'fixture-status.json'
|
||||
$server = $null
|
||||
$fixture = $null
|
||||
$publisher = $null
|
||||
$preflightMedia = $null
|
||||
$pgStarted = $false
|
||||
$savedEnvironment = @{}
|
||||
$sensitiveValues = @()
|
||||
|
||||
function Get-FreePort {
|
||||
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
|
||||
@@ -61,15 +65,68 @@ function Set-TestEnvironment([string]$Name, [string]$Value) {
|
||||
}
|
||||
[Environment]::SetEnvironmentVariable($Name, $Value, 'Process')
|
||||
}
|
||||
function Wait-Http([string]$Uri, [int]$Attempts = 120) {
|
||||
function Get-FreeUdpPort {
|
||||
$client = [Net.Sockets.UdpClient]::new([Net.IPEndPoint]::new([Net.IPAddress]::Loopback, 0))
|
||||
try { return ([Net.IPEndPoint]$client.Client.LocalEndPoint).Port } finally { $client.Dispose() }
|
||||
}
|
||||
function Copy-TrackedSenseSource([string]$RepositoryRoot, [string]$Destination) {
|
||||
$tracked = @(& git -C $RepositoryRoot ls-files -- 'Sense')
|
||||
if ($LASTEXITCODE -ne 0 -or $tracked.Count -eq 0) { throw 'Could not enumerate tracked Sense source files' }
|
||||
$sensePrefix = 'Sense\'
|
||||
foreach ($relative in $tracked) {
|
||||
$normalized = ([string]$relative).Replace('/', '\')
|
||||
if (-not $normalized.StartsWith($sensePrefix, [StringComparison]::Ordinal)) {
|
||||
throw "Unexpected tracked path outside Sense: $relative"
|
||||
}
|
||||
$source = Join-Path $RepositoryRoot $normalized
|
||||
if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { throw "Tracked Sense source is missing: $relative" }
|
||||
$target = Join-Path $Destination $normalized.Substring($sensePrefix.Length)
|
||||
$parent = Split-Path -Parent $target
|
||||
if (-not (Test-Path -LiteralPath $parent)) { [void](New-Item -ItemType Directory -Path $parent -Force) }
|
||||
Copy-Item -LiteralPath $source -Destination $target
|
||||
}
|
||||
}
|
||||
function Get-SafeLogSummary([string[]]$Paths, [int]$MaximumCharacters = 2000) {
|
||||
$parts = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($path in @($Paths)) {
|
||||
if ([string]::IsNullOrWhiteSpace($path) -or -not (Test-Path -LiteralPath $path -PathType Leaf)) { continue }
|
||||
$text = [string](@(Get-Content -LiteralPath $path -Tail 20 -ErrorAction SilentlyContinue) -join ' | ')
|
||||
foreach ($secret in @($script:sensitiveValues)) {
|
||||
if (-not [string]::IsNullOrWhiteSpace($secret) -and $secret.Length -ge 4) { $text = $text.Replace($secret, '<redacted>') }
|
||||
}
|
||||
$text = $text -replace '(?i)((?:password|token|secret|credential(?:_key)?|database(?:_url)?|cookie|authorization)["'']?\s*[:=]\s*["'']?)[^\s,;"'']+', '$1<redacted>'
|
||||
$text = $text -replace '(?i)(postgres(?:ql)?://)[^\s]+', '$1<redacted>'
|
||||
if ($text.Length -gt $MaximumCharacters) { $text = $text.Substring($text.Length - $MaximumCharacters) }
|
||||
if (-not [string]::IsNullOrWhiteSpace($text)) { $parts.Add("$([IO.Path]::GetFileName($path)): $text") }
|
||||
}
|
||||
if ($parts.Count -eq 0) { return '<no log output>' }
|
||||
return ($parts -join ' || ')
|
||||
}
|
||||
function Wait-Http {
|
||||
param(
|
||||
[string]$Uri,
|
||||
[int]$Attempts = 120,
|
||||
$Process = $null,
|
||||
[string]$Stage = 'HTTP endpoint',
|
||||
[string[]]$LogPaths = @()
|
||||
)
|
||||
for ($attempt = 0; $attempt -lt $Attempts; $attempt++) {
|
||||
if ($null -ne $Process -and $Process.HasExited) {
|
||||
try { $Process.WaitForExit(); $Process.Refresh() } catch {}
|
||||
$exitCode = try { [string]$Process.ExitCode } catch { 'unknown' }
|
||||
if ([string]::IsNullOrWhiteSpace($exitCode)) { $exitCode = 'unknown' }
|
||||
$summary = Get-SafeLogSummary $LogPaths
|
||||
throw "$Stage process exited before readiness: exit_code=$exitCode; log_files=$($LogPaths -join ','); summary=$summary"
|
||||
}
|
||||
try {
|
||||
$response = Invoke-WebRequest -UseBasicParsing -Uri $Uri -TimeoutSec 1
|
||||
if ($response.StatusCode -eq 200) { return }
|
||||
} catch {}
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
throw "HTTP endpoint did not become ready: $Uri"
|
||||
$processState = if ($null -eq $Process) { 'not-observed' } elseif ($Process.HasExited) { "exited:$($Process.ExitCode)" } else { 'running' }
|
||||
$summary = Get-SafeLogSummary $LogPaths
|
||||
throw "$Stage did not become ready: uri=$Uri; process_state=$processState; log_files=$($LogPaths -join ','); summary=$summary"
|
||||
}
|
||||
function Wait-Tcp([int]$Port, [bool]$Open, [int]$Attempts = 120) {
|
||||
for ($attempt = 0; $attempt -lt $Attempts; $attempt++) {
|
||||
@@ -104,13 +161,62 @@ function Invoke-SenseJson {
|
||||
function Start-SensePackage([string]$PackageRoot) {
|
||||
$launcher = Join-Path $PackageRoot 'start-sense.bat'
|
||||
$process = Start-Process -FilePath 'cmd.exe' -ArgumentList '/d', '/c', "`"$launcher`"" -WorkingDirectory $PackageRoot -RedirectStandardOutput $runtimeLog -RedirectStandardError $runtimeError -WindowStyle Hidden -PassThru
|
||||
Wait-Http "$script:baseUrl/"
|
||||
Wait-Http -Uri "$script:baseUrl/" -Process $process -Stage 'Sense HTTP' -LogPaths @($runtimeLog, $runtimeError)
|
||||
return $process
|
||||
}
|
||||
function Stop-ProcessTree($Process) {
|
||||
if ($Process -and -not $Process.HasExited) { & taskkill.exe /PID $Process.Id /T /F 2>$null | Out-Null }
|
||||
}
|
||||
|
||||
function Invoke-HarnessSelfTest {
|
||||
$root = Join-Path ([IO.Path]::GetTempPath()) ('sense-e2e-selftest-' + [guid]::NewGuid().ToString('N'))
|
||||
$originalSensitiveValues = @($script:sensitiveValues)
|
||||
try {
|
||||
$fixtureRepository = Join-Path $root 'repository'
|
||||
$trackedSource = Join-Path $fixtureRepository 'Sense\tracked.txt'
|
||||
$ignoredSource = Join-Path $fixtureRepository 'Sense\ui\node_modules\ignored.txt'
|
||||
[void](New-Item -ItemType Directory -Path (Split-Path -Parent $trackedSource) -Force)
|
||||
[void](New-Item -ItemType Directory -Path (Split-Path -Parent $ignoredSource) -Force)
|
||||
[IO.File]::WriteAllText($trackedSource, 'tracked', (New-Object Text.UTF8Encoding($false)))
|
||||
[IO.File]::WriteAllText($ignoredSource, 'ignored', (New-Object Text.UTF8Encoding($false)))
|
||||
& git -C $fixtureRepository init --quiet
|
||||
& git -C $fixtureRepository add -- 'Sense/tracked.txt'
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Harness self-test could not prepare tracked source' }
|
||||
$copy = Join-Path $root 'copy'
|
||||
Copy-TrackedSenseSource $fixtureRepository $copy
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $copy 'tracked.txt'))) { throw 'Harness self-test did not copy tracked source' }
|
||||
if (Test-Path -LiteralPath (Join-Path $copy 'ui\node_modules\ignored.txt')) { throw 'Harness self-test copied ignored node_modules content' }
|
||||
|
||||
$udpPort = Get-FreeUdpPort
|
||||
$udpProbe = [Net.Sockets.UdpClient]::new()
|
||||
try { $udpProbe.Client.Bind([Net.IPEndPoint]::new([Net.IPAddress]::Loopback, $udpPort)) } finally { $udpProbe.Dispose() }
|
||||
|
||||
$diagnosticLog = Join-Path $root 'sense.err.log'
|
||||
[IO.File]::WriteAllText($diagnosticLog, 'SENSE_JWT_SECRET=unit-secret-value', (New-Object Text.UTF8Encoding($false)))
|
||||
$script:sensitiveValues = @('unit-secret-value')
|
||||
$exited = [pscustomobject]@{ HasExited = $true; ExitCode = 23 }
|
||||
$earlyFailure = ''
|
||||
try { Wait-Http -Uri 'http://127.0.0.1:1/' -Attempts 3 -Process $exited -Stage 'Self-test early exit' -LogPaths @($diagnosticLog) } catch { $earlyFailure = $_.Exception.Message }
|
||||
if ($earlyFailure -notmatch 'exit_code=23' -or $earlyFailure.Contains('unit-secret-value') -or $earlyFailure -notmatch '<redacted>') {
|
||||
throw "Harness self-test early-exit diagnostic was unsafe or incomplete: $earlyFailure"
|
||||
}
|
||||
$timeoutFailure = ''
|
||||
try { Wait-Http -Uri 'http://127.0.0.1:1/' -Attempts 1 -Stage 'Self-test timeout' -LogPaths @($diagnosticLog) } catch { $timeoutFailure = $_.Exception.Message }
|
||||
if ($timeoutFailure -notmatch 'process_state=not-observed' -or $timeoutFailure.Contains('unit-secret-value') -or $timeoutFailure -notmatch '<redacted>') {
|
||||
throw "Harness self-test timeout diagnostic was unsafe or incomplete: $timeoutFailure"
|
||||
}
|
||||
Write-Host 'Sense E2E harness self-test passed: tracked copy, UDP bind, early exit, timeout and redaction.'
|
||||
} finally {
|
||||
$script:sensitiveValues = $originalSensitiveValues
|
||||
if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force }
|
||||
}
|
||||
}
|
||||
|
||||
if ($HarnessSelfTest) {
|
||||
Invoke-HarnessSelfTest
|
||||
exit 0
|
||||
}
|
||||
|
||||
try {
|
||||
foreach ($required in @(
|
||||
(Join-Path $PostgresBin 'initdb.exe'), (Join-Path $PostgresBin 'pg_ctl.exe'),
|
||||
@@ -121,25 +227,32 @@ try {
|
||||
}
|
||||
$ffmpeg = (Get-Command ffmpeg.exe -ErrorAction Stop).Source
|
||||
if ([string]::IsNullOrWhiteSpace($PreparedPackageRoot)) {
|
||||
New-Item -ItemType Directory -Path $repoCopy | Out-Null
|
||||
Copy-Item -LiteralPath $sourceSense -Destination $senseCopy -Recurse
|
||||
New-Item -ItemType Directory -Path $senseCopy -Force | Out-Null
|
||||
Copy-TrackedSenseSource $repositoryRoot $senseCopy
|
||||
& git -C $repoCopy init --quiet
|
||||
& git -C $repoCopy config user.name 'Sense E2E'
|
||||
& git -C $repoCopy config user.email 'sense-e2e@invalid.local'
|
||||
& git -C $repoCopy commit --allow-empty --quiet -m 'temporary acceptance source'
|
||||
& git -C $repoCopy config core.autocrlf false
|
||||
& git -C $repoCopy add -- Sense
|
||||
if ($LASTEXITCODE -ne 0) { throw 'temporary acceptance source staging failed' }
|
||||
& git -C $repoCopy commit --quiet -m 'temporary acceptance source'
|
||||
if ($LASTEXITCODE -ne 0) { throw 'temporary acceptance source commit failed' }
|
||||
|
||||
Write-Host 'Building Sense Windows package in an isolated temporary copy...'
|
||||
& pwsh.exe -NoProfile -File (Join-Path $senseCopy 'scripts\build\build-windows.ps1') -MediaMTXPath $MediaMTX
|
||||
if ($LASTEXITCODE -ne 0) { throw 'isolated Windows package build failed' }
|
||||
$packageRoot = Join-Path $senseCopy 'dist\sense-windows-amd64'
|
||||
} else {
|
||||
$packageRoot = [IO.Path]::GetFullPath($PreparedPackageRoot)
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $packageRoot 'sense.exe'))) { throw 'prepared Sense package is invalid' }
|
||||
$senseCopy = [IO.Path]::GetFullPath((Join-Path $packageRoot '..\..'))
|
||||
Write-Host "Using prepared isolated package: $packageRoot"
|
||||
$preparedInput = [IO.Path]::GetFullPath($PreparedPackageRoot)
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $preparedInput 'sense.exe'))) { throw 'prepared Sense package is invalid' }
|
||||
$packageRoot = Join-Path $temporary 'prepared-package'
|
||||
Copy-Item -LiteralPath $preparedInput -Destination $packageRoot -Recurse
|
||||
Write-Host "Using temporary copy of prepared package: $packageRoot"
|
||||
}
|
||||
|
||||
$pgPort, $sensePort, $rtspPort, $hlsPort, $webrtcPort, $webrtcUDPort, $mediaAPIPort, $onvifPort = Get-UniqueFreePorts 8
|
||||
$tcpPorts = @(Get-UniqueFreePorts 7)
|
||||
$pgPort, $sensePort, $rtspPort, $hlsPort, $webrtcPort, $mediaAPIPort, $onvifPort = $tcpPorts
|
||||
do { $webrtcUDPort = Get-FreeUdpPort } while ($tcpPorts -contains $webrtcUDPort)
|
||||
$script:baseUrl = "http://127.0.0.1:$sensePort"
|
||||
Write-Host "Initializing isolated PostgreSQL on port $pgPort..."
|
||||
& (Join-Path $PostgresBin 'initdb.exe') -D $pgData -U sense_e2e -A trust --encoding=UTF8 --no-locale | Out-Null
|
||||
@@ -162,6 +275,16 @@ try {
|
||||
'rtmp: false', 'srt: false', 'moq: false', 'metrics: false', 'paths:', ' fixture:'
|
||||
) -join "`n"
|
||||
[IO.File]::WriteAllText((Join-Path $packageRoot 'config\mediamtx.yml'), $mediaConfig, (New-Object Text.UTF8Encoding($false)))
|
||||
$preflightOut = Join-Path $temporary 'mediamtx-preflight.out.log'
|
||||
$preflightError = Join-Path $temporary 'mediamtx-preflight.err.log'
|
||||
$preflightBinary = Join-Path $packageRoot 'bin\mediamtx.exe'
|
||||
$preflightConfig = Join-Path $packageRoot 'config\mediamtx.yml'
|
||||
$preflightMedia = Start-Process -FilePath $preflightBinary -ArgumentList $preflightConfig -WorkingDirectory (Split-Path -Parent $preflightConfig) -RedirectStandardOutput $preflightOut -RedirectStandardError $preflightError -WindowStyle Hidden -PassThru
|
||||
Wait-Http -Uri "http://127.0.0.1:$mediaAPIPort/v3/config/global/get" -Process $preflightMedia -Stage 'MediaMTX preflight' -LogPaths @($preflightOut, $preflightError) -Attempts 60
|
||||
Stop-ProcessTree $preflightMedia
|
||||
Wait-Tcp -Port $mediaAPIPort -Open $false -Attempts 40
|
||||
$preflightMedia = $null
|
||||
Write-Host 'MediaMTX package/config preflight passed before managed Sense startup.'
|
||||
|
||||
$jwt = New-RandomText 48
|
||||
$bootstrap = New-RandomText 48
|
||||
@@ -173,6 +296,7 @@ try {
|
||||
$cameraUser = 'fixture_' + (New-RandomText 8)
|
||||
$cameraPassword = New-RandomText 24
|
||||
$database = "host=127.0.0.1 port=$pgPort user=sense_e2e dbname=sense_e2e sslmode=disable"
|
||||
$script:sensitiveValues = @($jwt, $bootstrap, $adminPassword, $credentialKey, $cameraUser, $cameraPassword, $database)
|
||||
$environment = @{
|
||||
SENSE_HOST = '127.0.0.1'; SENSE_PORT = "$sensePort"; SENSE_DATABASE_URL = $database;
|
||||
SENSE_JWT_SECRET = $jwt; SENSE_BOOTSTRAP_TOKEN = $bootstrap;
|
||||
@@ -198,7 +322,7 @@ try {
|
||||
if (-not (Test-Path $fixtureStatus)) { throw 'ONVIF fixture did not become ready' }
|
||||
|
||||
$server = Start-SensePackage $packageRoot
|
||||
Wait-Http "http://127.0.0.1:$mediaAPIPort/v3/config/global/get"
|
||||
Wait-Http -Uri "http://127.0.0.1:$mediaAPIPort/v3/config/global/get" -Process $server -Stage 'MediaMTX API' -LogPaths @($runtimeLog, $runtimeError)
|
||||
$publisherArguments = @(
|
||||
'-hide_banner', '-loglevel', 'error', '-re', '-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=10',
|
||||
'-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency', '-f', 'rtsp', '-rtsp_transport', 'tcp',
|
||||
@@ -214,6 +338,7 @@ try {
|
||||
if ([int]$bootstrapResponse.code -ne 200) { throw 'administrator bootstrap failed' }
|
||||
$login = Invoke-SenseJson POST '/api/v1/login' @{ username = 'acceptance-admin'; password = $adminPassword }
|
||||
$token = [string]$login.token
|
||||
$script:sensitiveValues += $token
|
||||
if ($token.Length -lt 20) { throw 'login did not return a usable token' }
|
||||
$unauthorized = Invoke-SenseJson GET '/api/v1/devices' $null '' 401
|
||||
|
||||
@@ -265,13 +390,12 @@ try {
|
||||
$area = @($areas.data.list | Where-Object id -eq $areaCreated.data.id)[0]
|
||||
if (-not $area.needsRecalibration) { throw 'resolution change did not mark the area for recalibration' }
|
||||
|
||||
$browserScript = Join-Path $senseCopy 'ui\sense-browser-smoke.cjs'
|
||||
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'browser-smoke.cjs') -Destination $browserScript
|
||||
$browserScript = Join-Path $PSScriptRoot 'browser-smoke.cjs'
|
||||
foreach ($item in @{
|
||||
SENSE_E2E_BASE_URL = $baseUrl; SENSE_E2E_TOKEN = $token; SENSE_E2E_BROWSER = $Browser;
|
||||
SENSE_E2E_SCREENSHOT = (Join-Path $temporary 'sense-browser.png')
|
||||
}.GetEnumerator()) { Set-TestEnvironment $item.Key $item.Value }
|
||||
Push-Location (Join-Path $senseCopy 'ui')
|
||||
Push-Location $temporary
|
||||
try { & node.exe $browserScript } finally { Pop-Location }
|
||||
if ($LASTEXITCODE -ne 0) { throw 'browser GoAdmin shell smoke failed' }
|
||||
|
||||
@@ -299,7 +423,7 @@ try {
|
||||
$plainCredentialCount = (& $psql -X -h 127.0.0.1 -p $pgPort -U sense_e2e -d sense_e2e -tAc "select count(*) from sense_device_credentials where position(convert_to('$cameraPassword','UTF8') in ciphertext) > 0;").Trim()
|
||||
if ([int]$plainCredentialCount -ne 0) { throw 'camera credential appeared in plaintext storage' }
|
||||
|
||||
foreach ($log in @($runtimeLog, $runtimeError, $fixtureLog, $fixtureError, $ffmpegLog, $ffmpegError)) {
|
||||
foreach ($log in @($runtimeLog, $runtimeError, $fixtureLog, $fixtureError, $ffmpegLog, $ffmpegError, $preflightOut, $preflightError)) {
|
||||
if (Test-Path $log) {
|
||||
$text = [string](Get-Content -LiteralPath $log -Raw -ErrorAction SilentlyContinue)
|
||||
if ($null -eq $text) { $text = '' }
|
||||
@@ -311,6 +435,7 @@ try {
|
||||
Stop-ProcessTree $publisher
|
||||
Stop-ProcessTree $fixture
|
||||
Stop-ProcessTree $server
|
||||
Stop-ProcessTree $preflightMedia
|
||||
if ($pgStarted) {
|
||||
$pgStopArguments = "-D `"$pgData`" -m fast stop"
|
||||
[void](Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $pgStopArguments -RedirectStandardOutput (Join-Path $temporary 'pg-stop.log') -RedirectStandardError (Join-Path $temporary 'pg-stop.err.log') -WindowStyle Hidden -PassThru)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Standard event contract v1
|
||||
|
||||
`yovision.event/v1` is the only shared representation of an anonymous safety event. It is an immutable fact, not a Bell Alert. Bell owns all rule matching, Alert, acknowledgement, close, notification and user/audit state.
|
||||
|
||||
## Identity and idempotency
|
||||
|
||||
The permanent idempotency key is the exact UTF-8 pair `(producer_id, source_event_id)`. `producer_id` always names the original producer. A Sense gateway/relay sends its own authenticated transport identity and optional `X-YoVision-Relay-ID`, but it must forward both key fields and the business payload unchanged. A retry is not a new event.
|
||||
|
||||
After schema validation, calculate `payload_sha256` from the RFC 8785 JSON Canonicalization Scheme representation of the complete Event. The checked-in vector fixes the expected digest for supported implementations. Bell stores key, digest and Bell `event_id` permanently:
|
||||
|
||||
- absent key: atomically create Event/Receipt and return `201` with `disposition=created`;
|
||||
- same key and digest: return the original `event_id` and digest with `200`, `disposition=duplicate`;
|
||||
- same key but another digest: return `409 idempotency_conflict`, append an audit fact, and mutate neither Event nor Alert;
|
||||
- identity lookup and insert must share a transaction/unique constraint so concurrent duplicates have the same result.
|
||||
|
||||
Canonical timestamps in Event v1 are UTC RFC 3339 with exactly three fractional digits and `Z`. Optional members are omitted, never sent as `null`. Producers must reject non-finite numbers before canonicalization.
|
||||
|
||||
## Mapper responsibilities
|
||||
|
||||
| Role | Required responsibility | Must not do |
|
||||
|---|---|---|
|
||||
| Brain producer mapper | Convert `brain.internal.event-candidate/v1` into stable original identity, logical site/device/profile/rule/region refs, model version and anonymous observation; generate one `source_event_id` once and persist/reuse it across retries. | Expose internal candidate fields, face/person identity, camera credentials, file paths, Alert state, or regenerate identity during retry. |
|
||||
| Sense producer/evidence mapper | When Sense originates an event, apply the same original-identity rule; map its internal evidence record to a logical evidence reference and own later status resolution. | Put local path, RTSP URL, signed URL, credential or Outbox attempt ID into Event. |
|
||||
| Sense relay | Authenticate as a transport hop, preserve original `producer_id`, `source_event_id` and payload, retain retry/audit state outside the Event, and return Bell's response unchanged enough for deterministic retry handling. | Replace producer identity, create a new source ID, enrich/reorder semantics, or treat `409`/`422` as a transient retry. |
|
||||
| Bell consumer mapper | Validate before persistence; canonicalize; enforce permanent idempotency; map the immutable shared Event into Bell's private Event/Receipt and then independently evaluate rules to create an Alert. Unknown evidence becomes degraded evidence, not a rejected Event. | Persist arbitrary extension fields, import producer internals, or accept shared ack/close/notification/user state. |
|
||||
|
||||
Field ownership is deliberately narrow:
|
||||
|
||||
| Contract fields | Authoritative writer | Relay/Bell responsibility |
|
||||
|---|---|---|
|
||||
| `schema_version`, `producer_id`, `source_event_id` | Original Brain or Sense producer mapper | Relay preserves; Bell uses version gate and permanent idempotency key. |
|
||||
| `site_ref`, `device_ref`, `profile_ref` | Producer mapper from versioned logical configuration | Relay preserves; Bell treats as opaque external refs. |
|
||||
| `event_type`, `occurred_at`, `severity`, `rule`, `model`, `observation`, `region` | Brain/Sense mapper at the detection decision | Relay preserves; Bell validates and stores the immutable snapshot. |
|
||||
| `evidence[]` identity and initial status | Evidence-owning producer, normally Sense | Relay preserves; Bell stores the Event snapshot and resolves current metadata separately. |
|
||||
| `X-YoVision-Relay-ID` | Authenticated Sense transport hop | Bell audits transport metadata outside the immutable Event. |
|
||||
| `event_id`, `disposition`, `payload_sha256` | Bell ingest boundary | Producer/relay retain the receipt for deterministic retries. |
|
||||
|
||||
## Errors, compatibility and fallback
|
||||
|
||||
- `400 invalid_event`: schema, canonical form, or sensitive/unknown member violation. Terminal until the producer fixes the payload.
|
||||
- `409 idempotency_conflict`: same permanent key with a different payload. Terminal and audited; never overwrite the first Event.
|
||||
- `422 unsupported_schema_version`: unknown major/revision. Terminal for that payload.
|
||||
- Evidence `pending`, `processing`, `success` and `failed` are valid Event states. Bell keeps the Event and resolves/degrades evidence independently.
|
||||
|
||||
v1 is closed (`additionalProperties=false`). Producers may enable a compatible revision only after all relays and Bell validate it. Any removed/renamed required field, changed meaning, enum narrowing, identity/canonicalization change, or new required member publishes a new major path such as `/v2`. During the compatibility window Bell keeps the previous version endpoint. Rollback disables the new producer version and resumes the last accepted version; it does not delete Event, Receipt, Outbox or audit facts.
|
||||
|
||||
Unknown-version fallback is explicit: Bell returns `422`; relay records the terminal rejection without rewriting the payload; producer may remap the same internal candidate into a supported v1 payload only if it has not previously assigned that `(producer_id, source_event_id)` to a different canonical payload. Otherwise it must stop and require operator reconciliation.
|
||||
|
||||
## Reproducible verification
|
||||
|
||||
No third-party package is needed:
|
||||
|
||||
```powershell
|
||||
python contracts/tests/events-v1/test_contract.py
|
||||
python contracts/tests/evidence-v1/test_contract.py
|
||||
```
|
||||
|
||||
The tests validate Schema/OpenAPI references, mapper fixtures, RFC 8785-compatible canonical vectors used by v1 examples, duplicate/conflict behavior, unknown versions and sensitive-field rejection.
|
||||
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://yovision.local/contracts/events/v1/event.schema.json",
|
||||
"title": "YoVision anonymous safety event v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version", "producer_id", "source_event_id", "site_ref", "device_ref",
|
||||
"profile_ref", "event_type", "occurred_at", "severity", "rule", "model",
|
||||
"observation", "region", "evidence"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {"const": "yovision.event/v1"},
|
||||
"producer_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
|
||||
"source_event_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
|
||||
"site_ref": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
|
||||
"device_ref": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
|
||||
"profile_ref": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
|
||||
"event_type": {"enum": ["dangerous_area_entered", "directional_line_crossed"]},
|
||||
"occurred_at": {"type": "string", "format": "date-time", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$"},
|
||||
"severity": {"enum": ["low", "medium", "high", "critical"]},
|
||||
"rule": {
|
||||
"type": "object", "additionalProperties": false, "required": ["rule_id", "version"],
|
||||
"properties": {
|
||||
"rule_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
|
||||
"version": {"type": "string", "minLength": 1, "maxLength": 64}
|
||||
}
|
||||
},
|
||||
"model": {
|
||||
"type": "object", "additionalProperties": false, "required": ["name", "version"],
|
||||
"properties": {
|
||||
"name": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||
"version": {"type": "string", "minLength": 1, "maxLength": 64}
|
||||
}
|
||||
},
|
||||
"observation": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["track_id", "category", "confidence"],
|
||||
"properties": {
|
||||
"track_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
|
||||
"category": {"enum": ["person", "vehicle", "other"]},
|
||||
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
|
||||
"bbox_normalized": {
|
||||
"type": "array", "minItems": 4, "maxItems": 4,
|
||||
"items": {"type": "number", "minimum": 0, "maximum": 1}
|
||||
}
|
||||
}
|
||||
},
|
||||
"region": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["region_id", "kind"],
|
||||
"properties": {
|
||||
"region_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
|
||||
"kind": {"enum": ["area", "line"]},
|
||||
"crossing_direction": {"enum": ["a_to_b", "b_to_a"]}
|
||||
},
|
||||
"allOf": [
|
||||
{"if": {"properties": {"kind": {"const": "line"}}, "required": ["kind"]}, "then": {"required": ["crossing_direction"]}},
|
||||
{"if": {"properties": {"kind": {"const": "area"}}, "required": ["kind"]}, "then": {"not": {"required": ["crossing_direction"]}}}
|
||||
]
|
||||
},
|
||||
"evidence": {
|
||||
"type": "array", "maxItems": 8, "uniqueItems": true,
|
||||
"items": {"$ref": "../../evidence/v1/evidence-reference.schema.json"}
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {"properties": {"event_type": {"const": "dangerous_area_entered"}}, "required": ["event_type"]},
|
||||
"then": {"properties": {"region": {"properties": {"kind": {"const": "area"}}}}}
|
||||
},
|
||||
{
|
||||
"if": {"properties": {"event_type": {"const": "directional_line_crossed"}}, "required": ["event_type"]},
|
||||
"then": {"properties": {"region": {"properties": {"kind": {"const": "line"}}}}}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"schema_version": "yovision.event/v1",
|
||||
"producer_id": "brain-school-a",
|
||||
"source_event_id": "evt-area-20260831-0001",
|
||||
"site_ref": "site-school-a",
|
||||
"device_ref": "camera-east-gate",
|
||||
"profile_ref": "profile-main-stream",
|
||||
"event_type": "dangerous_area_entered",
|
||||
"occurred_at": "2026-08-31T00:00:01.125Z",
|
||||
"severity": "high",
|
||||
"rule": {"rule_id": "rule-east-danger", "version": "3"},
|
||||
"model": {"name": "anonymous-detector", "version": "2026.08"},
|
||||
"observation": {"track_id": "track-0042", "category": "person", "confidence": 0.93, "bbox_normalized": [0.12, 0.2, 0.31, 0.74]},
|
||||
"region": {"region_id": "region-east-danger", "kind": "area"},
|
||||
"evidence": [
|
||||
{
|
||||
"schema_version": "yovision.evidence-reference/v1",
|
||||
"evidence_id": "ev-school-east-0001",
|
||||
"owner_id": "sense-school-a",
|
||||
"type": "snapshot",
|
||||
"status": "pending",
|
||||
"captured_at": "2026-08-31T00:00:01.125Z",
|
||||
"status_updated_at": "2026-08-31T00:00:01.125Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"schema_version": "yovision.event/v1",
|
||||
"producer_id": "brain-school-a",
|
||||
"source_event_id": "evt-line-20260831-0002",
|
||||
"site_ref": "site-school-a",
|
||||
"device_ref": "camera-north-corridor",
|
||||
"profile_ref": "profile-main-stream",
|
||||
"event_type": "directional_line_crossed",
|
||||
"occurred_at": "2026-08-31T00:03:10.000Z",
|
||||
"severity": "medium",
|
||||
"rule": {"rule_id": "rule-north-one-way", "version": "1"},
|
||||
"model": {"name": "anonymous-detector", "version": "2026.08"},
|
||||
"observation": {"track_id": "track-0088", "category": "person", "confidence": 0.88},
|
||||
"region": {"region_id": "line-north-one-way", "kind": "line", "crossing_direction": "b_to_a"},
|
||||
"evidence": [
|
||||
{
|
||||
"schema_version": "yovision.evidence-reference/v1",
|
||||
"evidence_id": "ev-school-east-0002",
|
||||
"owner_id": "sense-school-a",
|
||||
"type": "clip",
|
||||
"status": "failed",
|
||||
"captured_at": "2026-08-31T00:03:10.000Z",
|
||||
"status_updated_at": "2026-08-31T00:03:13.100Z",
|
||||
"failure": {"code": "processing_failed", "retryable": true}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"event_id": "bell-event-00000042",
|
||||
"producer_id": "brain-school-a",
|
||||
"source_event_id": "evt-area-20260831-0001",
|
||||
"disposition": "duplicate",
|
||||
"payload_sha256": "4cc1e93820195caf713ea675ff33f178c9d4997dd8a81cb61287e9fea0e3d5e1"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"code": "idempotency_conflict",
|
||||
"message": "idempotency key already belongs to another canonical payload",
|
||||
"existing_event_id": "bell-event-00000042"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"code": "unsupported_schema_version",
|
||||
"message": "schema_version yovision.event/v2 is not accepted",
|
||||
"field": "schema_version"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://yovision.local/contracts/events/v1/ingest-result.schema.json",
|
||||
"title": "YoVision Bell event ingest result v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["event_id", "producer_id", "source_event_id", "disposition", "payload_sha256"],
|
||||
"properties": {
|
||||
"event_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
|
||||
"producer_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
|
||||
"source_event_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
|
||||
"disposition": {"enum": ["created", "duplicate"]},
|
||||
"payload_sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "YoVision standard event ingest API", "version": "1.0.0"},
|
||||
"paths": {
|
||||
"/v1/events": {
|
||||
"post": {
|
||||
"summary": "Ingest one immutable anonymous safety event",
|
||||
"parameters": [
|
||||
{"name": "X-YoVision-Relay-ID", "in": "header", "required": false, "description": "Audited transport hop. A relay must not change producer_id or source_event_id.", "schema": {"type": "string", "maxLength": 128}}
|
||||
],
|
||||
"requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "./event.schema.json"}}}},
|
||||
"responses": {
|
||||
"201": {"description": "Created", "content": {"application/json": {"schema": {"$ref": "./ingest-result.schema.json"}}}},
|
||||
"200": {"description": "Exact duplicate; returns the original Bell Event identity", "content": {"application/json": {"schema": {"$ref": "./ingest-result.schema.json"}}}},
|
||||
"400": {"description": "Invalid or sensitive payload", "content": {"application/problem+json": {"schema": {"$ref": "./problem.schema.json"}}}},
|
||||
"409": {"description": "Same idempotency key with a different canonical payload", "content": {"application/problem+json": {"schema": {"$ref": "./problem.schema.json"}}}},
|
||||
"422": {"description": "Unsupported schema major version", "content": {"application/problem+json": {"schema": {"$ref": "./problem.schema.json"}}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://yovision.local/contracts/events/v1/problem.schema.json",
|
||||
"title": "YoVision contract problem v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["code", "message"],
|
||||
"properties": {
|
||||
"code": {"enum": ["invalid_event", "unsupported_schema_version", "idempotency_conflict", "evidence_not_found", "evidence_expired"]},
|
||||
"message": {"type": "string", "minLength": 1, "maxLength": 512},
|
||||
"field": {"type": "string", "pattern": "^[A-Za-z0-9_.\\[\\]-]{1,128}$"},
|
||||
"existing_event_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
# Evidence reference contract v1
|
||||
|
||||
This contract shares metadata about a logical evidence object. It never grants object access. `owner_id` identifies the service that owns resolution; `evidence_id` is opaque to every consumer. Neither field may be interpreted as a URL or local path.
|
||||
|
||||
## State and degradation
|
||||
|
||||
- `pending`: capture was accepted but no processing started.
|
||||
- `processing`: capture or encoding is in progress.
|
||||
- `success`: capture completed; `content_type` and SHA-256 `integrity` are required. Access authorization is negotiated outside this payload by the machine-identity/connector work.
|
||||
- `failed`: `failure.code` and `retryable` are required. Bell keeps the immutable Event and renders evidence unavailable; it must not reject or close the Alert because evidence failed.
|
||||
- HTTP `404` means an unknown logical reference. `410` means expired evidence. Both degrade evidence only, not the Event.
|
||||
|
||||
The payload forbids arbitrary properties, so filesystem paths, camera credentials, bearer/user tokens, signed URLs, face templates and notification/Alert state fail schema validation. Do not add access URLs to v1. A short-lived download grant, if later required, needs a separately reviewed endpoint and security contract.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Brain may request evidence but maps only logical metadata it actually knows.
|
||||
- Sense is the default evidence owner and advances the status monotonically for a given capture attempt: `pending -> processing -> success|failed`. It must retain the same `evidence_id` while status changes.
|
||||
- A relay transports the reference unchanged and must not resolve it into a path or URL.
|
||||
- Bell stores the latest evidence metadata separately from its immutable Event. Evidence failure/expiry never changes Alert ack/close state.
|
||||
|
||||
## Compatibility and rollback
|
||||
|
||||
v1 consumers ignore no unknown fields because the v1 schema is closed. Additive fields therefore require a new schema revision that producers enable only after consumers accept it. Changed meaning, removed fields, or new required fields require `/v2`. Rollback disables the new producer and continues resolving stored v1 references; it never deletes Event, Receipt, Outbox, or evidence audit facts.
|
||||
|
||||
Run the standalone contract check from the repository root:
|
||||
|
||||
```powershell
|
||||
python contracts/tests/evidence-v1/test_contract.py
|
||||
```
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://yovision.local/contracts/evidence/v1/evidence-reference.schema.json",
|
||||
"title": "YoVision evidence logical reference v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version",
|
||||
"evidence_id",
|
||||
"owner_id",
|
||||
"type",
|
||||
"status",
|
||||
"captured_at",
|
||||
"status_updated_at"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {"const": "yovision.evidence-reference/v1"},
|
||||
"evidence_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
|
||||
"owner_id": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"},
|
||||
"type": {"enum": ["snapshot", "clip"]},
|
||||
"status": {"enum": ["pending", "processing", "success", "failed"]},
|
||||
"captured_at": {"type": "string", "format": "date-time"},
|
||||
"status_updated_at": {"type": "string", "format": "date-time"},
|
||||
"expires_at": {"type": "string", "format": "date-time"},
|
||||
"content_type": {"enum": ["image/jpeg", "image/png", "video/mp4"]},
|
||||
"integrity": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["algorithm", "digest", "size_bytes"],
|
||||
"properties": {
|
||||
"algorithm": {"const": "sha256"},
|
||||
"digest": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
|
||||
"size_bytes": {"type": "integer", "minimum": 0}
|
||||
}
|
||||
},
|
||||
"failure": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["code", "retryable"],
|
||||
"properties": {
|
||||
"code": {"enum": ["capture_failed", "processing_failed", "expired", "unavailable"]},
|
||||
"retryable": {"type": "boolean"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {"properties": {"status": {"const": "success"}}, "required": ["status"]},
|
||||
"then": {"required": ["content_type", "integrity"], "not": {"required": ["failure"]}}
|
||||
},
|
||||
{
|
||||
"if": {"properties": {"status": {"const": "failed"}}, "required": ["status"]},
|
||||
"then": {"required": ["failure"], "not": {"anyOf": [{"required": ["content_type"]}, {"required": ["integrity"]}]}}
|
||||
},
|
||||
{
|
||||
"if": {"properties": {"status": {"enum": ["pending", "processing"]}}, "required": ["status"]},
|
||||
"then": {"not": {"anyOf": [{"required": ["content_type"]}, {"required": ["integrity"]}, {"required": ["failure"]}]}}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"schema_version": "yovision.evidence-reference/v1",
|
||||
"evidence_id": "ev-school-east-0002",
|
||||
"owner_id": "sense-school-a",
|
||||
"type": "clip",
|
||||
"status": "failed",
|
||||
"captured_at": "2026-08-31T00:03:10.000Z",
|
||||
"status_updated_at": "2026-08-31T00:03:13.100Z",
|
||||
"failure": {"code": "processing_failed", "retryable": true}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"schema_version": "yovision.evidence-reference/v1",
|
||||
"evidence_id": "ev-school-east-0001",
|
||||
"owner_id": "sense-school-a",
|
||||
"type": "snapshot",
|
||||
"status": "pending",
|
||||
"captured_at": "2026-08-31T00:00:01.125Z",
|
||||
"status_updated_at": "2026-08-31T00:00:01.125Z"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"schema_version": "yovision.evidence-reference/v1",
|
||||
"evidence_id": "ev-school-east-0001",
|
||||
"owner_id": "sense-school-a",
|
||||
"type": "snapshot",
|
||||
"status": "success",
|
||||
"captured_at": "2026-08-31T00:00:01.125Z",
|
||||
"status_updated_at": "2026-08-31T00:00:02.450Z",
|
||||
"expires_at": "2026-09-07T00:00:01.125Z",
|
||||
"content_type": "image/jpeg",
|
||||
"integrity": {
|
||||
"algorithm": "sha256",
|
||||
"digest": "2f77668a9dfbf8d5848b9e6d7da867800b7b6790625f16b45a101f1ca1f7da75",
|
||||
"size_bytes": 48215
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {"title": "YoVision evidence reference API", "version": "1.0.0"},
|
||||
"paths": {
|
||||
"/v1/evidence/{evidence_id}": {
|
||||
"get": {
|
||||
"summary": "Resolve current metadata for a logical evidence reference",
|
||||
"parameters": [
|
||||
{"name": "evidence_id", "in": "path", "required": true, "schema": {"type": "string"}}
|
||||
],
|
||||
"responses": {
|
||||
"200": {"description": "Current metadata, including pending, processing, success or failed states", "content": {"application/json": {"schema": {"$ref": "./evidence-reference.schema.json"}}}},
|
||||
"404": {"description": "Unknown logical reference", "content": {"application/problem+json": {"schema": {"$ref": "../../events/v1/problem.schema.json"}}}},
|
||||
"410": {"description": "Evidence expired; event remains valid", "content": {"application/problem+json": {"schema": {"$ref": "../../events/v1/problem.schema.json"}}}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
# Machine identity contract v1
|
||||
|
||||
`yovision.machine-identity/v1` defines service-to-service identity for YoVision connectors. It is deliberately separate from Sense and Bell users, GoAdmin JWT/Cookie state, database roles and operating-system accounts.
|
||||
|
||||
## Authentication mechanism
|
||||
|
||||
Every request uses HTTPS and one compact Ed25519 JWS in `Authorization: Bearer <token>`. The protected header is closed and contains `alg=EdDSA`, `typ=YOVISION-MACHINE+JWT`, `kid` and `ver=yovision.machine-identity/v1`. The closed claims object contains:
|
||||
|
||||
mTLS is not the primary v1 identity mechanism. A customer PKI may add mTLS later as transport hardening, but it cannot replace or weaken the v1 principal, audience, scope, request binding, replay and revocation checks.
|
||||
|
||||
- one instance-specific `iss`/`sub` principal;
|
||||
- one exact service `aud`;
|
||||
- the minimum required `scope` values;
|
||||
- `iat`, `nbf`, `exp` and a single-use random `jti`;
|
||||
- uppercase HTTP method `htm`, normalized absolute-path reference `htu`, and lowercase SHA-256 `body_sha256`.
|
||||
|
||||
Tokens live for at most 300 seconds. Consumers allow at most 30 seconds of clock skew, verify the signature and active key/principal before authorization, then atomically consume `jti` until `exp + skew`. Retrying transport creates a new token and `jti`; business idempotency keys remain unchanged.
|
||||
|
||||
Production consumers persist the replay key `(principal, jti)` in their own durable store so a process restart cannot reopen the replay window. The checked-in process-local replay stores are adapter test/default primitives only; connector tasks must inject an atomic durable implementation and test restart behavior without sharing a database across products.
|
||||
|
||||
The v1 scopes are:
|
||||
|
||||
| Caller | Audience | Scope |
|
||||
|---|---|---|
|
||||
| Sense | `yovision-brain` | `source-config:write` |
|
||||
| Brain | `yovision-sense` | `runtime-status:write` |
|
||||
| Brain or Sense | `yovision-bell` | `events:ingest` |
|
||||
| Bell | `yovision-sense` | `evidence:read` |
|
||||
|
||||
No wildcard audience or scope exists. A relay authenticates as its own transport principal and never replaces the original event producer identity.
|
||||
|
||||
## Key lifecycle
|
||||
|
||||
Private Ed25519 keys are generated per product instance and stored outside the repository in an OS-protected file or secret store. Runtime configuration contains only the private-key path. Public registries are local consumer configuration, not a shared database.
|
||||
|
||||
Rotation first registers a new `kid`, switches the caller, and removes the old key after an overlap no longer than 24 hours. A disabled principal or revoked `kid` is rejected on every request, including tokens that have not expired. Emergency rollback disables the connector; it never enables a shared password, browser token, query token, plaintext transport or signature bypass.
|
||||
|
||||
## Threat boundary
|
||||
|
||||
v1 protects against token modification, wrong audience/scope, expired or premature tokens, captured-token replay, key/principal revocation and accidental credential mixing. It does not protect a host after administrator/root compromise, a stolen usable private key before revocation, compromised TLS trust roots, endpoint implementation flaws or denial of service. Rate and body-size limits remain consumer responsibilities.
|
||||
|
||||
See `../transport/v1/README.md` for HTTPS and request policy. Stable failures are defined in `errors.md`; callers and logs must expose only the stable code, principal/kid when already authenticated, and correlation ID—never the token, signature, private/public key material or complete Authorization header.
|
||||
|
||||
## Compatibility
|
||||
|
||||
v1 is closed. New optional claims require all consumers to accept them before producers emit them. Any change to signing input, algorithm, claim meaning, replay semantics, maximum lifetime, audience or scope meaning publishes a new major version. Consumers keep the last accepted major during a controlled migration; rollback disables the new producer version without weakening verification.
|
||||
|
||||
## Reproducible verification
|
||||
|
||||
From the repository root:
|
||||
|
||||
```powershell
|
||||
pwsh -NoProfile -File contracts/tests/machine-identity-v1/run.ps1
|
||||
|
||||
cd Sense/server
|
||||
go test -race ./app/sense/integration/machine_identity
|
||||
|
||||
cd ../../Bell/server
|
||||
go test -race ./app/bell/integration/machine_identity
|
||||
```
|
||||
|
||||
The isolated contract test validates both JSON Schemas, the fixed Go/Python Ed25519 vector, request binding, exact audience/scope, expiry, replay, rotation overlap, revocation, bearer-only extraction and verified TLS policy. Product connector tasks remain responsible for injecting a durable replay store and testing restart recovery.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Machine identity v1 stable errors
|
||||
|
||||
| Code | Meaning | Retry |
|
||||
|---|---|---|
|
||||
| `machine_token_missing` | Authorization bearer token is absent or malformed | No, fix request |
|
||||
| `machine_token_invalid` | Header, claims, signature, request binding or key is invalid | No |
|
||||
| `machine_token_expired` | Token is outside its accepted time window | Mint a new token |
|
||||
| `machine_audience_denied` | Exact audience does not match | No |
|
||||
| `machine_scope_denied` | Required scope is absent or not granted to the key | No |
|
||||
| `machine_identity_revoked` | Principal or key is disabled/revoked | No; operator action |
|
||||
| `machine_token_replayed` | The same principal/jti was already accepted | Retry with a new token and the same business idempotency key |
|
||||
| `machine_transport_required` | HTTPS policy is not satisfied | No; fix deployment |
|
||||
|
||||
Responses and audit facts never include the token, signature, key material or Authorization header.
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://yovision.local/contracts/machine-identity/v1/machine-token.schema.json",
|
||||
"title": "YoVision machine token claims v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["ver", "iss", "sub", "aud", "scope", "iat", "nbf", "exp", "jti", "htm", "htu", "body_sha256"],
|
||||
"properties": {
|
||||
"ver": {"const": "yovision.machine-identity/v1"},
|
||||
"iss": {"type": "string", "pattern": "^yv:(sense|brain|bell):[a-z0-9][a-z0-9.-]{0,62}$"},
|
||||
"sub": {"type": "string", "pattern": "^yv:(sense|brain|bell):[a-z0-9][a-z0-9.-]{0,62}$"},
|
||||
"aud": {"enum": ["yovision-sense", "yovision-brain", "yovision-bell"]},
|
||||
"scope": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 4,
|
||||
"uniqueItems": true,
|
||||
"items": {"enum": ["source-config:write", "runtime-status:write", "events:ingest", "evidence:read"]}
|
||||
},
|
||||
"iat": {"type": "integer", "minimum": 0},
|
||||
"nbf": {"type": "integer", "minimum": 0},
|
||||
"exp": {"type": "integer", "minimum": 0},
|
||||
"jti": {"type": "string", "pattern": "^[A-Za-z0-9_-]{22,64}$"},
|
||||
"htm": {"type": "string", "pattern": "^(GET|POST|PUT|PATCH|DELETE)$"},
|
||||
"htu": {"type": "string", "pattern": "^/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*$"},
|
||||
"body_sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://yovision.local/contracts/machine-identity/v1/principal-registry.schema.json",
|
||||
"title": "YoVision machine principal registry v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["version", "audience", "principals"],
|
||||
"properties": {
|
||||
"version": {"const": "yovision.machine-principal-registry/v1"},
|
||||
"audience": {"enum": ["yovision-sense", "yovision-brain", "yovision-bell"]},
|
||||
"principals": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["principal_id", "enabled", "keys"],
|
||||
"properties": {
|
||||
"principal_id": {"type": "string", "pattern": "^yv:(sense|brain|bell):[a-z0-9][a-z0-9.-]{0,62}$"},
|
||||
"enabled": {"type": "boolean"},
|
||||
"keys": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["kid", "public_key_base64url", "status", "scopes"],
|
||||
"properties": {
|
||||
"kid": {"type": "string", "pattern": "^[A-Za-z0-9._-]{8,64}$"},
|
||||
"public_key_base64url": {"type": "string", "pattern": "^[A-Za-z0-9_-]{43}$"},
|
||||
"status": {"enum": ["active", "revoked"]},
|
||||
"scopes": {"type": "array", "minItems": 1, "maxItems": 4, "uniqueItems": true, "items": {"enum": ["source-config:write", "runtime-status:write", "events:ingest", "evidence:read"]}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
# Brain → Sense 运行与健康状态契约 v1
|
||||
|
||||
本目录是 Brain 运行状态到 Sense 运维投影的版本化事实源。Brain 只发布脱敏状态事实;Sense 不读取 Brain 的缓存、数据库或内部运行对象,也不能借此契约执行远程命令。
|
||||
|
||||
## 消息与时间语义
|
||||
|
||||
- `schema_version` 固定为 `yovision.runtime-status/v1`。生产者必须先通过 `runtime-status.schema.json` 再发布。
|
||||
- `status_id` 是消息幂等键;`sequence` 在单个 `brain_instance_ref` 内单调递增。重复消息可忽略;小于当前已保存 sequence 的消息不得覆盖投影。
|
||||
- `observed_at` 是 Brain 完成该次观测的 UTC RFC 3339 时间,不是 Sense 的接收时间。允许最大 30 秒未来时钟偏差;超过时拒绝该消息,并保留最后已知投影。
|
||||
- Brain 的推荐发布周期是 30 秒。Sense 以 `evaluation_time - observed_at > 90 秒` 推导 `stale`;恰好 90 秒仍为 fresh。`stale` 和 `offline` 都是 Sense 的传输/时间投影,不是 Brain 写入的运行状态。
|
||||
- 未收到任何有效状态时显示 `not_received`;传输断开但最后状态未过期时显示 `offline_fresh`;传输断开或无新消息且超过 90 秒时显示 `offline_stale` / `stale`,同时保留最后已知状态及其观测时间。
|
||||
|
||||
## 状态机
|
||||
|
||||
Brain 报告的 `runtime.state` 和每个输入的 `state` 使用同一枚举:
|
||||
|
||||
| 状态 | 含义 | 允许的下一状态 |
|
||||
|---|---|---|
|
||||
| `unconfigured` | 尚无可运行配置 | `starting`, `stopped` |
|
||||
| `starting` | 已接受启动,资源准备中 | `running`, `degraded`, `failed`, `stopped` |
|
||||
| `running` | 正常提供推理 | `degraded`, `failed`, `stopped` |
|
||||
| `degraded` | 仍提供有限服务 | `running`, `failed`, `stopped` |
|
||||
| `failed` | 无法继续提供服务 | `starting`, `stopped` |
|
||||
| `stopped` | 已有序停止 | `starting`, `unconfigured` |
|
||||
|
||||
首次有效消息可为任一状态;Sense 只校验同实例连续消息的迁移。`stale`、`offline_*` 不参与 Brain 状态迁移。恢复连接后,只有 schema、时间、sequence 和状态迁移均有效的新消息才能更新投影。
|
||||
|
||||
## 配置流与 revision
|
||||
|
||||
`configurations` 按 #148 的配置流报告,可以为空,也可以包含多个配置。每项 `config_id` 必须唯一,并与 `yovision.source-config/v1` 的 `config_id` 一致;重复 ID 使整条状态无效,不能覆盖最后已知投影。`applied_revision` 是 Brain 对该配置流已实际应用的 integer revision。Sense 必须逐个 `config_id` 与自己已投递的期望 revision 比较:相等为 synchronized,不相等为 mismatch;Sense 的期望 revision 不进入本消息,避免产生第二事实源。
|
||||
|
||||
- `not_configured`:尚未应用该配置,revision 必须为 null。
|
||||
- `applying`:正在应用;revision 为 null 或仍在运行的上一个 revision。
|
||||
- `applied`:应用成功,revision 必须是大于等于 1 的整数。
|
||||
- `rejected`:本次应用被拒绝;revision 为 null 或最后成功 revision,且必须带稳定错误码。
|
||||
|
||||
## 兼容与回退
|
||||
|
||||
- v1 字段语义冻结,未知字段被拒绝。新增可选字段或错误码前必须更新本契约及双方测试;改变字段语义或删除字段发布新主版本。
|
||||
- 消费者必须按 `schema_version` 先分派到对应版本验证器。未知主版本停止摄取并记录 `UNSUPPORTED_SCHEMA_VERSION`,不得清空或覆盖最后已知投影。
|
||||
- 回退时 Sense 停止摄取新版本,继续使用上一冻结版本的 adapter 和最后已知投影。回退不触发 Brain 重启或运行态修改。
|
||||
|
||||
## 安全边界
|
||||
|
||||
只允许 Schema 列出的字段。逻辑引用不允许 `/` 或 `\\`,因此不能携带绝对路径。消息不得包含凭据/token、堆栈、内部路径、用户会话、客户视频/图像、人脸信息或业务 Alert。结构化错误只传稳定错误码,不传自由文本错误详情。
|
||||
|
||||
错误码、映射责任和可复制验证分别见 `error-codes.md`、`mapping.md` 与 `../../tests/runtime-status-v1/README.md`。
|
||||
@@ -0,0 +1,16 @@
|
||||
# v1 稳定错误码
|
||||
|
||||
生产者可以发布以下稳定错误码。消费者遇到符合格式但尚未认识的 v1 错误码时显示“未识别的远端错误”,保留原始代码用于排障,不把它转换成业务 Alert。
|
||||
|
||||
| 错误码 | 责任域 | 含义 |
|
||||
|---|---|---|
|
||||
| `CONFIG_INVALID` | 配置 | 配置结构或值无效 |
|
||||
| `CONFIG_REVISION_UNAVAILABLE` | 配置 | 指定 revision 无法取得 |
|
||||
| `INPUT_UNREACHABLE` | 输入 | 逻辑输入暂时不可达 |
|
||||
| `INPUT_DECODE_FAILED` | 输入 | 输入解码失败 |
|
||||
| `MODEL_LOAD_FAILED` | 模型 | 模型载入失败 |
|
||||
| `INFERENCE_FAILED` | 推理 | 推理管线失败 |
|
||||
| `RESOURCE_PRESSURE` | 运行 | 资源压力导致降级 |
|
||||
| `INTERNAL_COMPONENT_FAILED` | 运行 | 内部组件失败;不随消息暴露组件路径或堆栈 |
|
||||
|
||||
`UNSUPPORTED_SCHEMA_VERSION`、`FUTURE_OBSERVATION`、`OUT_OF_ORDER_STATUS` 与 `INVALID_STATUS_TRANSITION` 是 Sense adapter 的本地摄取错误,不由 Brain 发布。
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "yovision.runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d205",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 1,
|
||||
"observed_at": "2026-08-31T00:00:00Z",
|
||||
"runtime": { "state": "running", "version": "1.0.0", "started_at": null },
|
||||
"model": { "model_ref": "people-detection", "version": "1.0.0" },
|
||||
"configurations": [
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 1, "error_code": null }
|
||||
],
|
||||
"health": { "overall": "healthy", "error_codes": [], "metrics": { "load_percent": 1, "queue_depth": 0, "latency_ms": 1 } },
|
||||
"inputs": [],
|
||||
"alert": { "kind": "intrusion" }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "yovision.runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d202",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 1,
|
||||
"observed_at": "2026-08-31T00:00:00Z",
|
||||
"runtime": { "state": "running", "version": "1.0.0", "started_at": null },
|
||||
"model": { "model_ref": "people-detection", "version": "1.0.0" },
|
||||
"configurations": [
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 1, "error_code": null }
|
||||
],
|
||||
"health": { "overall": "healthy", "error_codes": [], "metrics": { "load_percent": 1, "queue_depth": 0, "latency_ms": 1 } },
|
||||
"inputs": [],
|
||||
"access_token": "forbidden-example"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"schema_version": "yovision.runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d206",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 47,
|
||||
"observed_at": "2026-08-31T00:05:00Z",
|
||||
"runtime": { "state": "degraded", "version": "1.0.0", "started_at": "2026-08-30T23:55:00Z" },
|
||||
"model": { "model_ref": "people-detection", "version": "2026.08.1" },
|
||||
"configurations": [
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 21, "error_code": null },
|
||||
{ "config_id": "gate-primary", "apply_state": "rejected", "applied_revision": 20, "error_code": "CONFIG_INVALID" }
|
||||
],
|
||||
"health": {
|
||||
"overall": "degraded",
|
||||
"error_codes": ["CONFIG_INVALID"],
|
||||
"metrics": { "load_percent": 42, "queue_depth": 1, "latency_ms": 31 }
|
||||
},
|
||||
"inputs": []
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"schema_version": "yovision.runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d203",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 1,
|
||||
"observed_at": "2026-08-31T00:00:00Z",
|
||||
"runtime": { "state": "running", "version": "1.0.0", "started_at": null },
|
||||
"model": { "model_ref": "C:\\models\\private.pt", "version": "1.0.0" },
|
||||
"configurations": [
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 1, "error_code": null }
|
||||
],
|
||||
"health": { "overall": "healthy", "error_codes": [], "metrics": { "load_percent": 1, "queue_depth": 0, "latency_ms": 1 } },
|
||||
"inputs": []
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"schema_version": "yovision.runtime-status/v2",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d201",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 1,
|
||||
"observed_at": "2026-08-31T00:00:00Z",
|
||||
"runtime": { "state": "running", "version": "1.0.0", "started_at": null },
|
||||
"model": { "model_ref": "people-detection", "version": "1.0.0" },
|
||||
"configurations": [
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 1, "error_code": null }
|
||||
],
|
||||
"health": { "overall": "healthy", "error_codes": [], "metrics": { "load_percent": 1, "queue_depth": 0, "latency_ms": 1 } },
|
||||
"inputs": []
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "yovision.runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d204",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 1,
|
||||
"observed_at": "2026-08-31T00:00:00Z",
|
||||
"runtime": { "state": "running", "version": "1.0.0", "started_at": null },
|
||||
"model": { "model_ref": "people-detection", "version": "1.0.0" },
|
||||
"configurations": [
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 1, "error_code": null }
|
||||
],
|
||||
"health": { "overall": "healthy", "error_codes": [], "metrics": { "load_percent": 1, "queue_depth": 0, "latency_ms": 1 } },
|
||||
"inputs": [],
|
||||
"user_session": { "user": "forbidden" }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schema_version": "yovision.runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d103",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 43,
|
||||
"observed_at": "2026-08-31T00:01:00Z",
|
||||
"runtime": { "state": "running", "version": "1.0.0", "started_at": "2026-08-30T23:55:00Z" },
|
||||
"model": { "model_ref": "people-detection", "version": "2026.08.1" },
|
||||
"configurations": [
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 20, "error_code": null }
|
||||
],
|
||||
"health": {
|
||||
"overall": "healthy",
|
||||
"error_codes": [],
|
||||
"metrics": { "load_percent": 40.0, "queue_depth": 0, "latency_ms": 22.0 }
|
||||
},
|
||||
"inputs": []
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"schema_version": "yovision.runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d107",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 46,
|
||||
"observed_at": "2026-08-31T00:04:30Z",
|
||||
"runtime": { "state": "degraded", "version": "1.0.0", "started_at": "2026-08-30T23:55:00Z" },
|
||||
"model": { "model_ref": "people-detection", "version": "2026.08.1" },
|
||||
"configurations": [
|
||||
{ "config_id": "new-stream", "apply_state": "not_configured", "applied_revision": null, "error_code": null },
|
||||
{ "config_id": "yard-secondary", "apply_state": "applying", "applied_revision": 8, "error_code": null },
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 21, "error_code": null },
|
||||
{ "config_id": "warehouse", "apply_state": "rejected", "applied_revision": 3, "error_code": "CONFIG_INVALID" }
|
||||
],
|
||||
"health": {
|
||||
"overall": "degraded",
|
||||
"error_codes": ["CONFIG_INVALID"],
|
||||
"metrics": { "load_percent": 42, "queue_depth": 1, "latency_ms": 31 }
|
||||
},
|
||||
"inputs": []
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"schema_version": "yovision.runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d102",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 42,
|
||||
"observed_at": "2026-08-31T00:00:30Z",
|
||||
"runtime": { "state": "degraded", "version": "1.0.0", "started_at": "2026-08-30T23:55:00Z" },
|
||||
"model": { "model_ref": "people-detection", "version": "2026.08.1" },
|
||||
"configurations": [
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 21, "error_code": null },
|
||||
{ "config_id": "yard-secondary", "apply_state": "applying", "applied_revision": 8, "error_code": null }
|
||||
],
|
||||
"health": {
|
||||
"overall": "degraded",
|
||||
"error_codes": ["RESOURCE_PRESSURE"],
|
||||
"metrics": { "load_percent": 91.5, "queue_depth": 7, "latency_ms": 115.0 }
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"input_ref": "camera-gate-01",
|
||||
"state": "degraded",
|
||||
"error_codes": ["INPUT_DECODE_FAILED"],
|
||||
"metrics": { "load_percent": 5.2, "queue_depth": 3, "latency_ms": 92.0 }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"schema_version": "yovision.runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d106",
|
||||
"brain_instance_ref": "brain-east-02",
|
||||
"sequence": 1,
|
||||
"observed_at": "2026-08-31T00:00:00Z",
|
||||
"runtime": { "state": "unconfigured", "version": "1.0.0", "started_at": null },
|
||||
"model": { "model_ref": "people-detection", "version": "2026.08.1" },
|
||||
"configurations": [],
|
||||
"health": {
|
||||
"overall": "healthy",
|
||||
"error_codes": [],
|
||||
"metrics": { "load_percent": 0, "queue_depth": 0, "latency_ms": 0 }
|
||||
},
|
||||
"inputs": []
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schema_version": "yovision.runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d104",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 44,
|
||||
"observed_at": "2026-08-31T00:01:30Z",
|
||||
"runtime": { "state": "degraded", "version": "1.0.0", "started_at": "2026-08-30T23:55:00Z" },
|
||||
"model": { "model_ref": "people-detection", "version": "2026.08.1" },
|
||||
"configurations": [
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 21, "error_code": null }
|
||||
],
|
||||
"health": {
|
||||
"overall": "degraded",
|
||||
"error_codes": ["INPUT_UNREACHABLE"],
|
||||
"metrics": { "load_percent": 30.0, "queue_depth": 1, "latency_ms": 30.0 }
|
||||
},
|
||||
"inputs": []
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schema_version": "yovision.runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d105",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 45,
|
||||
"observed_at": "2026-08-31T00:04:00Z",
|
||||
"runtime": { "state": "running", "version": "1.0.0", "started_at": "2026-08-30T23:55:00Z" },
|
||||
"model": { "model_ref": "people-detection", "version": "2026.08.1" },
|
||||
"configurations": [
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 21, "error_code": null }
|
||||
],
|
||||
"health": {
|
||||
"overall": "healthy",
|
||||
"error_codes": [],
|
||||
"metrics": { "load_percent": 36.0, "queue_depth": 0, "latency_ms": 20.0 }
|
||||
},
|
||||
"inputs": []
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"schema_version": "yovision.runtime-status/v1",
|
||||
"status_id": "018f4d6a-8d1b-4a25-8b37-9085f9c0d101",
|
||||
"brain_instance_ref": "brain-east-01",
|
||||
"sequence": 41,
|
||||
"observed_at": "2026-08-31T00:00:00Z",
|
||||
"runtime": { "state": "running", "version": "1.0.0", "started_at": "2026-08-30T23:55:00Z" },
|
||||
"model": { "model_ref": "people-detection", "version": "2026.08.1" },
|
||||
"configurations": [
|
||||
{ "config_id": "gate-primary", "apply_state": "applied", "applied_revision": 21, "error_code": null }
|
||||
],
|
||||
"health": {
|
||||
"overall": "healthy",
|
||||
"error_codes": [],
|
||||
"metrics": { "load_percent": 38.5, "queue_depth": 0, "latency_ms": 21.4 }
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"input_ref": "camera-gate-01",
|
||||
"state": "running",
|
||||
"error_codes": [],
|
||||
"metrics": { "load_percent": 5.2, "queue_depth": 0, "latency_ms": 18.1 }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# Brain → Sense mapper 字段责任
|
||||
|
||||
| 契约字段 | Brain 生产者责任 | Sense 消费者投影责任 |
|
||||
|---|---|---|
|
||||
| `schema_version` | 固定发布 `yovision.runtime-status/v1` | 先按主版本分派;未知版本不覆盖最后投影 |
|
||||
| `status_id` | 每次观测生成唯一幂等键 | 去重,不把重复消息当成新观测 |
|
||||
| `brain_instance_ref` | 发布部署时分配的逻辑引用 | 映射到内部 edge node;不把它当数据库主键 |
|
||||
| `sequence` | 同实例单调递增 | 拒绝倒序消息,保留最后已知投影 |
|
||||
| `observed_at` | 发布观测完成时间 | 校验未来偏差;用它推导 fresh/stale,不用接收时间覆盖 |
|
||||
| `runtime.*` | 报告真实运行状态和脱敏版本 | 校验迁移并形成只读运维状态 |
|
||||
| `model.*` | 报告逻辑模型引用及版本,不报告文件路径 | 显示版本差异,不推导模型下载或重启命令 |
|
||||
| `configurations[]` | 每个 `config_id` 报告真实应用结果和 integer revision;同一消息内 ID 唯一 | 按 `config_id` 与 Sense 内部期望 revision 比较;拒绝重复 ID,不回写 Brain 状态 |
|
||||
| `health.*` | 聚合无敏感健康与有界指标 | 展示健康、指标和稳定错误码,不生成业务 Alert |
|
||||
| `inputs[]` | 按逻辑输入发布安全摘要 | 按 `input_ref` 映射运维投影,不读取视频或检测内容 |
|
||||
|
||||
## 契约测试责任
|
||||
|
||||
- Brain:对所有发布消息执行 Schema 校验;覆盖各运行状态、配置应用结果、降级/失败以及敏感字段拒绝。
|
||||
- Sense:使用同一有效/无效样例;覆盖版本分派、幂等与倒序、30 秒未来偏差、90 秒陈旧边界、状态迁移、offline/recovery、revision mismatch 及回退不覆盖最后投影。
|
||||
- 协调契约:`contracts/tests/runtime-status-v1/test_contract.py` 是双方最小共同测试。产品 adapter 仍需在各自工单中增加本地模型映射测试。
|
||||
@@ -0,0 +1,172 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://yovision.local/contracts/runtime-status/v1/runtime-status.schema.json",
|
||||
"title": "YoVision Brain runtime status v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version",
|
||||
"status_id",
|
||||
"brain_instance_ref",
|
||||
"sequence",
|
||||
"observed_at",
|
||||
"runtime",
|
||||
"model",
|
||||
"configurations",
|
||||
"health",
|
||||
"inputs"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": { "const": "yovision.runtime-status/v1" },
|
||||
"status_id": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
||||
},
|
||||
"brain_instance_ref": { "$ref": "#/$defs/logicalRef" },
|
||||
"sequence": { "type": "integer", "minimum": 0 },
|
||||
"observed_at": { "type": "string", "format": "date-time" },
|
||||
"runtime": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["state", "version"],
|
||||
"properties": {
|
||||
"state": { "$ref": "#/$defs/runtimeState" },
|
||||
"version": { "$ref": "#/$defs/version" },
|
||||
"started_at": { "type": ["string", "null"], "format": "date-time" }
|
||||
}
|
||||
},
|
||||
"model": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["model_ref", "version"],
|
||||
"properties": {
|
||||
"model_ref": { "$ref": "#/$defs/logicalRef" },
|
||||
"version": { "$ref": "#/$defs/version" }
|
||||
}
|
||||
},
|
||||
"configurations": {
|
||||
"type": "array",
|
||||
"maxItems": 4096,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["config_id", "apply_state", "applied_revision", "error_code"],
|
||||
"properties": {
|
||||
"config_id": { "$ref": "#/$defs/configId" },
|
||||
"apply_state": {
|
||||
"type": "string",
|
||||
"enum": ["not_configured", "applying", "applied", "rejected"]
|
||||
},
|
||||
"applied_revision": {
|
||||
"type": ["integer", "null"],
|
||||
"minimum": 1
|
||||
},
|
||||
"error_code": { "$ref": "#/$defs/nullableErrorCode" }
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"required": ["apply_state"],
|
||||
"properties": { "apply_state": { "const": "not_configured" } }
|
||||
},
|
||||
"then": { "properties": { "applied_revision": { "type": "null" } } }
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"required": ["apply_state"],
|
||||
"properties": { "apply_state": { "const": "applied" } }
|
||||
},
|
||||
"then": { "properties": { "applied_revision": { "type": "integer", "minimum": 1 } } }
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"required": ["apply_state"],
|
||||
"properties": { "apply_state": { "const": "rejected" } }
|
||||
},
|
||||
"then": { "properties": { "error_code": { "$ref": "#/$defs/errorCode" } } }
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"health": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["overall", "error_codes", "metrics"],
|
||||
"properties": {
|
||||
"overall": {
|
||||
"type": "string",
|
||||
"enum": ["healthy", "degraded", "unhealthy"]
|
||||
},
|
||||
"error_codes": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"maxItems": 32,
|
||||
"items": { "$ref": "#/$defs/errorCode" }
|
||||
},
|
||||
"metrics": { "$ref": "#/$defs/metrics" }
|
||||
}
|
||||
},
|
||||
"inputs": {
|
||||
"type": "array",
|
||||
"maxItems": 4096,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["input_ref", "state", "error_codes", "metrics"],
|
||||
"properties": {
|
||||
"input_ref": { "$ref": "#/$defs/logicalRef" },
|
||||
"state": { "$ref": "#/$defs/runtimeState" },
|
||||
"error_codes": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"maxItems": 16,
|
||||
"items": { "$ref": "#/$defs/errorCode" }
|
||||
},
|
||||
"metrics": { "$ref": "#/$defs/metrics" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"configId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._~-]*$"
|
||||
},
|
||||
"logicalRef": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 64,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$"
|
||||
},
|
||||
"runtimeState": {
|
||||
"type": "string",
|
||||
"enum": ["unconfigured", "starting", "running", "degraded", "failed", "stopped"]
|
||||
},
|
||||
"errorCode": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Z][A-Z0-9_]{2,63}$"
|
||||
},
|
||||
"nullableErrorCode": {
|
||||
"type": ["string", "null"],
|
||||
"pattern": "^[A-Z][A-Z0-9_]{2,63}$"
|
||||
},
|
||||
"metrics": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["load_percent", "queue_depth", "latency_ms"],
|
||||
"properties": {
|
||||
"load_percent": { "type": "number", "minimum": 0, "maximum": 100 },
|
||||
"queue_depth": { "type": "integer", "minimum": 0 },
|
||||
"latency_ms": { "type": "number", "minimum": 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# Sense → Brain 媒体源与区域规则配置契约 v1
|
||||
|
||||
`yovision.source-config/v1` 是 Sense 发布、Brain 消费的完整配置快照。它只携带稳定逻辑标识、无凭据媒体引用、Profile 规格、归一化规则及完整性摘要,不暴露 Sense 数据库模型或 Brain 内部配置模型。
|
||||
|
||||
本版本选择 JSON Schema,而不是 OpenAPI:快照可经文件、消息或后续 connector 传输,工单 #148 不定义 HTTP 端点。后续 connector 若提供 HTTP API,应引用本 Schema,不复制字段定义。
|
||||
|
||||
## 文件
|
||||
|
||||
- `source-config.schema.json`:Draft 2020-12 JSON Schema。
|
||||
- `examples/valid/`:可接受的 active 与待重校准快照。
|
||||
- `examples/invalid/`:必须安全拒绝的版本、秘密、路径、坐标和绑定错误。
|
||||
- `compatibility.md`:版本、兼容周期、迁移和回退规则。
|
||||
- `mapper-fields.md`:Sense 生产者与 Brain 消费者字段映射和测试责任。
|
||||
|
||||
## 消费规则
|
||||
|
||||
1. 先按 JSON Schema 校验,再执行跨字段语义校验。
|
||||
2. `schema_version` 必须精确等于 `yovision.source-config/v1`;未知主版本不得降级猜测。
|
||||
3. `rule_set.profile_binding` 必须与 `profile.id/width/height` 完全一致。
|
||||
4. `rule_set.state != active` 时不得运行任何规则;`recalibration_required` 表示 Profile 规格变化后需重新标定。
|
||||
5. `areas` 与 `directional_lines` 的 `id` 在同一快照内必须全局唯一;多边形必须非退化,线段起终点不得相同。
|
||||
6. `effective_at` 不得早于 `published_at`。
|
||||
7. `integrity.value` 是移除顶层 `integrity` 后,对 RFC 8785 JCS 规范化 JSON 字节计算的 SHA-256 小写十六进制摘要。生产消费者应使用合规 JCS 实现;仓库样例只使用 JCS 简单类型子集。
|
||||
|
||||
`media.ref` 是 connector 解析的无凭据不透明引用,固定以 `media:` 开头。它不能包含 URI authority、用户名、密码、查询参数、fragment、Windows 盘符或文件系统路径。RTSP 凭据交换与机器身份不属于本契约。
|
||||
|
||||
## 可复制验证
|
||||
|
||||
从仓库根目录运行:
|
||||
|
||||
```powershell
|
||||
& contracts\tests\source-config-v1\run.ps1
|
||||
```
|
||||
|
||||
脚本在系统临时目录创建隔离虚拟环境、安装固定版本的 Schema 校验器并运行测试,不修改产品目录。测试结束后会清理临时环境。
|
||||
@@ -0,0 +1,29 @@
|
||||
# v1 兼容、迁移与回退
|
||||
|
||||
## 兼容规则
|
||||
|
||||
- v1 发布后只允许在预留的顶层 `extensions` 对象中增加命名空间化、非秘密的可选扩展。消费者必须忽略自己不认识的扩展命名空间,但仍须拒绝当前 Schema 或语义规则标记为非法的输入;发布扩展时应同步生产者/消费者测试。v1 核心对象保持封闭,不能通过新增核心字段规避新主版本。
|
||||
- 删除字段、把可选改为必填、收紧已发布取值范围,或改变字段类型、单位、坐标系、Profile 绑定、revision、状态及媒体引用语义,均为破坏性变化,必须发布新主版本目录和新的 `schema_version` 值。
|
||||
- 未知主版本必须安全拒绝并保留最后一个已验证配置。不得把未知版本转换成 v1,也不得继续启用来自未知版本的规则。
|
||||
- v1 的坐标始终是相对于 `profile.width × profile.height` 图像平面的 0–1 归一化坐标;原点在左上,x 向右、y 向下。该语义不得在 v1 内改变。
|
||||
|
||||
## revision 与生效
|
||||
|
||||
- `(config_id, revision)` 唯一标识一个不可变快照;同一 `config_id` 的新发布必须使用严格递增的 `revision`。
|
||||
- 消费者仅在 Schema、语义和完整性均通过后,按 `effective_at` 原子切换整个快照。重复收到同一 revision 应幂等处理;更小 revision 应拒绝为陈旧配置。
|
||||
- Profile ID、分辨率或编码变化时,生产者必须发布新 revision。已有几何尚未按新 Profile 校准时,必须设置 `rule_set.state = recalibration_required`;消费者不得启用其中规则。
|
||||
- 新 revision 校验失败或未到生效时间时,消费者保留上一份已验证且仍有效的 active revision。
|
||||
|
||||
## 支持周期
|
||||
|
||||
- 发布新主版本后,Sense 生产者与 Brain 消费者至少并行支持上一主版本一个正式发布周期,且不少于 90 天;具体停止日期必须在新版本协调工单中冻结。
|
||||
- 并行期内生产者按目标消费者能力选择版本,不得把两个主版本字段混在同一快照。
|
||||
|
||||
## 回退
|
||||
|
||||
1. 停止分发有问题的新主版本或新 revision。
|
||||
2. 重新发布上一主版本的最后一个已验证快照;若仍为同一 `config_id`,必须使用该主版本下新的、更大 revision,不能覆盖历史 revision。
|
||||
3. Brain 通过完整 Schema、语义和摘要校验后原子切回;切换前继续使用最后一个有效快照,或在没有有效快照时保持规则停用。
|
||||
4. 记录失败版本和拒绝原因,但不得记录媒体凭据或完整客户配置。
|
||||
|
||||
样例 `examples/valid/recalibration-required.json` 展示 Profile 变化后的安全停用状态。回退不修改已发布 v1 字段语义,也不要求读取 Sense 数据库。
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schema_version": "yovision.source-config/v1",
|
||||
"config_id": "school-east-entry-01",
|
||||
"revision": 7,
|
||||
"published_at": "2026-08-31T00:10:00Z",
|
||||
"effective_at": "2026-08-31T00:15:00Z",
|
||||
"site": {"id": "site-east"},
|
||||
"logical_device": {"id": "entry-camera-01"},
|
||||
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
|
||||
"media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"},
|
||||
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [{"id": "bad-area", "version": 1, "kind": "danger_area", "enabled": true, "points": [{"x": 0, "y": 0}, {"x": 1.2, "y": 0}, {"x": 0, "y": 1}]}], "directional_lines": []},
|
||||
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schema_version": "yovision.source-config/v1",
|
||||
"config_id": "school-east-entry-01",
|
||||
"revision": 7,
|
||||
"published_at": "2026-08-31T00:10:00Z",
|
||||
"effective_at": "2026-08-31T00:15:00Z",
|
||||
"site": {"id": "site-east"},
|
||||
"logical_device": {"id": "entry-camera-01"},
|
||||
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
|
||||
"media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp", "password": null},
|
||||
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []},
|
||||
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"coordinate-out-of-range.json": "schema",
|
||||
"credential-field.json": "secret",
|
||||
"internal-path.json": "internal path",
|
||||
"profile-binding-mismatch.json": "profile binding",
|
||||
"query-token.json": "secret",
|
||||
"unknown-major-version.json": "unknown schema"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schema_version": "yovision.source-config/v1",
|
||||
"config_id": "school-east-entry-01",
|
||||
"revision": 7,
|
||||
"published_at": "2026-08-31T00:10:00Z",
|
||||
"effective_at": "2026-08-31T00:15:00Z",
|
||||
"site": {"id": "site-east"},
|
||||
"logical_device": {"id": "entry-camera-01"},
|
||||
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
|
||||
"media": {"ref": "C:\\customers\\school-east\\camera-01", "transport": "rtsp"},
|
||||
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []},
|
||||
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schema_version": "yovision.source-config/v1",
|
||||
"config_id": "school-east-entry-01",
|
||||
"revision": 7,
|
||||
"published_at": "2026-08-31T00:10:00Z",
|
||||
"effective_at": "2026-08-31T00:15:00Z",
|
||||
"site": {"id": "site-east"},
|
||||
"logical_device": {"id": "entry-camera-01"},
|
||||
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
|
||||
"media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"},
|
||||
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1280, "height": 720}, "areas": [], "directional_lines": []},
|
||||
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schema_version": "yovision.source-config/v1",
|
||||
"config_id": "school-east-entry-01",
|
||||
"revision": 7,
|
||||
"published_at": "2026-08-31T00:10:00Z",
|
||||
"effective_at": "2026-08-31T00:15:00Z",
|
||||
"site": {"id": "site-east"},
|
||||
"logical_device": {"id": "entry-camera-01"},
|
||||
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
|
||||
"media": {"ref": "media:site-east/entry-01/main?token=", "transport": "rtsp"},
|
||||
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []},
|
||||
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schema_version": "yovision.source-config/v2",
|
||||
"config_id": "school-east-entry-01",
|
||||
"revision": 7,
|
||||
"published_at": "2026-08-31T00:10:00Z",
|
||||
"effective_at": "2026-08-31T00:15:00Z",
|
||||
"site": {"id": "site-east"},
|
||||
"logical_device": {"id": "entry-camera-01"},
|
||||
"profile": {"id": "main-stream", "width": 1920, "height": 1080, "encoding": "H264", "frame_rate": 25},
|
||||
"media": {"ref": "media:site-east/entry-01/main", "transport": "rtsp"},
|
||||
"rule_set": {"version": "entry-rules-7", "state": "active", "profile_binding": {"profile_id": "main-stream", "width": 1920, "height": 1080}, "areas": [], "directional_lines": []},
|
||||
"integrity": {"algorithm": "sha256", "value": "0000000000000000000000000000000000000000000000000000000000000000"}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"schema_version": "yovision.source-config/v1",
|
||||
"config_id": "school-east-entry-01",
|
||||
"revision": 7,
|
||||
"published_at": "2026-08-31T00:10:00Z",
|
||||
"effective_at": "2026-08-31T00:15:00Z",
|
||||
"site": {
|
||||
"id": "site-east"
|
||||
},
|
||||
"logical_device": {
|
||||
"id": "entry-camera-01"
|
||||
},
|
||||
"profile": {
|
||||
"id": "main-stream",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"encoding": "H264",
|
||||
"frame_rate": 25
|
||||
},
|
||||
"media": {
|
||||
"ref": "media:site-east/entry-01/main",
|
||||
"transport": "rtsp"
|
||||
},
|
||||
"rule_set": {
|
||||
"version": "entry-rules-7",
|
||||
"state": "active",
|
||||
"profile_binding": {
|
||||
"profile_id": "main-stream",
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
},
|
||||
"areas": [
|
||||
{
|
||||
"id": "danger-yard",
|
||||
"version": 3,
|
||||
"kind": "danger_area",
|
||||
"enabled": true,
|
||||
"points": [
|
||||
{"x": 0.12, "y": 0.18},
|
||||
{"x": 0.82, "y": 0.18},
|
||||
{"x": 0.76, "y": 0.78},
|
||||
{"x": 0.18, "y": 0.72}
|
||||
]
|
||||
}
|
||||
],
|
||||
"directional_lines": [
|
||||
{
|
||||
"id": "entry-line",
|
||||
"version": 2,
|
||||
"kind": "directional_line",
|
||||
"enabled": true,
|
||||
"start": {"x": 0.2, "y": 0.5},
|
||||
"end": {"x": 0.8, "y": 0.5},
|
||||
"trigger_direction": "left_to_right"
|
||||
}
|
||||
]
|
||||
},
|
||||
"integrity": {
|
||||
"algorithm": "sha256",
|
||||
"value": "3336fe595bf1401b1024ac0c95c31e1655228485465a4527900fcea2c713acfe"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"schema_version": "yovision.source-config/v1",
|
||||
"config_id": "school-east-entry-01",
|
||||
"revision": 8,
|
||||
"published_at": "2026-08-31T01:00:00Z",
|
||||
"effective_at": "2026-08-31T01:00:00Z",
|
||||
"site": {
|
||||
"id": "site-east"
|
||||
},
|
||||
"logical_device": {
|
||||
"id": "entry-camera-01"
|
||||
},
|
||||
"profile": {
|
||||
"id": "main-stream-v2",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"encoding": "H265",
|
||||
"frame_rate": 20
|
||||
},
|
||||
"media": {
|
||||
"ref": "media:site-east/entry-01/main-v2",
|
||||
"transport": "rtsp"
|
||||
},
|
||||
"rule_set": {
|
||||
"version": "entry-rules-8",
|
||||
"state": "recalibration_required",
|
||||
"profile_binding": {
|
||||
"profile_id": "main-stream-v2",
|
||||
"width": 1280,
|
||||
"height": 720
|
||||
},
|
||||
"areas": [],
|
||||
"directional_lines": []
|
||||
},
|
||||
"integrity": {
|
||||
"algorithm": "sha256",
|
||||
"value": "a53e6df8bab5c9a4e3f2dae2e82959939db09d34529af9ae65d66f322be833ba"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# 生产者与消费者 mapper 字段表
|
||||
|
||||
mapper 必须创建新的契约 DTO,不得直接序列化 Sense GORM 实体,也不得让 Brain 把共享快照当作 `brain.internal.input/v1`。
|
||||
|
||||
| 契约字段 | Sense 生产来源/规则 | Brain 消费目标/规则 |
|
||||
|---|---|---|
|
||||
| `schema_version` | 常量 `yovision.source-config/v1` | 在任何映射前精确校验;未知主版本拒绝 |
|
||||
| `config_id` | 新的稳定配置聚合 ID;不是数据库行 ID 语义 | 作为配置流逻辑 ID,不解释为 Brain 内部对象 ID |
|
||||
| `revision` | 聚合配置变更时严格递增;不可复用 | 与 `config_id` 共同做幂等、顺序和陈旧检查 |
|
||||
| `published_at` / `effective_at` | 发布时写 UTC RFC 3339;生效不得早于发布 | 完整校验后按生效时间原子切换 |
|
||||
| `site.id` | 对外稳定站点引用;不得映射客户名或数据库主键语义 | 仅作租户隔离后的逻辑关联;v1 不提供用户身份 |
|
||||
| `logical_device.id` | `area.Definition.DeviceID` / `media.Route.DeviceID` 经稳定外部 ID mapper | 映射到 `BrainInputConfig.logical_device_id` |
|
||||
| `profile.id` | `area.Definition.ProfileToken` 与 `media.Route.ProfileToken` 经稳定 Profile ID mapper | 映射到 `BrainInputConfig.profile.profile_id` |
|
||||
| `profile.width/height/encoding` | `area.Definition.ProfileWidth/ProfileHeight/ProfileEncoding`;必须与当前媒体 Profile 一致 | 映射到规则 `RuleSet` 的 Profile 绑定;不一致拒绝 |
|
||||
| `profile.frame_rate` | Sense 已验证 Profile 的帧率快照 | 映射到 `BrainInputConfig.profile.fps` |
|
||||
| `media.ref` | 由 `media.Route.ID/Path` 生成 `media:<opaque-resource>`;禁止读取或拼入 `admissionProfile.StreamURI` 及凭据 | 交给后续 connector 解析;不得当作 RTSP URL 或本地路径 |
|
||||
| `media.transport` | 当前固定 `rtsp`,仅描述媒体传输类别 | 选择后续 connector/decode adapter;不含认证信息 |
|
||||
| `rule_set.version` | 由一组 `area.Version` 聚合成稳定规则集版本 | 映射到 Brain `RuleSet.version` |
|
||||
| `rule_set.state` | `NeedsRecalibration=true` → `recalibration_required`;整体禁用 → `disabled`;否则 `active` | 只有 `active` 可构建并启用规则引擎 |
|
||||
| `rule_set.profile_binding` | 与本快照 `profile.id/width/height` 同源复制并交叉校验 | 必须精确等于 `profile`;之后才接受归一化几何 |
|
||||
| `rule_set.areas[].id/version` | `area.Version.DefinitionID/Version` 经稳定规则 ID mapper | 映射到 `AreaRule.rule_id`;version 用于可追溯性 |
|
||||
| `rule_set.areas[].kind` | Sense `polygon` 映射为 `danger_area` | 只映射到 Brain 危险区域规则,不透传 Sense 枚举 |
|
||||
| `rule_set.areas[].points` | `area.Version.GeometryJSON` 中 `{x,y}`;保持 0–1 | 映射到 Brain `Point(x,y)`;至少三点且非退化 |
|
||||
| `rule_set.directional_lines[].id/version` | `area.Version.DefinitionID/Version` 经稳定规则 ID mapper | 映射到 `DirectionalLineRule.rule_id` |
|
||||
| `rule_set.directional_lines[].start/end` | `direction_line` 几何的两个归一化点 | 映射到 Brain `Point`;相同点拒绝 |
|
||||
| `rule_set.directional_lines[].trigger_direction` | Sense `forward/reverse` 必须由 mapper 根据已确认的起终点方向转换为 `left_to_right/right_to_left` | 映射到 `DirectionalLineRule.trigger_direction`;不得直接猜测枚举 |
|
||||
| `integrity` | 对移除 `integrity` 的 JCS 快照计算 SHA-256 | 映射前重算并常量时间比较;失败保留上一有效 revision |
|
||||
|
||||
## 测试责任
|
||||
|
||||
- Sense 生产者契约测试:从设备、媒体 Route、Profile 与区域版本 fixture 生成快照;断言字段映射、revision 递增、Profile 变化触发新 revision/待重校准、无秘密媒体引用、Schema/语义/摘要通过。
|
||||
- Brain 消费者契约测试:加载本目录有效与无效样例;断言版本拒绝、幂等/陈旧处理、Profile 绑定、坐标、规则 ID、状态门禁和摘要;再映射为 Brain 内部配置,证明共享 `schema_version` 不等于 `brain.internal.input/v1`。
|
||||
- 协调契约测试(本工单):校验所有样例、秘密字段/URL/本地路径拒绝、跨字段语义和摘要。产品 adapter 测试在后续 connector 工单实施。
|
||||
|
||||
Sense 与 Brain 各自可增加内部字段,但不得将数据库主键、用户表、JWT、Cookie、摄像头凭据、客户内部路径或内部模型直接扩展进本契约。
|
||||
@@ -0,0 +1,273 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://yovision.local/contracts/source-config/v1/source-config.schema.json",
|
||||
"title": "YoVision Sense to Brain source configuration snapshot v1",
|
||||
"description": "Credential-free media source, profile binding, and normalized rule configuration published by Sense for Brain.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema_version",
|
||||
"config_id",
|
||||
"revision",
|
||||
"published_at",
|
||||
"effective_at",
|
||||
"site",
|
||||
"logical_device",
|
||||
"profile",
|
||||
"media",
|
||||
"rule_set",
|
||||
"integrity"
|
||||
],
|
||||
"properties": {
|
||||
"schema_version": {
|
||||
"const": "yovision.source-config/v1"
|
||||
},
|
||||
"config_id": {
|
||||
"$ref": "#/$defs/stable_id"
|
||||
},
|
||||
"revision": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"published_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"effective_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"site": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/$defs/stable_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
"logical_device": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/$defs/stable_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"$ref": "#/$defs/profile"
|
||||
},
|
||||
"media": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["ref", "transport"],
|
||||
"properties": {
|
||||
"ref": {
|
||||
"type": "string",
|
||||
"pattern": "^media:[A-Za-z0-9][A-Za-z0-9._~/-]{0,254}$",
|
||||
"description": "Opaque credential-free reference resolved by the connector. URI authority, userinfo, query strings, and fragments are forbidden."
|
||||
},
|
||||
"transport": {
|
||||
"enum": ["rtsp"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"rule_set": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"version",
|
||||
"state",
|
||||
"profile_binding",
|
||||
"areas",
|
||||
"directional_lines"
|
||||
],
|
||||
"properties": {
|
||||
"version": {
|
||||
"$ref": "#/$defs/stable_id"
|
||||
},
|
||||
"state": {
|
||||
"enum": ["active", "disabled", "recalibration_required"]
|
||||
},
|
||||
"profile_binding": {
|
||||
"$ref": "#/$defs/profile_binding"
|
||||
},
|
||||
"areas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/area_rule"
|
||||
},
|
||||
"maxItems": 1024
|
||||
},
|
||||
"directional_lines": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/directional_line_rule"
|
||||
},
|
||||
"maxItems": 1024
|
||||
}
|
||||
}
|
||||
},
|
||||
"integrity": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["algorithm", "value"],
|
||||
"properties": {
|
||||
"algorithm": {
|
||||
"const": "sha256"
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"type": "object",
|
||||
"description": "Optional namespaced, non-secret extension data. Consumers ignore unknown namespaces.",
|
||||
"propertyNames": {
|
||||
"pattern": "^[A-Za-z][A-Za-z0-9.-]{0,127}$"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"stable_id": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._~-]*$"
|
||||
},
|
||||
"positive_integer": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"positive_number": {
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0
|
||||
},
|
||||
"profile": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "width", "height", "encoding", "frame_rate"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/$defs/stable_id"
|
||||
},
|
||||
"width": {
|
||||
"$ref": "#/$defs/positive_integer"
|
||||
},
|
||||
"height": {
|
||||
"$ref": "#/$defs/positive_integer"
|
||||
},
|
||||
"encoding": {
|
||||
"enum": ["H264", "H265", "MJPEG"]
|
||||
},
|
||||
"frame_rate": {
|
||||
"$ref": "#/$defs/positive_number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"profile_binding": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["profile_id", "width", "height"],
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"$ref": "#/$defs/stable_id"
|
||||
},
|
||||
"width": {
|
||||
"$ref": "#/$defs/positive_integer"
|
||||
},
|
||||
"height": {
|
||||
"$ref": "#/$defs/positive_integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"point": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["x", "y"],
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"y": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"area_rule": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "version", "kind", "enabled", "points"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/$defs/stable_id"
|
||||
},
|
||||
"version": {
|
||||
"$ref": "#/$defs/positive_integer"
|
||||
},
|
||||
"kind": {
|
||||
"const": "danger_area"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"points": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/point"
|
||||
},
|
||||
"minItems": 3,
|
||||
"maxItems": 256
|
||||
}
|
||||
}
|
||||
},
|
||||
"directional_line_rule": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"version",
|
||||
"kind",
|
||||
"enabled",
|
||||
"start",
|
||||
"end",
|
||||
"trigger_direction"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/$defs/stable_id"
|
||||
},
|
||||
"version": {
|
||||
"$ref": "#/$defs/positive_integer"
|
||||
},
|
||||
"kind": {
|
||||
"const": "directional_line"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"start": {
|
||||
"$ref": "#/$defs/point"
|
||||
},
|
||||
"end": {
|
||||
"$ref": "#/$defs/point"
|
||||
},
|
||||
"trigger_direction": {
|
||||
"enum": ["left_to_right", "right_to_left"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Small dependency-free validator for the JSON Schema keywords used by v1 contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def load_json(path: Path) -> Any:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def canonical_bytes(value: Any) -> bytes:
|
||||
"""Canonical bytes for checked-in JCS vectors (all vector numbers are JCS-safe)."""
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
|
||||
|
||||
|
||||
def validate(instance: Any, schema: dict[str, Any], schema_path: Path, location: str = "$") -> list[str]:
|
||||
if "$ref" in schema:
|
||||
ref = schema["$ref"]
|
||||
if ref.startswith("#"):
|
||||
return [f"{location}: local fragments are not supported by the contract checker"]
|
||||
target = (schema_path.parent / ref).resolve()
|
||||
return validate(instance, load_json(target), target, location)
|
||||
|
||||
errors: list[str] = []
|
||||
for subschema in schema.get("allOf", []):
|
||||
errors.extend(validate(instance, subschema, schema_path, location))
|
||||
if "anyOf" in schema and not any(not validate(instance, item, schema_path, location) for item in schema["anyOf"]):
|
||||
errors.append(f"{location}: does not match anyOf")
|
||||
if "not" in schema and not validate(instance, schema["not"], schema_path, location):
|
||||
errors.append(f"{location}: matches forbidden schema")
|
||||
if "if" in schema and not validate(instance, schema["if"], schema_path, location):
|
||||
errors.extend(validate(instance, schema.get("then", {}), schema_path, location))
|
||||
|
||||
expected = schema.get("type")
|
||||
type_ok = {
|
||||
"object": lambda x: isinstance(x, dict),
|
||||
"array": lambda x: isinstance(x, list),
|
||||
"string": lambda x: isinstance(x, str),
|
||||
"integer": lambda x: isinstance(x, int) and not isinstance(x, bool),
|
||||
"number": lambda x: isinstance(x, (int, float)) and not isinstance(x, bool) and math.isfinite(x),
|
||||
"boolean": lambda x: isinstance(x, bool),
|
||||
}
|
||||
if expected and (expected not in type_ok or not type_ok[expected](instance)):
|
||||
return errors + [f"{location}: expected {expected}"]
|
||||
if "const" in schema and instance != schema["const"]:
|
||||
errors.append(f"{location}: expected constant {schema['const']!r}")
|
||||
if "enum" in schema and instance not in schema["enum"]:
|
||||
errors.append(f"{location}: value not in enum")
|
||||
|
||||
if isinstance(instance, dict):
|
||||
required = schema.get("required", [])
|
||||
errors.extend(f"{location}: missing {name}" for name in required if name not in instance)
|
||||
properties = schema.get("properties", {})
|
||||
if schema.get("additionalProperties") is False:
|
||||
errors.extend(f"{location}: unknown property {name}" for name in instance if name not in properties)
|
||||
for name, value in instance.items():
|
||||
if name in properties:
|
||||
errors.extend(validate(value, properties[name], schema_path, f"{location}.{name}"))
|
||||
elif isinstance(instance, list):
|
||||
if len(instance) < schema.get("minItems", 0):
|
||||
errors.append(f"{location}: too few items")
|
||||
if "maxItems" in schema and len(instance) > schema["maxItems"]:
|
||||
errors.append(f"{location}: too many items")
|
||||
if schema.get("uniqueItems") and len({canonical_bytes(item) for item in instance}) != len(instance):
|
||||
errors.append(f"{location}: duplicate items")
|
||||
for index, value in enumerate(instance):
|
||||
errors.extend(validate(value, schema.get("items", {}), schema_path, f"{location}[{index}]"))
|
||||
elif isinstance(instance, str):
|
||||
if len(instance) < schema.get("minLength", 0):
|
||||
errors.append(f"{location}: string too short")
|
||||
if "maxLength" in schema and len(instance) > schema["maxLength"]:
|
||||
errors.append(f"{location}: string too long")
|
||||
if "pattern" in schema and re.fullmatch(schema["pattern"], instance) is None:
|
||||
errors.append(f"{location}: pattern mismatch")
|
||||
if schema.get("format") == "date-time":
|
||||
try:
|
||||
datetime.fromisoformat(instance.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
errors.append(f"{location}: invalid date-time")
|
||||
elif isinstance(instance, (int, float)) and not isinstance(instance, bool):
|
||||
if "minimum" in schema and instance < schema["minimum"]:
|
||||
errors.append(f"{location}: below minimum")
|
||||
if "maximum" in schema and instance > schema["maximum"]:
|
||||
errors.append(f"{location}: above maximum")
|
||||
return errors
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"schema_version": "yovision.event/v1",
|
||||
"producer_id": "brain-school-a",
|
||||
"source_event_id": "evt-sensitive-0001",
|
||||
"site_ref": "site-school-a",
|
||||
"device_ref": "camera-east-gate",
|
||||
"profile_ref": "profile-main-stream",
|
||||
"event_type": "dangerous_area_entered",
|
||||
"occurred_at": "2026-08-31T00:00:01.125Z",
|
||||
"severity": "high",
|
||||
"rule": {"rule_id": "rule-east-danger", "version": "3"},
|
||||
"model": {"name": "anonymous-detector", "version": "2026.08"},
|
||||
"observation": {"track_id": "track-0042", "category": "person", "confidence": 0.93, "face_feature": "forbidden"},
|
||||
"region": {"region_id": "region-east-danger", "kind": "area"},
|
||||
"evidence": [],
|
||||
"camera_password": "forbidden"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"schema_version": "yovision.event/v2",
|
||||
"producer_id": "brain-school-a",
|
||||
"source_event_id": "evt-unknown-version-0001",
|
||||
"site_ref": "site-school-a",
|
||||
"device_ref": "camera-east-gate",
|
||||
"profile_ref": "profile-main-stream",
|
||||
"event_type": "dangerous_area_entered",
|
||||
"occurred_at": "2026-08-31T00:00:01.125Z",
|
||||
"severity": "high",
|
||||
"rule": {"rule_id": "rule-east-danger", "version": "3"},
|
||||
"model": {"name": "anonymous-detector", "version": "2026.08"},
|
||||
"observation": {"track_id": "track-0042", "category": "person", "confidence": 0.93},
|
||||
"region": {"region_id": "region-east-danger", "kind": "area"},
|
||||
"evidence": []
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"algorithm": "RFC8785-JCS+SHA-256",
|
||||
"vectors": [
|
||||
{
|
||||
"name": "dangerous-area-original-and-reordered-duplicate",
|
||||
"fixture": "../../events/v1/examples/dangerous-area.json",
|
||||
"idempotency_key": ["brain-school-a", "evt-area-20260831-0001"],
|
||||
"payload_sha256": "4cc1e93820195caf713ea675ff33f178c9d4997dd8a81cb61287e9fea0e3d5e1",
|
||||
"conflict_patch": {"severity": "critical"},
|
||||
"conflict_payload_sha256": "7076771f7827d97ef45831ae221046b8cb347f152dd222c2edd6f14a58e173b2"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
CONTRACTS = HERE.parents[1]
|
||||
sys.path.insert(0, str(HERE))
|
||||
|
||||
from contract_support import canonical_bytes, load_json, validate # noqa: E402
|
||||
|
||||
|
||||
class EventV1ContractTests(unittest.TestCase):
|
||||
schema_path = CONTRACTS / "events" / "v1" / "event.schema.json"
|
||||
schema = load_json(schema_path)
|
||||
|
||||
def assert_valid(self, payload: object) -> None:
|
||||
self.assertEqual([], validate(payload, self.schema, self.schema_path))
|
||||
|
||||
def test_anonymous_area_and_line_examples_are_valid(self) -> None:
|
||||
for name in ("dangerous-area.json", "directional-line-crossed.json"):
|
||||
with self.subTest(name=name):
|
||||
self.assert_valid(load_json(CONTRACTS / "events" / "v1" / "examples" / name))
|
||||
|
||||
def test_idempotency_vector_duplicate_and_conflict(self) -> None:
|
||||
vectors = load_json(HERE / "idempotency-vectors.json")["vectors"]
|
||||
for vector in vectors:
|
||||
payload = load_json((HERE / vector["fixture"]).resolve())
|
||||
self.assertEqual(vector["idempotency_key"], [payload["producer_id"], payload["source_event_id"]])
|
||||
digest = hashlib.sha256(canonical_bytes(payload)).hexdigest()
|
||||
self.assertEqual(vector["payload_sha256"], digest)
|
||||
reordered = dict(reversed(list(payload.items())))
|
||||
self.assertEqual(digest, hashlib.sha256(canonical_bytes(reordered)).hexdigest())
|
||||
conflict = copy.deepcopy(payload)
|
||||
conflict.update(vector["conflict_patch"])
|
||||
conflict_digest = hashlib.sha256(canonical_bytes(conflict)).hexdigest()
|
||||
self.assertEqual(vector["conflict_payload_sha256"], conflict_digest)
|
||||
self.assertNotEqual(digest, conflict_digest)
|
||||
|
||||
def test_unknown_version_is_rejected(self) -> None:
|
||||
payload = load_json(HERE / "fixtures" / "unknown-version.json")
|
||||
self.assertTrue(validate(payload, self.schema, self.schema_path))
|
||||
|
||||
def test_brain_producer_sense_relay_and_bell_consumer_fixture(self) -> None:
|
||||
produced = load_json(CONTRACTS / "events" / "v1" / "examples" / "dangerous-area.json")
|
||||
self.assert_valid(produced)
|
||||
relayed = copy.deepcopy(produced)
|
||||
self.assertEqual(
|
||||
(produced["producer_id"], produced["source_event_id"]),
|
||||
(relayed["producer_id"], relayed["source_event_id"]),
|
||||
)
|
||||
self.assertEqual(canonical_bytes(produced), canonical_bytes(relayed))
|
||||
bell_allowed = set(self.schema["properties"])
|
||||
self.assertEqual(set(produced), bell_allowed)
|
||||
self.assertNotIn("alert", produced)
|
||||
self.assertNotIn("receipt", produced)
|
||||
|
||||
def test_sensitive_and_internal_fields_are_rejected(self) -> None:
|
||||
payload = load_json(HERE / "fixtures" / "sensitive-field.json")
|
||||
errors = validate(payload, self.schema, self.schema_path)
|
||||
self.assertTrue(any("camera_password" in error for error in errors))
|
||||
self.assertTrue(any("face_feature" in error for error in errors))
|
||||
base = load_json(CONTRACTS / "events" / "v1" / "examples" / "dangerous-area.json")
|
||||
for forbidden, value in {
|
||||
"user_token": "forbidden", "ack_state": "acked", "local_path": "C:/forbidden",
|
||||
"signed_url": "https://forbidden.invalid/object?signature=forbidden"
|
||||
}.items():
|
||||
with self.subTest(forbidden=forbidden):
|
||||
candidate = copy.deepcopy(base)
|
||||
candidate[forbidden] = value
|
||||
self.assertTrue(validate(candidate, self.schema, self.schema_path))
|
||||
|
||||
def test_openapi_references_exist_and_responses_are_explicit(self) -> None:
|
||||
path = CONTRACTS / "events" / "v1" / "openapi.json"
|
||||
spec = load_json(path)
|
||||
operation = spec["paths"]["/v1/events"]["post"]
|
||||
self.assertEqual({"200", "201", "400", "409", "422"}, set(operation["responses"]))
|
||||
refs: list[str] = []
|
||||
|
||||
def collect(value: object) -> None:
|
||||
if isinstance(value, dict):
|
||||
refs.extend(item for key, item in value.items() if key == "$ref")
|
||||
for item in value.values(): collect(item)
|
||||
elif isinstance(value, list):
|
||||
for item in value: collect(item)
|
||||
|
||||
collect(spec)
|
||||
self.assertTrue(refs)
|
||||
for ref in refs:
|
||||
self.assertTrue((path.parent / ref).resolve().is_file(), ref)
|
||||
|
||||
def test_duplicate_conflict_and_unknown_version_response_examples(self) -> None:
|
||||
directory = CONTRACTS / "events" / "v1"
|
||||
cases = (
|
||||
("duplicate-result.json", "ingest-result.schema.json"),
|
||||
("idempotency-conflict-problem.json", "problem.schema.json"),
|
||||
("unsupported-version-problem.json", "problem.schema.json"),
|
||||
)
|
||||
for fixture_name, schema_name in cases:
|
||||
with self.subTest(fixture=fixture_name):
|
||||
schema_path = directory / schema_name
|
||||
errors = validate(load_json(directory / "examples" / fixture_name), load_json(schema_path), schema_path)
|
||||
self.assertEqual([], errors)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"schema_version": "yovision.evidence-reference/v1",
|
||||
"evidence_id": "ev-sensitive-0001",
|
||||
"owner_id": "sense-school-a",
|
||||
"type": "snapshot",
|
||||
"status": "success",
|
||||
"captured_at": "2026-08-31T00:00:01.125Z",
|
||||
"status_updated_at": "2026-08-31T00:00:02.450Z",
|
||||
"content_type": "image/jpeg",
|
||||
"integrity": {"algorithm": "sha256", "digest": "2f77668a9dfbf8d5848b9e6d7da867800b7b6790625f16b45a101f1ca1f7da75", "size_bytes": 48215},
|
||||
"local_path": "forbidden"
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
CONTRACTS = HERE.parents[1]
|
||||
EVENT_SUPPORT = HERE.parent / "events-v1"
|
||||
sys.path.insert(0, str(EVENT_SUPPORT))
|
||||
|
||||
from contract_support import load_json, validate # noqa: E402
|
||||
|
||||
|
||||
class EvidenceV1ContractTests(unittest.TestCase):
|
||||
schema_path = CONTRACTS / "evidence" / "v1" / "evidence-reference.schema.json"
|
||||
schema = load_json(schema_path)
|
||||
|
||||
def errors_for(self, payload: object) -> list[str]:
|
||||
return validate(payload, self.schema, self.schema_path)
|
||||
|
||||
def test_pending_success_and_failed_examples_are_valid(self) -> None:
|
||||
for name in ("pending.json", "success.json", "failed.json"):
|
||||
with self.subTest(name=name):
|
||||
payload = load_json(CONTRACTS / "evidence" / "v1" / "examples" / name)
|
||||
self.assertEqual([], self.errors_for(payload))
|
||||
|
||||
def test_state_specific_metadata_is_enforced(self) -> None:
|
||||
success = load_json(CONTRACTS / "evidence" / "v1" / "examples" / "success.json")
|
||||
for required in ("content_type", "integrity"):
|
||||
with self.subTest(success_requires=required):
|
||||
candidate = copy.deepcopy(success)
|
||||
del candidate[required]
|
||||
self.assertTrue(self.errors_for(candidate))
|
||||
|
||||
legacy_available = copy.deepcopy(success)
|
||||
legacy_available["status"] = "available"
|
||||
self.assertTrue(self.errors_for(legacy_available))
|
||||
|
||||
failed = load_json(CONTRACTS / "evidence" / "v1" / "examples" / "failed.json")
|
||||
del failed["failure"]
|
||||
self.assertTrue(self.errors_for(failed))
|
||||
|
||||
pending = load_json(CONTRACTS / "evidence" / "v1" / "examples" / "pending.json")
|
||||
pending["content_type"] = "image/jpeg"
|
||||
self.assertTrue(self.errors_for(pending))
|
||||
|
||||
def test_sensitive_access_material_and_unknown_version_are_rejected(self) -> None:
|
||||
fixture = load_json(HERE / "fixtures" / "sensitive-field.json")
|
||||
self.assertTrue(any("local_path" in error for error in self.errors_for(fixture)))
|
||||
base = load_json(CONTRACTS / "evidence" / "v1" / "examples" / "pending.json")
|
||||
for forbidden, value in {
|
||||
"camera_password": "forbidden",
|
||||
"user_token": "forbidden",
|
||||
"signed_url": "https://forbidden.invalid/object?signature=forbidden",
|
||||
"face_feature": "forbidden",
|
||||
"alert_state": "acked"
|
||||
}.items():
|
||||
with self.subTest(forbidden=forbidden):
|
||||
candidate = copy.deepcopy(base)
|
||||
candidate[forbidden] = value
|
||||
self.assertTrue(self.errors_for(candidate))
|
||||
unknown = copy.deepcopy(base)
|
||||
unknown["schema_version"] = "yovision.evidence-reference/v2"
|
||||
self.assertTrue(self.errors_for(unknown))
|
||||
|
||||
def test_openapi_refs_and_degradation_responses(self) -> None:
|
||||
path = CONTRACTS / "evidence" / "v1" / "openapi.json"
|
||||
spec = load_json(path)
|
||||
operation = spec["paths"]["/v1/evidence/{evidence_id}"]["get"]
|
||||
self.assertEqual({"200", "404", "410"}, set(operation["responses"]))
|
||||
refs: list[str] = []
|
||||
|
||||
def collect(value: object) -> None:
|
||||
if isinstance(value, dict):
|
||||
refs.extend(item for key, item in value.items() if key == "$ref")
|
||||
for item in value.values(): collect(item)
|
||||
elif isinstance(value, list):
|
||||
for item in value: collect(item)
|
||||
|
||||
collect(spec)
|
||||
for ref in refs:
|
||||
self.assertTrue((path.parent / ref).resolve().is_file(), ref)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user