Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e81f00e9aa | ||
|
|
5adee5c3b4 | ||
|
|
a69ef627c7 | ||
|
|
d4de462d44 | ||
|
|
0276bceab5 | ||
|
|
c2b2943a3a | ||
|
|
55b12df373 | ||
|
|
27d465c250 | ||
|
|
4a2c4aa638 | ||
|
|
504dd1a2e9 | ||
|
|
e2f7183ecf | ||
|
|
eb4e1a9ea1 | ||
|
|
9055f2522c | ||
|
|
130087a1ba | ||
|
|
0e53e04e95 | ||
|
|
04c5deecfb | ||
|
|
82afce1f81 | ||
|
|
cf00d73436 | ||
|
|
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 | ||
|
|
cabc29c18b | ||
|
|
452cd71035 |
@@ -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
|
||||
@@ -21,13 +21,12 @@ func (e System) GenerateCaptchaHandler(c *gin.Context) {
|
||||
e.Error(500, err, "服务初始化失败!")
|
||||
return
|
||||
}
|
||||
id, b64s, answer, err := captcha.DriverDigitFunc()
|
||||
id, b64s, _, err := captcha.DriverDigitFunc()
|
||||
if err != nil {
|
||||
e.Logger.Errorf("DriverDigitFunc error, %s", err.Error())
|
||||
e.Error(500, err, "验证码获取失败")
|
||||
return
|
||||
}
|
||||
e.Logger.Infof("DriverDigitFunc answer: %s", answer)
|
||||
e.Custom(gin.H{
|
||||
"code": 200,
|
||||
"data": b64s,
|
||||
|
||||
@@ -28,6 +28,9 @@ func sysCheckRoleRouterInit(r *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
|
||||
}
|
||||
|
||||
func registerBaseRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
|
||||
systemAPI := apis.System{}
|
||||
v1.GET("/captcha", systemAPI.GenerateCaptchaHandler)
|
||||
|
||||
api := apis.SysMenu{}
|
||||
v1auth := v1.Group("").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
{
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package contact
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var ErrChannelKeyUnavailable = errors.New("联系人通道加密密钥未配置或格式错误")
|
||||
|
||||
func ParseChannelKey(value string) ([]byte, error) {
|
||||
key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(value))
|
||||
if err != nil || len(key) != 32 {
|
||||
return nil, ErrChannelKeyUnavailable
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func encryptAddress(key []byte, value string) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, ErrChannelKeyUnavailable
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gcm.Seal(nonce, nonce, []byte(value), nil), nil
|
||||
}
|
||||
|
||||
func decryptAddress(key, encoded []byte) (string, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", ErrChannelKeyUnavailable
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(encoded) < gcm.NonceSize() {
|
||||
return "", errors.New("通道密文已损坏")
|
||||
}
|
||||
plain, err := gcm.Open(nil, encoded[:gcm.NonceSize()], encoded[gcm.NonceSize():], nil)
|
||||
return string(plain), err
|
||||
}
|
||||
|
||||
func fingerprint(value string) string {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package contact
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
)
|
||||
|
||||
type Handler struct{ api.Api }
|
||||
type enabledInput struct {
|
||||
Enabled *bool `json:"enabled" binding:"required"`
|
||||
ExpectedVersion int `json:"expectedVersion" binding:"required"`
|
||||
}
|
||||
type validationInput struct {
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
func (h Handler) service() (Service, error) {
|
||||
key, err := ParseChannelKey(os.Getenv("BELL_CONTACT_CHANNEL_KEY"))
|
||||
return NewService(h.Orm, key), err
|
||||
}
|
||||
func (h Handler) List(c *gin.Context) {
|
||||
var q PageQuery
|
||||
h.MakeContext(c).MakeOrm().Bind(&q, binding.Form)
|
||||
if h.Errors != nil {
|
||||
h.Error(400, ErrInvalid, ErrInvalid.Error())
|
||||
return
|
||||
}
|
||||
items, count, err := NewService(h.Orm, nil).List(c.Request.Context(), q)
|
||||
if err != nil {
|
||||
h.Error(500, errors.New("读取联系人失败"), "读取联系人失败")
|
||||
return
|
||||
}
|
||||
p, s := pageValues(q.PageIndex, q.PageSize)
|
||||
h.PageOK(items, int(count), p, s, "查询成功")
|
||||
}
|
||||
func (h Handler) Create(c *gin.Context) {
|
||||
if !admin(c) {
|
||||
h.MakeContext(c).Error(403, errors.New("仅管理员可维护联系人"), "仅管理员可维护联系人")
|
||||
return
|
||||
}
|
||||
var input WriteInput
|
||||
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
|
||||
if h.Errors != nil {
|
||||
h.Error(400, ErrInvalid, ErrInvalid.Error())
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm, nil).Create(c.Request.Context(), input, user.GetUserId(c))
|
||||
h.result(item, err)
|
||||
}
|
||||
func (h Handler) Update(c *gin.Context) {
|
||||
if !admin(c) {
|
||||
h.MakeContext(c).Error(403, errors.New("仅管理员可维护联系人"), "仅管理员可维护联系人")
|
||||
return
|
||||
}
|
||||
var input WriteInput
|
||||
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
|
||||
if h.Errors != nil {
|
||||
h.Error(400, ErrInvalid, ErrInvalid.Error())
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm, nil).Update(c.Request.Context(), c.Param("id"), input, user.GetUserId(c))
|
||||
h.result(item, err)
|
||||
}
|
||||
func (h Handler) SetEnabled(c *gin.Context) {
|
||||
if !admin(c) {
|
||||
h.MakeContext(c).Error(403, errors.New("仅管理员可维护联系人"), "仅管理员可维护联系人")
|
||||
return
|
||||
}
|
||||
var input enabledInput
|
||||
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
|
||||
if h.Errors != nil || input.Enabled == nil {
|
||||
h.Error(400, ErrInvalid, ErrInvalid.Error())
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm, nil).SetEnabled(c.Request.Context(), c.Param("id"), *input.Enabled, input.ExpectedVersion, user.GetUserId(c))
|
||||
h.result(item, err)
|
||||
}
|
||||
func (h Handler) AddChannel(c *gin.Context) {
|
||||
if !admin(c) {
|
||||
h.MakeContext(c).Error(403, errors.New("仅管理员可维护通道"), "仅管理员可维护通道")
|
||||
return
|
||||
}
|
||||
if err := restoreChannelBody(c); err != nil {
|
||||
h.MakeContext(c).Error(http.StatusBadRequest, ErrInvalid, ErrInvalid.Error())
|
||||
return
|
||||
}
|
||||
var input ChannelInput
|
||||
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
|
||||
if h.Errors != nil {
|
||||
h.Error(400, ErrInvalid, ErrInvalid.Error())
|
||||
return
|
||||
}
|
||||
service, keyErr := h.service()
|
||||
if keyErr != nil {
|
||||
h.Error(503, keyErr, keyErr.Error())
|
||||
return
|
||||
}
|
||||
item, err := service.AddChannel(c.Request.Context(), c.Param("id"), input, user.GetUserId(c))
|
||||
h.result(item, err)
|
||||
}
|
||||
func (h Handler) ValidateChannel(c *gin.Context) {
|
||||
if !admin(c) {
|
||||
h.MakeContext(c).Error(403, errors.New("仅管理员可验证通道"), "仅管理员可验证通道")
|
||||
return
|
||||
}
|
||||
var input validationInput
|
||||
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
|
||||
if h.Errors != nil {
|
||||
h.Error(400, ErrInvalid, ErrInvalid.Error())
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm, nil).RecordValidation(c.Request.Context(), c.Param("id"), input.Status, input.Detail, user.GetUserId(c))
|
||||
h.result(item, err)
|
||||
}
|
||||
func (h Handler) result(item any, err error) {
|
||||
switch {
|
||||
case err == nil:
|
||||
h.OK(item, "保存成功")
|
||||
case errors.Is(err, ErrInvalid):
|
||||
h.Error(400, err, err.Error())
|
||||
case errors.Is(err, ErrNotFound):
|
||||
h.Error(404, err, err.Error())
|
||||
case errors.Is(err, ErrConflict):
|
||||
h.Error(409, err, err.Error())
|
||||
case errors.Is(err, ErrChannelKeyUnavailable):
|
||||
h.Error(503, err, err.Error())
|
||||
default:
|
||||
h.Logger.Errorf("write Bell contact failed: %v", err)
|
||||
h.Error(409, errors.New("联系人保存失败"), "联系人保存失败")
|
||||
}
|
||||
}
|
||||
func admin(c *gin.Context) bool {
|
||||
claims := jwt.ExtractClaims(c)
|
||||
role, _ := claims[jwt.RoleKey].(string)
|
||||
return role == "admin"
|
||||
}
|
||||
|
||||
var _ = http.StatusOK
|
||||
@@ -0,0 +1,68 @@
|
||||
package contact
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Contact struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
Name string `json:"name" gorm:"size:128;not null"`
|
||||
Role string `json:"role" gorm:"size:128;not null"`
|
||||
Enabled bool `json:"enabled" gorm:"not null;default:true;index"`
|
||||
Version int `json:"version" gorm:"not null;default:1"`
|
||||
CreatedBy int `json:"createdBy" gorm:"not null"`
|
||||
UpdatedBy int `json:"updatedBy" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
|
||||
UpdatedAt time.Time `json:"updatedAt" gorm:"type:timestamptz;not null"`
|
||||
}
|
||||
|
||||
func (Contact) TableName() string { return "bell_contacts" }
|
||||
|
||||
type Channel struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
ContactID string `json:"contactId" gorm:"type:uuid;not null;index"`
|
||||
Kind string `json:"kind" gorm:"size:16;not null"`
|
||||
AddressCiphertext []byte `json:"-" gorm:"type:bytea;not null"`
|
||||
AddressFingerprint string `json:"-" gorm:"size:64;not null;index"`
|
||||
AddressMasked string `json:"addressMasked" gorm:"size:64;not null"`
|
||||
CreatedBy int `json:"createdBy" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
|
||||
}
|
||||
|
||||
func (Channel) TableName() string { return "bell_contact_channels" }
|
||||
|
||||
type ChannelValidation struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
ChannelID string `json:"channelId" gorm:"type:uuid;not null;index"`
|
||||
Status string `json:"status" gorm:"size:16;not null"`
|
||||
Detail string `json:"detail" gorm:"size:256;not null"`
|
||||
ActorID int `json:"actorId" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null;index"`
|
||||
}
|
||||
|
||||
func (ChannelValidation) TableName() string { return "bell_contact_channel_validations" }
|
||||
|
||||
type AuditFact struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
ContactID string `json:"contactId" gorm:"type:uuid;not null;index"`
|
||||
Action string `json:"action" gorm:"size:32;not null"`
|
||||
Snapshot json.RawMessage `json:"snapshot" gorm:"type:jsonb;not null"`
|
||||
ActorID int `json:"actorId" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
|
||||
}
|
||||
|
||||
func (AuditFact) TableName() string { return "bell_contact_audit_facts" }
|
||||
|
||||
type ChannelView struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
AddressMasked string `json:"addressMasked"`
|
||||
Status string `json:"status"`
|
||||
ValidatedAt *time.Time `json:"validatedAt,omitempty"`
|
||||
}
|
||||
|
||||
type ContactView struct {
|
||||
Contact
|
||||
Channels []ChannelView `json:"channels"`
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package contact
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const maxChannelRequestBytes = 8 * 1024
|
||||
const channelBodyKey = "bell.contact.channel-body"
|
||||
const channelBodyErrorKey = "bell.contact.channel-body-error"
|
||||
|
||||
var redactedChannelBody = []byte(`{"redacted":true}`)
|
||||
|
||||
// RedactRequestBody must run before GoAdmin's LoggerToFile middleware. The
|
||||
// handler restores the original body from Gin context, while sys_opera_log
|
||||
// only sees a fixed marker and never the contact address.
|
||||
func RedactRequestBody() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if c.Request.Method != http.MethodPost || !isChannelCreatePath(c.Request.URL.Path) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxChannelRequestBytes+1))
|
||||
if err != nil {
|
||||
c.Set(channelBodyErrorKey, err)
|
||||
} else if len(body) > maxChannelRequestBytes {
|
||||
c.Set(channelBodyErrorKey, errors.New("request body too large"))
|
||||
} else {
|
||||
c.Set(channelBodyKey, body)
|
||||
}
|
||||
_ = c.Request.Body.Close()
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(redactedChannelBody))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func restoreChannelBody(c *gin.Context) error {
|
||||
if value, ok := c.Get(channelBodyErrorKey); ok {
|
||||
return value.(error)
|
||||
}
|
||||
value, ok := c.Get(channelBodyKey)
|
||||
if !ok {
|
||||
return errors.New("channel request body was not captured")
|
||||
}
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(value.([]byte)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func isChannelCreatePath(path string) bool {
|
||||
const prefix = "/api/v1/bell/contacts/"
|
||||
const suffix = "/channels"
|
||||
if !strings.HasPrefix(path, prefix) || !strings.HasSuffix(path, suffix) {
|
||||
return false
|
||||
}
|
||||
id := strings.TrimSuffix(strings.TrimPrefix(path, prefix), suffix)
|
||||
return id != "" && !strings.Contains(id, "/")
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package contact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type PageQuery struct {
|
||||
PageIndex int `form:"pageIndex"`
|
||||
PageSize int `form:"pageSize"`
|
||||
Name string `form:"name"`
|
||||
Enabled *bool `form:"enabled"`
|
||||
}
|
||||
type Service struct {
|
||||
DB *gorm.DB
|
||||
Key []byte
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, key []byte) Service { return Service{DB: db, Key: key} }
|
||||
|
||||
func (s Service) List(ctx context.Context, query PageQuery) ([]ContactView, int64, error) {
|
||||
page, size := pageValues(query.PageIndex, query.PageSize)
|
||||
db := s.DB.WithContext(ctx).Model(&Contact{})
|
||||
if name := strings.TrimSpace(query.Name); name != "" {
|
||||
db = db.Where("name ILIKE ? OR role ILIKE ?", "%"+name+"%", "%"+name+"%")
|
||||
}
|
||||
if query.Enabled != nil {
|
||||
db = db.Where("enabled = ?", *query.Enabled)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Count(&count).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var contacts []Contact
|
||||
if err := db.Order("created_at DESC,id DESC").Offset((page - 1) * size).Limit(size).Find(&contacts).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
views := make([]ContactView, 0, len(contacts))
|
||||
for _, item := range contacts {
|
||||
view, err := s.view(ctx, item)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
views = append(views, view)
|
||||
}
|
||||
return views, count, nil
|
||||
}
|
||||
|
||||
func (s Service) Create(ctx context.Context, input WriteInput, actor int) (ContactView, error) {
|
||||
input, err := normalizeContact(input)
|
||||
if err != nil {
|
||||
return ContactView{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
item := Contact{ID: uuid.NewString(), Name: input.Name, Role: input.Role, Enabled: true, Version: 1, CreatedBy: actor, UpdatedBy: actor, CreatedAt: now, UpdatedAt: now}
|
||||
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return appendAudit(tx, item, "created", actor)
|
||||
})
|
||||
if err != nil {
|
||||
return ContactView{}, err
|
||||
}
|
||||
return ContactView{Contact: item, Channels: []ChannelView{}}, nil
|
||||
}
|
||||
|
||||
func (s Service) Update(ctx context.Context, id string, input WriteInput, actor int) (ContactView, error) {
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
return ContactView{}, ErrNotFound
|
||||
}
|
||||
input, err := normalizeContact(input)
|
||||
if err != nil {
|
||||
return ContactView{}, err
|
||||
}
|
||||
if input.ExpectedVersion < 1 {
|
||||
return ContactView{}, ErrInvalid
|
||||
}
|
||||
var item Contact
|
||||
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Model(&Contact{}).Where("id = ? AND version = ?", id, input.ExpectedVersion).Updates(map[string]any{"name": input.Name, "role": input.Role, "version": gorm.Expr("version + 1"), "updated_by": actor, "updated_at": time.Now().UTC()})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
var count int64
|
||||
_ = tx.Model(&Contact{}).Where("id = ?", id).Count(&count).Error
|
||||
if count == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return ErrConflict
|
||||
}
|
||||
if err := tx.First(&item, "id = ?", id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return appendAudit(tx, item, "updated", actor)
|
||||
})
|
||||
if err != nil {
|
||||
return ContactView{}, err
|
||||
}
|
||||
return s.view(ctx, item)
|
||||
}
|
||||
|
||||
func (s Service) SetEnabled(ctx context.Context, id string, enabled bool, expectedVersion, actor int) (ContactView, error) {
|
||||
if _, err := uuid.Parse(id); err != nil {
|
||||
return ContactView{}, ErrNotFound
|
||||
}
|
||||
if expectedVersion < 1 {
|
||||
return ContactView{}, ErrInvalid
|
||||
}
|
||||
var item Contact
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Model(&Contact{}).Where("id = ? AND version = ?", id, expectedVersion).Updates(map[string]any{"enabled": enabled, "version": gorm.Expr("version + 1"), "updated_by": actor, "updated_at": time.Now().UTC()})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return ErrConflict
|
||||
}
|
||||
if err := tx.First(&item, "id = ?", id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return appendAudit(tx, item, map[bool]string{true: "enabled", false: "disabled"}[enabled], actor)
|
||||
})
|
||||
if err != nil {
|
||||
return ContactView{}, err
|
||||
}
|
||||
return s.view(ctx, item)
|
||||
}
|
||||
|
||||
func (s Service) AddChannel(ctx context.Context, contactID string, input ChannelInput, actor int) (ChannelView, error) {
|
||||
if len(s.Key) != 32 {
|
||||
return ChannelView{}, ErrChannelKeyUnavailable
|
||||
}
|
||||
if _, err := uuid.Parse(contactID); err != nil {
|
||||
return ChannelView{}, ErrNotFound
|
||||
}
|
||||
input, err := normalizeChannel(input)
|
||||
if err != nil {
|
||||
return ChannelView{}, err
|
||||
}
|
||||
ciphertext, err := encryptAddress(s.Key, input.Address)
|
||||
if err != nil {
|
||||
return ChannelView{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
item := Channel{ID: uuid.NewString(), ContactID: contactID, Kind: input.Kind, AddressCiphertext: ciphertext, AddressFingerprint: fingerprint(input.Address), AddressMasked: maskAddress(input.Address), CreatedBy: actor, CreatedAt: now}
|
||||
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var contact Contact
|
||||
if err := tx.First(&contact, "id = ?", contactID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return appendAudit(tx, contact, "channel_added", actor)
|
||||
})
|
||||
if err != nil {
|
||||
return ChannelView{}, err
|
||||
}
|
||||
return ChannelView{ID: item.ID, Kind: item.Kind, AddressMasked: item.AddressMasked, Status: "pending"}, nil
|
||||
}
|
||||
|
||||
func (s Service) RecordValidation(ctx context.Context, channelID, status, detail string, actor int) (ChannelView, error) {
|
||||
status = strings.ToLower(strings.TrimSpace(status))
|
||||
detail = strings.TrimSpace(detail)
|
||||
if status != "verified" && status != "failed" {
|
||||
return ChannelView{}, ErrInvalid
|
||||
}
|
||||
if len([]rune(detail)) > 256 || hasControl(detail) {
|
||||
return ChannelView{}, ErrInvalid
|
||||
}
|
||||
var channel Channel
|
||||
fact := ChannelValidation{ID: uuid.NewString(), ChannelID: channelID, Status: status, Detail: detail, ActorID: actor, CreatedAt: time.Now().UTC()}
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.First(&channel, "id = ?", channelID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&fact).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var c Contact
|
||||
if err := tx.First(&c, "id = ?", channel.ContactID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return appendAudit(tx, c, "channel_validation_"+status, actor)
|
||||
})
|
||||
if err != nil {
|
||||
return ChannelView{}, err
|
||||
}
|
||||
return ChannelView{ID: channel.ID, Kind: channel.Kind, AddressMasked: channel.AddressMasked, Status: status, ValidatedAt: &fact.CreatedAt}, nil
|
||||
}
|
||||
|
||||
// DecryptChannelAddress is intentionally server-only. API responses never expose this value.
|
||||
func (s Service) DecryptChannelAddress(ctx context.Context, channelID string) (string, error) {
|
||||
var item Channel
|
||||
if err := s.DB.WithContext(ctx).First(&item, "id = ?", channelID).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return decryptAddress(s.Key, item.AddressCiphertext)
|
||||
}
|
||||
|
||||
func (s Service) view(ctx context.Context, item Contact) (ContactView, error) {
|
||||
var channels []Channel
|
||||
if err := s.DB.WithContext(ctx).Where("contact_id = ?", item.ID).Order("created_at,id").Find(&channels).Error; err != nil {
|
||||
return ContactView{}, err
|
||||
}
|
||||
views := make([]ChannelView, 0, len(channels))
|
||||
for _, ch := range channels {
|
||||
view := ChannelView{ID: ch.ID, Kind: ch.Kind, AddressMasked: ch.AddressMasked, Status: "pending"}
|
||||
var fact ChannelValidation
|
||||
err := s.DB.WithContext(ctx).Where("channel_id = ?", ch.ID).Order("created_at DESC,id DESC").Take(&fact).Error
|
||||
if err == nil {
|
||||
view.Status = fact.Status
|
||||
view.ValidatedAt = &fact.CreatedAt
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ContactView{}, err
|
||||
}
|
||||
views = append(views, view)
|
||||
}
|
||||
return ContactView{Contact: item, Channels: views}, nil
|
||||
}
|
||||
|
||||
func appendAudit(tx *gorm.DB, item Contact, action string, actor int) error {
|
||||
snapshot, err := json.Marshal(map[string]any{"id": item.ID, "name": item.Name, "role": item.Role, "enabled": item.Enabled, "version": item.Version})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&AuditFact{ID: uuid.NewString(), ContactID: item.ID, Action: action, Snapshot: snapshot, ActorID: actor, CreatedAt: time.Now().UTC()}).Error
|
||||
}
|
||||
func pageValues(page, size int) (int, int) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 100 {
|
||||
size = 20
|
||||
}
|
||||
return page, size
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package contact
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalid = errors.New("联系人或通道信息不符合要求")
|
||||
ErrNotFound = errors.New("联系人或通道不存在")
|
||||
ErrConflict = errors.New("数据已被其他人员更新,请刷新后重试")
|
||||
phonePattern = regexp.MustCompile(`^\+?[0-9]{6,20}$`)
|
||||
)
|
||||
|
||||
type WriteInput struct {
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
ExpectedVersion int `json:"expectedVersion"`
|
||||
}
|
||||
|
||||
type ChannelInput struct {
|
||||
Kind string `json:"kind"`
|
||||
Address string `json:"address"`
|
||||
}
|
||||
|
||||
func normalizeContact(input WriteInput) (WriteInput, error) {
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
input.Role = strings.TrimSpace(input.Role)
|
||||
if input.Name == "" || len([]rune(input.Name)) > 128 || input.Role == "" || len([]rune(input.Role)) > 128 || hasControl(input.Name+input.Role) {
|
||||
return WriteInput{}, ErrInvalid
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func normalizeChannel(input ChannelInput) (ChannelInput, error) {
|
||||
input.Kind = strings.ToLower(strings.TrimSpace(input.Kind))
|
||||
input.Address = strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(input.Address), " ", ""), "-", "")
|
||||
if (input.Kind != "sms" && input.Kind != "voice") || !phonePattern.MatchString(input.Address) {
|
||||
return ChannelInput{}, ErrInvalid
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func maskAddress(value string) string {
|
||||
runes := []rune(value)
|
||||
if len(runes) <= 4 {
|
||||
return "****"
|
||||
}
|
||||
return strings.Repeat("*", min(8, len(runes)-4)) + string(runes[len(runes)-4:])
|
||||
}
|
||||
|
||||
func hasControl(value string) bool {
|
||||
for _, r := range value {
|
||||
if r < 32 || r == 127 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package duty_schedule
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
)
|
||||
|
||||
type Handler struct{ api.Api }
|
||||
|
||||
func (h Handler) List(c *gin.Context) {
|
||||
var q PageQuery
|
||||
h.MakeContext(c).MakeOrm().Bind(&q, binding.Form)
|
||||
if h.Errors != nil {
|
||||
h.Error(400, ErrInvalid, ErrInvalid.Error())
|
||||
return
|
||||
}
|
||||
items, count, err := NewService(h.Orm).List(c.Request.Context(), q)
|
||||
if err != nil {
|
||||
h.Error(500, errors.New("读取排班失败"), "读取排班失败")
|
||||
return
|
||||
}
|
||||
p, s := pageValues(q.PageIndex, q.PageSize)
|
||||
h.PageOK(items, int(count), p, s, "查询成功")
|
||||
}
|
||||
func (h Handler) CreateGroup(c *gin.Context) {
|
||||
if !admin(c) {
|
||||
h.MakeContext(c).Error(403, errors.New("仅管理员可维护排班"), "仅管理员可维护排班")
|
||||
return
|
||||
}
|
||||
var input GroupInput
|
||||
h.bind(c, &input)
|
||||
if h.Errors != nil {
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm).CreateGroup(c.Request.Context(), input, user.GetUserId(c))
|
||||
h.result(item, err)
|
||||
}
|
||||
func (h Handler) UpdateGroup(c *gin.Context) {
|
||||
if !admin(c) {
|
||||
h.MakeContext(c).Error(403, errors.New("仅管理员可维护排班"), "仅管理员可维护排班")
|
||||
return
|
||||
}
|
||||
var input GroupInput
|
||||
h.bind(c, &input)
|
||||
if h.Errors != nil {
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm).UpdateGroup(c.Request.Context(), c.Param("id"), input, user.GetUserId(c))
|
||||
h.result(item, err)
|
||||
}
|
||||
func (h Handler) AddMember(c *gin.Context) {
|
||||
if !admin(c) {
|
||||
h.MakeContext(c).Error(403, errors.New("仅管理员可维护排班"), "仅管理员可维护排班")
|
||||
return
|
||||
}
|
||||
var input MemberInput
|
||||
h.bind(c, &input)
|
||||
if h.Errors != nil {
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm).AddMember(c.Request.Context(), c.Param("id"), input, user.GetUserId(c))
|
||||
h.result(item, err)
|
||||
}
|
||||
func (h Handler) CreateSchedule(c *gin.Context) {
|
||||
if !admin(c) {
|
||||
h.MakeContext(c).Error(403, errors.New("仅管理员可维护排班"), "仅管理员可维护排班")
|
||||
return
|
||||
}
|
||||
var input ScheduleInput
|
||||
h.bind(c, &input)
|
||||
if h.Errors != nil {
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm).CreateSchedule(c.Request.Context(), c.Param("id"), input, user.GetUserId(c))
|
||||
h.result(item, err)
|
||||
}
|
||||
func (h Handler) Publish(c *gin.Context) {
|
||||
if !admin(c) {
|
||||
h.MakeContext(c).Error(403, errors.New("仅管理员可发布排班"), "仅管理员可发布排班")
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm).Publish(c.Request.Context(), c.Param("id"), user.GetUserId(c))
|
||||
h.result(item, err)
|
||||
}
|
||||
func (h Handler) CreateOverride(c *gin.Context) {
|
||||
if !admin(c) {
|
||||
h.MakeContext(c).Error(403, errors.New("仅管理员可维护替班"), "仅管理员可维护替班")
|
||||
return
|
||||
}
|
||||
var input OverrideInput
|
||||
h.bind(c, &input)
|
||||
if h.Errors != nil {
|
||||
return
|
||||
}
|
||||
item, err := NewService(h.Orm).CreateOverride(c.Request.Context(), c.Param("id"), input, user.GetUserId(c))
|
||||
h.result(item, err)
|
||||
}
|
||||
func (h *Handler) bind(c *gin.Context, value any) {
|
||||
h.MakeContext(c).MakeOrm().Bind(value, binding.JSON)
|
||||
if h.Errors != nil {
|
||||
h.Error(400, ErrInvalid, ErrInvalid.Error())
|
||||
}
|
||||
}
|
||||
func (h Handler) result(item any, err error) {
|
||||
switch {
|
||||
case err == nil:
|
||||
h.OK(item, "保存成功")
|
||||
case errors.Is(err, ErrInvalid) || errors.Is(err, ErrCoverage):
|
||||
h.Error(400, err, err.Error())
|
||||
case errors.Is(err, ErrNotFound):
|
||||
h.Error(404, err, err.Error())
|
||||
case errors.Is(err, ErrConflict):
|
||||
h.Error(409, err, err.Error())
|
||||
default:
|
||||
h.Logger.Errorf("write Bell duty schedule failed: %v", err)
|
||||
h.Error(409, errors.New("排班保存失败"), "排班保存失败")
|
||||
}
|
||||
}
|
||||
func admin(c *gin.Context) bool {
|
||||
claims := jwt.ExtractClaims(c)
|
||||
role, _ := claims[jwt.RoleKey].(string)
|
||||
return role == "admin"
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package duty_schedule
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Group struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
Name string `json:"name" gorm:"size:128;not null;uniqueIndex"`
|
||||
Timezone string `json:"timezone" gorm:"size:64;not null"`
|
||||
Enabled bool `json:"enabled" gorm:"not null;default:true;index"`
|
||||
Version int `json:"version" gorm:"not null;default:1"`
|
||||
CreatedBy int `json:"createdBy" gorm:"not null"`
|
||||
UpdatedBy int `json:"updatedBy" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
|
||||
UpdatedAt time.Time `json:"updatedAt" gorm:"type:timestamptz;not null"`
|
||||
}
|
||||
|
||||
func (Group) TableName() string { return "bell_duty_groups" }
|
||||
|
||||
type Member struct {
|
||||
GroupID string `json:"groupId" gorm:"type:uuid;primaryKey"`
|
||||
ContactID string `json:"contactId" gorm:"type:uuid;primaryKey"`
|
||||
Role string `json:"role" gorm:"size:16;not null"`
|
||||
CreatedBy int `json:"createdBy" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
|
||||
}
|
||||
|
||||
func (Member) TableName() string { return "bell_duty_members" }
|
||||
|
||||
type ScheduleVersion struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
GroupID string `json:"groupId" gorm:"type:uuid;not null;index"`
|
||||
Version int `json:"version" gorm:"not null"`
|
||||
Timezone string `json:"timezone" gorm:"size:64;not null"`
|
||||
EffectiveFrom time.Time `json:"effectiveFrom" gorm:"type:timestamptz;not null"`
|
||||
EffectiveTo *time.Time `json:"effectiveTo,omitempty" gorm:"type:timestamptz"`
|
||||
Status string `json:"status" gorm:"size:16;not null"`
|
||||
CreatedBy int `json:"createdBy" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
|
||||
PublishedBy *int `json:"publishedBy,omitempty"`
|
||||
PublishedAt *time.Time `json:"publishedAt,omitempty" gorm:"type:timestamptz"`
|
||||
}
|
||||
|
||||
func (ScheduleVersion) TableName() string { return "bell_duty_schedule_versions" }
|
||||
|
||||
type RotationSlot struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
ScheduleVersionID string `json:"scheduleVersionId" gorm:"type:uuid;not null;index"`
|
||||
Weekday int `json:"weekday" gorm:"not null"`
|
||||
StartMinute int `json:"startMinute" gorm:"not null"`
|
||||
EndMinute int `json:"endMinute" gorm:"not null"`
|
||||
PrimaryContactID string `json:"primaryContactId" gorm:"type:uuid;not null"`
|
||||
BackupContactID string `json:"backupContactId" gorm:"type:uuid;not null"`
|
||||
}
|
||||
|
||||
func (RotationSlot) TableName() string { return "bell_duty_rotation_slots" }
|
||||
|
||||
type Override struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
GroupID string `json:"groupId" gorm:"type:uuid;not null;index"`
|
||||
OriginalContactID string `json:"originalContactId" gorm:"type:uuid;not null"`
|
||||
ReplacementContactID string `json:"replacementContactId" gorm:"type:uuid;not null"`
|
||||
StartsAt time.Time `json:"startsAt" gorm:"type:timestamptz;not null;index"`
|
||||
EndsAt time.Time `json:"endsAt" gorm:"type:timestamptz;not null"`
|
||||
Reason string `json:"reason" gorm:"size:256;not null"`
|
||||
CreatedBy int `json:"createdBy" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
|
||||
}
|
||||
|
||||
func (Override) TableName() string { return "bell_duty_overrides" }
|
||||
|
||||
type AuditFact struct {
|
||||
ID string `json:"id" gorm:"type:uuid;primaryKey"`
|
||||
GroupID string `json:"groupId" gorm:"type:uuid;not null;index"`
|
||||
Action string `json:"action" gorm:"size:32;not null"`
|
||||
Snapshot json.RawMessage `json:"snapshot" gorm:"type:jsonb;not null"`
|
||||
ActorID int `json:"actorId" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
|
||||
}
|
||||
|
||||
func (AuditFact) TableName() string { return "bell_duty_audit_facts" }
|
||||
|
||||
type GroupView struct {
|
||||
Group
|
||||
Members []Member `json:"members"`
|
||||
Schedules []ScheduleView `json:"schedules"`
|
||||
Overrides []Override `json:"overrides"`
|
||||
}
|
||||
type ScheduleView struct {
|
||||
ScheduleVersion
|
||||
Slots []RotationSlot `json:"slots"`
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package duty_schedule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"go-admin/app/bell/contact"
|
||||
)
|
||||
|
||||
type PageQuery struct {
|
||||
PageIndex int `form:"pageIndex"`
|
||||
PageSize int `form:"pageSize"`
|
||||
Name string `form:"name"`
|
||||
Enabled *bool `form:"enabled"`
|
||||
}
|
||||
type Service struct{ DB *gorm.DB }
|
||||
|
||||
func NewService(db *gorm.DB) Service { return Service{DB: db} }
|
||||
func (s Service) List(ctx context.Context, q PageQuery) ([]GroupView, int64, error) {
|
||||
p, z := pageValues(q.PageIndex, q.PageSize)
|
||||
db := s.DB.WithContext(ctx).Model(&Group{})
|
||||
if name := strings.TrimSpace(q.Name); name != "" {
|
||||
db = db.Where("name ILIKE ?", "%"+name+"%")
|
||||
}
|
||||
if q.Enabled != nil {
|
||||
db = db.Where("enabled = ?", *q.Enabled)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Count(&count).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var groups []Group
|
||||
if err := db.Order("created_at DESC,id DESC").Offset((p - 1) * z).Limit(z).Find(&groups).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
views := make([]GroupView, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
v, err := s.view(ctx, g)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
views = append(views, v)
|
||||
}
|
||||
return views, count, nil
|
||||
}
|
||||
func (s Service) CreateGroup(ctx context.Context, input GroupInput, actor int) (GroupView, error) {
|
||||
input, err := normalizeGroup(input)
|
||||
if err != nil {
|
||||
return GroupView{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
item := Group{ID: uuid.NewString(), Name: input.Name, Timezone: input.Timezone, Enabled: true, Version: 1, CreatedBy: actor, UpdatedBy: actor, CreatedAt: now, UpdatedAt: now}
|
||||
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return audit(tx, item.ID, "group_created", item, actor)
|
||||
})
|
||||
return GroupView{Group: item, Members: []Member{}, Schedules: []ScheduleView{}, Overrides: []Override{}}, err
|
||||
}
|
||||
func (s Service) UpdateGroup(ctx context.Context, id string, input GroupInput, actor int) (GroupView, error) {
|
||||
input, err := normalizeGroup(input)
|
||||
if err != nil {
|
||||
return GroupView{}, err
|
||||
}
|
||||
if input.ExpectedVersion < 1 {
|
||||
return GroupView{}, ErrInvalid
|
||||
}
|
||||
var item Group
|
||||
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
r := tx.Model(&Group{}).Where("id=? AND version=?", id, input.ExpectedVersion).Updates(map[string]any{"name": input.Name, "timezone": input.Timezone, "version": gorm.Expr("version+1"), "updated_by": actor, "updated_at": time.Now().UTC()})
|
||||
if r.Error != nil {
|
||||
return r.Error
|
||||
}
|
||||
if r.RowsAffected != 1 {
|
||||
return ErrConflict
|
||||
}
|
||||
if err := tx.First(&item, "id=?", id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return audit(tx, id, "group_updated", item, actor)
|
||||
})
|
||||
if err != nil {
|
||||
return GroupView{}, err
|
||||
}
|
||||
return s.view(ctx, item)
|
||||
}
|
||||
func (s Service) AddMember(ctx context.Context, groupID string, input MemberInput, actor int) (Member, error) {
|
||||
input.Role = strings.ToLower(strings.TrimSpace(input.Role))
|
||||
if input.Role != "primary" && input.Role != "backup" {
|
||||
return Member{}, ErrInvalid
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
item := Member{GroupID: groupID, ContactID: input.ContactID, Role: input.Role, CreatedBy: actor, CreatedAt: now}
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := assertEnabledContact(tx, input.ContactID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.First(&Group{}, "id=?", groupID).Error; err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "group_id"}, {Name: "contact_id"}}, DoUpdates: clause.AssignmentColumns([]string{"role", "created_by", "created_at"})}).Create(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return audit(tx, groupID, "member_saved", item, actor)
|
||||
})
|
||||
return item, err
|
||||
}
|
||||
func (s Service) CreateSchedule(ctx context.Context, groupID string, input ScheduleInput, actor int) (ScheduleView, error) {
|
||||
if input.EffectiveFrom.IsZero() || (input.EffectiveTo != nil && !input.EffectiveTo.After(input.EffectiveFrom)) {
|
||||
return ScheduleView{}, ErrInvalid
|
||||
}
|
||||
if err := validateSlots(input.Slots); err != nil {
|
||||
return ScheduleView{}, err
|
||||
}
|
||||
var result ScheduleView
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var group Group
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&group, "id=?", groupID).Error; err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
for _, slot := range input.Slots {
|
||||
if err := assertGroupMember(tx, groupID, slot.PrimaryContactID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := assertGroupMember(tx, groupID, slot.BackupContactID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var latest int
|
||||
tx.Model(&ScheduleVersion{}).Where("group_id=?", groupID).Select("coalesce(max(version),0)").Scan(&latest)
|
||||
now := time.Now().UTC()
|
||||
version := ScheduleVersion{ID: uuid.NewString(), GroupID: groupID, Version: latest + 1, Timezone: group.Timezone, EffectiveFrom: input.EffectiveFrom.UTC(), EffectiveTo: input.EffectiveTo, Status: "draft", CreatedBy: actor, CreatedAt: now}
|
||||
if err := tx.Create(&version).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
slots := make([]RotationSlot, 0, len(input.Slots))
|
||||
for _, in := range input.Slots {
|
||||
slots = append(slots, RotationSlot{ID: uuid.NewString(), ScheduleVersionID: version.ID, Weekday: in.Weekday, StartMinute: in.StartMinute, EndMinute: in.EndMinute, PrimaryContactID: in.PrimaryContactID, BackupContactID: in.BackupContactID})
|
||||
}
|
||||
if err := tx.Create(&slots).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := audit(tx, groupID, "schedule_created", version, actor); err != nil {
|
||||
return err
|
||||
}
|
||||
result = ScheduleView{ScheduleVersion: version, Slots: slots}
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
func (s Service) Publish(ctx context.Context, id string, actor int) (ScheduleView, error) {
|
||||
var result ScheduleView
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var item ScheduleVersion
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&item, "id=?", id).Error; err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
if item.Status != "draft" {
|
||||
return ErrConflict
|
||||
}
|
||||
var slots []RotationSlot
|
||||
if err := tx.Where("schedule_version_id=?", id).Find(&slots).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
inputs := make([]SlotInput, 0, len(slots))
|
||||
for _, v := range slots {
|
||||
inputs = append(inputs, SlotInput{Weekday: v.Weekday, StartMinute: v.StartMinute, EndMinute: v.EndMinute, PrimaryContactID: v.PrimaryContactID, BackupContactID: v.BackupContactID})
|
||||
}
|
||||
if err := validateSlots(inputs); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, v := range slots {
|
||||
if err := assertVerifiedContact(tx, v.PrimaryContactID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := assertVerifiedContact(tx, v.BackupContactID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if err := tx.Model(&item).Updates(map[string]any{"status": "published", "published_by": actor, "published_at": now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
item.Status = "published"
|
||||
item.PublishedBy = &actor
|
||||
item.PublishedAt = &now
|
||||
if err := audit(tx, item.GroupID, "schedule_published", item, actor); err != nil {
|
||||
return err
|
||||
}
|
||||
result = ScheduleView{ScheduleVersion: item, Slots: slots}
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
func (s Service) CreateOverride(ctx context.Context, groupID string, input OverrideInput, actor int) (Override, error) {
|
||||
input, err := normalizeOverride(input)
|
||||
if err != nil {
|
||||
return Override{}, err
|
||||
}
|
||||
item := Override{ID: uuid.NewString(), GroupID: groupID, OriginalContactID: input.OriginalContactID, ReplacementContactID: input.ReplacementContactID, StartsAt: input.StartsAt.UTC(), EndsAt: input.EndsAt.UTC(), Reason: input.Reason, CreatedBy: actor, CreatedAt: time.Now().UTC()}
|
||||
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := assertGroupMember(tx, groupID, input.OriginalContactID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := assertGroupMember(tx, groupID, input.ReplacementContactID); err != nil {
|
||||
return err
|
||||
}
|
||||
var overlaps int64
|
||||
if err := tx.Model(&Override{}).Where("group_id=? AND original_contact_id=? AND starts_at < ? AND ends_at > ?", groupID, input.OriginalContactID, item.EndsAt, item.StartsAt).Count(&overlaps).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if overlaps > 0 {
|
||||
return ErrConflict
|
||||
}
|
||||
if err := tx.Create(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return audit(tx, groupID, "override_created", item, actor)
|
||||
})
|
||||
return item, err
|
||||
}
|
||||
func (s Service) view(ctx context.Context, g Group) (GroupView, error) {
|
||||
v := GroupView{Group: g, Members: []Member{}, Schedules: []ScheduleView{}, Overrides: []Override{}}
|
||||
if err := s.DB.WithContext(ctx).Where("group_id=?", g.ID).Order("role,contact_id").Find(&v.Members).Error; err != nil {
|
||||
return v, err
|
||||
}
|
||||
var versions []ScheduleVersion
|
||||
if err := s.DB.WithContext(ctx).Where("group_id=?", g.ID).Order("version DESC").Find(&versions).Error; err != nil {
|
||||
return v, err
|
||||
}
|
||||
for _, sv := range versions {
|
||||
var slots []RotationSlot
|
||||
if err := s.DB.WithContext(ctx).Where("schedule_version_id=?", sv.ID).Order("weekday,start_minute").Find(&slots).Error; err != nil {
|
||||
return v, err
|
||||
}
|
||||
v.Schedules = append(v.Schedules, ScheduleView{ScheduleVersion: sv, Slots: slots})
|
||||
}
|
||||
if err := s.DB.WithContext(ctx).Where("group_id=?", g.ID).Order("starts_at DESC").Limit(50).Find(&v.Overrides).Error; err != nil {
|
||||
return v, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
func assertEnabledContact(tx *gorm.DB, id string) error {
|
||||
var c contact.Contact
|
||||
if err := tx.Where("id=? AND enabled=true", id).First(&c).Error; err != nil {
|
||||
return ErrInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func assertGroupMember(tx *gorm.DB, groupID, contactID string) error {
|
||||
if err := assertEnabledContact(tx, contactID); err != nil {
|
||||
return err
|
||||
}
|
||||
var count int64
|
||||
if err := tx.Model(&Member{}).Where("group_id=? AND contact_id=?", groupID, contactID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count != 1 {
|
||||
return ErrInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func assertVerifiedContact(tx *gorm.DB, contactID string) error {
|
||||
if err := assertEnabledContact(tx, contactID); err != nil {
|
||||
return err
|
||||
}
|
||||
var count int64
|
||||
err := tx.Raw(`SELECT count(*) FROM bell_contact_channels c WHERE c.contact_id=? AND (SELECT v.status FROM bell_contact_channel_validations v WHERE v.channel_id=c.id ORDER BY v.created_at DESC,v.id DESC LIMIT 1)='verified'`, contactID).Scan(&count).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return ErrInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func audit(tx *gorm.DB, groupID, action string, value any, actor int) error {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&AuditFact{ID: uuid.NewString(), GroupID: groupID, Action: action, Snapshot: data, ActorID: actor, CreatedAt: time.Now().UTC()}).Error
|
||||
}
|
||||
func pageValues(p, s int) (int, int) {
|
||||
if p < 1 {
|
||||
p = 1
|
||||
}
|
||||
if s < 1 || s > 100 {
|
||||
s = 20
|
||||
}
|
||||
return p, s
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package duty_schedule
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalid = errors.New("值班排班信息不符合要求")
|
||||
ErrNotFound = errors.New("值班组或排班不存在")
|
||||
ErrConflict = errors.New("数据已被其他人员更新,请刷新后重试")
|
||||
ErrCoverage = errors.New("周排班存在空档或重叠")
|
||||
)
|
||||
|
||||
type GroupInput struct {
|
||||
Name string `json:"name"`
|
||||
Timezone string `json:"timezone"`
|
||||
ExpectedVersion int `json:"expectedVersion"`
|
||||
}
|
||||
type MemberInput struct {
|
||||
ContactID string `json:"contactId"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
type SlotInput struct {
|
||||
Weekday int `json:"weekday"`
|
||||
StartMinute int `json:"startMinute"`
|
||||
EndMinute int `json:"endMinute"`
|
||||
PrimaryContactID string `json:"primaryContactId"`
|
||||
BackupContactID string `json:"backupContactId"`
|
||||
}
|
||||
type ScheduleInput struct {
|
||||
EffectiveFrom time.Time `json:"effectiveFrom"`
|
||||
EffectiveTo *time.Time `json:"effectiveTo"`
|
||||
Slots []SlotInput `json:"slots"`
|
||||
}
|
||||
type OverrideInput struct {
|
||||
OriginalContactID string `json:"originalContactId"`
|
||||
ReplacementContactID string `json:"replacementContactId"`
|
||||
StartsAt time.Time `json:"startsAt"`
|
||||
EndsAt time.Time `json:"endsAt"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
func normalizeGroup(input GroupInput) (GroupInput, error) {
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
input.Timezone = strings.TrimSpace(input.Timezone)
|
||||
if input.Name == "" || len([]rune(input.Name)) > 128 {
|
||||
return GroupInput{}, ErrInvalid
|
||||
}
|
||||
if _, err := time.LoadLocation(input.Timezone); err != nil {
|
||||
return GroupInput{}, ErrInvalid
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
func validateSlots(slots []SlotInput) error {
|
||||
if len(slots) == 0 {
|
||||
return ErrCoverage
|
||||
}
|
||||
byDay := map[int][]SlotInput{}
|
||||
for _, slot := range slots {
|
||||
if slot.Weekday < 0 || slot.Weekday > 6 || slot.StartMinute < 0 || slot.EndMinute > 1440 || slot.StartMinute >= slot.EndMinute || slot.PrimaryContactID == "" || slot.BackupContactID == "" || slot.PrimaryContactID == slot.BackupContactID {
|
||||
return ErrInvalid
|
||||
}
|
||||
byDay[slot.Weekday] = append(byDay[slot.Weekday], slot)
|
||||
}
|
||||
for day := 0; day < 7; day++ {
|
||||
daySlots := byDay[day]
|
||||
sort.Slice(daySlots, func(i, j int) bool { return daySlots[i].StartMinute < daySlots[j].StartMinute })
|
||||
cursor := 0
|
||||
for _, slot := range daySlots {
|
||||
if slot.StartMinute != cursor {
|
||||
return ErrCoverage
|
||||
}
|
||||
cursor = slot.EndMinute
|
||||
}
|
||||
if cursor != 1440 {
|
||||
return ErrCoverage
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func normalizeOverride(input OverrideInput) (OverrideInput, error) {
|
||||
input.Reason = strings.TrimSpace(input.Reason)
|
||||
if input.OriginalContactID == "" || input.ReplacementContactID == "" || input.OriginalContactID == input.ReplacementContactID || input.StartsAt.IsZero() || !input.EndsAt.After(input.StartsAt) || input.Reason == "" || len([]rune(input.Reason)) > 256 {
|
||||
return OverrideInput{}, ErrInvalid
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package event_ingress
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/bell/integration/machine_identity"
|
||||
)
|
||||
|
||||
type EvidenceClient struct {
|
||||
Endpoint string
|
||||
Signer machine_identity.Signer
|
||||
HTTP interface {
|
||||
Do(*http.Request) (*http.Response, error)
|
||||
}
|
||||
}
|
||||
|
||||
func NewEvidenceClient(endpoint string, signer machine_identity.Signer) (*EvidenceClient, error) {
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return nil, errors.New("Sense evidence endpoint must be an HTTPS origin without userinfo")
|
||||
}
|
||||
transport := &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, TLSHandshakeTimeout: 5 * time.Second, ResponseHeaderTimeout: 5 * time.Second}
|
||||
return &EvidenceClient{Endpoint: strings.TrimRight(endpoint, "/"), Signer: signer, HTTP: &http.Client{Transport: transport, Timeout: 8 * time.Second}}, nil
|
||||
}
|
||||
|
||||
func (c EvidenceClient) Refresh(ctx context.Context, db *gorm.DB, status EvidenceStatus) error {
|
||||
path := "/v1/evidence/" + status.EvidenceID
|
||||
token, err := c.Signer.Mint("yovision-sense", []string{"evidence:read"}, http.MethodGet, path, nil)
|
||||
if err != nil {
|
||||
return c.degrade(db, status, "unavailable", "machine_identity_error")
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Endpoint+path, nil)
|
||||
if err != nil {
|
||||
return c.degrade(db, status, "unavailable", "invalid_request")
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
request.Header.Set("X-Request-ID", newCorrelationID())
|
||||
response, err := c.HTTP.Do(request)
|
||||
if err != nil {
|
||||
code := "evidence_unavailable"
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
code = "evidence_timeout"
|
||||
}
|
||||
return c.degrade(db, status, "unavailable", code)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, readErr := io.ReadAll(io.LimitReader(response.Body, 64*1024+1))
|
||||
if readErr != nil || len(body) > 64*1024 {
|
||||
return c.degrade(db, status, "unavailable", "invalid_evidence_response")
|
||||
}
|
||||
if response.StatusCode == http.StatusNotFound {
|
||||
return c.degrade(db, status, "unavailable", "evidence_not_found")
|
||||
}
|
||||
if response.StatusCode == http.StatusGone {
|
||||
return c.degrade(db, status, "expired", "evidence_expired")
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return c.degrade(db, status, "unavailable", "evidence_unavailable")
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.DisallowUnknownFields()
|
||||
var evidence Evidence
|
||||
if err = decoder.Decode(&evidence); err != nil || evidence.EvidenceID != status.EvidenceID || evidence.OwnerID != status.OwnerID || validateEvidence(evidence) != nil {
|
||||
return c.degrade(db, status, "unavailable", "invalid_evidence_response")
|
||||
}
|
||||
canonical, err := canonicalJSON(body)
|
||||
if err != nil {
|
||||
return c.degrade(db, status, "unavailable", "invalid_evidence_response")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
var expiresAt *time.Time
|
||||
if evidence.ExpiresAt != "" {
|
||||
parsedExpiry, parseErr := time.Parse(time.RFC3339Nano, evidence.ExpiresAt)
|
||||
if parseErr != nil {
|
||||
return c.degrade(db, status, "unavailable", "invalid_evidence_response")
|
||||
}
|
||||
parsedExpiry = parsedExpiry.UTC()
|
||||
expiresAt = &parsedExpiry
|
||||
}
|
||||
return db.Model(&EvidenceStatus{}).Where("event_id = ? AND evidence_id = ?", status.EventID, status.EvidenceID).Updates(map[string]any{"status": evidence.Status, "resolution": "current", "current_payload": canonical, "last_error": "", "expires_at": expiresAt, "checked_at": now, "updated_at": now}).Error
|
||||
}
|
||||
|
||||
func (c EvidenceClient) degrade(db *gorm.DB, status EvidenceStatus, resolution, code string) error {
|
||||
now := time.Now().UTC()
|
||||
return db.Model(&EvidenceStatus{}).Where("event_id = ? AND evidence_id = ?", status.EventID, status.EvidenceID).Updates(map[string]any{"resolution": resolution, "last_error": code, "checked_at": now, "updated_at": now}).Error
|
||||
}
|
||||
|
||||
func newCorrelationID() string {
|
||||
raw := make([]byte, 16)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "request-id-fallback"
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(raw)
|
||||
}
|
||||
|
||||
func LoadEvidenceClient(getenv func(string) string) (*EvidenceClient, error) {
|
||||
key, err := machine_identity.LoadPrivateKey(getenv("BELL_SENSE_PRIVATE_KEY_PATH"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load Bell evidence key: %w", err)
|
||||
}
|
||||
signer := machine_identity.Signer{Principal: strings.TrimSpace(getenv("BELL_SENSE_PRINCIPAL_ID")), KeyID: strings.TrimSpace(getenv("BELL_SENSE_KEY_ID")), PrivateKey: key}
|
||||
return NewEvidenceClient(strings.TrimSpace(getenv("BELL_SENSE_EVIDENCE_ENDPOINT")), signer)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package event_ingress
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/bell/integration/machine_identity"
|
||||
)
|
||||
|
||||
const MaxRequestBytes = 64 * 1024
|
||||
|
||||
var requestIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$`)
|
||||
|
||||
type Handler struct {
|
||||
DB *gorm.DB
|
||||
Verifier machine_identity.Verifier
|
||||
Enabled bool
|
||||
Resolver EvidenceRefresher
|
||||
}
|
||||
|
||||
func (h Handler) Post(c *gin.Context) {
|
||||
if !h.Enabled {
|
||||
writeProblem(c, http.StatusServiceUnavailable, "connector_disabled", "event connector is disabled", "")
|
||||
return
|
||||
}
|
||||
requestID := c.GetHeader("X-Request-ID")
|
||||
if requestID == "" {
|
||||
requestID = uuid.NewString()
|
||||
} else if !requestIDPattern.MatchString(requestID) {
|
||||
writeProblem(c, http.StatusBadRequest, "invalid_request_id", "X-Request-ID must be an opaque 16-128 character value", "")
|
||||
return
|
||||
}
|
||||
c.Header("X-Request-ID", requestID)
|
||||
if c.Request.URL.RawQuery != "" || c.Request.URL.Fragment != "" || c.Request.URL.EscapedPath() != "/v1/events" {
|
||||
writeProblem(c, http.StatusBadRequest, "invalid_request_target", "event request target must be the normalized /v1/events path", "")
|
||||
return
|
||||
}
|
||||
if relayHeader := c.GetHeader("X-YoVision-Relay-ID"); relayHeader != "" {
|
||||
relayID := strings.TrimSpace(relayHeader)
|
||||
if relayID != relayHeader || !validID(relayID) {
|
||||
writeProblem(c, http.StatusBadRequest, "invalid_event", "relay identity header is invalid", "")
|
||||
return
|
||||
}
|
||||
}
|
||||
body, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, MaxRequestBytes))
|
||||
if err != nil {
|
||||
writeProblem(c, http.StatusBadRequest, "invalid_event", "event payload is invalid or too large", "")
|
||||
return
|
||||
}
|
||||
token, err := machine_identity.BearerToken(c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
writeProblem(c, http.StatusUnauthorized, machineErrorCode(err), "machine identity was rejected", "")
|
||||
return
|
||||
}
|
||||
if _, err = h.Verifier.Verify(token, "yovision-bell", "events:ingest", c.Request.Method, c.Request.URL.EscapedPath(), body); err != nil {
|
||||
status := http.StatusUnauthorized
|
||||
code := machineErrorCode(err)
|
||||
if code == "machine_scope_denied" || code == "machine_audience_denied" {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
writeProblem(c, status, code, "machine identity was rejected", "")
|
||||
return
|
||||
}
|
||||
parsed, err := ParseEvent(body)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUnsupportedSchema) {
|
||||
writeProblem(c, http.StatusUnprocessableEntity, "unsupported_schema_version", "event schema version is unsupported", "")
|
||||
return
|
||||
}
|
||||
writeProblem(c, http.StatusBadRequest, "invalid_event", "event payload failed validation", "")
|
||||
return
|
||||
}
|
||||
result, err := (Service{DB: h.DB, Resolver: h.Resolver}).Ingest(c.Request.Context(), parsed)
|
||||
if errors.Is(err, ErrIdempotencyConflict) {
|
||||
writeProblem(c, http.StatusConflict, "idempotency_conflict", "idempotency key is already bound to another payload", result.EventID)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeProblem(c, http.StatusServiceUnavailable, "ingest_unavailable", "event ingest is temporarily unavailable", "")
|
||||
return
|
||||
}
|
||||
status := http.StatusCreated
|
||||
if result.Disposition == "duplicate" {
|
||||
status = http.StatusOK
|
||||
}
|
||||
c.JSON(status, result)
|
||||
}
|
||||
|
||||
func writeProblem(c *gin.Context, status int, code, message, existing string) {
|
||||
c.Header("Content-Type", "application/problem+json")
|
||||
c.JSON(status, Problem{Code: code, Message: message, ExistingEventID: existing})
|
||||
}
|
||||
|
||||
func machineErrorCode(err error) string {
|
||||
var machineErr *machine_identity.Error
|
||||
if errors.As(err, &machineErr) {
|
||||
return machineErr.Code
|
||||
}
|
||||
return "machine_token_invalid"
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package event_ingress
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
EventSchemaVersion = "yovision.event/v1"
|
||||
EvidenceSchemaVersion = "yovision.evidence-reference/v1"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
ProducerID string `json:"producer_id"`
|
||||
SourceEventID string `json:"source_event_id"`
|
||||
SiteRef string `json:"site_ref"`
|
||||
DeviceRef string `json:"device_ref"`
|
||||
ProfileRef string `json:"profile_ref"`
|
||||
EventType string `json:"event_type"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
Severity string `json:"severity"`
|
||||
Rule Rule `json:"rule"`
|
||||
Model Model `json:"model"`
|
||||
Observation Observation `json:"observation"`
|
||||
Region Region `json:"region"`
|
||||
Evidence []Evidence `json:"evidence"`
|
||||
}
|
||||
|
||||
type Rule struct {
|
||||
RuleID string `json:"rule_id"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type Observation struct {
|
||||
TrackID string `json:"track_id"`
|
||||
Category string `json:"category"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
BBoxNormalized []float64 `json:"bbox_normalized,omitempty"`
|
||||
}
|
||||
|
||||
type Region struct {
|
||||
RegionID string `json:"region_id"`
|
||||
Kind string `json:"kind"`
|
||||
CrossingDirection string `json:"crossing_direction,omitempty"`
|
||||
}
|
||||
|
||||
type Evidence struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
EvidenceID string `json:"evidence_id"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
CapturedAt string `json:"captured_at"`
|
||||
StatusUpdatedAt string `json:"status_updated_at"`
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
Integrity *EvidenceIntegrity `json:"integrity,omitempty"`
|
||||
Failure *EvidenceFailure `json:"failure,omitempty"`
|
||||
}
|
||||
|
||||
type EvidenceIntegrity struct {
|
||||
Algorithm string `json:"algorithm"`
|
||||
Digest string `json:"digest"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
}
|
||||
|
||||
type EvidenceFailure struct {
|
||||
Code string `json:"code"`
|
||||
Retryable bool `json:"retryable"`
|
||||
}
|
||||
|
||||
type IngestResult struct {
|
||||
EventID string `json:"event_id"`
|
||||
ProducerID string `json:"producer_id"`
|
||||
SourceEventID string `json:"source_event_id"`
|
||||
Disposition string `json:"disposition"`
|
||||
PayloadSHA256 string `json:"payload_sha256"`
|
||||
}
|
||||
|
||||
type Problem struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Field string `json:"field,omitempty"`
|
||||
ExistingEventID string `json:"existing_event_id,omitempty"`
|
||||
}
|
||||
|
||||
type ParsedEvent struct {
|
||||
Event Event
|
||||
Canonical json.RawMessage
|
||||
Digest string
|
||||
Occurred time.Time
|
||||
}
|
||||
|
||||
// EvidenceStatus is mutable Bell-owned resolution metadata kept separately
|
||||
// from the immutable Event and from Alert acknowledgement/close facts.
|
||||
type EvidenceStatus struct {
|
||||
EventID string `gorm:"type:uuid;primaryKey"`
|
||||
EvidenceID string `gorm:"size:128;primaryKey"`
|
||||
OwnerID string `gorm:"size:128;not null;index"`
|
||||
Status string `gorm:"size:16;not null"`
|
||||
Resolution string `gorm:"size:16;not null;index"`
|
||||
CurrentPayload json.RawMessage `gorm:"column:current_payload;type:jsonb;not null"`
|
||||
LastError string `gorm:"size:64;not null;default:''"`
|
||||
ExpiresAt *time.Time `gorm:"index"`
|
||||
CheckedAt *time.Time
|
||||
CreatedAt time.Time `gorm:"not null"`
|
||||
UpdatedAt time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (EvidenceStatus) TableName() string { return "bell_evidence_status" }
|
||||
@@ -0,0 +1,42 @@
|
||||
package event_ingress
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// ReplayToken is Bell-owned security state. It is intentionally separate from
|
||||
// business Receipt idempotency and remains effective across process restarts.
|
||||
type ReplayToken struct {
|
||||
Principal string `gorm:"size:128;primaryKey"`
|
||||
TokenID string `gorm:"size:64;primaryKey"`
|
||||
ExpiresAt time.Time `gorm:"not null;index"`
|
||||
CreatedAt time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (ReplayToken) TableName() string { return "bell_machine_token_replays" }
|
||||
|
||||
type PersistentReplayStore struct{ DB *gorm.DB }
|
||||
|
||||
func (s PersistentReplayStore) Consume(principal, tokenID string, expiresAt, now time.Time) bool {
|
||||
if s.DB == nil {
|
||||
return false
|
||||
}
|
||||
accepted := false
|
||||
err := s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("expires_at <= ?", now.UTC()).Delete(&ReplayToken{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&ReplayToken{
|
||||
Principal: principal, TokenID: tokenID, ExpiresAt: expiresAt.UTC(), CreatedAt: now.UTC(),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
accepted = result.RowsAffected == 1
|
||||
return nil
|
||||
})
|
||||
return err == nil && accepted
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package event_ingress
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk"
|
||||
|
||||
"go-admin/app/bell/integration/machine_identity"
|
||||
)
|
||||
|
||||
func RegisterRuntime(engine *gin.Engine) error {
|
||||
enabled := strings.EqualFold(strings.TrimSpace(os.Getenv("BELL_EVENT_INGRESS_ENABLED")), "true")
|
||||
if !enabled {
|
||||
return nil
|
||||
}
|
||||
db := sdk.Runtime.GetDbByKey("")
|
||||
if db == nil {
|
||||
return fmt.Errorf("Bell event ingress database is unavailable")
|
||||
}
|
||||
if !db.Migrator().HasTable(&ReplayToken{}) || !db.Migrator().HasTable(&EvidenceStatus{}) {
|
||||
return fmt.Errorf("Bell event ingress migration is required")
|
||||
}
|
||||
registry, err := machine_identity.LoadRegistry(os.Getenv("BELL_MACHINE_PRINCIPAL_REGISTRY"), "yovision-bell")
|
||||
if err != nil {
|
||||
return fmt.Errorf("load Bell machine identity registry: %w", err)
|
||||
}
|
||||
var resolver EvidenceRefresher
|
||||
if strings.EqualFold(strings.TrimSpace(os.Getenv("BELL_EVIDENCE_RESOLVER_ENABLED")), "true") {
|
||||
resolver, err = LoadEvidenceClient(os.Getenv)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load Bell evidence resolver: %w", err)
|
||||
}
|
||||
}
|
||||
handler := Handler{DB: db, Enabled: true, Resolver: resolver, Verifier: machine_identity.Verifier{Registry: registry, Replay: PersistentReplayStore{DB: db}}}
|
||||
engine.POST("/v1/events", handler.Post)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package event_ingress
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"go-admin/app/bell/event"
|
||||
"go-admin/app/bell/receipt"
|
||||
)
|
||||
|
||||
var ErrIdempotencyConflict = errors.New("idempotency_conflict")
|
||||
|
||||
type EvidenceRefresher interface {
|
||||
Refresh(context.Context, *gorm.DB, EvidenceStatus) error
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
DB *gorm.DB
|
||||
Resolver EvidenceRefresher
|
||||
}
|
||||
|
||||
func (s Service) Ingest(ctx context.Context, parsed ParsedEvent) (IngestResult, error) {
|
||||
if s.DB == nil {
|
||||
return IngestResult{}, errors.New("event database is unavailable")
|
||||
}
|
||||
var output IngestResult
|
||||
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if tx.Dialector.Name() == "postgres" {
|
||||
key := fmt.Sprintf("%d:%s:%s", len(parsed.Event.ProducerID), parsed.Event.ProducerID, parsed.Event.SourceEventID)
|
||||
if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtextextended(?, 0))", key).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var existing struct {
|
||||
EventID string
|
||||
PayloadSHA256 string
|
||||
}
|
||||
err := tx.Model(&receipt.Receipt{}).Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Select("event_id", "payload_sha256").Where("producer_id = ? AND source_event_id = ?", parsed.Event.ProducerID, parsed.Event.SourceEventID).First(&existing).Error
|
||||
if err == nil {
|
||||
if existing.PayloadSHA256 != parsed.Digest {
|
||||
output.EventID = existing.EventID
|
||||
return ErrIdempotencyConflict
|
||||
}
|
||||
output = IngestResult{EventID: existing.EventID, ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, Disposition: "duplicate", PayloadSHA256: parsed.Digest}
|
||||
return tx.Create(&receipt.IngestAudit{ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, PayloadSHA256: parsed.Digest, Outcome: receipt.OutcomeReplay, ActorID: 0, CreatedAt: time.Now().UTC()}).Error
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
eventID := uuid.NewString()
|
||||
var evidenceRef *string
|
||||
if len(parsed.Event.Evidence) > 0 {
|
||||
value := parsed.Event.Evidence[0].EvidenceID
|
||||
evidenceRef = &value
|
||||
}
|
||||
item := event.Event{ID: eventID, ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID,
|
||||
EventType: parsed.Event.EventType, OccurredAt: parsed.Occurred, Location: parsed.Event.SiteRef + "/" + parsed.Event.DeviceRef,
|
||||
Severity: parsed.Event.Severity, EvidenceRef: evidenceRef, NormalizedPayload: parsed.Canonical, PayloadSHA256: parsed.Digest, ReceivedAt: now}
|
||||
receiptItem := receipt.Receipt{ID: uuid.NewString(), EventID: eventID, ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, PayloadSHA256: parsed.Digest, AcceptedAt: now}
|
||||
if err := tx.Create(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Create(&receiptItem).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, evidence := range parsed.Event.Evidence {
|
||||
payload, marshalErr := json.Marshal(evidence)
|
||||
if marshalErr != nil {
|
||||
return marshalErr
|
||||
}
|
||||
canonical, canonicalErr := canonicalJSON(payload)
|
||||
if canonicalErr != nil {
|
||||
return canonicalErr
|
||||
}
|
||||
var expiresAt *time.Time
|
||||
if evidence.ExpiresAt != "" {
|
||||
parsedExpiry, parseErr := time.Parse(time.RFC3339Nano, evidence.ExpiresAt)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
parsedExpiry = parsedExpiry.UTC()
|
||||
expiresAt = &parsedExpiry
|
||||
}
|
||||
status := EvidenceStatus{EventID: eventID, EvidenceID: evidence.EvidenceID, OwnerID: evidence.OwnerID,
|
||||
Status: evidence.Status, Resolution: "snapshot", CurrentPayload: canonical, ExpiresAt: expiresAt, CreatedAt: now, UpdatedAt: now}
|
||||
if err := tx.Create(&status).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Create(&receipt.IngestAudit{ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, PayloadSHA256: parsed.Digest, Outcome: receipt.OutcomeAccepted, ActorID: 0, CreatedAt: now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
output = IngestResult{EventID: eventID, ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, Disposition: "created", PayloadSHA256: parsed.Digest}
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, ErrIdempotencyConflict) {
|
||||
auditErr := s.DB.WithContext(ctx).Create(&receipt.IngestAudit{ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, PayloadSHA256: parsed.Digest, Outcome: receipt.OutcomeConflict, ActorID: 0, CreatedAt: time.Now().UTC()}).Error
|
||||
if auditErr != nil {
|
||||
return IngestResult{}, fmt.Errorf("record conflict audit: %w", auditErr)
|
||||
}
|
||||
return output, ErrIdempotencyConflict
|
||||
}
|
||||
if err != nil || s.Resolver == nil {
|
||||
return output, err
|
||||
}
|
||||
var statuses []EvidenceStatus
|
||||
if err = s.DB.WithContext(ctx).Where("event_id = ?", output.EventID).Find(&statuses).Error; err != nil {
|
||||
return IngestResult{}, err
|
||||
}
|
||||
for _, status := range statuses {
|
||||
// Evidence lookup is supplementary. The immutable Event/Receipt boundary
|
||||
// remains accepted even when Sense is unavailable.
|
||||
_ = s.Resolver.Refresh(ctx, s.DB.WithContext(ctx), status)
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package event_ingress
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidEvent = errors.New("invalid_event")
|
||||
ErrUnsupportedSchema = errors.New("unsupported_schema_version")
|
||||
identifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`)
|
||||
hexDigestPattern = regexp.MustCompile(`^[a-f0-9]{64}$`)
|
||||
)
|
||||
|
||||
func ParseEvent(raw []byte) (ParsedEvent, error) {
|
||||
var event Event
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&event); err != nil {
|
||||
return ParsedEvent{}, fmt.Errorf("%w: malformed or unknown member", ErrInvalidEvent)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
return ParsedEvent{}, fmt.Errorf("%w: trailing JSON value", ErrInvalidEvent)
|
||||
}
|
||||
if event.SchemaVersion == "" {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
if event.SchemaVersion != EventSchemaVersion {
|
||||
return ParsedEvent{}, ErrUnsupportedSchema
|
||||
}
|
||||
occurred, err := time.Parse("2006-01-02T15:04:05.000Z", event.OccurredAt)
|
||||
if err != nil || !validID(event.ProducerID) || !validID(event.SourceEventID) || !validID(event.SiteRef) ||
|
||||
!validID(event.DeviceRef) || !validID(event.ProfileRef) || !validID(event.Rule.RuleID) ||
|
||||
!validID(event.Observation.TrackID) || !validID(event.Region.RegionID) || event.Rule.Version == "" ||
|
||||
len(event.Rule.Version) > 64 || event.Model.Name == "" || len(event.Model.Name) > 128 ||
|
||||
event.Model.Version == "" || len(event.Model.Version) > 64 || event.Observation.Confidence < 0 ||
|
||||
event.Observation.Confidence > 1 || math.IsNaN(event.Observation.Confidence) || math.IsInf(event.Observation.Confidence, 0) {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
if event.EventType != "dangerous_area_entered" && event.EventType != "directional_line_crossed" {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
if event.Severity != "low" && event.Severity != "medium" && event.Severity != "high" && event.Severity != "critical" {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
if event.Observation.Category != "person" && event.Observation.Category != "vehicle" && event.Observation.Category != "other" {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
if len(event.Observation.BBoxNormalized) != 0 && len(event.Observation.BBoxNormalized) != 4 {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
for _, value := range event.Observation.BBoxNormalized {
|
||||
if value < 0 || value > 1 || math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
}
|
||||
if (event.EventType == "dangerous_area_entered" && (event.Region.Kind != "area" || event.Region.CrossingDirection != "")) ||
|
||||
(event.EventType == "directional_line_crossed" && (event.Region.Kind != "line" || (event.Region.CrossingDirection != "a_to_b" && event.Region.CrossingDirection != "b_to_a"))) {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
if event.Evidence == nil || len(event.Evidence) > 8 {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
seenEvidence := map[string]bool{}
|
||||
for _, evidence := range event.Evidence {
|
||||
evidenceJSON, marshalErr := json.Marshal(evidence)
|
||||
canonicalEvidence, canonicalErr := canonicalJSON(evidenceJSON)
|
||||
if err := validateEvidence(evidence); err != nil || marshalErr != nil || canonicalErr != nil || seenEvidence[string(canonicalEvidence)] {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
seenEvidence[string(canonicalEvidence)] = true
|
||||
}
|
||||
canonical, err := canonicalJSON(raw)
|
||||
if err != nil {
|
||||
return ParsedEvent{}, ErrInvalidEvent
|
||||
}
|
||||
if containsExplicitNull(raw) {
|
||||
return ParsedEvent{}, fmt.Errorf("%w: optional members must be omitted", ErrInvalidEvent)
|
||||
}
|
||||
digest := sha256.Sum256(canonical)
|
||||
return ParsedEvent{Event: event, Canonical: canonical, Digest: hex.EncodeToString(digest[:]), Occurred: occurred}, nil
|
||||
}
|
||||
|
||||
func validateEvidence(value Evidence) error {
|
||||
if value.SchemaVersion != EvidenceSchemaVersion || !validID(value.EvidenceID) || !validID(value.OwnerID) ||
|
||||
(value.Type != "snapshot" && value.Type != "clip") {
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339Nano, value.CapturedAt); err != nil {
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339Nano, value.StatusUpdatedAt); err != nil {
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
if value.ExpiresAt != "" {
|
||||
if _, err := time.Parse(time.RFC3339Nano, value.ExpiresAt); err != nil {
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
}
|
||||
switch value.Status {
|
||||
case "pending", "processing":
|
||||
if value.ContentType != "" || value.Integrity != nil || value.Failure != nil {
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
case "success":
|
||||
if value.Integrity == nil || value.Failure != nil || (value.ContentType != "image/jpeg" && value.ContentType != "image/png" && value.ContentType != "video/mp4") ||
|
||||
value.Integrity.Algorithm != "sha256" || !hexDigestPattern.MatchString(value.Integrity.Digest) || value.Integrity.SizeBytes < 0 {
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
case "failed":
|
||||
if value.Failure == nil || value.ContentType != "" || value.Integrity != nil ||
|
||||
(value.Failure.Code != "capture_failed" && value.Failure.Code != "processing_failed" && value.Failure.Code != "expired" && value.Failure.Code != "unavailable") {
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
default:
|
||||
return ErrInvalidEvent
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func canonicalJSON(raw []byte) ([]byte, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var value any
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, err := normalizeJCSNumbers(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var buffer bytes.Buffer
|
||||
encoder := json.NewEncoder(&buffer)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
canonical := bytes.TrimSuffix(buffer.Bytes(), []byte("\n"))
|
||||
canonical = bytes.ReplaceAll(canonical, []byte(`\u2028`), []byte("\u2028"))
|
||||
canonical = bytes.ReplaceAll(canonical, []byte(`\u2029`), []byte("\u2029"))
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
func normalizeJCSNumbers(value any) (any, error) {
|
||||
switch typed := value.(type) {
|
||||
case json.Number:
|
||||
number, err := strconv.ParseFloat(string(typed), 64)
|
||||
if err != nil || math.IsNaN(number) || math.IsInf(number, 0) {
|
||||
return nil, errors.New("JSON number is outside the RFC 8785 domain")
|
||||
}
|
||||
if number == 0 {
|
||||
return float64(0), nil
|
||||
}
|
||||
return number, nil
|
||||
case []any:
|
||||
for index, item := range typed {
|
||||
normalized, err := normalizeJCSNumbers(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
typed[index] = normalized
|
||||
}
|
||||
case map[string]any:
|
||||
for key, item := range typed {
|
||||
normalized, err := normalizeJCSNumbers(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
typed[key] = normalized
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func containsExplicitNull(raw []byte) bool {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var value any
|
||||
if decoder.Decode(&value) != nil {
|
||||
return true
|
||||
}
|
||||
return hasNull(value)
|
||||
}
|
||||
|
||||
func hasNull(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return true
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
if hasNull(item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case map[string]any:
|
||||
for _, item := range typed {
|
||||
if hasNull(item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validID(value string) bool {
|
||||
return identifierPattern.MatchString(value) && !strings.ContainsAny(strings.ToLower(value), "\\/@")
|
||||
}
|
||||
@@ -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,21 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
|
||||
"go-admin/app/bell/contact"
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
|
||||
func init() { registrars = append(registrars, registerContactRouter) }
|
||||
func registerContactRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
|
||||
h := contact.Handler{}
|
||||
secured := v1.Group("").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
secured.GET("/contacts", h.List)
|
||||
secured.POST("/contacts", h.Create)
|
||||
secured.PUT("/contacts/:id", h.Update)
|
||||
secured.PUT("/contacts/:id/enabled", h.SetEnabled)
|
||||
secured.POST("/contacts/:id/channels", h.AddChannel)
|
||||
secured.POST("/contact-channels/:id/validations", h.ValidateChannel)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
|
||||
"go-admin/app/bell/duty_schedule"
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
|
||||
func init() { registrars = append(registrars, registerDutyScheduleRouter) }
|
||||
func registerDutyScheduleRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
|
||||
h := duty_schedule.Handler{}
|
||||
secured := v1.Group("").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
secured.GET("/duty-groups", h.List)
|
||||
secured.POST("/duty-groups", h.CreateGroup)
|
||||
secured.PUT("/duty-groups/:id", h.UpdateGroup)
|
||||
secured.POST("/duty-groups/:id/members", h.AddMember)
|
||||
secured.POST("/duty-groups/:id/schedules", h.CreateSchedule)
|
||||
secured.POST("/duty-schedules/:id/publish", h.Publish)
|
||||
secured.POST("/duty-groups/:id/overrides", h.CreateOverride)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/go-admin-team/go-admin-core/sdk/config"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
|
||||
"go-admin/app/bell/integration/event_ingress"
|
||||
"go-admin/app/bell/synthetic"
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
@@ -32,6 +33,9 @@ func InitRouter() {
|
||||
for _, register := range registrars {
|
||||
register(v1, authMiddleware)
|
||||
}
|
||||
if err := event_ingress.RegisterRuntime(engine); err != nil {
|
||||
log.Errorf("Bell event ingress init error: %v", err)
|
||||
}
|
||||
if synthetic.Enabled(config.ApplicationConfig.Mode, os.Getenv) {
|
||||
registerSyntheticRouter(v1, authMiddleware)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"go-admin/app/admin/models"
|
||||
"go-admin/app/admin/router"
|
||||
"go-admin/app/bell/alert_lifecycle"
|
||||
"go-admin/app/bell/contact"
|
||||
bellrouter "go-admin/app/bell/router"
|
||||
"go-admin/app/bell/synthetic"
|
||||
"go-admin/common/bellconfig"
|
||||
@@ -184,7 +185,8 @@ func initRouter() {
|
||||
Use(common.RequestId(pkg.TrafficKey)).
|
||||
Use(api.SetRequestLogger).
|
||||
Use(synthetic.RedactRequestBody()).
|
||||
Use(alert_lifecycle.RedactRequestBody())
|
||||
Use(alert_lifecycle.RedactRequestBody()).
|
||||
Use(contact.RedactRequestBody())
|
||||
|
||||
common.InitMiddleware(r)
|
||||
|
||||
|
||||
@@ -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,29 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"go-admin/app/bell/integration/event_ingress"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateBellEventIngress)
|
||||
}
|
||||
|
||||
func migrateBellEventIngress(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(
|
||||
&event_ingress.ReplayToken{},
|
||||
&event_ingress.EvidenceStatus{},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/bell/integration/event_ingress"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestBellEventIngressMigrationIsIdempotent(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&common.Migration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const version = "2026083112000"
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
if err = migrateBellEventIngress(db, version); err != nil {
|
||||
t.Fatalf("migration attempt %d: %v", attempt+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
for name, model := range map[string]any{
|
||||
"replay tokens": &event_ingress.ReplayToken{},
|
||||
"evidence statuses": &event_ingress.EvidenceStatus{},
|
||||
} {
|
||||
if !db.Migrator().HasTable(model) {
|
||||
t.Fatalf("%s table missing", name)
|
||||
}
|
||||
var count int64
|
||||
if err = db.Model(model).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count %s: %v", name, err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("migration inserted %d %s fixtures", count, name)
|
||||
}
|
||||
}
|
||||
if !db.Migrator().HasIndex(&event_ingress.ReplayToken{}, "ExpiresAt") {
|
||||
t.Fatal("replay expiry index missing")
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
token := event_ingress.ReplayToken{Principal: "brain", TokenID: "token-1", ExpiresAt: now.Add(time.Minute), CreatedAt: now}
|
||||
if err = db.Create(&token).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Create(&token).Error; err == nil {
|
||||
t.Fatal("duplicate replay token accepted")
|
||||
}
|
||||
|
||||
var applied int64
|
||||
if err = db.Model(&common.Migration{}).Where("version = ?", version).Count(&applied).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if applied != 1 {
|
||||
t.Fatalf("migration records=%d, want 1", applied)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"go-admin/app/bell/contact"
|
||||
duty "go-admin/app/bell/duty_schedule"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateBellContactSchedule)
|
||||
}
|
||||
func migrateBellContactSchedule(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(&contact.Contact{}, &contact.Channel{}, &contact.ChannelValidation{}, &contact.AuditFact{}, &duty.Group{}, &duty.Member{}, &duty.ScheduleVersion{}, &duty.RotationSlot{}, &duty.Override{}, &duty.AuditFact{}); err != nil {
|
||||
return err
|
||||
}
|
||||
if tx.Dialector.Name() == "postgres" {
|
||||
for _, sql := range contactScheduleSQL {
|
||||
if err := tx.Exec(sql).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := seedContactScheduleAccess(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
|
||||
var contactScheduleSQL = []string{
|
||||
`ALTER TABLE bell_contacts ADD CONSTRAINT bell_contacts_version_check CHECK (version > 0)`,
|
||||
`ALTER TABLE bell_contact_channels ADD CONSTRAINT bell_contact_channels_kind_check CHECK (kind IN ('sms','voice'))`,
|
||||
`ALTER TABLE bell_contact_channels ADD CONSTRAINT bell_contact_channels_contact_fk FOREIGN KEY (contact_id) REFERENCES bell_contacts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`CREATE UNIQUE INDEX bell_contact_channel_identity_idx ON bell_contact_channels(contact_id,kind,address_fingerprint)`,
|
||||
`CREATE TRIGGER bell_contact_channels_immutable BEFORE UPDATE OR DELETE ON bell_contact_channels FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
`ALTER TABLE bell_contact_channel_validations ADD CONSTRAINT bell_contact_validation_status_check CHECK (status IN ('verified','failed'))`,
|
||||
`ALTER TABLE bell_contact_channel_validations ADD CONSTRAINT bell_contact_validation_channel_fk FOREIGN KEY (channel_id) REFERENCES bell_contact_channels(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_contact_audit_facts ADD CONSTRAINT bell_contact_audit_contact_fk FOREIGN KEY (contact_id) REFERENCES bell_contacts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_duty_groups ADD CONSTRAINT bell_duty_groups_version_check CHECK (version > 0)`,
|
||||
`ALTER TABLE bell_duty_members ADD CONSTRAINT bell_duty_member_role_check CHECK (role IN ('primary','backup'))`,
|
||||
`ALTER TABLE bell_duty_members ADD CONSTRAINT bell_duty_member_group_fk FOREIGN KEY (group_id) REFERENCES bell_duty_groups(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_duty_members ADD CONSTRAINT bell_duty_member_contact_fk FOREIGN KEY (contact_id) REFERENCES bell_contacts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`CREATE UNIQUE INDEX bell_duty_schedule_group_version_idx ON bell_duty_schedule_versions(group_id,version)`,
|
||||
`ALTER TABLE bell_duty_schedule_versions ADD CONSTRAINT bell_duty_schedule_status_check CHECK (status IN ('draft','published'))`,
|
||||
`ALTER TABLE bell_duty_schedule_versions ADD CONSTRAINT bell_duty_schedule_group_fk FOREIGN KEY (group_id) REFERENCES bell_duty_groups(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`CREATE OR REPLACE FUNCTION bell_guard_schedule_version() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF TG_OP = 'DELETE' OR OLD.status = 'published' THEN RAISE EXCEPTION 'Bell published schedule cannot be changed' USING ERRCODE = '55000'; END IF; IF NEW.status <> 'published' OR OLD.status <> 'draft' THEN RAISE EXCEPTION 'Bell schedule transition is invalid' USING ERRCODE = '55000'; END IF; RETURN NEW; END $$`,
|
||||
`CREATE TRIGGER bell_duty_schedule_version_guard BEFORE UPDATE OR DELETE ON bell_duty_schedule_versions FOR EACH ROW EXECUTE FUNCTION bell_guard_schedule_version()`,
|
||||
`ALTER TABLE bell_duty_rotation_slots ADD CONSTRAINT bell_duty_slot_range_check CHECK (weekday BETWEEN 0 AND 6 AND start_minute >= 0 AND end_minute <= 1440 AND start_minute < end_minute AND primary_contact_id <> backup_contact_id)`,
|
||||
`ALTER TABLE bell_duty_rotation_slots ADD CONSTRAINT bell_duty_slot_version_fk FOREIGN KEY (schedule_version_id) REFERENCES bell_duty_schedule_versions(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_duty_rotation_slots ADD CONSTRAINT bell_duty_slot_primary_fk FOREIGN KEY (primary_contact_id) REFERENCES bell_contacts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_duty_rotation_slots ADD CONSTRAINT bell_duty_slot_backup_fk FOREIGN KEY (backup_contact_id) REFERENCES bell_contacts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_duty_overrides ADD CONSTRAINT bell_duty_override_range_check CHECK (starts_at < ends_at AND original_contact_id <> replacement_contact_id)`,
|
||||
`ALTER TABLE bell_duty_overrides ADD CONSTRAINT bell_duty_override_group_fk FOREIGN KEY (group_id) REFERENCES bell_duty_groups(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_duty_overrides ADD CONSTRAINT bell_duty_override_original_fk FOREIGN KEY (original_contact_id) REFERENCES bell_contacts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_duty_overrides ADD CONSTRAINT bell_duty_override_replacement_fk FOREIGN KEY (replacement_contact_id) REFERENCES bell_contacts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_duty_audit_facts ADD CONSTRAINT bell_duty_audit_group_fk FOREIGN KEY (group_id) REFERENCES bell_duty_groups(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`CREATE TRIGGER bell_contact_validations_immutable BEFORE UPDATE OR DELETE ON bell_contact_channel_validations FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
`CREATE TRIGGER bell_contact_audit_immutable BEFORE UPDATE OR DELETE ON bell_contact_audit_facts FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
`CREATE TRIGGER bell_duty_slots_immutable BEFORE UPDATE OR DELETE ON bell_duty_rotation_slots FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
`CREATE TRIGGER bell_duty_overrides_immutable BEFORE UPDATE OR DELETE ON bell_duty_overrides FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
`CREATE TRIGGER bell_duty_audit_immutable BEFORE UPDATE OR DELETE ON bell_duty_audit_facts FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
}
|
||||
|
||||
type contactScheduleSeed struct {
|
||||
ID int
|
||||
Path string
|
||||
Action string
|
||||
}
|
||||
|
||||
func seedContactScheduleAccess(tx *gorm.DB) error {
|
||||
if err := tx.Exec(`SELECT setval(pg_get_serial_sequence('sys_menu','menu_id'),GREATEST((SELECT max(menu_id) FROM sys_menu),1));SELECT setval(pg_get_serial_sequence('sys_api','id'),GREATEST((SELECT max(id) FROM sys_api),1))`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var rootID int
|
||||
if err := tx.Raw("SELECT menu_id FROM sys_menu WHERE path='/bell' AND parent_id=0 ORDER BY menu_id LIMIT 1").Scan(&rootID).Error; err != nil || rootID == 0 {
|
||||
return fmt.Errorf("Bell menu root missing")
|
||||
}
|
||||
contacts, err := insertContactScheduleMenu(tx, rootID, "BellContacts", "联系人与通道", "user", "contacts", "C", "bell:contact:list", "", "/bell/contacts/index", 4)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dutyMenu, err := insertContactScheduleMenu(tx, rootID, "BellDutySchedules", "值班排班", "time", "duty-schedules", "C", "bell:duty:list", "", "/bell/duty-schedules/index", 5)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
contactWrite, err := insertContactScheduleMenu(tx, contacts.ID, "", "维护联系人", "", "", "F", "bell:contact:write", "POST", "", 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dutyWrite, err := insertContactScheduleMenu(tx, dutyMenu.ID, "", "维护排班", "", "", "F", "bell:duty:write", "POST", "", 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
specs := []struct{ title, path, action string }{
|
||||
{"联系人列表", "/api/v1/bell/contacts", "GET"}, {"新增联系人", "/api/v1/bell/contacts", "POST"}, {"修改联系人", "/api/v1/bell/contacts/:id", "PUT"}, {"启停联系人", "/api/v1/bell/contacts/:id/enabled", "PUT"}, {"新增联系通道", "/api/v1/bell/contacts/:id/channels", "POST"}, {"记录通道验证", "/api/v1/bell/contact-channels/:id/validations", "POST"},
|
||||
{"值班组列表", "/api/v1/bell/duty-groups", "GET"}, {"新增值班组", "/api/v1/bell/duty-groups", "POST"}, {"修改值班组", "/api/v1/bell/duty-groups/:id", "PUT"}, {"保存值班成员", "/api/v1/bell/duty-groups/:id/members", "POST"}, {"新增排班版本", "/api/v1/bell/duty-groups/:id/schedules", "POST"}, {"发布排班版本", "/api/v1/bell/duty-schedules/:id/publish", "POST"}, {"新增临时替班", "/api/v1/bell/duty-groups/:id/overrides", "POST"},
|
||||
}
|
||||
apis := make([]contactScheduleSeed, 0, len(specs))
|
||||
for _, s := range specs {
|
||||
v, e := insertContactScheduleAPI(tx, s.title, s.path, s.action)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
apis = append(apis, v)
|
||||
}
|
||||
links := map[int][]contactScheduleSeed{contacts.ID: {apis[0]}, contactWrite.ID: apis[1:6], dutyMenu.ID: {apis[6]}, dutyWrite.ID: apis[7:]}
|
||||
for menu, items := range links {
|
||||
for _, item := range items {
|
||||
if err := tx.Exec("INSERT INTO sys_menu_api_rule(sys_menu_menu_id,sys_api_id) VALUES(?,?) ON CONFLICT DO NOTHING", menu, item.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
var operator int
|
||||
if err := tx.Raw("SELECT role_id FROM sys_role WHERE role_key='operator' AND deleted_at IS NULL ORDER BY role_id LIMIT 1").Scan(&operator).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if operator != 0 {
|
||||
for _, menu := range []contactScheduleSeed{contacts, dutyMenu} {
|
||||
if err := tx.Exec("INSERT INTO sys_role_menu(role_id,menu_id) VALUES(?,?) ON CONFLICT DO NOTHING", operator, menu.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, item := range []contactScheduleSeed{apis[0], apis[6]} {
|
||||
if err := tx.Exec("INSERT INTO casbin_rule(ptype,v0,v1,v2,v3,v4,v5) VALUES('p','operator',?,?, '', '', '') ON CONFLICT DO NOTHING", item.Path, item.Action).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func insertContactScheduleMenu(tx *gorm.DB, parent int, name, title, icon, path, menuType, permission, action, component string, sort int) (contactScheduleSeed, error) {
|
||||
var id int
|
||||
err := tx.Raw(`INSERT INTO sys_menu(menu_name,title,icon,path,paths,menu_type,action,permission,parent_id,no_cache,breadcrumb,component,sort,visible,is_frame,create_by,update_by,created_at,updated_at) VALUES(?,?,?,?, '',?,?,?,?,false,'',?,?, '0','1',1,1,now(),now()) RETURNING menu_id`, name, title, icon, path, menuType, action, permission, parent, component, sort).Scan(&id).Error
|
||||
if err != nil {
|
||||
return contactScheduleSeed{}, err
|
||||
}
|
||||
var parentPaths string
|
||||
if err = tx.Raw("SELECT paths FROM sys_menu WHERE menu_id=?", parent).Scan(&parentPaths).Error; err != nil {
|
||||
return contactScheduleSeed{}, err
|
||||
}
|
||||
if err = tx.Exec("UPDATE sys_menu SET paths=? WHERE menu_id=?", fmt.Sprintf("%s/%d", parentPaths, id), id).Error; err != nil {
|
||||
return contactScheduleSeed{}, err
|
||||
}
|
||||
return contactScheduleSeed{ID: id}, nil
|
||||
}
|
||||
func insertContactScheduleAPI(tx *gorm.DB, title, path, action string) (contactScheduleSeed, error) {
|
||||
var id int
|
||||
err := tx.Raw(`INSERT INTO sys_api(handle,title,path,type,action,created_at,updated_at,create_by,update_by) VALUES('',?,?, 'BUS',?,now(),now(),1,1) RETURNING id`, title, path, action).Scan(&id).Error
|
||||
return contactScheduleSeed{ID: id, Path: path, Action: action}, err
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go-admin/app/bell/contact"
|
||||
duty "go-admin/app/bell/duty_schedule"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestBellContactScheduleMigrationIsIdempotent(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&common.Migration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
if err = migrateBellContactSchedule(db, "2026090110000"); err != nil {
|
||||
t.Fatalf("attempt %d: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
for name, model := range map[string]any{"contacts": &contact.Contact{}, "channels": &contact.Channel{}, "validations": &contact.ChannelValidation{}, "groups": &duty.Group{}, "members": &duty.Member{}, "versions": &duty.ScheduleVersion{}, "slots": &duty.RotationSlot{}, "overrides": &duty.Override{}} {
|
||||
if !db.Migrator().HasTable(model) {
|
||||
t.Fatalf("%s table missing", name)
|
||||
}
|
||||
}
|
||||
var count int64
|
||||
if err = db.Model(&common.Migration{}).Where("version=?", "2026090110000").Count(&count).Error; err != nil || count != 1 {
|
||||
t.Fatalf("migration records=%d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package bell_contact_schedule_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
adminmodels "go-admin/app/admin/models"
|
||||
"go-admin/app/bell/contact"
|
||||
duty "go-admin/app/bell/duty_schedule"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestContactSchedulePostgres(t *testing.T) {
|
||||
dsn := os.Getenv("BELL_CONTACT_SCHEDULE_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("set BELL_CONTACT_SCHEDULE_TEST_DATABASE_URL to run PostgreSQL verification")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
key := []byte("0123456789abcdef0123456789abcdef")
|
||||
contacts := contact.NewService(db, key)
|
||||
|
||||
primary, err := contacts.Create(ctx, contact.WriteInput{Name: "联系人甲", Role: "主值班"}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backup, err := contacts.Create(ctx, contact.WriteInput{Name: "联系人乙", Role: "备值班"}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
primaryChannel, err := contacts.AddChannel(ctx, primary.ID, contact.ChannelInput{Kind: "sms", Address: "+8613800000001"}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backupChannel, err := contacts.AddChannel(ctx, backup.ID, contact.ChannelInput{Kind: "voice", Address: "+8613800000002"}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(primaryChannel.AddressMasked, "13800000001") {
|
||||
t.Fatal("channel response leaked address")
|
||||
}
|
||||
plain, err := contacts.DecryptChannelAddress(ctx, primaryChannel.ID)
|
||||
if err != nil || plain != "+8613800000001" {
|
||||
t.Fatalf("server-only decrypt failed: %q %v", plain, err)
|
||||
}
|
||||
encoded, _ := json.Marshal(primaryChannel)
|
||||
if strings.Contains(string(encoded), plain) {
|
||||
t.Fatal("serialized channel leaked plaintext")
|
||||
}
|
||||
if _, err = contacts.RecordValidation(ctx, primaryChannel.ID, "verified", "合成验证", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = contacts.RecordValidation(ctx, backupChannel.ID, "verified", "合成验证", 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = contacts.Update(ctx, primary.ID, contact.WriteInput{Name: "联系人甲", Role: "主值班", ExpectedVersion: 99}, 1); !errors.Is(err, contact.ErrConflict) {
|
||||
t.Fatalf("stale contact update err=%v", err)
|
||||
}
|
||||
if _, err = contacts.SetEnabled(ctx, primary.ID, false, primary.Version, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listed, _, err := contacts.List(ctx, contact.PageQuery{PageIndex: 1, PageSize: 20})
|
||||
if err != nil || len(listed) != 2 {
|
||||
t.Fatalf("contact list len=%d err=%v", len(listed), err)
|
||||
}
|
||||
var primaryView *contact.ContactView
|
||||
for index := range listed {
|
||||
if listed[index].ID == primary.ID {
|
||||
primaryView = &listed[index]
|
||||
}
|
||||
}
|
||||
if primaryView == nil || primaryView.Enabled || len(primaryView.Channels) != 1 || primaryView.Channels[0].Status != "verified" {
|
||||
t.Fatalf("contact enabled state was coupled to validation: %#v", primaryView)
|
||||
}
|
||||
// Re-enable with the new version before assigning duty.
|
||||
var disabled contact.Contact
|
||||
if err = db.First(&disabled, "id=?", primary.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = contacts.SetEnabled(ctx, primary.ID, true, disabled.Version, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
duties := duty.NewService(db)
|
||||
group, err := duties.CreateGroup(ctx, duty.GroupInput{Name: "夜间值班组", Timezone: "Asia/Shanghai"}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = duties.AddMember(ctx, group.ID, duty.MemberInput{ContactID: primary.ID, Role: "primary"}, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = duties.AddMember(ctx, group.ID, duty.MemberInput{ContactID: backup.ID, Role: "backup"}, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = duties.CreateSchedule(ctx, group.ID, duty.ScheduleInput{EffectiveFrom: time.Now().UTC().Add(time.Hour), Slots: []duty.SlotInput{{Weekday: 0, StartMinute: 0, EndMinute: 720, PrimaryContactID: primary.ID, BackupContactID: backup.ID}}}, 1); !errors.Is(err, duty.ErrCoverage) {
|
||||
t.Fatalf("schedule gap was accepted: %v", err)
|
||||
}
|
||||
slots := make([]duty.SlotInput, 0, 7)
|
||||
for day := 0; day < 7; day++ {
|
||||
slots = append(slots, duty.SlotInput{Weekday: day, StartMinute: 0, EndMinute: 1440, PrimaryContactID: primary.ID, BackupContactID: backup.ID})
|
||||
}
|
||||
v1, err := duties.CreateSchedule(ctx, group.ID, duty.ScheduleInput{EffectiveFrom: time.Now().UTC().Add(time.Hour), Slots: slots}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
published, err := duties.Publish(ctx, v1.ID, 1)
|
||||
if err != nil || published.Status != "published" {
|
||||
t.Fatalf("publish status=%s err=%v", published.Status, err)
|
||||
}
|
||||
v2, err := duties.CreateSchedule(ctx, group.ID, duty.ScheduleInput{EffectiveFrom: time.Now().UTC().Add(24 * time.Hour), Slots: slots}, 1)
|
||||
if err != nil || v2.Version != 2 {
|
||||
t.Fatalf("second version=%d err=%v", v2.Version, err)
|
||||
}
|
||||
var persisted duty.ScheduleVersion
|
||||
if err = db.First(&persisted, "id=?", v1.ID).Error; err != nil || persisted.Version != 1 || persisted.Status != "published" {
|
||||
t.Fatalf("historical version changed: %#v err=%v", persisted, err)
|
||||
}
|
||||
now := time.Now().UTC().Add(2 * time.Hour)
|
||||
if _, err = duties.CreateOverride(ctx, group.ID, duty.OverrideInput{OriginalContactID: primary.ID, ReplacementContactID: backup.ID, StartsAt: now, EndsAt: now.Add(time.Hour), Reason: "合成替班"}, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = duties.CreateOverride(ctx, group.ID, duty.OverrideInput{OriginalContactID: primary.ID, ReplacementContactID: backup.ID, StartsAt: now.Add(30 * time.Minute), EndsAt: now.Add(90 * time.Minute), Reason: "重叠替班"}, 1); !errors.Is(err, duty.ErrConflict) {
|
||||
t.Fatalf("overlap err=%v", err)
|
||||
}
|
||||
|
||||
if err = db.Model(&contact.ChannelValidation{}).Where("channel_id=?", primaryChannel.ID).Update("detail", "tampered").Error; err == nil {
|
||||
t.Fatal("validation fact was mutable")
|
||||
}
|
||||
if err = db.Model(&duty.RotationSlot{}).Where("schedule_version_id=?", v1.ID).Update("start_minute", 1).Error; err == nil {
|
||||
t.Fatal("published rotation slot was mutable")
|
||||
}
|
||||
if err = db.Model(&duty.ScheduleVersion{}).Where("id=?", v1.ID).Update("effective_from", time.Now().UTC()).Error; err == nil {
|
||||
t.Fatal("published schedule version was mutable")
|
||||
}
|
||||
var menus, reads, writes int64
|
||||
if err = db.Table("sys_role_menu rm").Joins("JOIN sys_role r ON r.role_id=rm.role_id").Joins("JOIN sys_menu m ON m.menu_id=rm.menu_id").Where("r.role_key=? AND m.path IN ?", "operator", []string{"contacts", "duty-schedules"}).Count(&menus).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Table("casbin_rule").Where("v0=? AND v2=? AND v1 IN ?", "operator", "GET", []string{"/api/v1/bell/contacts", "/api/v1/bell/duty-groups"}).Count(&reads).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Table("casbin_rule").Where("v0=? AND v2<>? AND (v1 LIKE ? OR v1 LIKE ?)", "operator", "GET", "/api/v1/bell/contacts%", "/api/v1/bell/duty-%").Count(&writes).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if menus != 2 || reads != 2 || writes != 0 {
|
||||
t.Fatalf("operator access escaped scope: menus=%d reads=%d writes=%d", menus, reads, writes)
|
||||
}
|
||||
password := os.Getenv("BELL_RULE_ALERT_OPERATOR_PASSWORD")
|
||||
if password != "" {
|
||||
var roleID int
|
||||
if err = db.Table("sys_role").Select("role_id").Where("role_key=?", "operator").Scan(&roleID).Error; err != nil || roleID == 0 {
|
||||
t.Fatalf("operator role id=%d err=%v", roleID, err)
|
||||
}
|
||||
user := adminmodels.SysUser{Username: "bell_132_operator", Password: password, NickName: "Bell 处置员", RoleId: roleID, DeptId: 1, PostId: 1, Status: "2"}
|
||||
if err = db.Create(&user).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
[CmdletBinding()]
|
||||
param([string]$PostgresBin = 'D:\pgsql17\bin')
|
||||
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$pgStarted = $false
|
||||
$server = $null
|
||||
$testRoot = Join-Path ([IO.Path]::GetTempPath()) ('yovision-bell-183-' + [guid]::NewGuid().ToString('N'))
|
||||
$data = Join-Path $testRoot 'postgres'
|
||||
$log = Join-Path $testRoot 'postgres.log'
|
||||
$pgOut = Join-Path $testRoot 'pg.out'
|
||||
$pgErr = Join-Path $testRoot 'pg.err'
|
||||
$serverOut = Join-Path $testRoot 'bell.out.log'
|
||||
$serverErr = Join-Path $testRoot 'bell.err.log'
|
||||
$serverExe = Join-Path $testRoot 'bell-server.exe'
|
||||
$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-Port([int]$Port) {
|
||||
for ($attempt = 0; $attempt -lt 120; $attempt++) {
|
||||
try {
|
||||
$client = [Net.Sockets.TcpClient]::new()
|
||||
$open = $client.ConnectAsync('127.0.0.1', $Port).Wait(250) -and $client.Connected
|
||||
$client.Dispose()
|
||||
if ($open) { return }
|
||||
} catch {}
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
throw "PostgreSQL port $Port did not open"
|
||||
}
|
||||
function Wait-Health([string]$BaseUrl) {
|
||||
for ($attempt = 0; $attempt -lt 100; $attempt++) {
|
||||
try {
|
||||
$health = Invoke-RestMethod -Uri "$BaseUrl/healthz" -TimeoutSec 2 -NoProxy
|
||||
if ($health.status -eq 'ok') { return }
|
||||
} catch {}
|
||||
Start-Sleep -Milliseconds 300
|
||||
}
|
||||
throw 'Bell health endpoint did not become ready'
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $testRoot | Out-Null
|
||||
$pgPort = Get-FreePort
|
||||
$bellPort = Get-FreePort
|
||||
$baseUrl = "http://127.0.0.1:$bellPort"
|
||||
try {
|
||||
foreach ($name in @('initdb.exe', 'pg_ctl.exe', 'createdb.exe', 'psql.exe')) {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $PostgresBin $name) -PathType Leaf)) { throw "Missing PostgreSQL tool: $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 $pgPort -h 127.0.0.1`" start"
|
||||
Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $arguments -RedirectStandardOutput $pgOut -RedirectStandardError $pgErr -WindowStyle Hidden | Out-Null
|
||||
Wait-Port $pgPort
|
||||
$pgStarted = $true
|
||||
& (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $pgPort -U postgres bell_183
|
||||
if ($LASTEXITCODE -ne 0) { throw 'createdb failed' }
|
||||
|
||||
$env:GOTOOLCHAIN = 'go1.26.5'
|
||||
$env:BELL_DATABASE_URL = "host=127.0.0.1 port=$pgPort user=postgres dbname=bell_183 sslmode=disable"
|
||||
$env:BELL_CONTACT_SCHEDULE_TEST_DATABASE_URL = $env:BELL_DATABASE_URL
|
||||
$env:BELL_JWT_SECRET = [guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N')
|
||||
$env:BELL_BOOTSTRAP_USERNAME = 'bell_183_admin'
|
||||
$env:BELL_BOOTSTRAP_PASSWORD = [guid]::NewGuid().ToString('N')
|
||||
$env:BELL_RULE_ALERT_OPERATOR_PASSWORD = [guid]::NewGuid().ToString('N')
|
||||
$env:BELL_CONTACT_CHANNEL_KEY = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes('0123456789abcdef0123456789abcdef'))
|
||||
$env:BELL_HOST = '127.0.0.1'
|
||||
$env:BELL_PORT = $bellPort.ToString()
|
||||
|
||||
Push-Location $serverRoot
|
||||
try {
|
||||
go run . migrate -c config/settings.demo.yml *> (Join-Path $testRoot 'migrate.log')
|
||||
if ($LASTEXITCODE -ne 0) { throw "migration failed: $(Join-Path $testRoot 'migrate.log')" }
|
||||
go test ./tests/bell_contact_schedule -count=1 -v
|
||||
if ($LASTEXITCODE -ne 0) { throw 'contact schedule tests failed' }
|
||||
go build -o $serverExe .
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Bell build failed' }
|
||||
} finally { Pop-Location }
|
||||
|
||||
$server = Start-Process -FilePath $serverExe -ArgumentList @('server', '-c', 'config/settings.demo.yml') -WorkingDirectory $serverRoot -RedirectStandardOutput $serverOut -RedirectStandardError $serverErr -WindowStyle Hidden -PassThru
|
||||
Wait-Health $baseUrl
|
||||
$adminBody = @{ username = $env:BELL_BOOTSTRAP_USERNAME; password = $env:BELL_BOOTSTRAP_PASSWORD; code = '0'; uuid = '0' } | ConvertTo-Json -Compress
|
||||
$admin = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/login" -ContentType 'application/json' -Body $adminBody -NoProxy
|
||||
$adminHeaders = @{ Authorization = "Bearer $($admin.token)" }
|
||||
$contactBody = @{ name = 'HTTP联系人'; role = '测试值班' } | ConvertTo-Json -Compress
|
||||
$created = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/contacts" -Headers $adminHeaders -ContentType 'application/json; charset=utf-8' -Body $contactBody -NoProxy
|
||||
if ([int]$created.code -ne 200) { throw 'administrator contact create failed' }
|
||||
$address = '+8613900000003'
|
||||
$channelBody = @{ kind = 'sms'; address = $address } | ConvertTo-Json -Compress
|
||||
$channel = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/contacts/$($created.data.id)/channels" -Headers $adminHeaders -ContentType 'application/json' -Body $channelBody -NoProxy
|
||||
if ([int]$channel.code -ne 200 -or ($channel | ConvertTo-Json -Depth 10 -Compress).Contains($address)) { throw 'write-only channel HTTP boundary failed' }
|
||||
$logRow = ''
|
||||
for ($attempt = 0; $attempt -lt 40; $attempt++) {
|
||||
$logRow = & (Join-Path $PostgresBin 'psql.exe') -h 127.0.0.1 -p $pgPort -U postgres -d bell_183 -Atc "SELECT id::text || '|' || coalesce(oper_param,'') FROM sys_opera_log WHERE oper_url LIKE '/api/v1/bell/contacts/%/channels' ORDER BY id DESC LIMIT 1"
|
||||
if ($LASTEXITCODE -ne 0) { throw 'operation log query failed' }
|
||||
if ($logRow) { break }
|
||||
Start-Sleep -Milliseconds 100
|
||||
}
|
||||
if (-not $logRow -or $logRow.Contains($address)) { throw "operation log redaction failed: $logRow" }
|
||||
$loggedBody = ($logRow -split '\|', 2)[1]
|
||||
if ($loggedBody -and -not $loggedBody.Contains('"redacted":true')) { throw "unexpected operation log marker: $loggedBody" }
|
||||
|
||||
$operatorBody = @{ username = 'bell_132_operator'; password = $env:BELL_RULE_ALERT_OPERATOR_PASSWORD; code = '0'; uuid = '0' } | ConvertTo-Json -Compress
|
||||
$operator = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/login" -ContentType 'application/json' -Body $operatorBody -NoProxy
|
||||
$operatorHeaders = @{ Authorization = "Bearer $($operator.token)" }
|
||||
foreach ($path in @('/api/v1/bell/contacts', '/api/v1/bell/duty-groups')) {
|
||||
$read = Invoke-RestMethod -Uri "$baseUrl$path" -Headers $operatorHeaders -NoProxy
|
||||
if ([int]$read.code -ne 200) { throw "operator read failed: $path" }
|
||||
}
|
||||
$denied = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/contacts" -Headers $operatorHeaders -ContentType 'application/json' -Body $contactBody -NoProxy
|
||||
if ([int]$denied.code -ne 403) { throw "operator write returned $($denied.code)" }
|
||||
$menu = Invoke-RestMethod -Uri "$baseUrl/api/v1/menurole" -Headers $operatorHeaders -NoProxy
|
||||
$menuJson = $menu.data | ConvertTo-Json -Depth 20 -Compress
|
||||
foreach ($title in @('联系人与通道', '值班排班')) { if (-not $menuJson.Contains($title)) { throw "operator menu missing $title" } }
|
||||
Write-Output 'BELL_183_HTTP admin_contact=200 channel_write_only=true operator_reads=200 operator_write=403 menus=true'
|
||||
} finally {
|
||||
if ($null -ne $server -and -not $server.HasExited) { Stop-Process -Id $server.Id -Force; $server.WaitForExit(5000) | Out-Null }
|
||||
if ($pgStarted) { & (Join-Path $PostgresBin 'pg_ctl.exe') -D $data -m fast stop *> (Join-Path $testRoot 'stop.log') }
|
||||
foreach ($name in @('BELL_DATABASE_URL', 'BELL_CONTACT_SCHEDULE_TEST_DATABASE_URL', 'BELL_JWT_SECRET', 'BELL_BOOTSTRAP_USERNAME', 'BELL_BOOTSTRAP_PASSWORD', 'BELL_RULE_ALERT_OPERATOR_PASSWORD', 'BELL_CONTACT_CHANNEL_KEY', 'BELL_HOST', 'BELL_PORT')) { Remove-Item "Env:$name" -ErrorAction SilentlyContinue }
|
||||
Write-Verbose "Bell #183 artifacts: $testRoot"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package bell_contact_schedule_test
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/bell/contact"
|
||||
)
|
||||
|
||||
func TestChannelKeyAndWriteOnlyRoundTrip(t *testing.T) {
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef"))
|
||||
key, err := contact.ParseChannelKey(encoded)
|
||||
if err != nil || len(key) != 32 {
|
||||
t.Fatalf("key parse failed: len=%d err=%v", len(key), err)
|
||||
}
|
||||
for _, value := range []string{"", "short", base64.StdEncoding.EncodeToString([]byte("0123456789abcdef"))} {
|
||||
if _, err = contact.ParseChannelKey(value); !errors.Is(err, contact.ErrChannelKeyUnavailable) {
|
||||
t.Fatalf("invalid key accepted: %q err=%v", value, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,195 @@
|
||||
package bell_production_login_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/config/source/file"
|
||||
"github.com/go-admin-team/go-admin-core/sdk"
|
||||
sdkapi "github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/config"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/captcha"
|
||||
"github.com/mojocn/base64Captcha"
|
||||
"gorm.io/gorm"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
|
||||
adminrouter "go-admin/app/admin/router"
|
||||
bellrouter "go-admin/app/bell/router"
|
||||
"go-admin/common/bellconfig"
|
||||
"go-admin/common/database"
|
||||
"go-admin/common/middleware"
|
||||
"go-admin/common/storage"
|
||||
ext "go-admin/config"
|
||||
)
|
||||
|
||||
type apiResponse struct {
|
||||
Code int `json:"code"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
ID string `json:"id"`
|
||||
Msg string `json:"msg"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
func TestProductionCaptchaLoginAndRouteBoundary(t *testing.T) {
|
||||
if os.Getenv("BELL_PRODUCTION_LOGIN_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("production login database is not configured")
|
||||
}
|
||||
if err := os.MkdirAll("temp/logs", 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gin.SetMode(gin.TestMode)
|
||||
config.ExtendConfig = &ext.ExtConfig
|
||||
config.Setup(file.NewSource(file.WithPath("../../config/settings.yml")))
|
||||
if err := bellconfig.ApplyRequiredEnvironment(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.ApplicationConfig.Mode != "prod" {
|
||||
t.Fatalf("expected production mode, got %q", config.ApplicationConfig.Mode)
|
||||
}
|
||||
database.Setup()
|
||||
storage.Setup()
|
||||
|
||||
engine := gin.New()
|
||||
sdk.Runtime.SetEngine(engine)
|
||||
engine.Use(sdkapi.SetRequestLogger)
|
||||
engine.Use(middleware.WithContextDb)
|
||||
authMiddleware, err := middleware.AuthInit()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
adminrouter.InitSysRouter(engine, authMiddleware)
|
||||
adminrouter.InitExamplesRouter(engine, authMiddleware)
|
||||
bellrouter.InitRouter()
|
||||
|
||||
captchaResponse := requestJSON(t, engine, http.MethodGet, "/api/v1/captcha", nil, "")
|
||||
if captchaResponse.Code != 200 || captchaResponse.ID == "" || !bytes.Contains(captchaResponse.Data, []byte("data:image/")) {
|
||||
t.Fatalf("unexpected captcha response: code=%d id=%q data=%s", captchaResponse.Code, captchaResponse.ID, captchaResponse.Data)
|
||||
}
|
||||
|
||||
username := os.Getenv("BELL_BOOTSTRAP_USERNAME")
|
||||
password := os.Getenv("BELL_BOOTSTRAP_PASSWORD")
|
||||
answer := "813907"
|
||||
validID := "bell-138-valid"
|
||||
if err := base64Captcha.DefaultMemStore.Set(validID, answer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !captcha.Verify(validID, answer, false) {
|
||||
t.Fatal("known captcha was not stored")
|
||||
}
|
||||
login := requestJSON(t, engine, http.MethodPost, "/api/v1/login", loginBody(username, password, validID, answer), "")
|
||||
if login.Code != 200 || login.Token == "" {
|
||||
t.Fatalf("valid captcha login failed: code=%d msg=%q", login.Code, login.Msg)
|
||||
}
|
||||
replay := requestJSON(t, engine, http.MethodPost, "/api/v1/login", loginBody(username, password, validID, answer), "")
|
||||
if replay.Code == 200 {
|
||||
t.Fatal("used captcha was accepted again")
|
||||
}
|
||||
|
||||
wrongID := "bell-138-wrong"
|
||||
if err := base64Captcha.DefaultMemStore.Set(wrongID, answer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wrong := requestJSON(t, engine, http.MethodPost, "/api/v1/login", loginBody(username, password, wrongID, "000000"), "")
|
||||
if wrong.Code == 200 {
|
||||
t.Fatal("incorrect captcha was accepted")
|
||||
}
|
||||
consumed := requestJSON(t, engine, http.MethodPost, "/api/v1/login", loginBody(username, password, wrongID, answer), "")
|
||||
if consumed.Code == 200 {
|
||||
t.Fatal("captcha used by a failed attempt was not consumed")
|
||||
}
|
||||
|
||||
unauthenticated := requestJSON(t, engine, http.MethodGet, "/api/v1/bell/alerts", nil, "")
|
||||
if unauthenticated.Code == 200 {
|
||||
t.Fatal("unauthenticated Bell business API was accepted")
|
||||
}
|
||||
disabled := httptest.NewRecorder()
|
||||
engine.ServeHTTP(disabled, httptest.NewRequest(http.MethodGet, "/api/v1/config", nil))
|
||||
if disabled.Code != http.StatusNotFound {
|
||||
t.Fatalf("disabled default route returned HTTP %d", disabled.Code)
|
||||
}
|
||||
|
||||
assertSecretsAbsentFromAudit(t, password, answer, login.Token)
|
||||
assertSecretsAbsentFromLogs(t, password, answer, login.Token)
|
||||
if captcha.Verify(captchaResponse.ID, "deliberately-wrong", true) {
|
||||
t.Fatal("generated captcha accepted a deliberately incorrect answer")
|
||||
}
|
||||
}
|
||||
|
||||
func loginBody(username, password, id, answer string) map[string]string {
|
||||
return map[string]string{"username": username, "password": password, "uuid": id, "code": answer}
|
||||
}
|
||||
|
||||
func requestJSON(t *testing.T, engine http.Handler, method, path string, body any, token string) apiResponse {
|
||||
t.Helper()
|
||||
var payload []byte
|
||||
var err error
|
||||
if body != nil {
|
||||
payload, err = json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
request := httptest.NewRequest(method, path, bytes.NewReader(payload))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
var response apiResponse
|
||||
if err = json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode %s response (HTTP %d): %v: %s", path, recorder.Code, err, recorder.Body.String())
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func assertSecretsAbsentFromAudit(t *testing.T, secrets ...string) {
|
||||
t.Helper()
|
||||
db := sdk.Runtime.GetDbByKey("").Session(&gorm.Session{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
|
||||
for _, table := range []string{"sys_login_log", "sys_opera_log"} {
|
||||
for _, secret := range secrets {
|
||||
var count int64
|
||||
query := "SELECT count(*) FROM " + table + " WHERE row_to_json(" + table + ")::text LIKE ?"
|
||||
if err := db.Raw(query, "%"+secret+"%").Scan(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("secret leaked into %s", table)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertSecretsAbsentFromLogs(t *testing.T, secrets ...string) {
|
||||
t.Helper()
|
||||
patterns := append([]string{"DriverDigitFunc answer:"}, secrets...)
|
||||
err := filepath.WalkDir("temp", func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
content, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
for _, pattern := range patterns {
|
||||
if pattern != "" && strings.Contains(string(content), pattern) {
|
||||
t.Fatalf("sensitive value found in server log %s", path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
[CmdletBinding()]
|
||||
param([string]$PostgresBin = 'D:\pgsql17\bin')
|
||||
|
||||
Set-StrictMode -Version 3.0
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$started = $false
|
||||
$root = Join-Path ([IO.Path]::GetTempPath()) ('yovision-bell-138-' + [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_138
|
||||
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_138 sslmode=disable"
|
||||
$env:BELL_PRODUCTION_LOGIN_TEST_DATABASE_URL = $env:BELL_DATABASE_URL
|
||||
$env:BELL_JWT_SECRET = [guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N')
|
||||
$env:BELL_BOOTSTRAP_USERNAME = 'bell_138_admin'
|
||||
$env:BELL_BOOTSTRAP_PASSWORD = [guid]::NewGuid().ToString('N')
|
||||
$env:BELL_HOST = '127.0.0.1'
|
||||
$env:BELL_PORT = (Get-FreePort).ToString()
|
||||
|
||||
Remove-Item -LiteralPath (Join-Path $PSScriptRoot 'temp') -Recurse -Force -ErrorAction SilentlyContinue
|
||||
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_production_login -count=1 -v
|
||||
if ($LASTEXITCODE -ne 0) { throw 'production login test failed' }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
Write-Output 'BELL_138_PRODUCTION_LOGIN captcha=200 valid_login=200 wrong_rejected=true replay_rejected=true secrets_absent=true'
|
||||
} 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_PRODUCTION_LOGIN_TEST_DATABASE_URL', 'BELL_JWT_SECRET', 'BELL_BOOTSTRAP_USERNAME', 'BELL_BOOTSTRAP_PASSWORD', 'BELL_HOST', 'BELL_PORT')) {
|
||||
Remove-Item "Env:$name" -ErrorAction SilentlyContinue
|
||||
}
|
||||
Remove-Item -LiteralPath (Join-Path $PSScriptRoot 'temp') -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package event_ingress_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/bell/event"
|
||||
"go-admin/app/bell/integration/event_ingress"
|
||||
"go-admin/app/bell/integration/machine_identity"
|
||||
"go-admin/app/bell/receipt"
|
||||
)
|
||||
|
||||
func TestPostgresConcurrentBusinessAndSecurityIdempotency(t *testing.T) {
|
||||
dsn := os.Getenv("BELL_EVENT_INGRESS_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("set BELL_EVENT_INGRESS_TEST_DATABASE_URL to run PostgreSQL concurrency verification")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&event.Event{}, &receipt.Receipt{}, &receipt.IngestAudit{}, &event_ingress.ReplayToken{}, &event_ingress.EvidenceStatus{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := event_ingress.ParseEvent(fixture(t, "dangerous-area.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const workers = 12
|
||||
var created, duplicate, failures atomic.Int32
|
||||
var wait sync.WaitGroup
|
||||
for range workers {
|
||||
wait.Add(1)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
result, ingestErr := (event_ingress.Service{DB: db}).Ingest(context.Background(), parsed)
|
||||
if ingestErr != nil {
|
||||
failures.Add(1)
|
||||
return
|
||||
}
|
||||
if result.Disposition == "created" {
|
||||
created.Add(1)
|
||||
} else if result.Disposition == "duplicate" {
|
||||
duplicate.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wait.Wait()
|
||||
if created.Load() != 1 || duplicate.Load() != workers-1 || failures.Load() != 0 {
|
||||
t.Fatalf("concurrent ingest created=%d duplicate=%d failures=%d", created.Load(), duplicate.Load(), failures.Load())
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
var consumed atomic.Int32
|
||||
for range workers {
|
||||
wait.Add(1)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
if (event_ingress.PersistentReplayStore{DB: db}).Consume("yv:sense:school-a", "concurrent-token-id-0001", now.Add(time.Minute), now) {
|
||||
consumed.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wait.Wait()
|
||||
if consumed.Load() != 1 {
|
||||
t.Fatalf("concurrent replay consume accepted %d requests", consumed.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRegistrationIsOptionalAndMigrationGated(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("BELL_EVENT_INGRESS_ENABLED", "")
|
||||
disabled := gin.New()
|
||||
if err := event_ingress.RegisterRuntime(disabled); err != nil || len(disabled.Routes()) != 0 {
|
||||
t.Fatalf("disabled runtime err=%v routes=%#v", err, disabled.Routes())
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("file:bell-runtime?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sdk.Runtime.SetDb("", db)
|
||||
t.Cleanup(func() { sdk.Runtime.SetDb("", nil) })
|
||||
t.Setenv("BELL_EVENT_INGRESS_ENABLED", "true")
|
||||
if err = event_ingress.RegisterRuntime(gin.New()); err == nil {
|
||||
t.Fatal("enabled runtime started without formal migration")
|
||||
}
|
||||
if err = db.AutoMigrate(&event_ingress.ReplayToken{}, &event_ingress.EvidenceStatus{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registryPath := writeRegistry(t, "yovision-bell", "yv:sense:school-a", "sense-key-0001")
|
||||
t.Setenv("BELL_MACHINE_PRINCIPAL_REGISTRY", registryPath)
|
||||
registered := gin.New()
|
||||
if err = event_ingress.RegisterRuntime(registered); err != nil {
|
||||
t.Fatalf("enabled runtime did not register after migration: %v", err)
|
||||
}
|
||||
routes := registered.Routes()
|
||||
if len(routes) != 1 || routes[0].Method != http.MethodPost || routes[0].Path != "/v1/events" {
|
||||
t.Fatalf("unexpected ingress routes: %#v", routes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContractFixtureIdempotencyConflictAndReplayPersistence(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
databasePath := filepath.Join(t.TempDir(), "bell-ingress.sqlite")
|
||||
db := openDatabasePath(t, databasePath)
|
||||
body := fixture(t, "dangerous-area.json")
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry, err := machine_identity.NewRegistry(machine_identity.KeyRecord{Principal: "yv:sense:school-a", KeyID: "sense-key-0001", PublicKey: publicKey, Audience: "yovision-bell", Scopes: []string{"events:ingest"}, Enabled: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 8, 31, 1, 0, 0, 0, time.UTC)
|
||||
signer := machine_identity.Signer{Principal: "yv:sense:school-a", KeyID: "sense-key-0001", PrivateKey: privateKey, Now: func() time.Time { return now }}
|
||||
newHandler := func() event_ingress.Handler {
|
||||
return event_ingress.Handler{DB: db, Enabled: true, Verifier: machine_identity.Verifier{Registry: registry, Replay: event_ingress.PersistentReplayStore{DB: db}, Now: func() time.Time { return now }}}
|
||||
}
|
||||
|
||||
firstToken := mint(t, signer, body)
|
||||
first := request(t, newHandler(), body, firstToken)
|
||||
if first.Code != http.StatusCreated {
|
||||
t.Fatalf("first ingest status=%d body=%s", first.Code, first.Body.String())
|
||||
}
|
||||
var created event_ingress.IngestResult
|
||||
decode(t, first, &created)
|
||||
if created.Disposition != "created" || created.PayloadSHA256 != "4cc1e93820195caf713ea675ff33f178c9d4997dd8a81cb61287e9fea0e3d5e1" {
|
||||
t.Fatalf("unexpected created result: %+v", created)
|
||||
}
|
||||
sqlDatabase, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = sqlDatabase.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db = openDatabasePath(t, databasePath)
|
||||
|
||||
// A new process-local Handler and replay store still reject the old token,
|
||||
// proving that security replay state is durable rather than in-memory.
|
||||
replayedToken := request(t, newHandler(), body, firstToken)
|
||||
if replayedToken.Code != http.StatusUnauthorized || !strings.Contains(replayedToken.Body.String(), "machine_token_replayed") {
|
||||
t.Fatalf("token replay status=%d body=%s", replayedToken.Code, replayedToken.Body.String())
|
||||
}
|
||||
|
||||
duplicate := request(t, newHandler(), body, mint(t, signer, body))
|
||||
if duplicate.Code != http.StatusOK {
|
||||
t.Fatalf("business duplicate status=%d body=%s", duplicate.Code, duplicate.Body.String())
|
||||
}
|
||||
var duplicateResult event_ingress.IngestResult
|
||||
decode(t, duplicate, &duplicateResult)
|
||||
if duplicateResult.Disposition != "duplicate" || duplicateResult.EventID != created.EventID {
|
||||
t.Fatalf("duplicate did not retain event identity: %+v", duplicateResult)
|
||||
}
|
||||
numericVariant := bytes.Replace(body, []byte(`0.93`), []byte(`0.930`), 1)
|
||||
numericDuplicate := request(t, newHandler(), numericVariant, mint(t, signer, numericVariant))
|
||||
if numericDuplicate.Code != http.StatusOK || !strings.Contains(numericDuplicate.Body.String(), created.PayloadSHA256) {
|
||||
t.Fatalf("JCS-equivalent numeric payload was not a duplicate: %d %s", numericDuplicate.Code, numericDuplicate.Body.String())
|
||||
}
|
||||
|
||||
var changed map[string]any
|
||||
if err = json.Unmarshal(body, &changed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changed["severity"] = "critical"
|
||||
conflicting, _ := json.Marshal(changed)
|
||||
conflict := request(t, newHandler(), conflicting, mint(t, signer, conflicting))
|
||||
if conflict.Code != http.StatusConflict || !strings.Contains(conflict.Body.String(), "idempotency_conflict") || !strings.Contains(conflict.Body.String(), created.EventID) {
|
||||
t.Fatalf("conflict status=%d body=%s", conflict.Code, conflict.Body.String())
|
||||
}
|
||||
assertCount(t, db, &event.Event{}, 1)
|
||||
assertCount(t, db, &receipt.Receipt{}, 1)
|
||||
assertCount(t, db, &receipt.IngestAudit{}, 4)
|
||||
}
|
||||
|
||||
func TestEvidenceDegradationIdentityErrorsAndDisabledConnector(t *testing.T) {
|
||||
db := openDatabase(t)
|
||||
publicKey, privateKey, _ := ed25519.GenerateKey(rand.Reader)
|
||||
registry, _ := machine_identity.NewRegistry(machine_identity.KeyRecord{Principal: "yv:brain:school-a", KeyID: "brain-key-0001", PublicKey: publicKey, Audience: "yovision-bell", Scopes: []string{"events:ingest"}, Enabled: true})
|
||||
now := time.Date(2026, 8, 31, 1, 0, 0, 0, time.UTC)
|
||||
signer := machine_identity.Signer{Principal: "yv:brain:school-a", KeyID: "brain-key-0001", PrivateKey: privateKey, Now: func() time.Time { return now }}
|
||||
handler := event_ingress.Handler{DB: db, Enabled: true, Verifier: machine_identity.Verifier{Registry: registry, Replay: event_ingress.PersistentReplayStore{DB: db}, Now: func() time.Time { return now }}}
|
||||
|
||||
pending := fixture(t, "dangerous-area.json")
|
||||
if response := requestWithID(t, handler, pending, mint(t, signer, pending), "short"); response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "invalid_request_id") {
|
||||
t.Fatalf("invalid request id status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
missingRequestID := requestWithID(t, handler, pending, mint(t, signer, pending), "")
|
||||
if missingRequestID.Code != http.StatusCreated || !requestIDPatternForTest(missingRequestID.Header().Get("X-Request-ID")) {
|
||||
t.Fatalf("trusted hop did not create a request id: %d %s", missingRequestID.Code, missingRequestID.Body.String())
|
||||
}
|
||||
queryResponse := requestTarget(t, handler, pending, mint(t, signer, pending), "/v1/events?debug=true")
|
||||
if queryResponse.Code != http.StatusBadRequest || !strings.Contains(queryResponse.Body.String(), "invalid_request_target") {
|
||||
t.Fatalf("query target was accepted: %d %s", queryResponse.Code, queryResponse.Body.String())
|
||||
}
|
||||
if response := request(t, handler, pending, mint(t, signer, pending)); response.Code != http.StatusOK {
|
||||
t.Fatalf("pending evidence rejected: %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
failed := fixture(t, "directional-line-crossed.json")
|
||||
if response := request(t, handler, failed, mint(t, signer, failed)); response.Code != http.StatusCreated {
|
||||
t.Fatalf("failed evidence rejected: %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
assertCount(t, db, &event.Event{}, 2)
|
||||
|
||||
wrongAudienceToken, err := signer.Mint("yovision-sense", []string{"events:ingest"}, http.MethodPost, "/v1/events", pending)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response := request(t, handler, pending, wrongAudienceToken); response.Code != http.StatusForbidden {
|
||||
t.Fatalf("wrong audience was not forbidden: %d %s", response.Code, response.Body.String())
|
||||
}
|
||||
if response := request(t, event_ingress.Handler{Enabled: false}, pending, "none"); response.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("disabled connector status=%d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvidenceResolverCurrentMissingExpiredAndTimeout(t *testing.T) {
|
||||
db := openDatabase(t)
|
||||
now := time.Date(2026, 8, 31, 1, 0, 0, 0, time.UTC)
|
||||
status := event_ingress.EvidenceStatus{EventID: "event-1", EvidenceID: "ev-school-east-0001", OwnerID: "sense-school-a", Status: "pending", Resolution: "snapshot", CurrentPayload: json.RawMessage(`{"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"}`), CreatedAt: now, UpdatedAt: now}
|
||||
if err := db.Create(&status).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, privateKey, _ := ed25519.GenerateKey(rand.Reader)
|
||||
signer := machine_identity.Signer{Principal: "yv:bell:school-a", KeyID: "bell-key-0001", PrivateKey: privateKey, Now: func() time.Time { return now }}
|
||||
response := func(code int, body string) *http.Response {
|
||||
return &http.Response{StatusCode: code, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}
|
||||
}
|
||||
client := event_ingress.EvidenceClient{Endpoint: "https://sense.example", Signer: signer, HTTP: doFunc(func(*http.Request) (*http.Response, error) {
|
||||
return response(http.StatusNotFound, `{}`), nil
|
||||
})}
|
||||
if err := client.Refresh(context.Background(), db, status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&status, "event_id = ? AND evidence_id = ?", "event-1", "ev-school-east-0001").Error; err != nil || status.Resolution != "unavailable" || status.LastError != "evidence_not_found" {
|
||||
t.Fatalf("missing resolution=%s error=%s db=%v", status.Resolution, status.LastError, err)
|
||||
}
|
||||
client.HTTP = doFunc(func(*http.Request) (*http.Response, error) { return response(http.StatusGone, `{}`), nil })
|
||||
if err := client.Refresh(context.Background(), db, status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&status, "event_id = ? AND evidence_id = ?", "event-1", "ev-school-east-0001").Error; err != nil || status.Resolution != "expired" {
|
||||
t.Fatalf("expired resolution=%s db=%v", status.Resolution, err)
|
||||
}
|
||||
current := `{"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.125Z","content_type":"image/jpeg","integrity":{"algorithm":"sha256","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size_bytes":1}}`
|
||||
client.HTTP = doFunc(func(*http.Request) (*http.Response, error) { return response(http.StatusOK, current), nil })
|
||||
if err := client.Refresh(context.Background(), db, status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&status, "event_id = ? AND evidence_id = ?", "event-1", "ev-school-east-0001").Error; err != nil || status.Resolution != "current" || status.Status != "success" || status.LastError != "" {
|
||||
t.Fatalf("current status=%s resolution=%s error=%s db=%v", status.Status, status.Resolution, status.LastError, err)
|
||||
}
|
||||
client.HTTP = doFunc(func(*http.Request) (*http.Response, error) { return nil, context.DeadlineExceeded })
|
||||
if err := client.Refresh(context.Background(), db, status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&status, "event_id = ? AND evidence_id = ?", "event-1", "ev-school-east-0001").Error; err != nil || status.Resolution != "unavailable" || status.LastError != "evidence_timeout" {
|
||||
t.Fatalf("timeout resolution=%s error=%s db=%v", status.Resolution, status.LastError, err)
|
||||
}
|
||||
}
|
||||
|
||||
func openDatabase(t *testing.T) *gorm.DB {
|
||||
return openDatabasePath(t, filepath.Join(t.TempDir(), "bell-ingress.sqlite"))
|
||||
}
|
||||
|
||||
func openDatabasePath(t *testing.T, databasePath string) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(databasePath), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&event.Event{}, &receipt.Receipt{}, &receipt.IngestAudit{}, &event_ingress.ReplayToken{}, &event_ingress.EvidenceStatus{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDatabase, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = sqlDatabase.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func fixture(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
path := filepath.Join("..", "..", "..", "..", "..", "contracts", "events", "v1", "examples", name)
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func writeRegistry(t *testing.T, audience, principal, keyID string) string {
|
||||
t.Helper()
|
||||
publicKey, _, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
document := map[string]any{
|
||||
"version": "yovision.machine-principal-registry/v1", "audience": audience,
|
||||
"principals": []any{map[string]any{
|
||||
"principal_id": principal, "enabled": true,
|
||||
"keys": []any{map[string]any{
|
||||
"kid": keyID, "public_key_base64url": base64.RawURLEncoding.EncodeToString(publicKey),
|
||||
"status": "active", "scopes": []string{"events:ingest"},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
encoded, err := json.Marshal(document)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "registry.json")
|
||||
if err = os.WriteFile(path, encoded, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func mint(t *testing.T, signer machine_identity.Signer, body []byte) string {
|
||||
t.Helper()
|
||||
token, err := signer.Mint("yovision-bell", []string{"events:ingest"}, http.MethodPost, "/v1/events", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func request(t *testing.T, handler event_ingress.Handler, body []byte, token string) *httptest.ResponseRecorder {
|
||||
return requestWithID(t, handler, body, token, "request-id-0000001")
|
||||
}
|
||||
|
||||
func requestWithID(t *testing.T, handler event_ingress.Handler, body []byte, token, requestID string) *httptest.ResponseRecorder {
|
||||
return requestTargetWithID(t, handler, body, token, "/v1/events", requestID)
|
||||
}
|
||||
|
||||
func requestTarget(t *testing.T, handler event_ingress.Handler, body []byte, token, target string) *httptest.ResponseRecorder {
|
||||
return requestTargetWithID(t, handler, body, token, target, "request-id-0000001")
|
||||
}
|
||||
|
||||
func requestTargetWithID(t *testing.T, handler event_ingress.Handler, body []byte, token, target, requestID string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(http.MethodPost, target, bytes.NewReader(body))
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
request.Header.Set("X-Request-ID", requestID)
|
||||
response := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(response)
|
||||
context.Request = request
|
||||
handler.Post(context)
|
||||
return response
|
||||
}
|
||||
|
||||
func requestIDPatternForTest(value string) bool {
|
||||
if len(value) < 16 || len(value) > 128 {
|
||||
return false
|
||||
}
|
||||
for index, r := range value {
|
||||
if !(r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || index > 0 && strings.ContainsRune("._:-", r)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func decode(t *testing.T, response *httptest.ResponseRecorder, target any) {
|
||||
t.Helper()
|
||||
if err := json.Unmarshal(response.Body.Bytes(), target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertCount(t *testing.T, db *gorm.DB, model any, expected int64) {
|
||||
t.Helper()
|
||||
var count int64
|
||||
if err := db.Model(model).Count(&count).Error; err != nil || count != expected {
|
||||
t.Fatalf("count %T=%d expected=%d err=%v", model, count, expected, err)
|
||||
}
|
||||
}
|
||||
|
||||
type doFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (function doFunc) Do(request *http.Request) (*http.Response, error) { return function(request) }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import request from '@/utils/request'
|
||||
export function listContacts(query) { return request({ url: '/api/v1/bell/contacts', method: 'get', params: query }) }
|
||||
export function createContact(data) { return request({ url: '/api/v1/bell/contacts', method: 'post', data }) }
|
||||
export function updateContact(id, data) { return request({ url: `/api/v1/bell/contacts/${id}`, method: 'put', data }) }
|
||||
export function setContactEnabled(id, enabled, expectedVersion) { return request({ url: `/api/v1/bell/contacts/${id}/enabled`, method: 'put', data: { enabled, expectedVersion }}) }
|
||||
export function addContactChannel(id, data) { return request({ url: `/api/v1/bell/contacts/${id}/channels`, method: 'post', data }) }
|
||||
export function validateContactChannel(id, data) { return request({ url: `/api/v1/bell/contact-channels/${id}/validations`, method: 'post', data }) }
|
||||
@@ -0,0 +1,8 @@
|
||||
import request from '@/utils/request'
|
||||
export function listDutyGroups(query) { return request({ url: '/api/v1/bell/duty-groups', method: 'get', params: query }) }
|
||||
export function createDutyGroup(data) { return request({ url: '/api/v1/bell/duty-groups', method: 'post', data }) }
|
||||
export function updateDutyGroup(id, data) { return request({ url: `/api/v1/bell/duty-groups/${id}`, method: 'put', data }) }
|
||||
export function saveDutyMember(id, data) { return request({ url: `/api/v1/bell/duty-groups/${id}/members`, method: 'post', data }) }
|
||||
export function createDutySchedule(id, data) { return request({ url: `/api/v1/bell/duty-groups/${id}/schedules`, method: 'post', data }) }
|
||||
export function publishDutySchedule(id) { return request({ url: `/api/v1/bell/duty-schedules/${id}/publish`, method: 'post' }) }
|
||||
export function createDutyOverride(id, data) { return request({ url: `/api/v1/bell/duty-groups/${id}/overrides`, method: 'post', data }) }
|
||||
@@ -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,21 @@
|
||||
<template>
|
||||
<BasicLayout><template #wrapper><el-card>
|
||||
<template #header><div class="heading"><div><h2>联系人与通道</h2><p>通道地址保存后只显示脱敏值,验证状态与联系人启用状态相互独立。</p></div><el-button v-permisaction="['bell:contact:write']" type="primary" @click="openCreate">新增联系人</el-button></div></template>
|
||||
<el-form ref="queryForm" :model="query" :inline="true"><el-form-item label="联系人" prop="name"><el-input v-model="query.name" clearable placeholder="姓名或岗位" @keyup.enter="search" /></el-form-item><el-form-item label="状态" prop="enabled"><el-select v-model="query.enabled" clearable placeholder="全部" style="width:120px"><el-option label="启用" :value="true" /><el-option label="停用" :value="false" /></el-select></el-form-item><el-form-item><el-button type="primary" @click="search">搜索</el-button><el-button @click="reset">重置</el-button></el-form-item></el-form>
|
||||
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false" class="state" />
|
||||
<el-table v-loading="loading" :data="items" border row-key="id">
|
||||
<el-table-column prop="name" label="联系人" min-width="130" /><el-table-column prop="role" label="岗位" min-width="130" />
|
||||
<el-table-column label="通道" min-width="260"><template #default="scope"><div v-if="scope.row.channels.length"><el-tag v-for="ch in scope.row.channels" :key="ch.id" :type="statusType(ch.status)" class="channel">{{ kindName(ch.kind) }} {{ ch.addressMasked }} · {{ statusName(ch.status) }}</el-tag></div><span v-else class="muted">未配置</span></template></el-table-column>
|
||||
<el-table-column label="状态" width="100"><template #default="scope"><el-switch v-model="scope.row.enabled" :disabled="!canWrite" inline-prompt active-text="启" inactive-text="停" @change="toggle(scope.row)" /></template></el-table-column><el-table-column prop="version" label="版本" width="70" />
|
||||
<el-table-column label="操作" width="210" fixed="right"><template #default="scope"><el-button v-permisaction="['bell:contact:write']" link type="primary" @click="openEdit(scope.row)">编辑</el-button><el-button v-permisaction="['bell:contact:write']" link type="primary" @click="openChannel(scope.row)">新增通道</el-button><el-dropdown v-if="scope.row.channels.length && canWrite" @command="command => validate(scope.row, command)"><el-button link type="primary">验证通道</el-button><template #dropdown><el-dropdown-menu><el-dropdown-item v-for="ch in scope.row.channels" :key="ch.id" :command="ch">{{ kindName(ch.kind) }} {{ ch.addressMasked }}</el-dropdown-item></el-dropdown-menu></template></el-dropdown></template></el-table-column>
|
||||
<template #empty><el-empty description="暂无联系人" /></template>
|
||||
</el-table><pagination v-show="total>0" v-model:current-page="query.pageIndex" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
|
||||
<el-dialog v-model="contactDialog" :title="editing?'编辑联系人':'新增联系人'" width="min(520px, calc(100vw - 32px))" :close-on-click-modal="false"><el-form ref="contactForm" :model="form" :rules="rules" label-position="top"><el-form-item label="称呼" prop="name"><el-input v-model.trim="form.name" maxlength="128" /></el-form-item><el-form-item label="岗位" prop="role"><el-input v-model.trim="form.role" maxlength="128" /></el-form-item></el-form><template #footer><el-button @click="contactDialog=false">取消</el-button><el-button type="primary" :loading="saving" @click="saveContact">保存</el-button></template></el-dialog>
|
||||
<el-dialog v-model="channelDialog" title="新增联系通道" width="min(520px, calc(100vw - 32px))" :close-on-click-modal="false"><el-alert title="号码仅在本次填写时可见,保存后只返回脱敏值。" type="info" :closable="false" class="state" /><el-form ref="channelForm" :model="channel" :rules="channelRules" label-position="top"><el-form-item label="通道" prop="kind"><el-select v-model="channel.kind" style="width:100%"><el-option label="短信" value="sms" /><el-option label="语音" value="voice" /></el-select></el-form-item><el-form-item label="号码" prop="address"><el-input v-model.trim="channel.address" autocomplete="off" placeholder="请输入合法测试号码" /></el-form-item></el-form><template #footer><el-button @click="channelDialog=false">取消</el-button><el-button type="primary" :loading="saving" @click="saveChannel">保存</el-button></template></el-dialog>
|
||||
</el-card></template></BasicLayout>
|
||||
</template>
|
||||
<script>
|
||||
import { addContactChannel, createContact, listContacts, setContactEnabled, updateContact, validateContactChannel } from '@/api/bell/contact'
|
||||
export default { name: 'BellContacts', data() { return { loading: false, saving: false, error: '', items: [], total: 0, contactDialog: false, channelDialog: false, editing: false, editingId: '', channelContactId: '', query: { pageIndex: 1, pageSize: 10, name: '', enabled: null }, form: { name: '', role: '', expectedVersion: 0 }, channel: { kind: 'sms', address: '' }, rules: { name: [{ required: true, message: '请输入称呼', trigger: 'blur' }], role: [{ required: true, message: '请输入岗位', trigger: 'blur' }] }, channelRules: { kind: [{ required: true, message: '请选择通道', trigger: 'change' }], address: [{ required: true, pattern: /^\+?[0-9 -]{6,24}$/, message: '请输入有效号码', trigger: 'blur' }] }} }, computed: { canWrite() { const p = this.$store.getters.permisaction || []; return p.includes('*:*:*') || p.includes('bell:contact:write') } }, created() { this.load() }, methods: { async load() { this.loading = true; this.error = ''; try { const r = await listContacts(this.query); this.items = r.data.list || []; this.total = r.data.count || 0 } catch (e) { this.error = e.message || '联系人加载失败' } finally { this.loading = false } }, search() { this.query.pageIndex = 1; this.load() }, reset() { this.$refs.queryForm.resetFields(); this.query.enabled = null; this.search() }, openCreate() { this.editing = false; this.editingId = ''; this.form = { name: '', role: '', expectedVersion: 0 }; this.contactDialog = true }, openEdit(row) { this.editing = true; this.editingId = row.id; this.form = { name: row.name, role: row.role, expectedVersion: row.version }; this.contactDialog = true }, openChannel(row) { this.channelContactId = row.id; this.channel = { kind: 'sms', address: '' }; this.channelDialog = true }, async saveContact() { try { await this.$refs.contactForm.validate(); this.saving = true; if (this.editing) await updateContact(this.editingId, this.form); else await createContact(this.form); this.msgSuccess('联系人已保存'); this.contactDialog = false; await this.load() } catch (e) { if (e && e.message) this.error = e.message } finally { this.saving = false } }, async saveChannel() { try { await this.$refs.channelForm.validate(); this.saving = true; await addContactChannel(this.channelContactId, this.channel); this.msgSuccess('通道已加密保存,等待验证'); this.channelDialog = false; await this.load() } catch (e) { if (e && e.message) this.error = e.message } finally { this.saving = false } }, async toggle(row) { try { await setContactEnabled(row.id, row.enabled, row.version); this.msgSuccess(row.enabled ? '联系人已启用' : '联系人已停用'); await this.load() } catch (e) { row.enabled = !row.enabled; this.error = e.message || '状态更新失败' } }, async validate(row, ch) { try { await this.$confirm(`确认合成验证 ${ch.addressMasked} 成功?本操作不会发送外部消息。`, '记录验证结果', { type: 'warning' }); await validateContactChannel(ch.id, { status: 'verified', detail: '人工合成验证' }); this.msgSuccess('验证事实已记录'); await this.load() } catch (e) { if (e !== 'cancel' && e !== 'close' && e && e.message) this.error = e.message } }, kindName(v) { return { sms: '短信', voice: '语音' }[v] || v }, statusName(v) { return { pending: '待验证', verified: '已验证', failed: '验证失败' }[v] || v }, statusType(v) { return { pending: 'warning', verified: 'success', failed: 'danger' }[v] || 'info' } }}
|
||||
</script>
|
||||
<style scoped>.heading{display:flex;align-items:center;justify-content:space-between;gap:16px}.heading h2{margin:0}.heading p{margin:6px 0 0;color:var(--el-text-color-secondary)}.state{margin-bottom:16px}.channel{margin:2px 6px 2px 0}.muted{color:var(--el-text-color-secondary)}</style>
|
||||
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -11,6 +11,7 @@ from yovision_brain.config import ConfigError
|
||||
from yovision_brain.decode import DecoderError
|
||||
from yovision_brain.events import JsonLinesSink
|
||||
from yovision_brain.input import InputError
|
||||
from yovision_brain.integration.event_export import build_event_export_sink
|
||||
from yovision_brain.rules import RuleConfigError
|
||||
|
||||
from .runner import run_pipeline
|
||||
@@ -38,14 +39,16 @@ def main(argv: list[str] | None = None) -> int:
|
||||
stream = sys.stdout
|
||||
owned_stream = None
|
||||
try:
|
||||
if args.output != "-":
|
||||
export_sink = build_event_export_sink(raw.get("event_export"), base_dir=config_path.parent)
|
||||
if export_sink is None and args.output != "-":
|
||||
try:
|
||||
owned_stream = Path(args.output).open("w", encoding="utf-8", newline="\n")
|
||||
except OSError:
|
||||
print(json.dumps({"status": "error", "message": "event output cannot be opened"}), file=sys.stderr)
|
||||
return 2
|
||||
stream = owned_stream
|
||||
summary = run_pipeline(raw, JsonLinesSink(stream), base_dir=config_path.parent)
|
||||
sink = export_sink if export_sink is not None else JsonLinesSink(stream)
|
||||
summary = run_pipeline(raw, sink, base_dir=config_path.parent)
|
||||
except (ConfigError, DecoderError, InputError, RuleConfigError, RuntimeError, ValueError) as exc:
|
||||
print(json.dumps({"status": "error", "message": str(exc)}, ensure_ascii=False), file=sys.stderr)
|
||||
return 3
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Map Brain-internal candidates to the frozen anonymous event contract."""
|
||||
|
||||
from .mapper import (
|
||||
EVENT_SCHEMA_VERSION,
|
||||
EVIDENCE_SCHEMA_VERSION,
|
||||
EventExportError,
|
||||
canonical_json,
|
||||
canonical_json_bytes,
|
||||
export_event,
|
||||
payload_sha256,
|
||||
)
|
||||
from .replay import SQLiteReplayCache
|
||||
from .client import DeliveryResult, EventDeliveryError, HTTPSMachineIdentitySender
|
||||
from .runtime import EventExportSink, build_event_export_sink
|
||||
|
||||
__all__ = [
|
||||
"EVENT_SCHEMA_VERSION",
|
||||
"EVIDENCE_SCHEMA_VERSION",
|
||||
"EventExportError",
|
||||
"EventDeliveryError",
|
||||
"DeliveryResult",
|
||||
"EventExportSink",
|
||||
"HTTPSMachineIdentitySender",
|
||||
"SQLiteReplayCache",
|
||||
"canonical_json",
|
||||
"canonical_json_bytes",
|
||||
"export_event",
|
||||
"payload_sha256",
|
||||
"build_event_export_sink",
|
||||
]
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Synchronous, request-bound HTTPS delivery for Brain event exports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from yovision_brain.integration.machine_identity import Signer, TransportPolicy
|
||||
|
||||
EVENT_PATH = "/v1/events"
|
||||
_REQUEST_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$")
|
||||
|
||||
|
||||
class EventDeliveryError(RuntimeError):
|
||||
"""An event was not accepted; callers must retain or reproduce the fact."""
|
||||
|
||||
def __init__(self, code: str, *, terminal: bool) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.terminal = terminal
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DeliveryResult:
|
||||
disposition: str
|
||||
request_id: str
|
||||
|
||||
|
||||
class _Headers(Protocol):
|
||||
def get(self, name: str, default: str | None = None) -> str | None: ...
|
||||
|
||||
|
||||
class _Response(Protocol):
|
||||
status: int
|
||||
headers: _Headers
|
||||
|
||||
def read(self, amount: int = -1) -> bytes: ...
|
||||
def close(self) -> None: ...
|
||||
|
||||
|
||||
class _Opener(Protocol):
|
||||
def open(self, request: urllib.request.Request, timeout: float) -> _Response: ...
|
||||
|
||||
|
||||
class HTTPSMachineIdentitySender:
|
||||
def __init__(
|
||||
self,
|
||||
endpoint: str,
|
||||
signer: Signer,
|
||||
policy: TransportPolicy,
|
||||
*,
|
||||
opener: _Opener | None = None,
|
||||
) -> None:
|
||||
parsed = urlsplit(endpoint)
|
||||
if (
|
||||
parsed.scheme != "https"
|
||||
or not parsed.hostname
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.path not in {"", "/"}
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ValueError("event export endpoint must be an HTTPS origin")
|
||||
policy.validate()
|
||||
self._endpoint = endpoint.rstrip("/")
|
||||
self._signer = signer
|
||||
self._policy = policy
|
||||
self._opener = opener or urllib.request.build_opener(
|
||||
urllib.request.HTTPSHandler(context=policy.ssl_context())
|
||||
)
|
||||
|
||||
def send(self, body: bytes) -> DeliveryResult:
|
||||
if len(body) > self._policy.max_request_bytes:
|
||||
raise EventDeliveryError("event_request_too_large", terminal=True)
|
||||
request_id = "req-" + secrets.token_urlsafe(16)
|
||||
if not _REQUEST_ID.fullmatch(request_id): # pragma: no cover - defensive invariant
|
||||
raise RuntimeError("generated request id is invalid")
|
||||
token = self._signer.mint(
|
||||
"yovision-sense", ("events:ingest",), "POST", EVENT_PATH, body
|
||||
)
|
||||
request = urllib.request.Request(
|
||||
self._endpoint + EVENT_PATH,
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={
|
||||
"Authorization": "Bearer " + token,
|
||||
"Content-Type": "application/json",
|
||||
"X-Request-ID": request_id,
|
||||
},
|
||||
)
|
||||
try:
|
||||
response = self._opener.open(
|
||||
request, timeout=self._policy.request_timeout_ms / 1000
|
||||
)
|
||||
except urllib.error.HTTPError as exc:
|
||||
response_body = exc.read(64 * 1024 + 1)
|
||||
code = _problem_code(response_body) or "event_delivery_rejected"
|
||||
raise EventDeliveryError(
|
||||
code,
|
||||
terminal=400 <= exc.code < 500 and exc.code != 429,
|
||||
) from None
|
||||
except (OSError, TimeoutError, urllib.error.URLError):
|
||||
raise EventDeliveryError("event_delivery_unavailable", terminal=False) from None
|
||||
|
||||
try:
|
||||
response_body = response.read(64 * 1024 + 1)
|
||||
if len(response_body) > 64 * 1024:
|
||||
raise EventDeliveryError("event_response_invalid", terminal=False)
|
||||
if response.status not in {200, 201, 202}:
|
||||
raise EventDeliveryError(
|
||||
"event_delivery_rejected",
|
||||
terminal=400 <= response.status < 500 and response.status != 429,
|
||||
)
|
||||
response_request_id = response.headers.get("X-Request-ID")
|
||||
if response_request_id != request_id:
|
||||
raise EventDeliveryError("event_response_invalid", terminal=False)
|
||||
disposition = _disposition(response_body, response.status, body)
|
||||
return DeliveryResult(disposition=disposition, request_id=request_id)
|
||||
finally:
|
||||
response.close()
|
||||
|
||||
|
||||
def _problem_code(body: bytes) -> str | None:
|
||||
try:
|
||||
value = json.loads(body)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
return None
|
||||
code = value.get("code") if isinstance(value, dict) else None
|
||||
return code if isinstance(code, str) and re.fullmatch(r"[a-z][a-z0-9_]{0,63}", code) else None
|
||||
|
||||
|
||||
def _disposition(body: bytes, status: int, request_body: bytes) -> str:
|
||||
try:
|
||||
value = json.loads(body)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
raise EventDeliveryError("event_response_invalid", terminal=False) from None
|
||||
disposition = value.get("disposition") if isinstance(value, dict) else None
|
||||
allowed = {"accepted", "created", "duplicate"}
|
||||
if disposition not in allowed:
|
||||
raise EventDeliveryError("event_response_invalid", terminal=False)
|
||||
if status == 202 and disposition != "accepted":
|
||||
raise EventDeliveryError("event_response_invalid", terminal=False)
|
||||
try:
|
||||
sent = json.loads(request_body)
|
||||
response_identity = (
|
||||
value["producer_id"],
|
||||
value["source_event_id"],
|
||||
value["payload_sha256"],
|
||||
)
|
||||
expected_identity = (
|
||||
sent["producer_id"],
|
||||
sent["source_event_id"],
|
||||
hashlib.sha256(request_body).hexdigest(),
|
||||
)
|
||||
except (KeyError, TypeError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
raise EventDeliveryError("event_response_invalid", terminal=False) from None
|
||||
if response_identity != expected_identity:
|
||||
raise EventDeliveryError("event_response_invalid", terminal=False)
|
||||
return disposition
|
||||
@@ -0,0 +1,378 @@
|
||||
"""Safe, deterministic Brain producer mapping for ``yovision.event/v1``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Mapping, Sequence
|
||||
|
||||
from yovision_brain.events import INTERNAL_EVENT_SCHEMA, InternalEventCandidate
|
||||
|
||||
EVENT_SCHEMA_VERSION = "yovision.event/v1"
|
||||
EVIDENCE_SCHEMA_VERSION = "yovision.evidence-reference/v1"
|
||||
|
||||
_REFERENCE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||||
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||
_URL = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://")
|
||||
_WINDOWS_PATH = re.compile(r"^[A-Za-z]:[\\/]")
|
||||
_SENSITIVE_NAMES = frozenset(
|
||||
{
|
||||
"path",
|
||||
"url",
|
||||
"uri",
|
||||
"password",
|
||||
"secret",
|
||||
"token",
|
||||
"credential",
|
||||
"signed_url",
|
||||
"camera_url",
|
||||
"face",
|
||||
"face_id",
|
||||
"face_template",
|
||||
"alert",
|
||||
"ack",
|
||||
"close",
|
||||
"notification",
|
||||
}
|
||||
)
|
||||
_EVIDENCE_FIELDS = frozenset(
|
||||
{
|
||||
"schema_version",
|
||||
"evidence_id",
|
||||
"owner_id",
|
||||
"type",
|
||||
"status",
|
||||
"captured_at",
|
||||
"status_updated_at",
|
||||
"expires_at",
|
||||
"content_type",
|
||||
"integrity",
|
||||
"failure",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class EventExportError(ValueError):
|
||||
"""The internal candidate cannot safely satisfy the frozen contract."""
|
||||
|
||||
|
||||
def export_event(
|
||||
candidate: InternalEventCandidate,
|
||||
*,
|
||||
producer_id: str,
|
||||
site_ref: str,
|
||||
device_ref: str | None = None,
|
||||
severity: str,
|
||||
evidence: Sequence[Mapping[str, object]] = (),
|
||||
region_ref: str | None = None,
|
||||
crossing_direction: str | None = None,
|
||||
category: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Return a new closed v1 payload without mutating the internal candidate.
|
||||
|
||||
The candidate's already stable ``event_id`` is the source identity. Callers
|
||||
must persist and retry the returned payload unchanged; transport attempts do
|
||||
not participate in either identity field.
|
||||
"""
|
||||
|
||||
if candidate.schema != INTERNAL_EVENT_SCHEMA:
|
||||
raise EventExportError("unsupported internal event candidate schema")
|
||||
producer_id = _reference("producer_id", producer_id)
|
||||
source_event_id = _reference("source_event_id", candidate.event_id)
|
||||
site_ref = _reference("site_ref", site_ref)
|
||||
device_ref = _reference("device_ref", device_ref or candidate.logical_input_id)
|
||||
profile_ref = _reference("profile_ref", candidate.profile_id)
|
||||
rule_id = _reference("rule.rule_id", candidate.rule_id)
|
||||
region_id = _reference("region.region_id", region_ref or candidate.rule_id)
|
||||
track_id = _reference("observation.track_id", candidate.track_id)
|
||||
|
||||
event_type = {
|
||||
"danger_area_entered": "dangerous_area_entered",
|
||||
"dangerous_area_entered": "dangerous_area_entered",
|
||||
"directional_line_crossed": "directional_line_crossed",
|
||||
}.get(candidate.event_type)
|
||||
if event_type is None:
|
||||
raise EventExportError("unsupported event type")
|
||||
if severity not in {"low", "medium", "high", "critical"}:
|
||||
raise EventExportError("unsupported severity")
|
||||
|
||||
observation = _observation(candidate, track_id=track_id, category=category)
|
||||
region: dict[str, object] = {
|
||||
"region_id": region_id,
|
||||
"kind": "area" if event_type == "dangerous_area_entered" else "line",
|
||||
}
|
||||
if event_type == "directional_line_crossed":
|
||||
if crossing_direction not in {"a_to_b", "b_to_a"}:
|
||||
raise EventExportError("line events require a contract crossing_direction")
|
||||
region["crossing_direction"] = crossing_direction
|
||||
elif crossing_direction is not None:
|
||||
raise EventExportError("area events cannot carry crossing_direction")
|
||||
|
||||
if len(evidence) > 8:
|
||||
raise EventExportError("at most eight evidence references are allowed")
|
||||
mapped_evidence = [_evidence_reference(item) for item in evidence]
|
||||
if len({canonical_json_bytes(item) for item in mapped_evidence}) != len(mapped_evidence):
|
||||
raise EventExportError("duplicate evidence references are not allowed")
|
||||
|
||||
payload: dict[str, object] = {
|
||||
"schema_version": EVENT_SCHEMA_VERSION,
|
||||
"producer_id": producer_id,
|
||||
"source_event_id": source_event_id,
|
||||
"site_ref": site_ref,
|
||||
"device_ref": device_ref,
|
||||
"profile_ref": profile_ref,
|
||||
"event_type": event_type,
|
||||
"occurred_at": _event_timestamp(candidate.occurred_at_ns),
|
||||
"severity": severity,
|
||||
"rule": {"rule_id": rule_id, "version": _bounded_text("rule.version", candidate.rule_version, 64)},
|
||||
"model": {
|
||||
"name": _bounded_text("model.name", candidate.model_name, 128),
|
||||
"version": _bounded_text("model.version", candidate.model_version, 64),
|
||||
},
|
||||
"observation": observation,
|
||||
"region": region,
|
||||
"evidence": mapped_evidence,
|
||||
}
|
||||
_reject_unsafe(payload)
|
||||
canonical_json_bytes(payload) # Reject non-finite or unsupported values now.
|
||||
return payload
|
||||
|
||||
|
||||
def canonical_json(value: object) -> str:
|
||||
"""Serialize the closed event-domain JCS subset used by frozen fixtures.
|
||||
|
||||
Contract values use JSON strings, containers, booleans, integers and finite
|
||||
ordinary decimals. Integer-valued floats are normalized to their JSON number
|
||||
form; the checked-in RFC 8785 vector fixes cross-language digest behavior.
|
||||
"""
|
||||
|
||||
return _encode_jcs(_normalize_numbers(value))
|
||||
|
||||
|
||||
def canonical_json_bytes(value: object) -> bytes:
|
||||
return canonical_json(value).encode("utf-8")
|
||||
|
||||
|
||||
def payload_sha256(event: Mapping[str, object]) -> str:
|
||||
return hashlib.sha256(canonical_json_bytes(event)).hexdigest()
|
||||
|
||||
|
||||
def _observation(
|
||||
candidate: InternalEventCandidate, *, track_id: str, category: str | None
|
||||
) -> dict[str, object]:
|
||||
internal = candidate.observation
|
||||
confidence = internal.get("confidence")
|
||||
if isinstance(confidence, bool) or not isinstance(confidence, (int, float)):
|
||||
raise EventExportError("observation confidence must be numeric")
|
||||
confidence = float(confidence)
|
||||
if not math.isfinite(confidence) or not 0 <= confidence <= 1:
|
||||
raise EventExportError("observation confidence must be finite and between zero and one")
|
||||
|
||||
internal_category = internal.get("category")
|
||||
exported_category = category or {
|
||||
"anonymous_target": "person",
|
||||
"person": "person",
|
||||
"vehicle": "vehicle",
|
||||
"other": "other",
|
||||
}.get(internal_category)
|
||||
if exported_category not in {"person", "vehicle", "other"}:
|
||||
raise EventExportError("observation category requires an explicit anonymous contract mapping")
|
||||
|
||||
result: dict[str, object] = {
|
||||
"track_id": track_id,
|
||||
"category": exported_category,
|
||||
"confidence": confidence,
|
||||
}
|
||||
box = internal.get("box")
|
||||
if box is not None:
|
||||
if not isinstance(box, Mapping) or set(box) != {"left", "top", "right", "bottom"}:
|
||||
raise EventExportError("internal observation box is malformed")
|
||||
if candidate.frame_width <= 0 or candidate.frame_height <= 0:
|
||||
raise EventExportError("frame dimensions must be positive")
|
||||
coordinates = (box["left"], box["top"], box["right"], box["bottom"])
|
||||
if any(isinstance(value, bool) or not isinstance(value, (int, float)) for value in coordinates):
|
||||
raise EventExportError("box coordinates must be numeric")
|
||||
normalized = [
|
||||
float(coordinates[0]) / candidate.frame_width,
|
||||
float(coordinates[1]) / candidate.frame_height,
|
||||
float(coordinates[2]) / candidate.frame_width,
|
||||
float(coordinates[3]) / candidate.frame_height,
|
||||
]
|
||||
if any(not math.isfinite(value) or not 0 <= value <= 1 for value in normalized):
|
||||
raise EventExportError("normalized box coordinates must be finite and between zero and one")
|
||||
result["bbox_normalized"] = normalized
|
||||
return result
|
||||
|
||||
|
||||
def _evidence_reference(source: Mapping[str, object]) -> dict[str, object]:
|
||||
if not isinstance(source, Mapping):
|
||||
raise EventExportError("evidence reference must be an object")
|
||||
unknown = set(source) - _EVIDENCE_FIELDS
|
||||
if unknown:
|
||||
raise EventExportError(f"evidence reference contains forbidden fields: {sorted(unknown)!r}")
|
||||
required = {
|
||||
"schema_version",
|
||||
"evidence_id",
|
||||
"owner_id",
|
||||
"type",
|
||||
"status",
|
||||
"captured_at",
|
||||
"status_updated_at",
|
||||
}
|
||||
missing = required - set(source)
|
||||
if missing:
|
||||
raise EventExportError(f"evidence reference is missing fields: {sorted(missing)!r}")
|
||||
result = dict(source)
|
||||
if result["schema_version"] != EVIDENCE_SCHEMA_VERSION:
|
||||
raise EventExportError("unsupported evidence schema version")
|
||||
_reference("evidence.evidence_id", result["evidence_id"])
|
||||
_reference("evidence.owner_id", result["owner_id"])
|
||||
if result["type"] not in {"snapshot", "clip"}:
|
||||
raise EventExportError("unsupported evidence type")
|
||||
status = result["status"]
|
||||
if status not in {"pending", "processing", "success", "failed"}:
|
||||
raise EventExportError("unsupported evidence status")
|
||||
for field in ("captured_at", "status_updated_at", "expires_at"):
|
||||
if field in result:
|
||||
_date_time(field, result[field])
|
||||
|
||||
if status in {"pending", "processing"}:
|
||||
if any(field in result for field in ("content_type", "integrity", "failure")):
|
||||
raise EventExportError(f"{status} evidence cannot claim content or failure")
|
||||
elif status == "success":
|
||||
if "failure" in result or "content_type" not in result or "integrity" not in result:
|
||||
raise EventExportError("successful evidence requires content metadata and no failure")
|
||||
if result["content_type"] not in {"image/jpeg", "image/png", "video/mp4"}:
|
||||
raise EventExportError("unsupported evidence content type")
|
||||
integrity = result["integrity"]
|
||||
if not isinstance(integrity, Mapping) or set(integrity) != {"algorithm", "digest", "size_bytes"}:
|
||||
raise EventExportError("evidence integrity is malformed")
|
||||
if integrity["algorithm"] != "sha256" or not isinstance(integrity["digest"], str) or not _SHA256.fullmatch(integrity["digest"]):
|
||||
raise EventExportError("evidence integrity must contain a SHA-256 digest")
|
||||
if isinstance(integrity["size_bytes"], bool) or not isinstance(integrity["size_bytes"], int) or integrity["size_bytes"] < 0:
|
||||
raise EventExportError("evidence size must be a non-negative integer")
|
||||
else:
|
||||
if "content_type" in result or "integrity" in result or "failure" not in result:
|
||||
raise EventExportError("failed evidence requires only failure metadata")
|
||||
failure = result["failure"]
|
||||
if not isinstance(failure, Mapping) or set(failure) != {"code", "retryable"}:
|
||||
raise EventExportError("evidence failure is malformed")
|
||||
if failure["code"] not in {"capture_failed", "processing_failed", "expired", "unavailable"}:
|
||||
raise EventExportError("unsupported evidence failure code")
|
||||
if not isinstance(failure["retryable"], bool):
|
||||
raise EventExportError("evidence retryable must be boolean")
|
||||
_reject_unsafe(result)
|
||||
return result
|
||||
|
||||
|
||||
def _reference(name: str, value: object) -> str:
|
||||
if not isinstance(value, str) or not _REFERENCE.fullmatch(value):
|
||||
raise EventExportError(f"{name} is not a valid logical reference")
|
||||
return value
|
||||
|
||||
|
||||
def _bounded_text(name: str, value: object, maximum: int) -> str:
|
||||
if not isinstance(value, str) or not 1 <= len(value) <= maximum:
|
||||
raise EventExportError(f"{name} must be 1..{maximum} characters")
|
||||
return value
|
||||
|
||||
|
||||
def _event_timestamp(nanoseconds: int) -> str:
|
||||
if isinstance(nanoseconds, bool) or not isinstance(nanoseconds, int) or nanoseconds < 0:
|
||||
raise EventExportError("occurred_at_ns must be a non-negative integer")
|
||||
seconds, remainder = divmod(nanoseconds, 1_000_000_000)
|
||||
value = datetime(1970, 1, 1, tzinfo=timezone.utc) + timedelta(
|
||||
seconds=seconds, milliseconds=remainder // 1_000_000
|
||||
)
|
||||
return value.strftime("%Y-%m-%dT%H:%M:%S.") + f"{value.microsecond // 1000:03d}Z"
|
||||
|
||||
|
||||
def _date_time(name: str, value: object) -> None:
|
||||
if not isinstance(value, str):
|
||||
raise EventExportError(f"evidence {name} must be a date-time string")
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise EventExportError(f"evidence {name} must be a valid date-time") from exc
|
||||
if parsed.tzinfo is None:
|
||||
raise EventExportError(f"evidence {name} must include a timezone")
|
||||
|
||||
|
||||
def _reject_unsafe(value: object, *, key: str = "") -> None:
|
||||
if isinstance(value, Mapping):
|
||||
for child_key, child in value.items():
|
||||
lowered = str(child_key).lower()
|
||||
if lowered in _SENSITIVE_NAMES or lowered.endswith("_path") or lowered.endswith("_url"):
|
||||
raise EventExportError(f"sensitive field {child_key!r} is forbidden")
|
||||
_reject_unsafe(child, key=lowered)
|
||||
elif isinstance(value, (list, tuple)):
|
||||
for child in value:
|
||||
_reject_unsafe(child, key=key)
|
||||
elif isinstance(value, str):
|
||||
if _URL.match(value) or _WINDOWS_PATH.match(value) or value.startswith(("/", "\\\\")):
|
||||
raise EventExportError(f"path or URL value in {key or 'payload'} is forbidden")
|
||||
|
||||
|
||||
def _normalize_numbers(value: object) -> object:
|
||||
if value is None or isinstance(value, (str, bool, int)):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
if not math.isfinite(value):
|
||||
raise EventExportError("canonical JSON rejects non-finite numbers")
|
||||
if value == 0:
|
||||
return 0
|
||||
return value
|
||||
if isinstance(value, Mapping):
|
||||
if any(not isinstance(key, str) for key in value):
|
||||
raise EventExportError("canonical JSON object keys must be strings")
|
||||
return {key: _normalize_numbers(child) for key, child in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_normalize_numbers(child) for child in value]
|
||||
raise EventExportError(f"canonical JSON does not support {type(value).__name__}")
|
||||
|
||||
|
||||
def _encode_jcs(value: object) -> str:
|
||||
if value is None:
|
||||
return "null"
|
||||
if value is True:
|
||||
return "true"
|
||||
if value is False:
|
||||
return "false"
|
||||
if isinstance(value, str):
|
||||
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||
if isinstance(value, int):
|
||||
return str(value)
|
||||
if isinstance(value, float):
|
||||
return _jcs_float(value)
|
||||
if isinstance(value, list):
|
||||
return "[" + ",".join(_encode_jcs(item) for item in value) + "]"
|
||||
if isinstance(value, Mapping):
|
||||
# Frozen contract keys are ASCII. Sorting them is therefore identical
|
||||
# to RFC 8785's UTF-16 code-unit ordering without accepting extensions.
|
||||
return "{" + ",".join(
|
||||
_encode_jcs(key) + ":" + _encode_jcs(value[key]) for key in sorted(value)
|
||||
) + "}"
|
||||
raise EventExportError(f"canonical JSON does not support {type(value).__name__}")
|
||||
|
||||
|
||||
def _jcs_float(value: float) -> str:
|
||||
if not math.isfinite(value):
|
||||
raise EventExportError("canonical JSON rejects non-finite numbers")
|
||||
if value == 0:
|
||||
return "0"
|
||||
rendered = repr(value).lower()
|
||||
absolute = abs(value)
|
||||
if 1e-6 <= absolute < 1e21 and "e" in rendered:
|
||||
return format(Decimal(rendered), "f")
|
||||
if "e" in rendered:
|
||||
mantissa, exponent = rendered.split("e", 1)
|
||||
sign = ""
|
||||
if exponent.startswith(("+", "-")):
|
||||
sign, exponent = exponent[0], exponent[1:]
|
||||
exponent = exponent.lstrip("0") or "0"
|
||||
rendered = mantissa + "e" + sign + exponent
|
||||
return rendered[:-2] if rendered.endswith(".0") else rendered
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Durable atomic replay protection owned by the Brain connector."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class SQLiteReplayCache:
|
||||
"""SQLite implementation of the machine-identity ``ReplayCache`` protocol.
|
||||
|
||||
A primary key makes consumption atomic across threads and processes. Entries
|
||||
remain durable across connector restarts until their verifier expiry passes.
|
||||
"""
|
||||
|
||||
def __init__(self, database: str | Path, *, timeout_seconds: float = 5.0) -> None:
|
||||
self._database = str(Path(database))
|
||||
self._timeout_seconds = timeout_seconds
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("SQLite replay timeout must be positive")
|
||||
with self._connect() as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS machine_token_replay (
|
||||
principal TEXT NOT NULL,
|
||||
token_id TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (principal, token_id)
|
||||
) WITHOUT ROWID
|
||||
"""
|
||||
)
|
||||
|
||||
def consume(self, principal: str, token_id: str, expires_at: int, now: int) -> bool:
|
||||
if not principal or not token_id:
|
||||
raise ValueError("replay identity must be non-empty")
|
||||
if any(isinstance(value, bool) or not isinstance(value, int) for value in (expires_at, now)):
|
||||
raise ValueError("replay timestamps must be integers")
|
||||
if expires_at <= now:
|
||||
return False
|
||||
|
||||
connection = self._connect()
|
||||
try:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute("DELETE FROM machine_token_replay WHERE expires_at <= ?", (now,))
|
||||
try:
|
||||
connection.execute(
|
||||
"INSERT INTO machine_token_replay (principal, token_id, expires_at) VALUES (?, ?, ?)",
|
||||
(principal, token_id, expires_at),
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
connection.rollback()
|
||||
return False
|
||||
connection.commit()
|
||||
return True
|
||||
except BaseException:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(
|
||||
self._database,
|
||||
timeout=self._timeout_seconds,
|
||||
isolation_level=None,
|
||||
)
|
||||
connection.execute(f"PRAGMA busy_timeout={int(self._timeout_seconds * 1000)}")
|
||||
return connection
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Closed runtime configuration and event sink for Brain-to-Sense export."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Mapping
|
||||
|
||||
from yovision_brain.events import InternalEventCandidate
|
||||
from yovision_brain.integration.machine_identity import (
|
||||
Signer,
|
||||
TransportPolicy,
|
||||
load_private_key,
|
||||
)
|
||||
|
||||
from .client import HTTPSMachineIdentitySender
|
||||
from .mapper import canonical_json_bytes, export_event
|
||||
|
||||
|
||||
class EventExportSink:
|
||||
def __init__(
|
||||
self,
|
||||
sender: HTTPSMachineIdentitySender,
|
||||
*,
|
||||
producer_id: str,
|
||||
site_ref: str,
|
||||
severity: str,
|
||||
region_refs: Mapping[str, str],
|
||||
crossing_directions: Mapping[str, str],
|
||||
) -> None:
|
||||
self._sender = sender
|
||||
self._producer_id = producer_id
|
||||
self._site_ref = site_ref
|
||||
self._severity = severity
|
||||
self._region_refs = dict(region_refs)
|
||||
self._crossing_directions = dict(crossing_directions)
|
||||
|
||||
def write(self, candidate: InternalEventCandidate) -> None:
|
||||
event = export_event(
|
||||
candidate,
|
||||
producer_id=self._producer_id,
|
||||
site_ref=self._site_ref,
|
||||
severity=self._severity,
|
||||
region_ref=self._region_refs.get(candidate.rule_id),
|
||||
crossing_direction=self._crossing_directions.get(candidate.rule_id),
|
||||
)
|
||||
# Mapping and serialization happen before minting, so the exact bytes are
|
||||
# bound to the machine token and remain unchanged for this delivery.
|
||||
self._sender.send(canonical_json_bytes(event))
|
||||
|
||||
|
||||
def build_event_export_sink(raw: object, *, base_dir: Path) -> EventExportSink | None:
|
||||
if raw is None:
|
||||
return None
|
||||
config = _object("event_export", raw)
|
||||
_closed(
|
||||
"event_export",
|
||||
config,
|
||||
{
|
||||
"enabled", "endpoint", "producer_id", "site_ref", "severity",
|
||||
"region_refs", "crossing_directions", "machine_identity", "transport",
|
||||
},
|
||||
)
|
||||
enabled = config.get("enabled", False)
|
||||
if not isinstance(enabled, bool):
|
||||
raise ValueError("event_export.enabled must be a boolean")
|
||||
if not enabled:
|
||||
if set(config) != {"enabled"}:
|
||||
raise ValueError("disabled event_export may only contain enabled")
|
||||
return None
|
||||
|
||||
identity = _object("event_export.machine_identity", config.get("machine_identity"))
|
||||
_closed(
|
||||
"event_export.machine_identity",
|
||||
identity,
|
||||
{"principal", "key_id", "private_key_path"},
|
||||
)
|
||||
transport_raw = _object("event_export.transport", config.get("transport"))
|
||||
_closed(
|
||||
"event_export.transport",
|
||||
transport_raw,
|
||||
{
|
||||
"tls_min_version", "verify_certificate", "verify_hostname",
|
||||
"connect_timeout_ms", "response_header_timeout_ms", "request_timeout_ms",
|
||||
"max_request_bytes",
|
||||
},
|
||||
)
|
||||
policy = TransportPolicy(**transport_raw) # type: ignore[arg-type]
|
||||
policy.validate()
|
||||
key_path = _string("private_key_path", identity.get("private_key_path"))
|
||||
resolved_key_path = Path(key_path)
|
||||
if not resolved_key_path.is_absolute():
|
||||
resolved_key_path = base_dir / resolved_key_path
|
||||
signer = Signer(
|
||||
_string("principal", identity.get("principal")),
|
||||
_string("key_id", identity.get("key_id")),
|
||||
load_private_key(resolved_key_path),
|
||||
)
|
||||
return EventExportSink(
|
||||
HTTPSMachineIdentitySender(
|
||||
_string("endpoint", config.get("endpoint")), signer, policy
|
||||
),
|
||||
producer_id=_string("producer_id", config.get("producer_id")),
|
||||
site_ref=_string("site_ref", config.get("site_ref")),
|
||||
severity=_string("severity", config.get("severity")),
|
||||
region_refs=_string_map("region_refs", config.get("region_refs", {})),
|
||||
crossing_directions=_string_map(
|
||||
"crossing_directions", config.get("crossing_directions", {})
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _object(name: str, value: object) -> Mapping[str, object]:
|
||||
if not isinstance(value, Mapping) or any(not isinstance(key, str) for key in value):
|
||||
raise ValueError(f"{name} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _closed(name: str, value: Mapping[str, object], allowed: set[str]) -> None:
|
||||
unknown = set(value) - allowed
|
||||
if unknown:
|
||||
raise ValueError(f"{name} contains unsupported fields")
|
||||
|
||||
|
||||
def _string(name: str, value: object) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(f"event_export.{name} must be a non-empty string")
|
||||
return value
|
||||
|
||||
|
||||
def _string_map(name: str, value: object) -> Mapping[str, str]:
|
||||
mapping = _object(f"event_export.{name}", value)
|
||||
if any(not isinstance(item, str) or not item for item in mapping.values()):
|
||||
raise ValueError(f"event_export.{name} values must be non-empty strings")
|
||||
return mapping # type: ignore[return-value]
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Credential-free Sense control-plane connector for Brain."""
|
||||
|
||||
from .consumer import ApplyResult, SourceConfigConsumer, SourceConfigError
|
||||
from .replay import SQLiteReplayStore
|
||||
from .status import RuntimeStatusPublisher
|
||||
|
||||
__all__ = ["ApplyResult", "SourceConfigConsumer", "SourceConfigError", "SQLiteReplayStore", "RuntimeStatusPublisher"]
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Machine-authenticated adapters with bounded timeout/backoff and a kill switch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
|
||||
from yovision_brain.integration.machine_identity.token import Signer, Verifier, bearer_token
|
||||
|
||||
from .consumer import ApplyResult, SourceConfigConsumer
|
||||
|
||||
_REQUEST_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConnectorResponse:
|
||||
status: int
|
||||
body: bytes
|
||||
correlation_id: str
|
||||
|
||||
|
||||
class SourceConfigEndpoint:
|
||||
def __init__(self, consumer: SourceConfigConsumer, verifier: Verifier, *, enabled: bool = True, max_body_bytes: int = 10 * 1024 * 1024) -> None:
|
||||
self._consumer, self._verifier, self._enabled, self._max = consumer, verifier, enabled, max_body_bytes
|
||||
|
||||
def receive(self, authorization: str, body: bytes, correlation_id: str) -> ApplyResult:
|
||||
if not self._enabled: raise RuntimeError("CONNECTOR_DISABLED")
|
||||
if not _REQUEST_ID.fullmatch(correlation_id): raise ValueError("INVALID_CORRELATION_ID")
|
||||
if len(body) > self._max: raise ValueError("REQUEST_TOO_LARGE")
|
||||
token=bearer_token(authorization)
|
||||
self._verifier.verify(token,"yovision-brain","source-config:write","POST","/machine/v1/source-config",body)
|
||||
return self._consumer.apply(body)
|
||||
|
||||
|
||||
class StatusSender:
|
||||
def __init__(self, signer: Signer, send: Callable[[str, bytes, str, float], ConnectorResponse], *, enabled: bool = True, timeout_seconds: float = 5.0, max_attempts: int = 4, sleeper: Callable[[float], None] = time.sleep) -> None:
|
||||
if timeout_seconds <= 0 or max_attempts < 1: raise ValueError("invalid connector retry policy")
|
||||
self._signer,self._send,self._enabled,self._timeout,self._attempts,self._sleep=signer,send,enabled,timeout_seconds,max_attempts,sleeper
|
||||
|
||||
def publish(self, body: bytes, correlation_id: str) -> ConnectorResponse:
|
||||
if not self._enabled: raise RuntimeError("CONNECTOR_DISABLED")
|
||||
if not _REQUEST_ID.fullmatch(correlation_id): raise ValueError("INVALID_CORRELATION_ID")
|
||||
last: Exception|None=None
|
||||
for attempt in range(self._attempts):
|
||||
try:
|
||||
# A retry gets a fresh jti: the previous request may have been
|
||||
# accepted even when its response was lost.
|
||||
token=self._signer.mint("yovision-sense",("runtime-status:write",),"POST","/machine/v1/runtime-status",body)
|
||||
response=self._send("Bearer "+token,body,correlation_id,self._timeout)
|
||||
if 200<=response.status<300:return response
|
||||
if response.status<500:raise RuntimeError(f"STATUS_REJECTED_{response.status}")
|
||||
last=RuntimeError(f"STATUS_REMOTE_{response.status}")
|
||||
except (TimeoutError,ConnectionError) as exc:last=exc
|
||||
if attempt+1<self._attempts:self._sleep(min(2**attempt,30))
|
||||
raise RuntimeError("STATUS_DELIVERY_EXHAUSTED") from last
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Strict source-config/v1 validation and atomic last-known-good application."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from contextlib import closing
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Callable, Mapping
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from yovision_brain.rules.models import AreaDefinition, DirectionalLineDefinition, NormalizedPoint, RuleSet
|
||||
|
||||
VERSION = "yovision.source-config/v1"
|
||||
_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$")
|
||||
_EXTENSION_NAMESPACE = re.compile(r"^[A-Za-z][A-Za-z0-9.-]{0,127}$")
|
||||
_SECRET = re.compile(r"password|secret|credential|cookie|jwt|username|stream_uri", re.I)
|
||||
|
||||
|
||||
class SourceConfigError(ValueError):
|
||||
def __init__(self, code: str) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AppliedConfig:
|
||||
config_id: str
|
||||
revision: int
|
||||
logical_device_id: str
|
||||
media_ref: str
|
||||
profile_encoding: str
|
||||
frame_rate: float
|
||||
rule_state: str
|
||||
rules: RuleSet | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApplyResult:
|
||||
config_id: str
|
||||
revision: int
|
||||
state: str
|
||||
config: AppliedConfig | None
|
||||
|
||||
|
||||
class SourceConfigConsumer:
|
||||
"""Persists validated snapshots before atomically changing the active pointer."""
|
||||
|
||||
def __init__(self, state_path: str | Path, *, clock: Callable[[], float] | None = None) -> None:
|
||||
self._path = str(state_path)
|
||||
self._clock = clock or time.time
|
||||
self._lock = threading.RLock()
|
||||
with closing(self._connect()) as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS source_snapshots (
|
||||
config_id TEXT NOT NULL, revision INTEGER NOT NULL, effective_at INTEGER NOT NULL,
|
||||
state TEXT NOT NULL, payload TEXT NOT NULL, PRIMARY KEY(config_id, revision));
|
||||
CREATE TABLE IF NOT EXISTS source_active (
|
||||
config_id TEXT PRIMARY KEY, revision INTEGER NOT NULL,
|
||||
FOREIGN KEY(config_id, revision) REFERENCES source_snapshots(config_id, revision));
|
||||
"""
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self._path, timeout=5)
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
return connection
|
||||
|
||||
def apply(self, body: bytes) -> ApplyResult:
|
||||
document = _parse_and_validate(body)
|
||||
config_id, revision = document["config_id"], document["revision"]
|
||||
mapped = _map(document)
|
||||
effective_at = int(_timestamp(document["effective_at"]))
|
||||
with self._lock, closing(self._connect()) as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
latest = connection.execute(
|
||||
"SELECT revision, payload, state, effective_at FROM source_snapshots WHERE config_id=? ORDER BY revision DESC LIMIT 1",
|
||||
(config_id,),
|
||||
).fetchone()
|
||||
if latest and revision < latest[0]:
|
||||
connection.rollback()
|
||||
raise SourceConfigError("STALE_REVISION")
|
||||
canonical = body.decode("utf-8")
|
||||
if latest and revision == latest[0]:
|
||||
if json.loads(latest[1]) != document:
|
||||
connection.rollback()
|
||||
raise SourceConfigError("REVISION_CONFLICT")
|
||||
connection.rollback()
|
||||
return ApplyResult(config_id, revision, "idempotent", self.get_active(config_id))
|
||||
connection.execute(
|
||||
"INSERT INTO source_snapshots(config_id, revision, effective_at, state, payload) VALUES (?, ?, ?, ?, ?)",
|
||||
(config_id, revision, effective_at, document["rule_set"]["state"], canonical),
|
||||
)
|
||||
if effective_at <= int(self._clock()):
|
||||
connection.execute(
|
||||
"INSERT INTO source_active(config_id, revision) VALUES (?, ?) ON CONFLICT(config_id) DO UPDATE SET revision=excluded.revision",
|
||||
(config_id, revision),
|
||||
)
|
||||
state = "applied"
|
||||
else:
|
||||
state = "scheduled"
|
||||
connection.commit()
|
||||
return ApplyResult(config_id, revision, state, mapped if state == "applied" else self.get_active(config_id))
|
||||
|
||||
def activate_due(self) -> tuple[AppliedConfig, ...]:
|
||||
now = int(self._clock())
|
||||
activated: list[AppliedConfig] = []
|
||||
with self._lock, closing(self._connect()) as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
rows = connection.execute(
|
||||
"SELECT s.payload FROM source_snapshots s JOIN (SELECT config_id, MAX(revision) revision FROM source_snapshots WHERE effective_at<=? GROUP BY config_id) d ON d.config_id=s.config_id AND d.revision=s.revision",
|
||||
(now,),
|
||||
).fetchall()
|
||||
for (payload,) in rows:
|
||||
document = json.loads(payload)
|
||||
connection.execute(
|
||||
"INSERT INTO source_active(config_id, revision) VALUES (?, ?) ON CONFLICT(config_id) DO UPDATE SET revision=excluded.revision",
|
||||
(document["config_id"], document["revision"]),
|
||||
)
|
||||
activated.append(_map(document))
|
||||
connection.commit()
|
||||
return tuple(activated)
|
||||
|
||||
def get_active(self, config_id: str) -> AppliedConfig | None:
|
||||
with closing(self._connect()) as connection:
|
||||
row = connection.execute(
|
||||
"SELECT s.payload FROM source_active a JOIN source_snapshots s ON s.config_id=a.config_id AND s.revision=a.revision WHERE a.config_id=?",
|
||||
(config_id,),
|
||||
).fetchone()
|
||||
return _map(json.loads(row[0])) if row else None
|
||||
|
||||
|
||||
def _parse_and_validate(body: bytes) -> dict[str, object]:
|
||||
try:
|
||||
document = json.loads(body.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
raise SourceConfigError("CONFIG_INVALID") from None
|
||||
if not isinstance(document, dict):
|
||||
raise SourceConfigError("CONFIG_INVALID")
|
||||
if document.get("schema_version") != VERSION:
|
||||
raise SourceConfigError("UNSUPPORTED_SCHEMA_VERSION")
|
||||
if _contains_secret(document):
|
||||
raise SourceConfigError("CONFIG_INVALID")
|
||||
required = {"schema_version", "config_id", "revision", "published_at", "effective_at", "site", "logical_device", "profile", "media", "rule_set", "integrity"}
|
||||
if set(document) - (required | {"extensions"}) or not required <= set(document):
|
||||
raise SourceConfigError("CONFIG_INVALID")
|
||||
extensions = document.get("extensions", {})
|
||||
if not isinstance(extensions, dict) or any(
|
||||
not isinstance(namespace, str)
|
||||
or not _EXTENSION_NAMESPACE.fullmatch(namespace)
|
||||
or not isinstance(value, dict)
|
||||
for namespace, value in extensions.items()
|
||||
):
|
||||
raise SourceConfigError("CONFIG_INVALID")
|
||||
integrity = document.get("integrity")
|
||||
if not isinstance(integrity, dict) or set(integrity) != {"algorithm", "value"} or integrity.get("algorithm") != "sha256":
|
||||
raise SourceConfigError("CONFIG_INVALID")
|
||||
unsigned = dict(document); unsigned.pop("integrity")
|
||||
digest = hashlib.sha256(json.dumps(unsigned, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode()).hexdigest()
|
||||
if not hmac.compare_digest(digest, str(integrity.get("value", ""))):
|
||||
raise SourceConfigError("CONFIG_INVALID")
|
||||
try:
|
||||
if not _ID.fullmatch(document["config_id"]) or isinstance(document["revision"], bool) or document["revision"] < 1:
|
||||
raise ValueError
|
||||
published, effective = _timestamp(document["published_at"]), _timestamp(document["effective_at"])
|
||||
if effective < published:
|
||||
raise ValueError
|
||||
for field in ("site", "logical_device"):
|
||||
if not isinstance(document[field], dict) or set(document[field]) != {"id"} or not _ID.fullmatch(document[field]["id"]): raise ValueError
|
||||
_validate_profile(document["profile"])
|
||||
media = document["media"]
|
||||
if not isinstance(media, dict) or set(media) != {"ref", "transport"} or media["transport"] != "rtsp" or not isinstance(media["ref"], str) or not media["ref"].startswith("media:") or any(marker in media["ref"] for marker in ("?", "#", "@", "\\", "://")): raise ValueError
|
||||
_validate_rules(document["rule_set"], document["profile"])
|
||||
except (KeyError, TypeError, ValueError, AttributeError):
|
||||
raise SourceConfigError("CONFIG_INVALID") from None
|
||||
return document
|
||||
|
||||
|
||||
def _validate_profile(profile: object) -> None:
|
||||
if not isinstance(profile, dict) or set(profile) != {"id", "width", "height", "encoding", "frame_rate"}: raise ValueError
|
||||
if not _ID.fullmatch(profile["id"]) or profile["encoding"] not in {"H264", "H265", "MJPEG"}: raise ValueError
|
||||
for field in ("width", "height"):
|
||||
if isinstance(profile[field], bool) or not isinstance(profile[field], int) or profile[field] < 1: raise ValueError
|
||||
if isinstance(profile["frame_rate"], bool) or not isinstance(profile["frame_rate"], (int, float)) or profile["frame_rate"] <= 0: raise ValueError
|
||||
|
||||
|
||||
def _validate_rules(rules: object, profile: Mapping[str, object]) -> None:
|
||||
if not isinstance(rules, dict) or set(rules) != {"version", "state", "profile_binding", "areas", "directional_lines"}: raise ValueError
|
||||
if not _ID.fullmatch(rules["version"]) or rules["state"] not in {"active", "disabled", "recalibration_required"}: raise ValueError
|
||||
binding = rules["profile_binding"]
|
||||
if binding != {"profile_id": profile["id"], "width": profile["width"], "height": profile["height"]}: raise ValueError
|
||||
if not isinstance(rules["areas"], list) or not isinstance(rules["directional_lines"], list) or len(rules["areas"]) > 1024 or len(rules["directional_lines"]) > 1024: raise ValueError
|
||||
identifiers: set[str] = set()
|
||||
for area in rules["areas"]:
|
||||
if not isinstance(area, dict) or set(area) != {"id", "version", "kind", "enabled", "points"} or area["kind"] != "danger_area" or not isinstance(area["enabled"], bool) or not 3 <= len(area["points"]) <= 256: raise ValueError
|
||||
_rule_identity(area, identifiers); points = tuple(_point(value) for value in area["points"])
|
||||
polygon = sum(a[0]*b[1]-b[0]*a[1] for a,b in zip(points, points[1:]+points[:1])) / 2
|
||||
if abs(polygon) < 1e-9: raise ValueError
|
||||
for line in rules["directional_lines"]:
|
||||
if not isinstance(line, dict) or set(line) != {"id", "version", "kind", "enabled", "start", "end", "trigger_direction"} or line["kind"] != "directional_line" or not isinstance(line["enabled"], bool) or line["trigger_direction"] not in {"left_to_right", "right_to_left"}: raise ValueError
|
||||
_rule_identity(line, identifiers)
|
||||
if _point(line["start"]) == _point(line["end"]): raise ValueError
|
||||
|
||||
|
||||
def _rule_identity(rule: Mapping[str, object], identifiers: set[str]) -> None:
|
||||
if not isinstance(rule["id"], str) or not _ID.fullmatch(rule["id"]) or rule["id"] in identifiers or isinstance(rule["version"], bool) or not isinstance(rule["version"], int) or rule["version"] < 1: raise ValueError
|
||||
identifiers.add(rule["id"])
|
||||
|
||||
|
||||
def _point(value: object) -> tuple[float, float]:
|
||||
if not isinstance(value, dict) or set(value) != {"x", "y"}: raise ValueError
|
||||
x, y = value["x"], value["y"]
|
||||
if isinstance(x, bool) or isinstance(y, bool) or not isinstance(x, (int,float)) or not isinstance(y,(int,float)) or not 0 <= x <= 1 or not 0 <= y <= 1: raise ValueError
|
||||
return float(x), float(y)
|
||||
|
||||
|
||||
def _map(document: Mapping[str, object]) -> AppliedConfig:
|
||||
profile, rules = document["profile"], document["rule_set"]
|
||||
rule_set = None
|
||||
if rules["state"] == "active":
|
||||
rule_set = RuleSet(
|
||||
version=rules["version"], profile_id=profile["id"], width=profile["width"], height=profile["height"],
|
||||
areas=tuple(AreaDefinition(a["id"], tuple(NormalizedPoint(**p) for p in a["points"])) for a in rules["areas"] if a["enabled"]),
|
||||
directional_lines=tuple(DirectionalLineDefinition(l["id"], NormalizedPoint(**l["start"]), NormalizedPoint(**l["end"]), l["trigger_direction"]) for l in rules["directional_lines"] if l["enabled"]),
|
||||
)
|
||||
return AppliedConfig(document["config_id"], document["revision"], document["logical_device"]["id"], document["media"]["ref"], profile["encoding"], float(profile["frame_rate"]), rules["state"], rule_set)
|
||||
|
||||
|
||||
def _timestamp(value: object) -> float:
|
||||
if not isinstance(value, str) or not value.endswith("Z"): raise ValueError
|
||||
return datetime.fromisoformat(value[:-1] + "+00:00").astimezone(timezone.utc).timestamp()
|
||||
|
||||
|
||||
def _contains_secret(value: object) -> bool:
|
||||
if isinstance(value, dict): return any(_SECRET.search(str(k)) or _contains_secret(v) for k,v in value.items())
|
||||
if isinstance(value, list): return any(_contains_secret(item) for item in value)
|
||||
if isinstance(value, str):
|
||||
split=urlsplit(value)
|
||||
return bool(split.username or split.password or value.startswith("file:") or re.match(r"^[A-Za-z]:[\\/]", value))
|
||||
return False
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Brain-owned durable replay storage; never shared with Sense or Bell."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import threading
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class SQLiteReplayStore:
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self._path = str(path)
|
||||
self._lock = threading.Lock()
|
||||
with closing(self._connect()) as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"CREATE TABLE IF NOT EXISTS machine_replay (principal TEXT NOT NULL, token_id TEXT NOT NULL, expires_at INTEGER NOT NULL, PRIMARY KEY(principal, token_id))"
|
||||
)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self._path, timeout=5, isolation_level=None)
|
||||
connection.execute("PRAGMA busy_timeout=5000")
|
||||
return connection
|
||||
|
||||
def consume(self, principal: str, token_id: str, expires_at: int, now: int) -> bool:
|
||||
if not principal or not token_id or expires_at <= now:
|
||||
return False
|
||||
with self._lock, closing(self._connect()) as connection:
|
||||
try:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute("DELETE FROM machine_replay WHERE expires_at <= ?", (now,))
|
||||
connection.execute(
|
||||
"INSERT INTO machine_replay(principal, token_id, expires_at) VALUES (?, ?, ?)",
|
||||
(principal, token_id, expires_at),
|
||||
)
|
||||
connection.execute("COMMIT")
|
||||
return True
|
||||
except sqlite3.IntegrityError:
|
||||
connection.execute("ROLLBACK")
|
||||
return False
|
||||
except Exception:
|
||||
connection.execute("ROLLBACK")
|
||||
return False
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Persistent sequence allocation and safe runtime-status/v1 production."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import uuid
|
||||
from contextlib import closing
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Mapping, Sequence
|
||||
|
||||
_LOGICAL_REF = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||||
_VERSION = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$")
|
||||
_ERROR_CODE = re.compile(r"^[A-Z][A-Z0-9_]{2,63}$")
|
||||
_RUNTIME_STATES = {"unconfigured", "starting", "running", "degraded", "failed", "stopped"}
|
||||
|
||||
|
||||
class RuntimeStatusPublisher:
|
||||
def __init__(self, state_path: str | Path, brain_instance_ref: str) -> None:
|
||||
self._path, self._instance, self._lock = str(state_path), brain_instance_ref, threading.Lock()
|
||||
with closing(self._connect()) as connection:
|
||||
connection.execute("CREATE TABLE IF NOT EXISTS runtime_sequence(instance_ref TEXT PRIMARY KEY, sequence INTEGER NOT NULL)")
|
||||
connection.commit()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
return sqlite3.connect(self._path, timeout=5)
|
||||
|
||||
def build(self, *, runtime_state: str, runtime_version: str, started_at: datetime | None, model_ref: str, model_version: str, configurations: Sequence[Mapping[str, object]], health: Mapping[str, object], inputs: Sequence[Mapping[str, object]], observed_at: datetime | None = None) -> bytes:
|
||||
_validate(runtime_state, runtime_version, self._instance, model_ref, model_version, configurations, health, inputs)
|
||||
with self._lock, closing(self._connect()) as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row=connection.execute("SELECT sequence FROM runtime_sequence WHERE instance_ref=?",(self._instance,)).fetchone();sequence=(row[0]+1) if row else 0
|
||||
connection.execute("INSERT INTO runtime_sequence(instance_ref,sequence) VALUES(?,?) ON CONFLICT(instance_ref) DO UPDATE SET sequence=excluded.sequence",(self._instance,sequence));connection.commit()
|
||||
observed=(observed_at or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||
document={"schema_version":"yovision.runtime-status/v1","status_id":str(uuid.uuid4()),"brain_instance_ref":self._instance,"sequence":sequence,"observed_at":_utc(observed),"runtime":{"state":runtime_state,"version":runtime_version,"started_at":_utc(started_at) if started_at else None},"model":{"model_ref":model_ref,"version":model_version},"configurations":list(configurations),"health":dict(health),"inputs":list(inputs)}
|
||||
raw=json.dumps(document,separators=(",",":"),sort_keys=True).encode()
|
||||
lowered=raw.lower();
|
||||
for marker in (b"password",b"credential",b"stream_uri",b"cookie",b"jwt",b"file://"):
|
||||
if marker in lowered: raise ValueError("runtime status contains sensitive field")
|
||||
return raw
|
||||
|
||||
|
||||
def _utc(value: datetime) -> str:
|
||||
if value.tzinfo is None: raise ValueError("runtime timestamp must be timezone-aware")
|
||||
return value.astimezone(timezone.utc).isoformat(timespec="seconds").replace("+00:00","Z")
|
||||
|
||||
|
||||
def _validate(runtime_state: str, runtime_version: str, instance: str, model_ref: str, model_version: str, configurations: Sequence[Mapping[str, object]], health: Mapping[str, object], inputs: Sequence[Mapping[str, object]]) -> None:
|
||||
if runtime_state not in _RUNTIME_STATES or not _VERSION.fullmatch(runtime_version) or not _LOGICAL_REF.fullmatch(instance) or not _LOGICAL_REF.fullmatch(model_ref) or not _VERSION.fullmatch(model_version):
|
||||
raise ValueError("invalid runtime identity or version")
|
||||
if len(configurations) > 4096 or len(inputs) > 4096:
|
||||
raise ValueError("runtime status collection too large")
|
||||
seen: set[str] = set()
|
||||
for item in configurations:
|
||||
if set(item) != {"config_id", "apply_state", "applied_revision", "error_code"} or not isinstance(item["config_id"], str) or not _LOGICAL_REF.fullmatch(item["config_id"]) or item["config_id"] in seen:
|
||||
raise ValueError("invalid configuration status")
|
||||
seen.add(item["config_id"]); state=item["apply_state"]; revision=item["applied_revision"]; error=item["error_code"]
|
||||
if state not in {"not_configured","applying","applied","rejected"} or (state=="not_configured" and revision is not None) or (state=="applied" and (isinstance(revision,bool) or not isinstance(revision,int) or revision<1)) or (state=="rejected" and (not isinstance(error,str) or not _ERROR_CODE.fullmatch(error))):
|
||||
raise ValueError("invalid configuration status")
|
||||
if set(health) != {"overall","error_codes","metrics"} or health["overall"] not in {"healthy","degraded","unhealthy"} or not _codes(health["error_codes"],32) or not _metrics(health["metrics"]):
|
||||
raise ValueError("invalid health status")
|
||||
for item in inputs:
|
||||
if set(item) != {"input_ref","state","error_codes","metrics"} or not isinstance(item["input_ref"],str) or not _LOGICAL_REF.fullmatch(item["input_ref"]) or item["state"] not in _RUNTIME_STATES or not _codes(item["error_codes"],16) or not _metrics(item["metrics"]):
|
||||
raise ValueError("invalid input status")
|
||||
|
||||
|
||||
def _codes(value: object, limit: int) -> bool:
|
||||
return isinstance(value,list) and len(value)<=limit and len(set(value))==len(value) and all(isinstance(code,str) and _ERROR_CODE.fullmatch(code) for code in value)
|
||||
|
||||
|
||||
def _metrics(value: object) -> bool:
|
||||
if not isinstance(value,Mapping) or set(value)!={"load_percent","queue_depth","latency_ms"}: return False
|
||||
load,queue,latency=value["load_percent"],value["queue_depth"],value["latency_ms"]
|
||||
return not isinstance(load,bool) and isinstance(load,(int,float)) and 0<=load<=100 and not isinstance(queue,bool) and isinstance(queue,int) and queue>=0 and not isinstance(latency,bool) and isinstance(latency,(int,float)) and latency>=0
|
||||
@@ -0,0 +1,254 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
import yovision_brain.app.__main__ as cli
|
||||
import yovision_brain.integration.event_export.runtime as export_runtime
|
||||
from yovision_brain.integration.event_export import (
|
||||
EventDeliveryError,
|
||||
HTTPSMachineIdentitySender,
|
||||
)
|
||||
from yovision_brain.integration.machine_identity import (
|
||||
KeyRecord,
|
||||
Registry,
|
||||
ReplayStore,
|
||||
Signer,
|
||||
TransportPolicy,
|
||||
Verifier,
|
||||
bearer_token,
|
||||
)
|
||||
|
||||
FIXTURE = Path(__file__).parents[1] / "fixtures" / "events" / "area.json"
|
||||
|
||||
|
||||
def _policy() -> TransportPolicy:
|
||||
return TransportPolicy(
|
||||
tls_min_version="1.2",
|
||||
verify_certificate=True,
|
||||
verify_hostname=True,
|
||||
connect_timeout_ms=1_000,
|
||||
response_header_timeout_ms=1_000,
|
||||
request_timeout_ms=2_000,
|
||||
max_request_bytes=64 * 1024,
|
||||
)
|
||||
|
||||
|
||||
class _Response:
|
||||
status = 202
|
||||
|
||||
def __init__(self, body: bytes, request_id: str) -> None:
|
||||
self.body = body
|
||||
self.headers = {"X-Request-ID": request_id}
|
||||
self.closed = False
|
||||
|
||||
def read(self, amount: int = -1) -> bytes:
|
||||
return self.body[:amount] if amount >= 0 else self.body
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class _VerifyingOpener:
|
||||
def __init__(self, verifier: Verifier) -> None:
|
||||
self.verifier = verifier
|
||||
self.requests = []
|
||||
|
||||
def open(self, request, timeout: float) -> _Response: # noqa: ANN001
|
||||
self.requests.append((request, timeout))
|
||||
body = request.data
|
||||
self.verifier.verify(
|
||||
bearer_token(request.get_header("Authorization")),
|
||||
"yovision-sense",
|
||||
"events:ingest",
|
||||
request.method,
|
||||
"/v1/events",
|
||||
body,
|
||||
)
|
||||
event = json.loads(body)
|
||||
return _Response(json.dumps({
|
||||
"producer_id": event["producer_id"],
|
||||
"source_event_id": event["source_event_id"],
|
||||
"payload_sha256": hashlib.sha256(body).hexdigest(),
|
||||
"disposition": "accepted",
|
||||
}).encode(), request.get_header("X-request-id"))
|
||||
|
||||
|
||||
def test_sender_binds_exact_path_body_and_safe_request_id() -> None:
|
||||
private_key = Ed25519PrivateKey.generate()
|
||||
signer = Signer("yv:brain:school-a", "brain-key-0001", private_key, clock=lambda: 100)
|
||||
registry = Registry(
|
||||
[
|
||||
KeyRecord(
|
||||
principal="yv:brain:school-a",
|
||||
key_id="brain-key-0001",
|
||||
public_key=private_key.public_key(),
|
||||
audience="yovision-sense",
|
||||
scopes=frozenset({"events:ingest"}),
|
||||
)
|
||||
]
|
||||
)
|
||||
opener = _VerifyingOpener(Verifier(registry, ReplayStore(), clock=lambda: 100))
|
||||
sender = HTTPSMachineIdentitySender(
|
||||
"https://sense.example:8443", signer, _policy(), opener=opener
|
||||
)
|
||||
|
||||
result = sender.send(
|
||||
b'{"producer_id":"brain-school-a","schema_version":"yovision.event/v1",'
|
||||
b'"source_event_id":"evt-1"}'
|
||||
)
|
||||
|
||||
request, timeout = opener.requests[0]
|
||||
assert request.full_url == "https://sense.example:8443/v1/events"
|
||||
assert request.method == "POST"
|
||||
assert request.get_header("Content-type") == "application/json"
|
||||
assert re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{15,127}", result.request_id)
|
||||
assert request.get_header("X-request-id") == result.request_id
|
||||
assert (result.disposition, timeout) == ("accepted", 2.0)
|
||||
|
||||
|
||||
def test_sender_rejects_plaintext_and_marks_conflict_terminal() -> None:
|
||||
key = Ed25519PrivateKey.generate()
|
||||
signer = Signer("yv:brain:school-a", "brain-key-0001", key)
|
||||
with pytest.raises(ValueError, match="HTTPS origin"):
|
||||
HTTPSMachineIdentitySender("http://sense.example", signer, _policy())
|
||||
|
||||
class ConflictOpener:
|
||||
def open(self, request, timeout: float): # noqa: ANN001, ARG002
|
||||
raise urllib.error.HTTPError(
|
||||
request.full_url,
|
||||
409,
|
||||
"Conflict",
|
||||
{},
|
||||
io.BytesIO(b'{"code":"event_identity_conflict"}'),
|
||||
)
|
||||
|
||||
sender = HTTPSMachineIdentitySender(
|
||||
"https://sense.example", signer, _policy(), opener=ConflictOpener()
|
||||
)
|
||||
with pytest.raises(EventDeliveryError) as caught:
|
||||
sender.send(b"{}")
|
||||
assert (caught.value.code, caught.value.terminal) == (
|
||||
"event_identity_conflict",
|
||||
True,
|
||||
)
|
||||
|
||||
|
||||
def test_disabled_connector_keeps_existing_json_lines_output(tmp_path: Path) -> None:
|
||||
raw = json.loads(FIXTURE.read_text(encoding="utf-8"))
|
||||
raw["event_export"] = {"enabled": False}
|
||||
config = tmp_path / "brain.json"
|
||||
config.write_text(json.dumps(raw), encoding="utf-8")
|
||||
output = tmp_path / "events.jsonl"
|
||||
|
||||
assert cli.main(["--config", str(config), "--output", str(output)]) == 0
|
||||
assert json.loads(output.read_text(encoding="utf-8"))["schema"] == (
|
||||
"brain.internal.event-candidate/v1"
|
||||
)
|
||||
|
||||
|
||||
def test_delivery_failure_is_nonzero_and_not_silently_reported_as_success(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
class FailingSink:
|
||||
def write(self, candidate) -> None: # noqa: ANN001, ARG002
|
||||
raise EventDeliveryError("event_delivery_unavailable", terminal=False)
|
||||
|
||||
monkeypatch.setattr(cli, "build_event_export_sink", lambda raw, base_dir: FailingSink())
|
||||
unused_output = tmp_path / "disabled-json-lines-target"
|
||||
unused_output.write_text("must remain unchanged", encoding="utf-8")
|
||||
result = cli.main(
|
||||
["--config", str(FIXTURE), "--output", str(unused_output)]
|
||||
)
|
||||
|
||||
assert result == 3
|
||||
error = json.loads(capsys.readouterr().err.splitlines()[0])
|
||||
assert error == {"status": "error", "message": "event_delivery_unavailable"}
|
||||
assert unused_output.read_text(encoding="utf-8") == "must remain unchanged"
|
||||
|
||||
|
||||
def test_inline_private_key_material_is_rejected_without_echo(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
raw = json.loads(FIXTURE.read_text(encoding="utf-8"))
|
||||
marker = "INLINE-PRIVATE-MATERIAL-MUST-NOT-LEAK"
|
||||
raw["event_export"] = {
|
||||
"enabled": True,
|
||||
"endpoint": "https://sense.example",
|
||||
"producer_id": "brain-school-a",
|
||||
"site_ref": "site-school-a",
|
||||
"severity": "high",
|
||||
"region_refs": {},
|
||||
"crossing_directions": {},
|
||||
"machine_identity": {
|
||||
"principal": "yv:brain:school-a",
|
||||
"key_id": "brain-key-0001",
|
||||
"private_key_path": "external.pem",
|
||||
"private_key": marker,
|
||||
},
|
||||
"transport": {},
|
||||
}
|
||||
config = tmp_path / "brain.json"
|
||||
config.write_text(json.dumps(raw), encoding="utf-8")
|
||||
|
||||
assert cli.main(["--config", str(config)]) == 3
|
||||
assert marker not in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_enabled_config_loads_machine_key_only_from_external_path(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
key_path = tmp_path / "brain-machine.pem"
|
||||
key_path.write_bytes(
|
||||
Ed25519PrivateKey.generate().private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption(),
|
||||
)
|
||||
)
|
||||
captured = {}
|
||||
|
||||
class Sender:
|
||||
def __init__(self, endpoint, signer, policy) -> None: # noqa: ANN001
|
||||
captured.update(endpoint=endpoint, signer=signer, policy=policy)
|
||||
|
||||
monkeypatch.setattr(export_runtime, "HTTPSMachineIdentitySender", Sender)
|
||||
sink = export_runtime.build_event_export_sink(
|
||||
{
|
||||
"enabled": True,
|
||||
"endpoint": "https://sense.example",
|
||||
"producer_id": "brain-school-a",
|
||||
"site_ref": "site-school-a",
|
||||
"severity": "high",
|
||||
"region_refs": {},
|
||||
"crossing_directions": {},
|
||||
"machine_identity": {
|
||||
"principal": "yv:brain:school-a",
|
||||
"key_id": "brain-key-0001",
|
||||
"private_key_path": key_path.name,
|
||||
},
|
||||
"transport": {
|
||||
"tls_min_version": "1.2",
|
||||
"verify_certificate": True,
|
||||
"verify_hostname": True,
|
||||
"connect_timeout_ms": 1_000,
|
||||
"response_header_timeout_ms": 1_000,
|
||||
"request_timeout_ms": 2_000,
|
||||
"max_request_bytes": 64 * 1024,
|
||||
},
|
||||
},
|
||||
base_dir=tmp_path,
|
||||
)
|
||||
|
||||
assert sink is not None
|
||||
assert captured["endpoint"] == "https://sense.example"
|
||||
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from yovision_brain.events import INTERNAL_EVENT_SCHEMA, InternalEventCandidate
|
||||
from yovision_brain.integration.event_export import EventExportError, canonical_json, export_event, payload_sha256
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[4]
|
||||
|
||||
|
||||
def _candidate(**changes: object) -> InternalEventCandidate:
|
||||
values: dict[str, object] = {
|
||||
"schema": INTERNAL_EVENT_SCHEMA,
|
||||
"event_id": "evt-area-20260831-0001",
|
||||
"logical_input_id": "camera-east-gate",
|
||||
"event_type": "danger_area_entered",
|
||||
"occurred_at_ns": 1_788_134_401_125_000_000,
|
||||
"rule_id": "rule-east-danger",
|
||||
"rule_version": "3",
|
||||
"model_name": "anonymous-detector",
|
||||
"model_version": "2026.08",
|
||||
"profile_id": "profile-main-stream",
|
||||
"frame_width": 100,
|
||||
"frame_height": 100,
|
||||
"track_id": "track-0042",
|
||||
"observation": {
|
||||
"category": "anonymous_target",
|
||||
"confidence": 0.93,
|
||||
"box": {"left": 12, "top": 20, "right": 31, "bottom": 74},
|
||||
"anchor": {"x": 0.21, "y": 0.74},
|
||||
},
|
||||
"reason": "entered polygon",
|
||||
}
|
||||
values.update(changes)
|
||||
return InternalEventCandidate(**values) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _fixture(relative: str) -> dict[str, object]:
|
||||
return json.loads((ROOT / relative).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_brain_mapper_matches_frozen_producer_fixture_and_jcs_digest() -> None:
|
||||
pending = _fixture("contracts/evidence/v1/examples/pending.json")
|
||||
expected = _fixture("contracts/events/v1/examples/dangerous-area.json")
|
||||
event = export_event(
|
||||
_candidate(),
|
||||
producer_id="brain-school-a",
|
||||
site_ref="site-school-a",
|
||||
severity="high",
|
||||
evidence=[pending],
|
||||
region_ref="region-east-danger",
|
||||
)
|
||||
|
||||
assert event == expected
|
||||
assert payload_sha256(event) == "4cc1e93820195caf713ea675ff33f178c9d4997dd8a81cb61287e9fea0e3d5e1"
|
||||
|
||||
|
||||
def test_retry_mapping_preserves_original_identity_and_payload() -> None:
|
||||
candidate = _candidate()
|
||||
arguments = {
|
||||
"producer_id": "brain-school-a",
|
||||
"site_ref": "site-school-a",
|
||||
"severity": "high",
|
||||
"evidence": [_fixture("contracts/evidence/v1/examples/pending.json")],
|
||||
"region_ref": "region-east-danger",
|
||||
}
|
||||
first = export_event(candidate, **arguments)
|
||||
retry = export_event(candidate, **arguments)
|
||||
|
||||
assert (first["producer_id"], first["source_event_id"]) == (
|
||||
"brain-school-a",
|
||||
candidate.event_id,
|
||||
)
|
||||
assert retry == first
|
||||
assert payload_sha256(retry) == payload_sha256(first)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fixture", ["pending.json", "failed.json"])
|
||||
def test_evidence_degradation_states_are_exported_unchanged(fixture: str) -> None:
|
||||
evidence = _fixture(f"contracts/evidence/v1/examples/{fixture}")
|
||||
candidate = _candidate()
|
||||
if fixture == "failed.json":
|
||||
candidate = _candidate(
|
||||
event_id="evt-line-20260831-0002",
|
||||
logical_input_id="camera-north-corridor",
|
||||
event_type="directional_line_crossed",
|
||||
occurred_at_ns=1_788_134_590_000_000_000,
|
||||
rule_id="rule-north-one-way",
|
||||
rule_version="1",
|
||||
track_id="track-0088",
|
||||
observation={"category": "anonymous_target", "confidence": 0.88},
|
||||
)
|
||||
event = export_event(
|
||||
candidate,
|
||||
producer_id="brain-school-a",
|
||||
site_ref="site-school-a",
|
||||
severity="medium" if fixture == "failed.json" else "high",
|
||||
evidence=[evidence],
|
||||
region_ref="line-north-one-way" if fixture == "failed.json" else "region-east-danger",
|
||||
crossing_direction="b_to_a" if fixture == "failed.json" else None,
|
||||
)
|
||||
|
||||
assert event["evidence"] == [evidence]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"unsafe",
|
||||
[
|
||||
{"local_path": "D:/captures/frame.jpg"},
|
||||
{"url": "https://example.invalid/signed"},
|
||||
{"token": "not-a-real-token"},
|
||||
],
|
||||
)
|
||||
def test_evidence_rejects_paths_urls_and_sensitive_fields(unsafe: dict[str, object]) -> None:
|
||||
evidence = _fixture("contracts/evidence/v1/examples/pending.json")
|
||||
evidence.update(unsafe)
|
||||
with pytest.raises(EventExportError, match="forbidden"):
|
||||
export_event(
|
||||
_candidate(),
|
||||
producer_id="brain-school-a",
|
||||
site_ref="site-school-a",
|
||||
severity="high",
|
||||
evidence=[evidence],
|
||||
region_ref="region-east-danger",
|
||||
)
|
||||
|
||||
|
||||
def test_failed_evidence_cannot_claim_success_content() -> None:
|
||||
evidence = _fixture("contracts/evidence/v1/examples/failed.json")
|
||||
evidence["content_type"] = "video/mp4"
|
||||
with pytest.raises(EventExportError, match="failed evidence"):
|
||||
export_event(
|
||||
_candidate(),
|
||||
producer_id="brain-school-a",
|
||||
site_ref="site-school-a",
|
||||
severity="high",
|
||||
evidence=[evidence],
|
||||
region_ref="region-east-danger",
|
||||
)
|
||||
|
||||
|
||||
def test_jcs_normalizes_number_lexemes_and_negative_zero() -> None:
|
||||
assert canonical_json({"small": 1e-7, "fixed": 1e20, "zero": -0.0}) == (
|
||||
'{"fixed":100000000000000000000,"small":1e-7,"zero":0}'
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
import pytest
|
||||
|
||||
from yovision_brain.integration.event_export import SQLiteReplayCache
|
||||
from yovision_brain.integration.machine_identity import (
|
||||
KeyRecord,
|
||||
MachineIdentityError,
|
||||
Registry,
|
||||
Signer,
|
||||
Verifier,
|
||||
)
|
||||
|
||||
|
||||
def test_replay_cache_rejects_same_jti_after_connector_restart(tmp_path) -> None:
|
||||
now = 1_800_000_000
|
||||
private_key = Ed25519PrivateKey.generate()
|
||||
registry = Registry(
|
||||
[
|
||||
KeyRecord(
|
||||
principal="yv:brain:node-a",
|
||||
key_id="brain-key-0001",
|
||||
public_key=private_key.public_key(),
|
||||
audience="yovision-sense",
|
||||
scopes=frozenset({"events:ingest"}),
|
||||
)
|
||||
]
|
||||
)
|
||||
token = Signer(
|
||||
"yv:brain:node-a",
|
||||
"brain-key-0001",
|
||||
private_key,
|
||||
clock=lambda: now,
|
||||
).mint("yovision-sense", ["events:ingest"], "POST", "/v1/events", b"{}")
|
||||
database = tmp_path / "machine-replay.sqlite3"
|
||||
|
||||
first_process = Verifier(registry, SQLiteReplayCache(database), clock=lambda: now)
|
||||
claims = first_process.verify(
|
||||
token, "yovision-sense", "events:ingest", "POST", "/v1/events", b"{}"
|
||||
)
|
||||
assert claims.iss == "yv:brain:node-a"
|
||||
|
||||
restarted_process = Verifier(registry, SQLiteReplayCache(database), clock=lambda: now)
|
||||
with pytest.raises(MachineIdentityError, match="machine_token_replayed") as caught:
|
||||
restarted_process.verify(
|
||||
token, "yovision-sense", "events:ingest", "POST", "/v1/events", b"{}"
|
||||
)
|
||||
assert caught.value.code == "machine_token_replayed"
|
||||
|
||||
|
||||
def test_replay_cache_atomically_reuses_expired_identity(tmp_path) -> None:
|
||||
cache = SQLiteReplayCache(tmp_path / "machine-replay.sqlite3")
|
||||
assert cache.consume("yv:brain:node-a", "jti-one", expires_at=110, now=100)
|
||||
assert not cache.consume("yv:brain:node-a", "jti-one", expires_at=110, now=101)
|
||||
assert cache.consume("yv:brain:node-a", "jti-one", expires_at=130, now=110)
|
||||
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import threading
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
from yovision_brain.integration.machine_identity.token import KeyRecord, Registry, Signer, Verifier
|
||||
from yovision_brain.integration.sense_control.connector import ConnectorResponse, SourceConfigEndpoint, StatusSender
|
||||
from yovision_brain.integration.sense_control.consumer import SourceConfigConsumer, SourceConfigError
|
||||
from yovision_brain.integration.sense_control.replay import SQLiteReplayStore
|
||||
from yovision_brain.integration.sense_control.status import RuntimeStatusPublisher
|
||||
|
||||
|
||||
def source_document(revision: int = 1, *, state: str = "active", effective: int = 0) -> bytes:
|
||||
now=datetime(2026,8,31,tzinfo=timezone.utc)
|
||||
document={"schema_version":"yovision.source-config/v1","config_id":"gate-primary","revision":revision,"published_at":now.isoformat().replace("+00:00","Z"),"effective_at":(now+timedelta(seconds=effective)).isoformat().replace("+00:00","Z"),"site":{"id":"site-east"},"logical_device":{"id":"camera-1"},"profile":{"id":"main","width":1920,"height":1080,"encoding":"H264","frame_rate":25},"media":{"ref":"media:site-east/camera-1/main","transport":"rtsp"},"rule_set":{"version":f"rules-{revision}","state":state,"profile_binding":{"profile_id":"main","width":1920,"height":1080},"areas":[{"id":"danger","version":1,"kind":"danger_area","enabled":True,"points":[{"x":.1,"y":.1},{"x":.8,"y":.1},{"x":.5,"y":.8}]}],"directional_lines":[]}}
|
||||
digest=hashlib.sha256(json.dumps(document,separators=(",",":"),sort_keys=True).encode()).hexdigest();document["integrity"]={"algorithm":"sha256","value":digest}
|
||||
return json.dumps(document,separators=(",",":"),sort_keys=True).encode()
|
||||
|
||||
|
||||
def with_extension(body: bytes, extensions: object) -> bytes:
|
||||
document=json.loads(body);document["extensions"]=extensions;unsigned=dict(document);unsigned.pop("integrity");document["integrity"]["value"]=hashlib.sha256(json.dumps(unsigned,separators=(",",":"),sort_keys=True).encode()).hexdigest();return json.dumps(document,separators=(",",":"),sort_keys=True).encode()
|
||||
|
||||
|
||||
def identity(tmp_path, now: int):
|
||||
private=Ed25519PrivateKey.generate();signer=Signer("yv:sense:east","sense-key-01",private,clock=lambda:now)
|
||||
registry=Registry([KeyRecord("yv:sense:east","sense-key-01",private.public_key(),"yovision-brain",frozenset({"source-config:write"}))])
|
||||
replay=SQLiteReplayStore(tmp_path/"replay.sqlite")
|
||||
return signer,Verifier(registry,replay,clock=lambda:now)
|
||||
|
||||
|
||||
def test_authenticated_apply_is_idempotent_and_replay_survives_restart(tmp_path):
|
||||
now=int(datetime(2026,8,31,tzinfo=timezone.utc).timestamp());signer,verifier=identity(tmp_path,now);body=source_document();consumer=SourceConfigConsumer(tmp_path/"state.sqlite",clock=lambda:now);endpoint=SourceConfigEndpoint(consumer,verifier)
|
||||
token=signer.mint("yovision-brain",("source-config:write",),"POST","/machine/v1/source-config",body)
|
||||
result=endpoint.receive("Bearer "+token,body,"corr-request-0001");assert result.state=="applied" and result.config.rules is not None
|
||||
with pytest.raises(ValueError,match="machine_token_replayed"):endpoint.receive("Bearer "+token,body,"corr-request-0001")
|
||||
restarted=SourceConfigEndpoint(SourceConfigConsumer(tmp_path/"state.sqlite",clock=lambda:now),Verifier(verifier._registry,SQLiteReplayStore(tmp_path/"replay.sqlite"),clock=lambda:now))
|
||||
with pytest.raises(ValueError,match="machine_token_replayed"):restarted.receive("Bearer "+token,body,"corr-request-0002")
|
||||
new_token=signer.mint("yovision-brain",("source-config:write",),"POST","/machine/v1/source-config",body);assert restarted.receive("Bearer "+new_token,body,"corr-request-0003").state=="idempotent"
|
||||
|
||||
|
||||
def test_atomic_replay_accepts_once_under_concurrency(tmp_path):
|
||||
store=SQLiteReplayStore(tmp_path/"atomic.sqlite");results=[]
|
||||
threads=[threading.Thread(target=lambda:results.append(store.consume("yv:sense:east","token-id",200,100))) for _ in range(12)]
|
||||
for thread in threads:thread.start()
|
||||
for thread in threads:thread.join()
|
||||
assert results.count(True)==1
|
||||
|
||||
|
||||
def test_stale_unknown_profile_and_recalibration_are_safe(tmp_path):
|
||||
now=int(datetime(2026,8,31,tzinfo=timezone.utc).timestamp());consumer=SourceConfigConsumer(tmp_path/"state.sqlite",clock=lambda:now)
|
||||
assert consumer.apply(source_document(2)).config.rules is not None
|
||||
with pytest.raises(SourceConfigError,match="STALE_REVISION"):consumer.apply(source_document(1))
|
||||
invalid=json.loads(source_document(3));invalid["profile"]["width"]=1280;invalid["integrity"]["value"]="0"*64
|
||||
with pytest.raises(SourceConfigError,match="CONFIG_INVALID"):consumer.apply(json.dumps(invalid).encode())
|
||||
unknown=json.loads(source_document(3));unknown["schema_version"]="yovision.source-config/v2"
|
||||
with pytest.raises(SourceConfigError,match="UNSUPPORTED_SCHEMA_VERSION"):consumer.apply(json.dumps(unknown).encode())
|
||||
safe=consumer.apply(source_document(3,state="recalibration_required"));assert safe.config.rule_state=="recalibration_required" and safe.config.rules is None
|
||||
|
||||
|
||||
def test_future_effective_snapshot_activates_atomically_after_restart(tmp_path):
|
||||
base=int(datetime(2026,8,31,tzinfo=timezone.utc).timestamp());current=[base];path=tmp_path/"state.sqlite";consumer=SourceConfigConsumer(path,clock=lambda:current[0])
|
||||
assert consumer.apply(source_document(1)).state=="applied";scheduled=consumer.apply(source_document(2,effective=60));assert scheduled.state=="scheduled" and scheduled.config.revision==1
|
||||
current[0]+=61;restarted=SourceConfigConsumer(path,clock=lambda:current[0]);activated=restarted.activate_due();assert activated[0].revision==2 and restarted.get_active("gate-primary").revision==2
|
||||
|
||||
|
||||
def test_unknown_valid_extension_namespace_is_ignored(tmp_path):
|
||||
now=int(datetime(2026,8,31,tzinfo=timezone.utc).timestamp());consumer=SourceConfigConsumer(tmp_path/"state.sqlite",clock=lambda:now)
|
||||
result=consumer.apply(with_extension(source_document(),{"vendor.example":{"feature":"safe"}}));assert result.state=="applied" and result.config.revision==1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("extensions", [[], {"1invalid":{}}, {"vendor_ok":{}}, {"vendor.example":"not-an-object"}])
|
||||
def test_invalid_extensions_are_rejected(tmp_path, extensions):
|
||||
now=int(datetime(2026,8,31,tzinfo=timezone.utc).timestamp());consumer=SourceConfigConsumer(tmp_path/"state.sqlite",clock=lambda:now)
|
||||
with pytest.raises(SourceConfigError,match="CONFIG_INVALID"):consumer.apply(with_extension(source_document(),extensions))
|
||||
|
||||
|
||||
def test_status_sequence_restart_retry_timeout_and_disable(tmp_path):
|
||||
path=tmp_path/"status.sqlite";publisher=RuntimeStatusPublisher(path,"brain-east-01");health={"overall":"healthy","error_codes":[],"metrics":{"load_percent":1.0,"queue_depth":0,"latency_ms":2.0}}
|
||||
one=json.loads(publisher.build(runtime_state="running",runtime_version="1.0.0",started_at=datetime.now(timezone.utc),model_ref="people-detection",model_version="1",configurations=[],health=health,inputs=[]));two=json.loads(RuntimeStatusPublisher(path,"brain-east-01").build(runtime_state="running",runtime_version="1.0.0",started_at=None,model_ref="people-detection",model_version="1",configurations=[],health=health,inputs=[]));assert (one["sequence"],two["sequence"])==(0,1)
|
||||
private=Ed25519PrivateKey.generate();signer=Signer("yv:brain:east","brain-key-01",private,clock=lambda:1_787_000_000);attempts=[]
|
||||
def send(_auth,_body,_corr,timeout):attempts.append(timeout);raise TimeoutError
|
||||
sender=StatusSender(signer,send,max_attempts=3,sleeper=lambda _:None)
|
||||
with pytest.raises(RuntimeError,match="STATUS_DELIVERY_EXHAUSTED"):sender.publish(b"{}","corr-request-0001")
|
||||
assert attempts==[5.0,5.0,5.0]
|
||||
disabled=StatusSender(signer,lambda *_:ConnectorResponse(204,b"","corr-request-0001"),enabled=False)
|
||||
with pytest.raises(RuntimeError,match="CONNECTOR_DISABLED"):disabled.publish(b"{}","corr-request-0001")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("request_id", ["short", "0123456789abcde\n", "0123456789abcde!", "a"*129])
|
||||
def test_connector_rejects_unsafe_request_ids(tmp_path, request_id):
|
||||
now=int(datetime(2026,8,31,tzinfo=timezone.utc).timestamp());signer,verifier=identity(tmp_path,now);body=source_document();endpoint=SourceConfigEndpoint(SourceConfigConsumer(tmp_path/"state.sqlite",clock=lambda:now),verifier)
|
||||
token=signer.mint("yovision-brain",("source-config:write",),"POST","/machine/v1/source-config",body)
|
||||
with pytest.raises(ValueError,match="INVALID_CORRELATION_ID"):endpoint.receive("Bearer "+token,body,request_id)
|
||||
status_signer=Signer("yv:brain:east","brain-key-01",Ed25519PrivateKey.generate(),clock=lambda:now)
|
||||
sender=StatusSender(status_signer,lambda *_:ConnectorResponse(204,b"",request_id))
|
||||
with pytest.raises(ValueError,match="INVALID_CORRELATION_ID"):sender.publish(b"{}",request_id)
|
||||
@@ -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,187 @@
|
||||
package bell_connector
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/machine_identity"
|
||||
)
|
||||
|
||||
type HTTPDoer interface {
|
||||
Do(*http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
var requestIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$`)
|
||||
|
||||
type Client struct {
|
||||
Endpoint string
|
||||
RelayID string
|
||||
Signer machine_identity.Signer
|
||||
HTTP HTTPDoer
|
||||
Enabled bool
|
||||
MaxRequestBytes int64
|
||||
}
|
||||
|
||||
func NewClient(endpoint, relayID string, signer machine_identity.Signer, policy machine_identity.TransportPolicy) (*Client, error) {
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return nil, errors.New("Bell connector endpoint must be an HTTPS origin")
|
||||
}
|
||||
httpClient, err := policy.HTTPClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Client{Endpoint: strings.TrimRight(endpoint, "/"), RelayID: relayID, Signer: signer, HTTP: httpClient, Enabled: true, MaxRequestBytes: policy.MaxRequestBytes}, nil
|
||||
}
|
||||
|
||||
func (c Client) Send(ctx context.Context, body []byte) (IngestResult, error) {
|
||||
if !c.Enabled {
|
||||
return IngestResult{}, &DeliveryError{Code: "connector_disabled", Detail: "Bell connector is disabled", Terminal: true}
|
||||
}
|
||||
if c.HTTP == nil || !json.Valid(body) || (strings.TrimSpace(c.RelayID) != "" && !safeIdentifier(c.RelayID)) {
|
||||
return IngestResult{}, errors.New("Bell connector is not configured")
|
||||
}
|
||||
maximum := c.MaxRequestBytes
|
||||
if maximum == 0 {
|
||||
maximum = MaxInboundBytes
|
||||
}
|
||||
if maximum < 1 || int64(len(body)) > maximum {
|
||||
return IngestResult{}, &DeliveryError{Code: "event_request_too_large", Detail: "event exceeds the configured request limit", Terminal: true}
|
||||
}
|
||||
requestID, err := newRequestID()
|
||||
if err != nil {
|
||||
return IngestResult{}, fmt.Errorf("generate request correlation id: %w", err)
|
||||
}
|
||||
token, err := c.Signer.Mint("yovision-bell", []string{"events:ingest"}, http.MethodPost, "/v1/events", body)
|
||||
if err != nil {
|
||||
return IngestResult{}, fmt.Errorf("mint Bell machine token: %w", err)
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.Endpoint, "/")+"/v1/events", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return IngestResult{}, err
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Request-ID", requestID)
|
||||
if strings.TrimSpace(c.RelayID) != "" {
|
||||
request.Header.Set("X-YoVision-Relay-ID", c.RelayID)
|
||||
}
|
||||
response, err := c.HTTP.Do(request)
|
||||
if err != nil {
|
||||
return IngestResult{}, fmt.Errorf("deliver event to Bell: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
responseBody, err := io.ReadAll(io.LimitReader(response.Body, 64*1024+1))
|
||||
if err != nil || len(responseBody) > 64*1024 {
|
||||
return IngestResult{}, errors.New("Bell response is invalid")
|
||||
}
|
||||
if response.StatusCode == http.StatusCreated || response.StatusCode == http.StatusOK {
|
||||
if response.Header.Get("X-Request-ID") != requestID {
|
||||
return IngestResult{}, errors.New("Bell response request id is invalid")
|
||||
}
|
||||
var result IngestResult
|
||||
if json.Unmarshal(responseBody, &result) != nil || result.EventID == "" || result.PayloadSHA256 == "" || (result.Disposition != "created" && result.Disposition != "duplicate") {
|
||||
return IngestResult{}, errors.New("Bell response is invalid")
|
||||
}
|
||||
identity, parseErr := parseEventIdentity(body)
|
||||
if parseErr != nil || result.ProducerID != identity.ProducerID || result.SourceEventID != identity.SourceEventID {
|
||||
return IngestResult{}, errors.New("Bell response changed event identity")
|
||||
}
|
||||
canonical, canonicalErr := canonicalPayload(body)
|
||||
if canonicalErr != nil {
|
||||
return IngestResult{}, errors.New("delivered event cannot be canonicalized")
|
||||
}
|
||||
digest := sha256.Sum256(canonical)
|
||||
if result.PayloadSHA256 != hex.EncodeToString(digest[:]) {
|
||||
return IngestResult{}, errors.New("Bell response payload digest does not match the delivered event")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
var problem Problem
|
||||
_ = json.Unmarshal(responseBody, &problem)
|
||||
terminal := response.StatusCode >= 400 && response.StatusCode < 500 && response.StatusCode != http.StatusTooManyRequests
|
||||
if problem.Code == "" {
|
||||
problem.Code = "bell_unavailable"
|
||||
}
|
||||
return IngestResult{}, &DeliveryError{Code: problem.Code, Detail: problem.Message, Terminal: terminal}
|
||||
}
|
||||
|
||||
func newRequestID() (string, error) {
|
||||
raw := make([]byte, 16)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
value := base64.RawURLEncoding.EncodeToString(raw)
|
||||
if !requestIDPattern.MatchString(value) {
|
||||
return "", errors.New("generated request id is invalid")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func canonicalPayload(raw []byte) ([]byte, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var value any
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, err := normalizeJCSNumbers(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var buffer bytes.Buffer
|
||||
encoder := json.NewEncoder(&buffer)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
canonical := bytes.TrimSuffix(buffer.Bytes(), []byte("\n"))
|
||||
canonical = bytes.ReplaceAll(canonical, []byte(`\u2028`), []byte("\u2028"))
|
||||
canonical = bytes.ReplaceAll(canonical, []byte(`\u2029`), []byte("\u2029"))
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
func normalizeJCSNumbers(value any) (any, error) {
|
||||
switch typed := value.(type) {
|
||||
case json.Number:
|
||||
number, err := strconv.ParseFloat(string(typed), 64)
|
||||
if err != nil || math.IsNaN(number) || math.IsInf(number, 0) {
|
||||
return nil, errors.New("JSON number is outside the RFC 8785 domain")
|
||||
}
|
||||
if number == 0 {
|
||||
return float64(0), nil
|
||||
}
|
||||
return number, nil
|
||||
case []any:
|
||||
for index, item := range typed {
|
||||
normalized, err := normalizeJCSNumbers(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
typed[index] = normalized
|
||||
}
|
||||
case map[string]any:
|
||||
for key, item := range typed {
|
||||
normalized, err := normalizeJCSNumbers(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
typed[key] = normalized
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package bell_connector
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/machine_identity"
|
||||
)
|
||||
|
||||
const MaxInboundBytes = 64 * 1024
|
||||
|
||||
var inboundRequestIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$`)
|
||||
|
||||
type IngressHandler struct {
|
||||
DB *gorm.DB
|
||||
Verifier machine_identity.Verifier
|
||||
EvidenceOwnerID string
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (h IngressHandler) Post(c *gin.Context) {
|
||||
if !prepareMachineRequest(c, "/v1/events") {
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, MaxInboundBytes))
|
||||
if err != nil {
|
||||
problem(c, http.StatusBadRequest, "invalid_event", "event payload is invalid or too large")
|
||||
return
|
||||
}
|
||||
token, err := machine_identity.BearerToken(c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
problem(c, http.StatusUnauthorized, machineCode(err), "machine identity was rejected")
|
||||
return
|
||||
}
|
||||
if _, err = h.Verifier.Verify(token, "yovision-sense", "events:ingest", c.Request.Method, c.Request.URL.EscapedPath(), body); err != nil {
|
||||
status := http.StatusUnauthorized
|
||||
if code := machineCode(err); code == "machine_scope_denied" || code == "machine_audience_denied" {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
problem(c, status, machineCode(err), "machine identity was rejected")
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if h.Now != nil {
|
||||
now = h.Now().UTC()
|
||||
}
|
||||
result, err := AcceptEvent(c.Request.Context(), h.DB, body, h.EvidenceOwnerID, now)
|
||||
if errors.Is(err, ErrInboundConflict) {
|
||||
problem(c, http.StatusConflict, "idempotency_conflict", "event identity is bound to another payload")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
problem(c, http.StatusBadRequest, "invalid_event", "event payload was rejected")
|
||||
return
|
||||
}
|
||||
status := http.StatusAccepted
|
||||
if result.Disposition == "duplicate" {
|
||||
status = http.StatusOK
|
||||
}
|
||||
c.JSON(status, result)
|
||||
}
|
||||
|
||||
type EvidenceHandler struct {
|
||||
DB *gorm.DB
|
||||
Verifier machine_identity.Verifier
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (h EvidenceHandler) Get(c *gin.Context) {
|
||||
path := c.Request.URL.EscapedPath()
|
||||
if !prepareMachineRequest(c, path) || !safeIdentifier(c.Param("evidence_id")) {
|
||||
if !c.Writer.Written() {
|
||||
problem(c, http.StatusBadRequest, "evidence_not_found", "evidence reference is invalid")
|
||||
}
|
||||
return
|
||||
}
|
||||
token, err := machine_identity.BearerToken(c.GetHeader("Authorization"))
|
||||
if err != nil {
|
||||
problem(c, http.StatusUnauthorized, machineCode(err), "machine identity was rejected")
|
||||
return
|
||||
}
|
||||
if _, err = h.Verifier.Verify(token, "yovision-sense", "evidence:read", c.Request.Method, path, nil); err != nil {
|
||||
status := http.StatusUnauthorized
|
||||
if code := machineCode(err); code == "machine_scope_denied" || code == "machine_audience_denied" {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
problem(c, status, machineCode(err), "machine identity was rejected")
|
||||
return
|
||||
}
|
||||
var record EvidenceRecord
|
||||
if err = h.DB.WithContext(c.Request.Context()).First(&record, "evidence_id = ?", c.Param("evidence_id")).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
problem(c, http.StatusNotFound, "evidence_not_found", "evidence reference is unknown")
|
||||
return
|
||||
}
|
||||
problem(c, http.StatusServiceUnavailable, "evidence_unavailable", "evidence metadata is temporarily unavailable")
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if h.Now != nil {
|
||||
now = h.Now().UTC()
|
||||
}
|
||||
if record.ExpiresAt != nil && !record.ExpiresAt.After(now) {
|
||||
problem(c, http.StatusGone, "evidence_expired", "evidence reference has expired")
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json", record.Payload)
|
||||
}
|
||||
|
||||
func prepareMachineRequest(c *gin.Context, expectedPath string) bool {
|
||||
requestID := c.GetHeader("X-Request-ID")
|
||||
if requestID == "" {
|
||||
requestID = uuid.NewString()
|
||||
} else if !inboundRequestIDPattern.MatchString(requestID) {
|
||||
problem(c, http.StatusBadRequest, "invalid_request_id", "X-Request-ID is invalid")
|
||||
return false
|
||||
}
|
||||
c.Header("X-Request-ID", requestID)
|
||||
if c.Request.URL.RawQuery != "" || c.Request.URL.Fragment != "" || c.Request.URL.EscapedPath() != expectedPath {
|
||||
problem(c, http.StatusBadRequest, "invalid_request_target", "request target is invalid")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func machineCode(err error) string {
|
||||
var machineErr *machine_identity.Error
|
||||
if errors.As(err, &machineErr) {
|
||||
return machineErr.Code
|
||||
}
|
||||
return "machine_token_invalid"
|
||||
}
|
||||
|
||||
func problem(c *gin.Context, status int, code, message string) {
|
||||
c.Header("Content-Type", "application/problem+json")
|
||||
c.JSON(status, Problem{Code: code, Message: message})
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package bell_connector
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var ErrInboundConflict = errors.New("idempotency_conflict")
|
||||
|
||||
func AcceptEvent(ctx context.Context, db *gorm.DB, payload []byte, evidenceOwnerID string, now time.Time) (AcceptResult, error) {
|
||||
identity, err := parseEventIdentity(payload)
|
||||
if err != nil {
|
||||
return AcceptResult{}, err
|
||||
}
|
||||
canonical, err := canonicalPayload(payload)
|
||||
if err != nil {
|
||||
return AcceptResult{}, err
|
||||
}
|
||||
digestBytes := sha256.Sum256(canonical)
|
||||
digest := hex.EncodeToString(digestBytes[:])
|
||||
result := AcceptResult{ProducerID: identity.ProducerID, SourceEventID: identity.SourceEventID, PayloadSHA256: digest}
|
||||
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if tx.Dialector.Name() == "postgres" {
|
||||
key := fmt.Sprintf("%d:%s:%s", len(identity.ProducerID), identity.ProducerID, identity.SourceEventID)
|
||||
if lockErr := tx.Exec("SELECT pg_advisory_xact_lock(hashtextextended(?, 0))", key).Error; lockErr != nil {
|
||||
return lockErr
|
||||
}
|
||||
}
|
||||
var existing InboundEvent
|
||||
lookup := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("producer_id = ? AND source_event_id = ?", identity.ProducerID, identity.SourceEventID).First(&existing).Error
|
||||
if lookup == nil {
|
||||
if existing.PayloadSHA256 != digest {
|
||||
return ErrInboundConflict
|
||||
}
|
||||
result.Disposition = "duplicate"
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(lookup, gorm.ErrRecordNotFound) {
|
||||
return lookup
|
||||
}
|
||||
fact := InboundEvent{ID: uuid.NewString(), ProducerID: identity.ProducerID, SourceEventID: identity.SourceEventID, Payload: append([]byte(nil), canonical...), PayloadSHA256: digest, ReceivedAt: now.UTC()}
|
||||
if err := tx.Create(&fact).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := EnqueueEvent(tx, canonical, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := retainEvidence(tx, canonical, evidenceOwnerID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
result.Disposition = "accepted"
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func retainEvidence(tx *gorm.DB, payload []byte, ownerID string, now time.Time) error {
|
||||
var envelope struct {
|
||||
Evidence []json.RawMessage `json:"evidence"`
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||||
if err := decoder.Decode(&envelope); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, raw := range envelope.Evidence {
|
||||
var metadata struct {
|
||||
EvidenceID string `json:"evidence_id"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
Status string `json:"status"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &metadata); err != nil || !safeIdentifier(metadata.EvidenceID) || !safeIdentifier(metadata.OwnerID) {
|
||||
return errors.New("invalid evidence metadata")
|
||||
}
|
||||
if ownerID != "" && metadata.OwnerID != ownerID {
|
||||
continue
|
||||
}
|
||||
canonical, err := canonicalPayload(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var expiresAt *time.Time
|
||||
if metadata.ExpiresAt != "" {
|
||||
parsed, parseErr := time.Parse(time.RFC3339Nano, metadata.ExpiresAt)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
parsed = parsed.UTC()
|
||||
expiresAt = &parsed
|
||||
}
|
||||
record := EvidenceRecord{EvidenceID: metadata.EvidenceID, OwnerID: metadata.OwnerID, Status: metadata.Status, Payload: canonical, ExpiresAt: expiresAt, UpdatedAt: now.UTC()}
|
||||
var existing EvidenceRecord
|
||||
lookup := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&existing, "evidence_id = ?", metadata.EvidenceID).Error
|
||||
if lookup == nil {
|
||||
if existing.OwnerID != metadata.OwnerID || !validEvidenceTransition(existing.Status, metadata.Status) {
|
||||
return errors.New("evidence metadata transition is invalid")
|
||||
}
|
||||
} else if !errors.Is(lookup, gorm.ErrRecordNotFound) {
|
||||
return lookup
|
||||
}
|
||||
if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "evidence_id"}}, DoUpdates: clause.AssignmentColumns([]string{"status", "payload", "expires_at", "updated_at"})}).Create(&record).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validEvidenceTransition(from, to string) bool {
|
||||
if from == to {
|
||||
return true
|
||||
}
|
||||
switch from {
|
||||
case "pending":
|
||||
return to == "processing" || to == "success" || to == "failed"
|
||||
case "processing":
|
||||
return to == "success" || to == "failed"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package bell_connector
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
const OutboxType = "bell_event_v1"
|
||||
|
||||
type eventIdentity struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
ProducerID string `json:"producer_id"`
|
||||
SourceEventID string `json:"source_event_id"`
|
||||
}
|
||||
|
||||
type IngestResult struct {
|
||||
EventID string `json:"event_id"`
|
||||
ProducerID string `json:"producer_id"`
|
||||
SourceEventID string `json:"source_event_id"`
|
||||
Disposition string `json:"disposition"`
|
||||
PayloadSHA256 string `json:"payload_sha256"`
|
||||
}
|
||||
|
||||
type Problem struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
ExistingEventID string `json:"existing_event_id,omitempty"`
|
||||
}
|
||||
|
||||
type DeliveryError struct {
|
||||
Code string
|
||||
Detail string
|
||||
Terminal bool
|
||||
RetryAfter time.Duration
|
||||
}
|
||||
|
||||
func (e *DeliveryError) Error() string { return e.Code + ": " + e.Detail }
|
||||
|
||||
// ReplayToken is Sense-owned verification state for authenticated evidence
|
||||
// requests. It is never shared with Bell's replay table or business receipts.
|
||||
type ReplayToken struct {
|
||||
Principal string `gorm:"size:128;primaryKey"`
|
||||
TokenID string `gorm:"size:64;primaryKey"`
|
||||
ExpiresAt time.Time `gorm:"not null;index"`
|
||||
CreatedAt time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (ReplayToken) TableName() string { return "sense_machine_token_replays" }
|
||||
|
||||
// InboundEvent is the Sense-owned local fact for an event received from Brain.
|
||||
// The immutable payload and Bell Outbox row are created in one transaction.
|
||||
type InboundEvent struct {
|
||||
ID string `gorm:"size:36;primaryKey"`
|
||||
ProducerID string `gorm:"size:128;not null;uniqueIndex:sense_inbound_event_key"`
|
||||
SourceEventID string `gorm:"size:128;not null;uniqueIndex:sense_inbound_event_key"`
|
||||
Payload json.RawMessage `gorm:"column:payload;type:jsonb;not null"`
|
||||
PayloadSHA256 string `gorm:"type:char(64);not null"`
|
||||
ReceivedAt time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (InboundEvent) TableName() string { return "sense_inbound_events" }
|
||||
|
||||
type EvidenceRecord struct {
|
||||
EvidenceID string `gorm:"size:128;primaryKey"`
|
||||
OwnerID string `gorm:"size:128;not null;index"`
|
||||
Status string `gorm:"size:16;not null;index"`
|
||||
Payload json.RawMessage `gorm:"column:payload;type:jsonb;not null"`
|
||||
ExpiresAt *time.Time `gorm:"index"`
|
||||
UpdatedAt time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (EvidenceRecord) TableName() string { return "sense_evidence_metadata" }
|
||||
|
||||
type AcceptResult struct {
|
||||
ProducerID string `json:"producer_id"`
|
||||
SourceEventID string `json:"source_event_id"`
|
||||
Disposition string `json:"disposition"`
|
||||
PayloadSHA256 string `json:"payload_sha256"`
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package bell_connector
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/outbox"
|
||||
)
|
||||
|
||||
func EnqueueEvent(tx *gorm.DB, payload []byte, now time.Time) (outbox.Message, error) {
|
||||
identity, err := parseEventIdentity(payload)
|
||||
if err != nil {
|
||||
return outbox.Message{}, err
|
||||
}
|
||||
keyDigest := sha256.Sum256([]byte(identity.ProducerID + "\x00" + identity.SourceEventID))
|
||||
return outbox.Enqueue(tx, outbox.EnqueueInput{
|
||||
InternalType: OutboxType,
|
||||
BusinessRef: identity.SourceEventID,
|
||||
IdempotencyKey: "bell-event-v1:" + hex.EncodeToString(keyDigest[:]),
|
||||
PayloadJSON: append([]byte(nil), payload...),
|
||||
}, now)
|
||||
}
|
||||
|
||||
func parseEventIdentity(payload []byte) (eventIdentity, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||||
var identity eventIdentity
|
||||
if err := decoder.Decode(&identity); err != nil || !json.Valid(payload) || identity.SchemaVersion != "yovision.event/v1" ||
|
||||
!safeIdentifier(identity.ProducerID) || !safeIdentifier(identity.SourceEventID) {
|
||||
return eventIdentity{}, errors.New("invalid yovision.event/v1 payload")
|
||||
}
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func safeIdentifier(value string) bool {
|
||||
if value == "" || len(value) > 128 || strings.ContainsAny(value, "\\/@\x00\r\n") {
|
||||
return false
|
||||
}
|
||||
for index, r := range value {
|
||||
allowed := r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || (index > 0 && strings.ContainsRune("._:-", r))
|
||||
if !allowed {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type Relay struct {
|
||||
DB *gorm.DB
|
||||
Client *Client
|
||||
Now func() time.Time
|
||||
Backoff func(int) time.Duration
|
||||
}
|
||||
|
||||
func (r Relay) DeliverBatch(ctx context.Context, worker string, limit int) (int, error) {
|
||||
if r.DB == nil || r.Client == nil || strings.TrimSpace(worker) == "" || limit < 1 || limit > 100 {
|
||||
return 0, errors.New("invalid Bell relay configuration")
|
||||
}
|
||||
items, err := r.claim(worker, limit)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
delivered := 0
|
||||
queueRelay := outbox.NewRelay(r.DB)
|
||||
queueRelay.Now = r.now
|
||||
if r.Backoff != nil {
|
||||
queueRelay.Backoff = r.Backoff
|
||||
}
|
||||
for _, item := range items {
|
||||
_, deliveryErr := r.Client.Send(ctx, []byte(item.PayloadJSON))
|
||||
if deliveryErr == nil {
|
||||
if err = queueRelay.MarkSuccess(item.ID, worker); err != nil {
|
||||
return delivered, err
|
||||
}
|
||||
delivered++
|
||||
continue
|
||||
}
|
||||
var classified *DeliveryError
|
||||
if errors.As(deliveryErr, &classified) && classified.Terminal {
|
||||
if err = r.markTerminal(item, worker, classified.Code); err != nil {
|
||||
return delivered, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err = queueRelay.MarkFailure(item.ID, worker, deliveryErr.Error()); err != nil {
|
||||
return delivered, err
|
||||
}
|
||||
}
|
||||
return delivered, nil
|
||||
}
|
||||
|
||||
func (r Relay) claim(worker string, limit int) ([]outbox.Message, error) {
|
||||
now := r.now()
|
||||
leaseUntil := now.Add(30 * time.Second)
|
||||
claimed := make([]outbox.Message, 0, limit)
|
||||
err := r.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var candidates []outbox.Message
|
||||
query := tx.Where("internal_type = ? AND (((state IN ?) AND available_at <= ?) OR (state = ? AND lease_until < ?))", OutboxType, []string{outbox.StatePending, outbox.StateRetry}, now, outbox.StateProcessing, now).Order("available_at, created_at").Limit(limit)
|
||||
if tx.Dialector.Name() == "postgres" {
|
||||
query = query.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"})
|
||||
}
|
||||
if err := query.Find(&candidates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range candidates {
|
||||
result := tx.Model(&outbox.Message{}).Where("id = ? AND version = ?", item.ID, item.Version).Updates(map[string]any{"state": outbox.StateProcessing, "lease_owner": worker, "lease_until": leaseUntil, "version": gorm.Expr("version + 1"), "updated_at": now})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 1 {
|
||||
item.State, item.LeaseOwner, item.LeaseUntil, item.Version = outbox.StateProcessing, worker, &leaseUntil, item.Version+1
|
||||
claimed = append(claimed, item)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return claimed, err
|
||||
}
|
||||
|
||||
func (r Relay) markTerminal(item outbox.Message, worker, detail string) error {
|
||||
now := r.now()
|
||||
return r.DB.Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Model(&outbox.Message{}).Where("id = ? AND state = ? AND lease_owner = ?", item.ID, outbox.StateProcessing, worker).Updates(map[string]any{
|
||||
"state": outbox.StateDead, "attempt_count": gorm.Expr("attempt_count + 1"), "last_error": detail,
|
||||
"lease_owner": "", "lease_until": nil, "version": gorm.Expr("version + 1"), "updated_at": now,
|
||||
})
|
||||
if result.Error != nil || result.RowsAffected != 1 {
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
return errors.New("Bell outbox lease lost")
|
||||
}
|
||||
return tx.Create(&outbox.Attempt{MessageID: item.ID, Number: item.AttemptCount + 1, Outcome: outbox.StateDead, Detail: detail, Worker: worker, CreatedAt: now}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r Relay) now() time.Time {
|
||||
if r.Now != nil {
|
||||
return r.Now().UTC()
|
||||
}
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
func PreserveIdentity(before, after []byte) error {
|
||||
left, err := parseEventIdentity(before)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
right, err := parseEventIdentity(after)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if left.ProducerID != right.ProducerID || left.SourceEventID != right.SourceEventID {
|
||||
return fmt.Errorf("relay changed original event identity")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package bell_connector
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type PersistentReplayStore struct{ DB *gorm.DB }
|
||||
|
||||
func (s PersistentReplayStore) Consume(principal, tokenID string, expiresAt, now time.Time) bool {
|
||||
if s.DB == nil {
|
||||
return false
|
||||
}
|
||||
accepted := false
|
||||
err := s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("expires_at <= ?", now.UTC()).Delete(&ReplayToken{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&ReplayToken{Principal: principal, TokenID: tokenID, ExpiresAt: expiresAt.UTC(), CreatedAt: now.UTC()})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
accepted = result.RowsAffected == 1
|
||||
return nil
|
||||
})
|
||||
return err == nil && accepted
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package bell_connector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/machine_identity"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/outbox"
|
||||
)
|
||||
|
||||
type Environment func(string) string
|
||||
|
||||
func StartRuntime(ctx context.Context, engine *gin.Engine, db *gorm.DB, getenv Environment) error {
|
||||
if getenv == nil {
|
||||
getenv = os.Getenv
|
||||
}
|
||||
ingressEnabled := enabled(getenv("SENSE_EVENT_INGRESS_ENABLED"))
|
||||
relayEnabled := enabled(getenv("SENSE_BELL_CONNECTOR_ENABLED"))
|
||||
if !ingressEnabled && !relayEnabled {
|
||||
return nil
|
||||
}
|
||||
if engine == nil || db == nil {
|
||||
return errors.New("Sense connector runtime requires engine and database")
|
||||
}
|
||||
for _, model := range []any{&InboundEvent{}, &EvidenceRecord{}, &ReplayToken{}, &outbox.Message{}, &outbox.DeliveryRecord{}, &outbox.Attempt{}} {
|
||||
if !db.Migrator().HasTable(model) {
|
||||
return fmt.Errorf("Sense connector migration is not applied for %T", model)
|
||||
}
|
||||
}
|
||||
if ingressEnabled {
|
||||
registry, err := machine_identity.LoadRegistry(getenv("SENSE_MACHINE_PRINCIPAL_REGISTRY"), "yovision-sense")
|
||||
if err != nil {
|
||||
return fmt.Errorf("load Sense machine identity registry: %w", err)
|
||||
}
|
||||
verifier := machine_identity.Verifier{Registry: registry, Replay: PersistentReplayStore{DB: db}}
|
||||
engine.POST("/v1/events", (IngressHandler{DB: db, Verifier: verifier, EvidenceOwnerID: strings.TrimSpace(getenv("SENSE_EVIDENCE_OWNER_ID"))}).Post)
|
||||
engine.GET("/v1/evidence/:evidence_id", (EvidenceHandler{DB: db, Verifier: verifier}).Get)
|
||||
}
|
||||
if !relayEnabled {
|
||||
return nil
|
||||
}
|
||||
privateKey, err := machine_identity.LoadPrivateKey(getenv("SENSE_BELL_PRIVATE_KEY_PATH"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
signer := machine_identity.Signer{Principal: strings.TrimSpace(getenv("SENSE_BELL_PRINCIPAL_ID")), KeyID: strings.TrimSpace(getenv("SENSE_BELL_KEY_ID")), PrivateKey: privateKey}
|
||||
policy := machine_identity.TransportPolicy{TLSMinVersion: tls.VersionTLS12, VerifyCertificate: true, VerifyHostname: true, ConnectTimeout: 5 * time.Second, ResponseHeaderTimeout: 10 * time.Second, RequestTimeout: 15 * time.Second, MaxRequestBytes: MaxInboundBytes}
|
||||
client, err := NewClient(strings.TrimSpace(getenv("SENSE_BELL_ENDPOINT")), strings.TrimSpace(getenv("SENSE_RELAY_ID")), signer, policy)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
interval := 2 * time.Second
|
||||
if raw := strings.TrimSpace(getenv("SENSE_BELL_RELAY_INTERVAL_MS")); raw != "" {
|
||||
milliseconds, parseErr := strconv.Atoi(raw)
|
||||
if parseErr != nil || milliseconds < 100 || milliseconds > 60000 {
|
||||
return errors.New("SENSE_BELL_RELAY_INTERVAL_MS must be between 100 and 60000")
|
||||
}
|
||||
interval = time.Duration(milliseconds) * time.Millisecond
|
||||
}
|
||||
go runRelay(ctx, Relay{DB: db, Client: client}, interval)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runRelay(ctx context.Context, relay Relay, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if _, err := relay.DeliverBatch(ctx, "sense-bell-runtime", 50); err != nil && ctx.Err() == nil {
|
||||
log.Printf("Sense Bell connector delivery failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func enabled(value string) bool { return strings.EqualFold(strings.TrimSpace(value), "true") }
|
||||
@@ -0,0 +1,118 @@
|
||||
package brain_control
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
mi "git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/machine_identity"
|
||||
)
|
||||
|
||||
const (
|
||||
sourceConfigPath = "/machine/v1/source-config"
|
||||
runtimeStatusPath = "/machine/v1/runtime-status"
|
||||
)
|
||||
|
||||
var requestIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$`)
|
||||
|
||||
type SendResponse struct {
|
||||
StatusCode int
|
||||
Body []byte
|
||||
CorrelationID string
|
||||
}
|
||||
type SendFunc func(authorization string, body []byte, correlationID string, timeout time.Duration) (SendResponse, error)
|
||||
|
||||
type ConfigSender struct {
|
||||
Signer mi.Signer
|
||||
Send SendFunc
|
||||
Enabled bool
|
||||
Timeout time.Duration
|
||||
MaxAttempts int
|
||||
Sleep func(time.Duration)
|
||||
}
|
||||
|
||||
func (s ConfigSender) Publish(config SourceConfig, correlationID string) (SendResponse, error) {
|
||||
if !s.Enabled {
|
||||
return SendResponse{}, errors.New("CONNECTOR_DISABLED")
|
||||
}
|
||||
if s.Send == nil || !requestIDPattern.MatchString(correlationID) {
|
||||
return SendResponse{}, errors.New("invalid connector configuration")
|
||||
}
|
||||
if s.Timeout <= 0 {
|
||||
s.Timeout = 5 * time.Second
|
||||
}
|
||||
if s.MaxAttempts == 0 {
|
||||
s.MaxAttempts = 4
|
||||
}
|
||||
if s.MaxAttempts < 1 {
|
||||
return SendResponse{}, errors.New("invalid connector retry policy")
|
||||
}
|
||||
if s.Sleep == nil {
|
||||
s.Sleep = time.Sleep
|
||||
}
|
||||
if err := ValidateSourceConfig(config); err != nil {
|
||||
return SendResponse{}, err
|
||||
}
|
||||
body, err := MarshalSourceConfig(config)
|
||||
if err != nil {
|
||||
return SendResponse{}, err
|
||||
}
|
||||
var last error
|
||||
for attempt := 0; attempt < s.MaxAttempts; attempt++ {
|
||||
token, mintErr := s.Signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", sourceConfigPath, body)
|
||||
if mintErr != nil {
|
||||
return SendResponse{}, mintErr
|
||||
}
|
||||
response, sendErr := s.Send("Bearer "+token, body, correlationID, s.Timeout)
|
||||
if sendErr == nil && response.StatusCode >= 200 && response.StatusCode < 300 {
|
||||
return response, nil
|
||||
}
|
||||
if sendErr == nil && response.StatusCode < 500 {
|
||||
return SendResponse{}, fmt.Errorf("source config rejected: %d", response.StatusCode)
|
||||
}
|
||||
if sendErr != nil {
|
||||
last = sendErr
|
||||
} else {
|
||||
last = fmt.Errorf("source config remote status: %d", response.StatusCode)
|
||||
}
|
||||
if attempt+1 < s.MaxAttempts {
|
||||
delay := time.Second << attempt
|
||||
if delay > 30*time.Second {
|
||||
delay = 30 * time.Second
|
||||
}
|
||||
s.Sleep(delay)
|
||||
}
|
||||
}
|
||||
return SendResponse{}, fmt.Errorf("source config delivery exhausted: %w", last)
|
||||
}
|
||||
|
||||
type RuntimeStatusEndpoint struct {
|
||||
Verifier mi.Verifier
|
||||
Store ProjectionStore
|
||||
Enabled bool
|
||||
MaxBodyBytes int
|
||||
}
|
||||
|
||||
func (e RuntimeStatusEndpoint) Receive(authorization string, body []byte, correlationID string, expected map[string]int64) (ProjectionView, error) {
|
||||
if !e.Enabled {
|
||||
return ProjectionView{}, errors.New("CONNECTOR_DISABLED")
|
||||
}
|
||||
if !requestIDPattern.MatchString(correlationID) {
|
||||
return ProjectionView{}, errors.New("INVALID_CORRELATION_ID")
|
||||
}
|
||||
if e.MaxBodyBytes == 0 {
|
||||
e.MaxBodyBytes = 10 * 1024 * 1024
|
||||
}
|
||||
if len(body) > e.MaxBodyBytes {
|
||||
return ProjectionView{}, errors.New("REQUEST_TOO_LARGE")
|
||||
}
|
||||
token, err := mi.BearerToken(authorization)
|
||||
if err != nil {
|
||||
return ProjectionView{}, err
|
||||
}
|
||||
if _, err = e.Verifier.Verify(token, "yovision-sense", "runtime-status:write", "POST", runtimeStatusPath, body); err != nil {
|
||||
return ProjectionView{}, err
|
||||
}
|
||||
return e.Store.Ingest(body, expected)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package brain_control
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
mi "git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/machine_identity"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func memoryDB(t *testing.T, name string) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+name+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&ReplayToken{}, &RuntimeProjection{}, &SourceRevision{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestSourceMapperAndRecalibration(t *testing.T) {
|
||||
now := time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC)
|
||||
facts := SourceFacts{ConfigID: "gate-primary", SiteID: "site-east", LogicalDeviceID: "camera-1", MediaPath: "site/camera/main", Revision: 1, PublishedAt: now, EffectiveAt: now, Profile: Profile{ID: "main", Width: 1920, Height: 1080, Encoding: "h264", FrameRate: 25}, RuleSetVersion: "rules-1", Areas: []AreaRule{{ID: "danger", Version: 1, Kind: "danger_area", Enabled: true, Points: []Point{{.1, .1}, {.8, .1}, {.5, .8}}}}}
|
||||
config, err := MapSourceConfig(facts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = ValidateSourceConfig(config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
facts.Revision = 2
|
||||
facts.NeedsRecalibration = true
|
||||
config, err = MapSourceConfig(facts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.RuleSet.State != "recalibration_required" || config.RuleSet.Areas[0].Enabled {
|
||||
t.Fatal("recalibration did not disable rules")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplayAtomicAndRestart(t *testing.T) {
|
||||
db := memoryDB(t, "replay-package")
|
||||
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
now := time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC)
|
||||
registry, _ := mi.NewRegistry(mi.KeyRecord{Principal: "yv:brain:east", KeyID: "brain-key-01", PublicKey: pub, Audience: "yovision-sense", Scopes: []string{"runtime-status:write"}, Enabled: true})
|
||||
signer := mi.Signer{Principal: "yv:brain:east", KeyID: "brain-key-01", PrivateKey: priv, Now: func() time.Time { return now }}
|
||||
body := []byte("{}")
|
||||
token, _ := signer.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
|
||||
accepted := 0
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
for range 8 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
v := mi.Verifier{Registry: registry, Replay: GORMReplayStore{DB: db}, Now: func() time.Time { return now }}
|
||||
if _, err := v.Verify(token, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); err == nil {
|
||||
mu.Lock()
|
||||
accepted++
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if accepted != 1 {
|
||||
t.Fatalf("accepted %d", accepted)
|
||||
}
|
||||
v := mi.Verifier{Registry: registry, Replay: GORMReplayStore{DB: db}, Now: func() time.Time { return now }}
|
||||
if _, err := v.Verify(token, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); err == nil {
|
||||
t.Fatal("restart replay accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectionStaleRecoveryAndMismatch(t *testing.T) {
|
||||
db := memoryDB(t, "projection-package")
|
||||
now := time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC)
|
||||
store := ProjectionStore{DB: db, Clock: func() time.Time { return now }, StaleAfter: 90 * time.Second}
|
||||
raw := runtimeFixture("018f4d6a-8d1b-4a25-8b37-9085f9c0d101", 1, now, 2)
|
||||
view, err := store.Ingest(raw, map[string]int64{"gate": 3})
|
||||
if err != nil || !view.RevisionMismatch {
|
||||
t.Fatalf("view %+v err %v", view, err)
|
||||
}
|
||||
now = now.Add(91 * time.Second)
|
||||
view, _ = store.View("brain-east-01")
|
||||
if !view.Offline {
|
||||
t.Fatal("not offline")
|
||||
}
|
||||
raw = runtimeFixture("018f4d6a-8d1b-4a25-8b37-9085f9c0d102", 2, now, 3)
|
||||
view, err = store.Ingest(raw, map[string]int64{"gate": 3})
|
||||
if err != nil || !view.Recovered {
|
||||
t.Fatalf("recovery %+v err %v", view, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectorRejectsUnsafeRequestIDs(t *testing.T) {
|
||||
for _, value := range []string{"short", "0123456789abcde\n", "0123456789abcde!", strings.Repeat("a", 129)} {
|
||||
sender := ConfigSender{Enabled: true, Send: func(string, []byte, string, time.Duration) (SendResponse, error) {
|
||||
t.Fatal("unsafe request id reached transport")
|
||||
return SendResponse{}, nil
|
||||
}}
|
||||
if _, err := sender.Publish(SourceConfig{}, value); err == nil {
|
||||
t.Fatalf("sender accepted request id %q", value)
|
||||
}
|
||||
endpoint := RuntimeStatusEndpoint{Enabled: true}
|
||||
if _, err := endpoint.Receive("Bearer ignored", nil, value, nil); err == nil || err.Error() != "INVALID_CORRELATION_ID" {
|
||||
t.Fatalf("endpoint accepted request id %q: %v", value, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runtimeFixture(id string, seq int64, observed time.Time, revision int64) []byte {
|
||||
value := map[string]any{"schema_version": RuntimeStatusVersion, "status_id": id, "brain_instance_ref": "brain-east-01", "sequence": seq, "observed_at": observed.Format(time.RFC3339), "runtime": map[string]any{"state": "running", "version": "1.0.0", "started_at": observed.Format(time.RFC3339)}, "model": map[string]any{"model_ref": "people", "version": "1"}, "configurations": []any{map[string]any{"config_id": "gate", "apply_state": "applied", "applied_revision": revision, "error_code": nil}}, "health": map[string]any{"overall": "healthy", "error_codes": []any{}, "metrics": map[string]any{"load_percent": 1.0, "queue_depth": 0, "latency_ms": 1.0}}, "inputs": []any{}}
|
||||
raw, _ := json.Marshal(value)
|
||||
return raw
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package brain_control
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var stableID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$`)
|
||||
|
||||
func MapSourceConfig(f SourceFacts) (SourceConfig, error) {
|
||||
if !stableID.MatchString(f.ConfigID) || !stableID.MatchString(f.SiteID) || !stableID.MatchString(f.LogicalDeviceID) || !stableID.MatchString(f.Profile.ID) || f.Revision < 1 || f.Profile.Width < 1 || f.Profile.Height < 1 || f.Profile.FrameRate <= 0 {
|
||||
return SourceConfig{}, errors.New("invalid source configuration facts")
|
||||
}
|
||||
encoding := strings.ToUpper(f.Profile.Encoding)
|
||||
if encoding != "H264" && encoding != "H265" && encoding != "MJPEG" {
|
||||
return SourceConfig{}, errors.New("unsupported profile encoding")
|
||||
}
|
||||
if f.PublishedAt.IsZero() || f.EffectiveAt.Before(f.PublishedAt) {
|
||||
return SourceConfig{}, errors.New("invalid source configuration time")
|
||||
}
|
||||
if !stableID.MatchString(f.RuleSetVersion) {
|
||||
return SourceConfig{}, errors.New("invalid rule set version")
|
||||
}
|
||||
if strings.ContainsAny(f.MediaPath, "?#@\\") || strings.Contains(f.MediaPath, "://") || f.MediaPath == "" {
|
||||
return SourceConfig{}, errors.New("media path must be opaque and credential-free")
|
||||
}
|
||||
if len(f.Areas) > 1024 || len(f.DirectionalLines) > 1024 {
|
||||
return SourceConfig{}, errors.New("too many rules")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, a := range f.Areas {
|
||||
if !stableID.MatchString(a.ID) || seen[a.ID] || a.Version < 1 || a.Kind != "danger_area" || len(a.Points) < 3 || len(a.Points) > 256 || !validPoints(a.Points) || polygonArea(a.Points) == 0 {
|
||||
return SourceConfig{}, errors.New("invalid area rule")
|
||||
}
|
||||
seen[a.ID] = true
|
||||
}
|
||||
for _, l := range f.DirectionalLines {
|
||||
if !stableID.MatchString(l.ID) || seen[l.ID] || l.Version < 1 || l.Kind != "directional_line" || (l.TriggerDirection != "left_to_right" && l.TriggerDirection != "right_to_left") || !validPoints([]Point{l.Start, l.End}) || l.Start == l.End {
|
||||
return SourceConfig{}, errors.New("invalid directional line rule")
|
||||
}
|
||||
seen[l.ID] = true
|
||||
}
|
||||
var out SourceConfig
|
||||
out.SchemaVersion, out.ConfigID, out.Revision = SourceConfigVersion, f.ConfigID, f.Revision
|
||||
out.PublishedAt, out.EffectiveAt = f.PublishedAt.UTC(), f.EffectiveAt.UTC()
|
||||
out.Site.ID, out.LogicalDevice.ID = f.SiteID, f.LogicalDeviceID
|
||||
out.Profile = f.Profile
|
||||
out.Profile.Encoding = encoding
|
||||
out.Media.Ref, out.Media.Transport = "media:"+strings.TrimPrefix(f.MediaPath, "/"), "rtsp"
|
||||
out.RuleSet.Version = f.RuleSetVersion
|
||||
out.RuleSet.State = "active"
|
||||
if f.Disabled {
|
||||
out.RuleSet.State = "disabled"
|
||||
}
|
||||
if f.NeedsRecalibration {
|
||||
out.RuleSet.State = "recalibration_required"
|
||||
}
|
||||
out.RuleSet.ProfileBinding.ProfileID, out.RuleSet.ProfileBinding.Width, out.RuleSet.ProfileBinding.Height = f.Profile.ID, f.Profile.Width, f.Profile.Height
|
||||
out.RuleSet.Areas = append([]AreaRule(nil), f.Areas...)
|
||||
out.RuleSet.DirectionalLines = append([]DirectionalLineRule(nil), f.DirectionalLines...)
|
||||
if out.RuleSet.State != "active" {
|
||||
for i := range out.RuleSet.Areas {
|
||||
out.RuleSet.Areas[i].Enabled = false
|
||||
}
|
||||
for i := range out.RuleSet.DirectionalLines {
|
||||
out.RuleSet.DirectionalLines[i].Enabled = false
|
||||
}
|
||||
}
|
||||
digest, err := sourceDigest(out)
|
||||
if err != nil {
|
||||
return SourceConfig{}, err
|
||||
}
|
||||
out.Integrity.Algorithm, out.Integrity.Value = "sha256", digest
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func MarshalSourceConfig(config SourceConfig) ([]byte, error) { return json.Marshal(config) }
|
||||
|
||||
func sourceDigest(config SourceConfig) (string, error) {
|
||||
raw, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var value map[string]any
|
||||
if err = json.Unmarshal(raw, &value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
delete(value, "integrity")
|
||||
canonical, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(canonical)
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
func validPoints(points []Point) bool {
|
||||
for _, p := range points {
|
||||
if p.X < 0 || p.X > 1 || p.Y < 0 || p.Y > 1 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func polygonArea(p []Point) float64 {
|
||||
var a float64
|
||||
for i := range p {
|
||||
n := p[(i+1)%len(p)]
|
||||
a += p[i].X*n.Y - n.X*p[i].Y
|
||||
}
|
||||
if a < 0 {
|
||||
a = -a
|
||||
}
|
||||
return a / 2
|
||||
}
|
||||
|
||||
func ValidateSourceConfig(config SourceConfig) error {
|
||||
if config.SchemaVersion != SourceConfigVersion {
|
||||
return fmt.Errorf("unsupported source config version")
|
||||
}
|
||||
digest, err := sourceDigest(config)
|
||||
if err != nil || config.Integrity.Algorithm != "sha256" || digest != config.Integrity.Value {
|
||||
return errors.New("source config integrity mismatch")
|
||||
}
|
||||
_, err = MapSourceConfig(SourceFacts{ConfigID: config.ConfigID, SiteID: config.Site.ID, LogicalDeviceID: config.LogicalDevice.ID, MediaPath: strings.TrimPrefix(config.Media.Ref, "media:"), Revision: config.Revision, PublishedAt: config.PublishedAt, EffectiveAt: config.EffectiveAt, Profile: config.Profile, RuleSetVersion: config.RuleSet.Version, Disabled: config.RuleSet.State == "disabled", NeedsRecalibration: config.RuleSet.State == "recalibration_required", Areas: config.RuleSet.Areas, DirectionalLines: config.RuleSet.DirectionalLines})
|
||||
return err
|
||||
}
|
||||
|
||||
func UTCNow() time.Time { return time.Now().UTC() }
|
||||
@@ -0,0 +1,122 @@
|
||||
package brain_control
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
SourceConfigVersion = "yovision.source-config/v1"
|
||||
RuntimeStatusVersion = "yovision.runtime-status/v1"
|
||||
)
|
||||
|
||||
type Point struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
}
|
||||
type Profile struct {
|
||||
ID string `json:"id"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Encoding string `json:"encoding"`
|
||||
FrameRate float64 `json:"frame_rate"`
|
||||
}
|
||||
type AreaRule struct {
|
||||
ID string `json:"id"`
|
||||
Version int64 `json:"version"`
|
||||
Kind string `json:"kind"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Points []Point `json:"points"`
|
||||
}
|
||||
type DirectionalLineRule struct {
|
||||
ID string `json:"id"`
|
||||
Version int64 `json:"version"`
|
||||
Kind string `json:"kind"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Start Point `json:"start"`
|
||||
End Point `json:"end"`
|
||||
TriggerDirection string `json:"trigger_direction"`
|
||||
}
|
||||
|
||||
type SourceConfig struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
ConfigID string `json:"config_id"`
|
||||
Revision int64 `json:"revision"`
|
||||
PublishedAt time.Time `json:"published_at"`
|
||||
EffectiveAt time.Time `json:"effective_at"`
|
||||
Site struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"site"`
|
||||
LogicalDevice struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"logical_device"`
|
||||
Profile Profile `json:"profile"`
|
||||
Media struct {
|
||||
Ref string `json:"ref"`
|
||||
Transport string `json:"transport"`
|
||||
} `json:"media"`
|
||||
RuleSet struct {
|
||||
Version string `json:"version"`
|
||||
State string `json:"state"`
|
||||
ProfileBinding struct {
|
||||
ProfileID string `json:"profile_id"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
} `json:"profile_binding"`
|
||||
Areas []AreaRule `json:"areas"`
|
||||
DirectionalLines []DirectionalLineRule `json:"directional_lines"`
|
||||
} `json:"rule_set"`
|
||||
Integrity struct {
|
||||
Algorithm string `json:"algorithm"`
|
||||
Value string `json:"value"`
|
||||
} `json:"integrity"`
|
||||
}
|
||||
|
||||
// SourceFacts is an explicit, credential-free boundary DTO. Callers map their
|
||||
// GORM entities into it; database models are never serialized as a contract.
|
||||
type SourceFacts struct {
|
||||
ConfigID, SiteID, LogicalDeviceID, MediaRouteID, MediaPath string
|
||||
Revision int64
|
||||
PublishedAt, EffectiveAt time.Time
|
||||
Profile Profile
|
||||
RuleSetVersion string
|
||||
Disabled, NeedsRecalibration bool
|
||||
Areas []AreaRule
|
||||
DirectionalLines []DirectionalLineRule
|
||||
}
|
||||
|
||||
type ReplayToken struct {
|
||||
Principal string `gorm:"size:128;primaryKey"`
|
||||
TokenID string `gorm:"size:96;primaryKey"`
|
||||
ExpiresAt time.Time `gorm:"not null;index"`
|
||||
CreatedAt time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (ReplayToken) TableName() string { return "sense_brain_runtime_replay_tokens" }
|
||||
|
||||
type RuntimeProjection struct {
|
||||
BrainInstanceRef string `gorm:"size:128;primaryKey"`
|
||||
StatusID string `gorm:"size:36;not null;uniqueIndex"`
|
||||
Sequence int64 `gorm:"not null"`
|
||||
ObservedAt time.Time `gorm:"not null;index"`
|
||||
ReceivedAt time.Time `gorm:"not null"`
|
||||
RuntimeState string `gorm:"size:32;not null"`
|
||||
RuntimeVersion string `gorm:"size:64;not null"`
|
||||
ModelRef string `gorm:"size:128;not null"`
|
||||
ModelVersion string `gorm:"size:64;not null"`
|
||||
HealthOverall string `gorm:"size:32;not null"`
|
||||
ExpectedRevisionsJSON string `gorm:"type:jsonb;not null"`
|
||||
ConfigurationsJSON string `gorm:"type:jsonb;not null"`
|
||||
HealthJSON string `gorm:"type:jsonb;not null"`
|
||||
InputsJSON string `gorm:"type:jsonb;not null"`
|
||||
WasOffline bool `gorm:"not null;default:false"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (RuntimeProjection) TableName() string { return "sense_brain_runtime_projections" }
|
||||
|
||||
type SourceRevision struct {
|
||||
ConfigID string `gorm:"size:128;primaryKey"`
|
||||
Revision int64 `gorm:"not null"`
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (SourceRevision) TableName() string { return "sense_brain_source_revisions" }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user