Compare commits

...
Author SHA1 Message Date
QiuSW ae9bd015c2 docs: 记录三项目机器身份与安全传输 v1 (#151) 2026-08-31 11:23:52 +08:00
ila 06e0790f00 Merge pull request '#162' from task/151-machine-identity
feat: 建立三项目机器身份与安全传输 v1 (#151)
2026-08-31 11:14:52 +08:00
QiuSW 009dc3cca0 feat: 建立三项目机器身份与安全传输 v1 (#151) 2026-08-31 10:53:51 +08:00
ila 573113eb3b Merge pull request '#161' from docs/150-contract-acceptance
docs: 同步 #150 标准事件与证据 v1 契约
2026-08-31 10:05:00 +08:00
QiuSW b548b05874 docs: 记录标准事件与证据 v1 契约 (#150) 2026-08-31 10:04:39 +08:00
ila 23a85278cb Merge pull request '#158' from feature/150-event-evidence-contract into dev
冻结 Sense/Brain→Bell 标准事件与证据引用契约 v1(#150)。
2026-08-31 09:55:35 +08:00
ila 96777a948f Merge pull request '#160' from docs/148-149-contract-acceptance into dev
同步 #148、#149 冻结契约的 Wiki 核心镜像。
2026-08-31 09:41:48 +08:00
QiuSW c2b023c9fe docs: 记录冻结的 Sense Brain v1 契约 (#148 #149) 2026-08-31 09:41:33 +08:00
ila 4c35da9ef6 Merge pull request '#157' from feature/149-runtime-status-contract into dev
冻结 Brain→Sense 推理运行与健康状态契约 v1(#149)。
2026-08-31 09:34:58 +08:00
ila 30c43aa8d7 Merge pull request '#159' from feature/148-source-config-contract into dev
冻结 Sense→Brain 媒体源与区域规则配置契约 v1(#148)。
2026-08-31 09:34:43 +08:00
QiuSW a22d3ce0f1 fix: 对齐多配置运行状态语义 (#149) 2026-08-31 08:59:48 +08:00
QiuSW 359c553452 fix: align evidence success state (#150) 2026-08-31 08:57:25 +08:00
QiuSW 2e61167500 fix: 统一源配置契约字段命名 (#148) 2026-08-31 08:53:22 +08:00
QiuSW 54c58551ae fix: 统一运行状态契约版本标识 (#149) 2026-08-31 08:49:49 +08:00
QiuSW 67391acb16 feat: 冻结源配置契约 v1 (#148) 2026-08-31 08:48:18 +08:00
QiuSW 2a395aa126 feat: freeze event evidence contract v1 (#150) 2026-08-31 08:45:57 +08:00
QiuSW e4fed702c4 feat: 冻结 Brain 运行状态契约 v1 (#149) 2026-08-31 08:44:36 +08:00
ila 49aa79f3b9 Merge pull request '#147' from docs/8-mvp-acceptance into dev
同步 MVP #8 验收后的核心 Wiki 镜像。
2026-08-29 20:49:44 +08:00
QiuSW 64e20e6aed docs: 记录 MVP #8 验收状态 (#8) 2026-08-29 20:49:25 +08:00
ila 19c0868c5d Merge PR #146: Sense 独立验收脚本隔离与诊断修复 (#145)
用户于 2026-08-29 验收通过 #145。
2026-08-29 20:15:35 +08:00
QiuSW 1c6b30fac0 fix: 修复 Sense 独立验收脚本隔离与诊断 (#145) 2026-08-29 20:00:04 +08:00
ila 6702b8a5b9 Merge PR #144: Bell 独立纵切与 Windows 交付验证 (#134)
用户于 2026-08-29 验收通过 #134。
2026-08-29 17:34:07 +08:00
QiuSW a35f1d6770 test: 建立 Bell 独立纵切与 Windows 交付验证 (#134) 2026-08-29 17:30:03 +08:00
ila 6194b664ee Merge PR #143: 修复 Bell 生产外壳白屏 (#142)
用户已于 2026-08-29 明确验收通过 #142。
2026-08-29 17:22:33 +08:00
QiuSW 1caa429cad fix: 修复 Bell 生产外壳白屏 (#142) 2026-08-29 17:11:24 +08:00
ila f99fe8d4f7 Merge PR #141: 收敛 Bell 管理员默认菜单 (#140)
用户已于 2026-08-29 明确验收通过 #140。
2026-08-29 16:45:28 +08:00
QiuSW b4a8e0e1f1 fix: 收敛 Bell 管理员默认菜单 (#140) 2026-08-29 16:43:52 +08:00
ila 86c3e79121 Merge pull request '#139' from feature/138-bell-production-captcha into dev
fix: 恢复 Bell 生产验证码登录 (#138)
2026-08-29 16:27:09 +08:00
QiuSW cabc29c18b fix: 恢复 Bell 生产验证码登录 (#138) 2026-08-29 16:20:34 +08:00
ila 452cd71035 Merge PR #137: Bell 预警处置生命周期
关联 #133;用户验收后关闭工单。
2026-08-29 11:56:57 +08:00
QiuSW f09a61e5fb feat: 重建 Bell 预警处置生命周期 (#133) 2026-08-29 11:56:41 +08:00
ila afc58f7bf4 merge: 完成 Bell 规则与预警链路 (#132)
实现版本化规则、不可变评估事实、open Alert 投影、Event-Alert 双向查询及 GoAdmin UI/RBAC。
2026-08-29 10:02:36 +08:00
QiuSW b01ca1fe09 feat: 重建 Bell 规则与预警链路 (#132) 2026-08-29 10:01:09 +08:00
ila 689de560bb Merge PR #135: Bell Event、Receipt 与合成事件入口
Implements #131; merge into dev for user acceptance.
2026-08-29 09:01:11 +08:00
QiuSW 8f7d91310b feat: 重建 Bell Event 与合成入口 (#131) 2026-08-29 09:00:40 +08:00
ila 96bd4ad2c8 feat: 串联 Brain 独立纵切与内部事件 (#16)
合入 dev,#16 保持待验收。
2026-08-28 23:25:39 +08:00
179 changed files with 10044 additions and 45 deletions
+59
View File
@@ -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 单测替代;未执行的项目必须在工单证据中明确说明。
+59
View File
@@ -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 或生命周期事实。
+9
View File
@@ -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。
+11
View File
@@ -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
+13
View File
@@ -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"
+5
View File
@@ -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%
+88
View File
@@ -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 }
}
+34
View File
@@ -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"
+117
View File
@@ -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"
}
+72
View File
@@ -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()
}
+5
View File
@@ -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%
+12
View File
@@ -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 }
+5
View File
@@ -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%
+41
View File
@@ -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 }
}
+5
View File
@@ -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%
+17
View File
@@ -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 }
+190
View File
@@ -0,0 +1,190 @@
[CmdletBinding()]
param(
[string]$PostgresBin = 'D:\pgsql17\bin'
)
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$server = $null
$pgStarted = $false
$testRoot = Join-Path ([IO.Path]::GetTempPath()) ('yovision-bell-131-' + [guid]::NewGuid().ToString('N'))
$pgData = Join-Path $testRoot 'postgres'
$pgLog = Join-Path $testRoot 'postgres.log'
$pgCtlOut = Join-Path $testRoot 'pg-ctl.out.log'
$pgCtlErr = Join-Path $testRoot 'pg-ctl.err.log'
$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 '..\server')).Path
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 Wait-Tcp([int]$Port, [bool]$Open, [int]$Attempts = 120) {
for ($attempt = 0; $attempt -lt $Attempts; $attempt++) {
$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 ($attempt = 0; $attempt -lt 100; $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 endpoint did not become ready'
}
function Start-Bell([string]$BaseUrl, [string]$Config = 'config/settings.demo.yml') {
$script:server = Start-Process -FilePath $serverExe -ArgumentList @('server', '-c', $Config) -WorkingDirectory $serverRoot -RedirectStandardOutput $serverOut -RedirectStandardError $serverErr -WindowStyle Hidden -PassThru
Wait-Health $BaseUrl
}
function Stop-Bell {
if ($null -ne $script:server -and -not $script:server.HasExited) {
Stop-Process -Id $script:server.Id -Force
$script:server.WaitForExit(5000) | Out-Null
}
$script:server = $null
}
New-Item -ItemType Directory -Path $testRoot | Out-Null
$pgPort = Get-FreeTcpPort
$bellPort = Get-FreeTcpPort
$baseUrl = "http://127.0.0.1:$bellPort"
$database = 'bell_131'
$adminPassword = [guid]::NewGuid().ToString('N')
try {
foreach ($required in @('initdb.exe', 'pg_ctl.exe', 'createdb.exe', 'psql.exe')) {
$path = Join-Path $PostgresBin $required
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Missing PostgreSQL tool: $path" }
}
& (Join-Path $PostgresBin 'initdb.exe') -D $pgData -U postgres -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 $pgCtlOut -RedirectStandardError $pgCtlErr -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 postgres $database
if ($LASTEXITCODE -ne 0) { throw 'isolated Bell database creation failed' }
$env:GOTOOLCHAIN = 'go1.26.5'
$env:BELL_DATABASE_URL = "host=127.0.0.1 port=$pgPort user=postgres dbname=$database sslmode=disable"
$env:BELL_JWT_SECRET = [guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N')
$env:BELL_BOOTSTRAP_USERNAME = 'bell_131_admin'
$env:BELL_BOOTSTRAP_PASSWORD = $adminPassword
$env:BELL_HOST = '127.0.0.1'
$env:BELL_PORT = $bellPort.ToString()
$env:BELL_SYNTHETIC_EVENTS_ENABLED = 'true'
Push-Location $serverRoot
try {
go run . migrate -c config/settings.demo.yml *> (Join-Path $testRoot 'migrate.log')
if ($LASTEXITCODE -ne 0) { throw 'Bell migration failed' }
go build -o $serverExe .
if ($LASTEXITCODE -ne 0) { throw 'Bell build failed' }
} finally {
Pop-Location
}
Start-Bell $baseUrl
$unauthorized = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/synthetic-events" -ContentType 'application/json' -Body '{}' -TimeoutSec 5 -NoProxy
if ([int]$unauthorized.code -ne 401) { throw "unauthenticated request returned code $($unauthorized.code)" }
$loginBody = @{ username = 'bell_131_admin'; password = $adminPassword; code = '0'; uuid = '0' } | ConvertTo-Json -Compress
$login = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/login" -ContentType 'application/json' -Body $loginBody -TimeoutSec 5 -NoProxy
if ([int]$login.code -ne 200 -or [string]::IsNullOrWhiteSpace($login.token)) { throw 'Bell login failed' }
$headers = @{ Authorization = "Bearer $($login.token)" }
$occurredAt = '2026-08-29T00:00:00Z'
$eventBody = @{
sourceEventId = 'acceptance-001'; eventType = 'danger_area_entered'; occurredAt = $occurredAt
location = '东门'; severity = 'high'; evidenceRef = 'evidence/acceptance-001'
attributes = @{ rule = 'area-01'; target = 'anonymous' }
} | ConvertTo-Json -Depth 6 -Compress
$created = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/synthetic-events" -Headers $headers -ContentType 'application/json; charset=utf-8' -Body $eventBody -TimeoutSec 10 -NoProxy
if ([int]$created.code -ne 200 -or $created.data.duplicate -ne $false) { throw 'first synthetic event was not accepted as new' }
$eventId = [string]$created.data.event.id
$receiptId = [string]$created.data.receipt.id
if ([string]::IsNullOrWhiteSpace($eventId) -or [string]::IsNullOrWhiteSpace($receiptId)) { throw 'event or receipt id missing' }
$replay = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/synthetic-events" -Headers $headers -ContentType 'application/json; charset=utf-8' -Body $eventBody -TimeoutSec 10 -NoProxy
if ([int]$replay.code -ne 200 -or $replay.data.duplicate -ne $true -or $replay.data.event.id -ne $eventId) { throw 'idempotent replay failed' }
$concurrentBody = @{
sourceEventId = 'acceptance-concurrent'; eventType = 'danger_area_entered'; occurredAt = $occurredAt
location = '西门'; severity = 'medium'; evidenceRef = 'evidence/acceptance-concurrent'
attributes = @{ rule = 'area-02'; target = 'anonymous' }
} | ConvertTo-Json -Depth 6 -Compress
$authHeader = [string]$headers.Authorization
$concurrent = 1..12 | ForEach-Object -Parallel {
$requestHeaders = @{ Authorization = $using:authHeader }
Invoke-RestMethod -Method Post -Uri "$using:baseUrl/api/v1/bell/synthetic-events" -Headers $requestHeaders -ContentType 'application/json; charset=utf-8' -Body $using:concurrentBody -TimeoutSec 20 -NoProxy
} -ThrottleLimit 12
$concurrentIds = @($concurrent | ForEach-Object { [string]$_.data.event.id } | Sort-Object -Unique)
$newCount = @($concurrent | Where-Object { $_.data.duplicate -eq $false }).Count
if ($concurrent.Count -ne 12 -or $concurrentIds.Count -ne 1 -or $newCount -ne 1) { throw 'concurrent idempotency did not converge to one Event' }
$conflictObject = $eventBody | ConvertFrom-Json
$conflictObject.severity = 'critical'
$conflict = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/synthetic-events" -Headers $headers -ContentType 'application/json; charset=utf-8' -Body ($conflictObject | ConvertTo-Json -Depth 6 -Compress) -TimeoutSec 10 -NoProxy
if ([int]$conflict.code -ne 409) { throw "idempotency conflict returned code $($conflict.code)" }
$malformed = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/synthetic-events" -Headers $headers -ContentType 'application/json' -Body '{' -TimeoutSec 10 -NoProxy
if ([int]$malformed.code -ne 400) { throw "malformed request returned code $($malformed.code)" }
$oversized = @{ sourceEventId='too-large'; eventType='test'; occurredAt=$occurredAt; location='lab'; severity='low'; attributes=@{ blob=('x' * 70000) } } | ConvertTo-Json -Depth 5 -Compress
$tooLarge = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/synthetic-events" -Headers $headers -ContentType 'application/json' -Body $oversized -TimeoutSec 10 -NoProxy
if ([int]$tooLarge.code -ne 400) { throw "oversized request returned code $($tooLarge.code)" }
$psql = Join-Path $PostgresBin 'psql.exe'
$counts = & $psql -h 127.0.0.1 -p $pgPort -U postgres -d $database -Atc "select count(*) from bell_events; select count(*) from bell_event_receipts; select count(*) from bell_event_ingest_audits where outcome='accepted'; select count(*) from bell_event_ingest_audits where outcome='replay'; select count(*) from bell_event_ingest_audits where outcome='conflict';"
if (($counts -join ',') -ne '2,2,2,12,1') { throw "unexpected Bell fact counts: $($counts -join ',')" }
$payloadAuditCount = & $psql -h 127.0.0.1 -p $pgPort -U postgres -d $database -Atc "select count(*) from sys_opera_log where oper_url='/api/v1/bell/synthetic-events' and (oper_param like '%sourceEventId%' or json_result like '%acceptance-001%');"
if ([int]$payloadAuditCount -ne 0) { throw 'generic GoAdmin audit retained synthetic event payload' }
& $psql -h 127.0.0.1 -p $pgPort -U postgres -d $database -v ON_ERROR_STOP=1 -c "update bell_events set severity='low' where id='$eventId'" 2>$null | Out-Null
if ($LASTEXITCODE -eq 0) { throw 'immutable Event update unexpectedly succeeded' }
& $psql -h 127.0.0.1 -p $pgPort -U postgres -d $database -v ON_ERROR_STOP=1 -c "delete from bell_event_receipts where id='$receiptId'" 2>$null | Out-Null
if ($LASTEXITCODE -eq 0) { throw 'immutable Receipt delete unexpectedly succeeded' }
Stop-Bell
Start-Bell $baseUrl
$afterRestart = Invoke-RestMethod -Uri "$baseUrl/api/v1/bell/events/$eventId" -Headers $headers -TimeoutSec 10 -NoProxy
if ([int]$afterRestart.code -ne 200 -or $afterRestart.data.id -ne $eventId) { throw 'Event was not readable after Bell restart' }
Stop-Bell
Start-Bell $baseUrl 'config/settings.yml'
$productionSynthetic = Invoke-WebRequest -Method Post -Uri "$baseUrl/api/v1/bell/synthetic-events" -ContentType 'application/json' -Body '{}' -TimeoutSec 10 -NoProxy -SkipHttpErrorCheck
if ([int]$productionSynthetic.StatusCode -ne 404) { throw "production synthetic route returned HTTP $($productionSynthetic.StatusCode)" }
Write-Output "BELL_131_SMOKE events=2 receipts=2 concurrent=12 audit=redacted immutable=true restart=true production_synthetic=404"
} finally {
Stop-Bell
if ($pgStarted) {
& (Join-Path $PostgresBin 'pg_ctl.exe') -D $pgData -m fast stop *> (Join-Path $testRoot 'pg-stop.log')
}
foreach ($name in @('BELL_DATABASE_URL','BELL_JWT_SECRET','BELL_BOOTSTRAP_USERNAME','BELL_BOOTSTRAP_PASSWORD','BELL_HOST','BELL_PORT','BELL_SYNTHETIC_EVENTS_ENABLED')) {
Remove-Item "Env:$name" -ErrorAction SilentlyContinue
}
Write-Verbose "Bell #131 temporary artifacts: $testRoot"
}
+7
View File
@@ -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
+1 -2
View File
@@ -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())
{
+86
View File
@@ -0,0 +1,86 @@
package alert
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-admin-team/go-admin-core/sdk/api"
"go-admin/app/bell/evaluation"
)
type Handler struct{ api.Api }
func (h Handler) List(c *gin.Context) {
var query PageQuery
h.MakeContext(c).MakeOrm().Bind(&query, binding.Form)
if h.Errors != nil {
h.Error(http.StatusBadRequest, errors.New("查询条件不正确"), "查询条件不正确")
return
}
items, count, err := NewService(h.Orm).List(c.Request.Context(), query)
if err != nil {
h.Logger.Errorf("list Bell alerts failed: %v", err)
h.Error(http.StatusInternalServerError, errors.New("读取预警失败"), "读取预警失败")
return
}
page, size := pageValues(query.PageIndex, query.PageSize)
h.PageOK(items, int(count), page, size, "查询成功")
}
func (h Handler) Get(c *gin.Context) {
h.MakeContext(c).MakeOrm()
if h.Errors != nil {
h.Error(http.StatusInternalServerError, errors.New("数据库连接获取失败"), "数据库连接获取失败")
return
}
detail, err := NewService(h.Orm).Get(c.Request.Context(), c.Param("id"))
if err != nil {
if errors.Is(err, ErrNotFound) {
h.Error(http.StatusNotFound, ErrNotFound, ErrNotFound.Error())
return
}
h.Logger.Errorf("get Bell alert failed: %v", err)
h.Error(http.StatusInternalServerError, errors.New("读取预警失败"), "读取预警失败")
return
}
h.OK(detail, "查询成功")
}
func (h Handler) ListEvents(c *gin.Context) {
var query EventPageQuery
h.MakeContext(c).MakeOrm().Bind(&query, binding.Form)
if h.Errors != nil {
h.Error(http.StatusBadRequest, errors.New("查询条件不正确"), "查询条件不正确")
return
}
items, count, err := NewService(h.Orm).ListEvents(c.Request.Context(), query)
if err != nil {
h.Logger.Errorf("list Bell events failed: %v", err)
h.Error(http.StatusInternalServerError, errors.New("读取事件失败"), "读取事件失败")
return
}
page, size := pageValues(query.PageIndex, query.PageSize)
h.PageOK(items, int(count), page, size, "查询成功")
}
func (h Handler) EventResults(c *gin.Context) {
h.MakeContext(c).MakeOrm()
if h.Errors != nil {
h.Error(http.StatusInternalServerError, errors.New("数据库连接获取失败"), "数据库连接获取失败")
return
}
result, err := evaluation.NewService(h.Orm).ForEvent(c.Request.Context(), c.Param("id"))
if err != nil {
if errors.Is(err, evaluation.ErrEventNotFound) {
h.Error(http.StatusNotFound, evaluation.ErrEventNotFound, evaluation.ErrEventNotFound.Error())
return
}
h.Logger.Errorf("get Bell event rule results failed: %v", err)
h.Error(http.StatusInternalServerError, errors.New("读取规则评估失败"), "读取规则评估失败")
return
}
h.OK(result, "查询成功")
}
+40
View File
@@ -0,0 +1,40 @@
package alert
import (
"encoding/json"
"time"
)
type Alert struct {
ID string `json:"id" gorm:"type:uuid;primaryKey"`
PrimaryRuleID string `json:"primaryRuleId" gorm:"type:uuid;not null;index"`
CorrelationKey string `json:"-" gorm:"size:384;not null"`
Status string `json:"status" gorm:"size:24;not null;default:open;index"`
Severity string `json:"severity" gorm:"size:16;not null;index"`
Summary string `json:"summary" gorm:"size:256;not null"`
Location string `json:"location" gorm:"size:256;not null;index"`
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null;index"`
UpdatedAt time.Time `json:"updatedAt" gorm:"type:timestamptz;not null"`
}
func (Alert) TableName() string { return "bell_alerts" }
type AlertEvent struct {
AlertID string `json:"alertId" gorm:"type:uuid;primaryKey"`
EventID string `json:"eventId" gorm:"type:uuid;primaryKey"`
LinkedAt time.Time `json:"linkedAt" gorm:"type:timestamptz;not null"`
}
func (AlertEvent) TableName() string { return "bell_alert_events" }
type RuleMatch struct {
EventID string `json:"eventId" gorm:"type:uuid;primaryKey"`
RuleID string `json:"ruleId" gorm:"type:uuid;primaryKey"`
AlertID string `json:"alertId" gorm:"type:uuid;not null;index"`
RuleVersion int `json:"ruleVersion" gorm:"not null"`
RuleSnapshot json.RawMessage `json:"ruleSnapshot" gorm:"type:jsonb;not null"`
Explanation string `json:"explanation" gorm:"size:512;not null"`
MatchedAt time.Time `json:"matchedAt" gorm:"type:timestamptz;not null"`
}
func (RuleMatch) TableName() string { return "bell_rule_matches" }
+136
View File
@@ -0,0 +1,136 @@
package alert
import (
"context"
"errors"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"go-admin/app/bell/event"
)
var ErrNotFound = errors.New("预警不存在")
type PageQuery struct {
PageIndex int `form:"pageIndex"`
PageSize int `form:"pageSize"`
Status string `form:"status"`
Severity string `form:"severity"`
Location string `form:"location"`
}
type Summary struct {
Alert
RuleName string `json:"ruleName"`
EventCount int64 `json:"eventCount"`
}
type LinkedEvent struct {
event.Event
LinkedAt time.Time `json:"linkedAt"`
}
type Detail struct {
Alert Summary `json:"alert"`
Events []LinkedEvent `json:"events"`
Matches []RuleMatch `json:"matches"`
}
type Service struct{ DB *gorm.DB }
func NewService(db *gorm.DB) Service { return Service{DB: db} }
func (s Service) List(ctx context.Context, query PageQuery) ([]Summary, int64, error) {
page, size := pageValues(query.PageIndex, query.PageSize)
base := s.DB.WithContext(ctx).Table("bell_alerts a")
if query.Status = strings.TrimSpace(query.Status); query.Status != "" {
base = base.Where("a.status = ?", query.Status)
}
if query.Severity = strings.TrimSpace(query.Severity); query.Severity != "" {
base = base.Where("a.severity = ?", query.Severity)
}
if query.Location = strings.TrimSpace(query.Location); query.Location != "" {
base = base.Where("a.location ILIKE ?", "%"+query.Location+"%")
}
var count int64
if err := base.Count(&count).Error; err != nil {
return nil, 0, err
}
items := make([]Summary, 0)
err := base.Select("a.*, r.name AS rule_name, (SELECT count(*) FROM bell_alert_events ae WHERE ae.alert_id = a.id) AS event_count").
Joins("JOIN bell_rules r ON r.id = a.primary_rule_id").
Order("a.created_at DESC, a.id DESC").Offset((page - 1) * size).Limit(size).Scan(&items).Error
return items, count, err
}
func (s Service) Get(ctx context.Context, id string) (Detail, error) {
if _, err := uuid.Parse(id); err != nil {
return Detail{}, ErrNotFound
}
detail := Detail{Events: make([]LinkedEvent, 0), Matches: make([]RuleMatch, 0)}
db := s.DB.WithContext(ctx)
err := db.Table("bell_alerts a").
Select("a.*, r.name AS rule_name, (SELECT count(*) FROM bell_alert_events ae WHERE ae.alert_id = a.id) AS event_count").
Joins("JOIN bell_rules r ON r.id = a.primary_rule_id").Where("a.id = ?", id).Take(&detail.Alert).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return Detail{}, ErrNotFound
}
if err != nil {
return Detail{}, err
}
if err = db.Table("bell_events e").Select("e.*, ae.linked_at").
Joins("JOIN bell_alert_events ae ON ae.event_id = e.id").
Where("ae.alert_id = ?", id).Order("e.occurred_at, e.id").Scan(&detail.Events).Error; err != nil {
return Detail{}, err
}
err = db.Where("alert_id = ?", id).Order("matched_at, event_id, rule_id").Find(&detail.Matches).Error
return detail, err
}
type EventPageQuery struct {
PageIndex int `form:"pageIndex"`
PageSize int `form:"pageSize"`
EventType string `form:"eventType"`
Severity string `form:"severity"`
Location string `form:"location"`
}
type EventSummary struct {
event.Event
AlertCount int64 `json:"alertCount"`
}
func (s Service) ListEvents(ctx context.Context, query EventPageQuery) ([]EventSummary, int64, error) {
page, size := pageValues(query.PageIndex, query.PageSize)
db := s.DB.WithContext(ctx).Model(&event.Event{})
if value := strings.TrimSpace(query.EventType); value != "" {
db = db.Where("event_type ILIKE ?", "%"+value+"%")
}
if value := strings.TrimSpace(query.Severity); value != "" {
db = db.Where("severity = ?", value)
}
if value := strings.TrimSpace(query.Location); value != "" {
db = db.Where("location ILIKE ?", "%"+value+"%")
}
var count int64
if err := db.Count(&count).Error; err != nil {
return nil, 0, err
}
items := make([]EventSummary, 0)
err := db.Select("bell_events.*, (SELECT count(*) FROM bell_alert_events ae WHERE ae.event_id = bell_events.id) AS alert_count").
Order("occurred_at DESC, id DESC").Offset((page - 1) * size).Limit(size).Scan(&items).Error
return items, count, err
}
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,83 @@
package alert_lifecycle
import (
"errors"
"net/http"
"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) Get(c *gin.Context) {
h.MakeContext(c).MakeOrm()
if h.Errors != nil {
h.Error(500, errors.New("数据库连接获取失败"), "数据库连接获取失败")
return
}
detail, err := NewService(h.Orm).Get(c.Request.Context(), c.Param("id"))
if err == nil {
current := actor(c)
detail.CanAck = detail.Projection.Status == StatusOpen && (current.Role == "admin" || current.Role == "operator")
detail.CanClose = detail.Projection.Status == StatusAcknowledged && (current.Role == "admin" || (detail.Projection.AcknowledgedBy != nil && *detail.Projection.AcknowledgedBy == current.ID))
}
h.respond(c, Result{Detail: detail}, err)
}
func (h Handler) Ack(c *gin.Context) {
h.MakeContext(c).MakeOrm()
if h.Errors != nil {
h.Error(500, errors.New("数据库连接获取失败"), "数据库连接获取失败")
return
}
result, err := NewService(h.Orm).Ack(c.Request.Context(), c.Param("id"), actor(c))
h.respond(c, result, err)
}
func (h Handler) Close(c *gin.Context) {
if err := restoreCloseBody(c); err != nil {
h.MakeContext(c).Error(http.StatusBadRequest, ErrOutcomeRequired, "请求内容格式不正确")
return
}
var input CloseInput
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
if h.Errors != nil {
h.Error(http.StatusBadRequest, ErrOutcomeRequired, "请求内容格式不正确")
return
}
result, err := NewService(h.Orm).Close(c.Request.Context(), c.Param("id"), input, actor(c))
h.respond(c, result, err)
}
func (h Handler) respond(c *gin.Context, result Result, err error) {
if err == nil {
h.OK(result, "操作成功")
c.Set("result", gin.H{"code": http.StatusOK, "data": "<redacted>"})
return
}
code := http.StatusInternalServerError
switch {
case errors.Is(err, ErrNotFound):
code = http.StatusNotFound
case errors.Is(err, ErrOutcomeRequired):
code = http.StatusBadRequest
case errors.Is(err, ErrAlreadyHandled), errors.Is(err, ErrInvalidTransition):
code = http.StatusConflict
case errors.Is(err, ErrForbidden):
code = http.StatusForbidden
default:
h.Logger.Errorf("Bell alert lifecycle failed: %v", err)
}
c.JSON(http.StatusOK, gin.H{"code": code, "msg": err.Error(), "data": result})
c.Set("result", gin.H{"code": code, "data": "<redacted>"})
}
func actor(c *gin.Context) Actor {
claims := jwt.ExtractClaims(c)
role, _ := claims[jwt.RoleKey].(string)
return Actor{ID: user.GetUserId(c), Name: user.GetUserName(c), Role: role}
}
@@ -0,0 +1,50 @@
package alert_lifecycle
import "time"
const (
StatusOpen = "open"
StatusAcknowledged = "acknowledged"
StatusClosed = "closed"
)
type Projection struct {
ID string `json:"id" gorm:"type:uuid;primaryKey"`
Status string `json:"status"`
AcknowledgedBy *int `json:"acknowledgedBy,omitempty"`
AcknowledgedByName *string `json:"acknowledgedByName,omitempty"`
AcknowledgedAt *time.Time `json:"acknowledgedAt,omitempty"`
ClosedBy *int `json:"closedBy,omitempty"`
ClosedByName *string `json:"closedByName,omitempty"`
ClosedAt *time.Time `json:"closedAt,omitempty"`
CloseOutcome *string `json:"closeOutcome,omitempty"`
CloseNote *string `json:"closeNote,omitempty"`
}
func (Projection) TableName() string { return "bell_alerts" }
type Fact struct {
ID string `json:"id" gorm:"type:uuid;primaryKey"`
AlertID string `json:"alertId" gorm:"type:uuid;not null;uniqueIndex:bell_alert_transition"`
Transition string `json:"transition" gorm:"size:24;not null;uniqueIndex:bell_alert_transition"`
ActorID int `json:"actorId" gorm:"not null"`
ActorName string `json:"actorName" gorm:"size:128;not null"`
Outcome *string `json:"outcome,omitempty" gorm:"size:32"`
Note *string `json:"note,omitempty" gorm:"size:500"`
OccurredAt time.Time `json:"occurredAt" gorm:"type:timestamptz;not null;index"`
}
func (Fact) TableName() string { return "bell_alert_lifecycle_facts" }
type RejectionAudit struct {
ID string `json:"id" gorm:"type:uuid;primaryKey"`
AlertID *string `json:"alertId,omitempty" gorm:"type:uuid;index"`
Action string `json:"action" gorm:"size:16;not null"`
ActorID int `json:"actorId" gorm:"not null;index"`
Reason string `json:"reason" gorm:"size:64;not null"`
ObservedStatus *string `json:"observedStatus,omitempty" gorm:"size:24"`
ObservedActor *int `json:"observedActor,omitempty"`
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null;index"`
}
func (RejectionAudit) TableName() string { return "bell_alert_lifecycle_rejections" }
@@ -0,0 +1,47 @@
package alert_lifecycle
import (
"bytes"
"errors"
"io"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
const maxCloseRequestBytes = 8 * 1024
const closeBodyKey = "bell.lifecycle.close-body"
const closeBodyErrorKey = "bell.lifecycle.close-body-error"
func RedactRequestBody() gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.Method != http.MethodPost || !strings.HasPrefix(c.Request.URL.Path, "/api/v1/bell/alerts/") || !strings.HasSuffix(c.Request.URL.Path, "/close") {
c.Next()
return
}
body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxCloseRequestBytes+1))
if err != nil {
c.Set(closeBodyErrorKey, err)
} else if len(body) > maxCloseRequestBytes {
c.Set(closeBodyErrorKey, errors.New("request body too large"))
} else {
c.Set(closeBodyKey, body)
}
_ = c.Request.Body.Close()
c.Request.Body = io.NopCloser(bytes.NewReader([]byte(`{"redacted":true}`)))
c.Next()
}
}
func restoreCloseBody(c *gin.Context) error {
if value, ok := c.Get(closeBodyErrorKey); ok {
return value.(error)
}
value, ok := c.Get(closeBodyKey)
if !ok {
return errors.New("close request body was not captured")
}
c.Request.Body = io.NopCloser(bytes.NewReader(value.([]byte)))
return nil
}
@@ -0,0 +1,165 @@
package alert_lifecycle
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Actor struct {
ID int
Name, Role string
}
type Detail struct {
Projection Projection `json:"projection"`
Timeline []Fact `json:"timeline"`
CanAck bool `json:"canAck"`
CanClose bool `json:"canClose"`
}
type Result struct {
Detail Detail `json:"detail"`
Idempotent bool `json:"idempotent"`
Won bool `json:"won"`
}
type Service struct{ DB *gorm.DB }
func NewService(db *gorm.DB) Service { return Service{DB: db} }
func (s Service) Get(ctx context.Context, alertID string) (Detail, error) {
if _, err := uuid.Parse(alertID); err != nil {
return Detail{}, ErrNotFound
}
var projection Projection
if err := s.DB.WithContext(ctx).First(&projection, "id = ?", alertID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return Detail{}, ErrNotFound
}
return Detail{}, err
}
facts := make([]Fact, 0)
if err := s.DB.WithContext(ctx).Where("alert_id = ?", alertID).Order("occurred_at, id").Find(&facts).Error; err != nil {
return Detail{}, err
}
return Detail{Projection: projection, Timeline: facts}, nil
}
func (s Service) Ack(ctx context.Context, alertID string, actor Actor) (Result, error) {
if _, err := uuid.Parse(alertID); err != nil {
s.reject(ctx, nil, "ack", actor.ID, "not_found", nil)
return Result{}, ErrNotFound
}
now := time.Now().UTC()
var projection Projection
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Raw(`UPDATE bell_alerts SET status='acknowledged', acknowledged_by=?, acknowledged_by_name=?, acknowledged_at=?, updated_at=? WHERE id=? AND status='open' RETURNING id,status,acknowledged_by,acknowledged_by_name,acknowledged_at,closed_by,closed_by_name,closed_at,close_outcome,close_note`, actor.ID, actor.Name, now, now, alertID).Scan(&projection)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return tx.Create(&Fact{ID: uuid.NewString(), AlertID: alertID, Transition: StatusAcknowledged, ActorID: actor.ID, ActorName: actor.Name, OccurredAt: now}).Error
})
if err == nil {
detail, getErr := s.Get(ctx, alertID)
return Result{Detail: detail, Won: true}, getErr
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return Result{}, err
}
detail, getErr := s.Get(ctx, alertID)
if getErr != nil {
return Result{}, getErr
}
if detail.Projection.AcknowledgedBy != nil && *detail.Projection.AcknowledgedBy == actor.ID {
s.reject(ctx, &alertID, "ack", actor.ID, "duplicate", &detail.Projection)
return Result{Detail: detail, Idempotent: true}, nil
}
s.reject(ctx, &alertID, "ack", actor.ID, "already_handled", &detail.Projection)
return Result{Detail: detail}, ErrAlreadyHandled
}
func (s Service) Close(ctx context.Context, alertID string, input CloseInput, actor Actor) (Result, error) {
normalized, err := normalizeClose(input)
if err != nil {
s.reject(ctx, validAlertID(alertID), "close", actor.ID, "invalid_outcome", nil)
return Result{}, err
}
if _, err = uuid.Parse(alertID); err != nil {
s.reject(ctx, nil, "close", actor.ID, "not_found", nil)
return Result{}, ErrNotFound
}
var projection Projection
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if lockErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&projection, "id = ?", alertID).Error; lockErr != nil {
return lockErr
}
if projection.Status == StatusClosed {
return ErrInvalidTransition
}
if projection.Status != StatusAcknowledged {
return ErrInvalidTransition
}
if actor.Role != "admin" && (projection.AcknowledgedBy == nil || *projection.AcknowledgedBy != actor.ID) {
return ErrForbidden
}
now := time.Now().UTC()
var note *string
if normalized.Note != "" {
note = &normalized.Note
}
if updateErr := tx.Model(&projection).Updates(map[string]any{"status": StatusClosed, "closed_by": actor.ID, "closed_by_name": actor.Name, "closed_at": now, "close_outcome": normalized.Outcome, "close_note": note, "updated_at": now}).Error; updateErr != nil {
return updateErr
}
return tx.Create(&Fact{ID: uuid.NewString(), AlertID: alertID, Transition: StatusClosed, ActorID: actor.ID, ActorName: actor.Name, Outcome: &normalized.Outcome, Note: note, OccurredAt: now}).Error
})
if err == nil {
detail, getErr := s.Get(ctx, alertID)
return Result{Detail: detail, Won: true}, getErr
}
if !errors.Is(err, ErrInvalidTransition) && !errors.Is(err, ErrForbidden) && !errors.Is(err, gorm.ErrRecordNotFound) {
return Result{}, err
}
detail, getErr := s.Get(ctx, alertID)
if getErr != nil {
return Result{}, getErr
}
if detail.Projection.Status == StatusClosed && detail.Projection.ClosedBy != nil && *detail.Projection.ClosedBy == actor.ID && detail.Projection.CloseOutcome != nil && *detail.Projection.CloseOutcome == normalized.Outcome && equalOptional(detail.Projection.CloseNote, normalized.Note) {
s.reject(ctx, &alertID, "close", actor.ID, "duplicate", &detail.Projection)
return Result{Detail: detail, Idempotent: true}, nil
}
reason := "invalid_transition"
publicErr := ErrInvalidTransition
if errors.Is(err, ErrForbidden) {
reason, publicErr = "forbidden", ErrForbidden
} else if detail.Projection.Status == StatusClosed {
reason = "conflicting_replay"
}
s.reject(ctx, &alertID, "close", actor.ID, reason, &detail.Projection)
return Result{Detail: detail}, publicErr
}
func (s Service) reject(ctx context.Context, alertID *string, action string, actorID int, reason string, projection *Projection) {
audit := RejectionAudit{ID: uuid.NewString(), AlertID: alertID, Action: action, ActorID: actorID, Reason: reason, CreatedAt: time.Now().UTC()}
if projection != nil {
audit.ObservedStatus = &projection.Status
audit.ObservedActor = projection.AcknowledgedBy
}
_ = s.DB.WithContext(ctx).Create(&audit).Error
}
func validAlertID(value string) *string {
if _, err := uuid.Parse(value); err != nil {
return nil
}
return &value
}
func equalOptional(value *string, other string) bool {
if value == nil {
return other == ""
}
return *value == other
}
@@ -0,0 +1,34 @@
package alert_lifecycle
import (
"errors"
"strings"
"unicode/utf8"
)
var (
ErrNotFound = errors.New("预警不存在")
ErrAlreadyHandled = errors.New("预警已由其他人员开始处理")
ErrInvalidTransition = errors.New("当前状态不能执行此操作")
ErrOutcomeRequired = errors.New("请选择有效的现场结果")
ErrForbidden = errors.New("您无权完成此预警")
)
type CloseInput struct {
Outcome string `json:"outcome"`
Note string `json:"note"`
}
func normalizeClose(input CloseInput) (CloseInput, error) {
input.Outcome = strings.TrimSpace(input.Outcome)
input.Note = strings.TrimSpace(input.Note)
switch input.Outcome {
case "danger_confirmed", "false_positive", "site_normal", "unable_to_confirm":
default:
return CloseInput{}, ErrOutcomeRequired
}
if !utf8.ValidString(input.Note) || utf8.RuneCountInString(input.Note) > 500 || strings.ContainsAny(input.Note, "\x00\r") {
return CloseInput{}, ErrOutcomeRequired
}
return input, nil
}
+20
View File
@@ -0,0 +1,20 @@
package evaluation
import (
"encoding/json"
"time"
)
// Evaluation is an immutable explanation of one rule version evaluated
// against one Event.
type Evaluation struct {
EventID string `json:"eventId" gorm:"type:uuid;primaryKey"`
RuleID string `json:"ruleId" gorm:"type:uuid;primaryKey"`
RuleVersion int `json:"ruleVersion" gorm:"not null"`
RuleSnapshot json.RawMessage `json:"ruleSnapshot" gorm:"type:jsonb;not null"`
Matched bool `json:"matched" gorm:"not null"`
Explanation string `json:"explanation" gorm:"size:512;not null"`
EvaluatedAt time.Time `json:"evaluatedAt" gorm:"type:timestamptz;not null"`
}
func (Evaluation) TableName() string { return "bell_rule_evaluations" }
@@ -0,0 +1,50 @@
package evaluation
import (
"context"
"errors"
"github.com/google/uuid"
"gorm.io/gorm"
)
var ErrEventNotFound = errors.New("事件不存在")
type EventResults struct {
Evaluations []Evaluation `json:"evaluations"`
Alerts []AlertLink `json:"alerts"`
}
type AlertLink struct {
ID string `json:"id"`
Summary string `json:"summary"`
Status string `json:"status"`
Severity string `json:"severity"`
Location string `json:"location"`
}
type Service struct{ DB *gorm.DB }
func NewService(db *gorm.DB) Service { return Service{DB: db} }
func (s Service) ForEvent(ctx context.Context, eventID string) (EventResults, error) {
if _, err := uuid.Parse(eventID); err != nil {
return EventResults{}, ErrEventNotFound
}
var count int64
if err := s.DB.WithContext(ctx).Table("bell_events").Where("id = ?", eventID).Count(&count).Error; err != nil {
return EventResults{}, err
}
if count == 0 {
return EventResults{}, ErrEventNotFound
}
result := EventResults{Evaluations: make([]Evaluation, 0), Alerts: make([]AlertLink, 0)}
if err := s.DB.WithContext(ctx).Where("event_id = ?", eventID).Order("evaluated_at, rule_id").Find(&result.Evaluations).Error; err != nil {
return EventResults{}, err
}
err := s.DB.WithContext(ctx).Table("bell_alerts a").
Select("a.id, a.summary, a.status, a.severity, a.location").
Joins("JOIN bell_alert_events ae ON ae.alert_id = a.id").
Where("ae.event_id = ?", eventID).Order("a.created_at, a.id").Scan(&result.Alerts).Error
return result, err
}
+9
View File
@@ -0,0 +1,9 @@
package event
import "errors"
var (
ErrInvalid = errors.New("事件字段无效")
ErrConflict = errors.New("幂等键已用于不同事件载荷")
ErrNotFound = errors.New("事件不存在")
)
+24
View File
@@ -0,0 +1,24 @@
package event
import (
"encoding/json"
"time"
)
// Event is an immutable Bell business fact. It intentionally does not embed
// GoAdmin's mutable/soft-delete model fields.
type Event struct {
ID string `json:"id" gorm:"type:uuid;primaryKey"`
ProducerID string `json:"producerId" gorm:"size:128;not null;uniqueIndex:bell_event_key"`
SourceEventID string `json:"sourceEventId" gorm:"size:256;not null;uniqueIndex:bell_event_key"`
EventType string `json:"eventType" gorm:"size:128;not null;index"`
OccurredAt time.Time `json:"occurredAt" gorm:"type:timestamptz;not null;index"`
Location string `json:"location" gorm:"size:256;not null;index"`
Severity string `json:"severity" gorm:"size:16;not null;index"`
EvidenceRef *string `json:"evidenceRef,omitempty" gorm:"size:512"`
NormalizedPayload json.RawMessage `json:"normalizedPayload" gorm:"type:jsonb;not null"`
PayloadSHA256 string `json:"-" gorm:"type:char(64);not null"`
ReceivedAt time.Time `json:"receivedAt" gorm:"type:timestamptz;not null;index"`
}
func (Event) TableName() string { return "bell_events" }
+105
View File
@@ -0,0 +1,105 @@
package event
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
"unicode/utf8"
)
const (
maxProducerID = 128
maxSourceEventID = 256
maxEventType = 128
maxLocation = 256
maxEvidenceRef = 512
maxAttributes = 48 * 1024
)
type Command struct {
ProducerID string `json:"producerId"`
SourceEventID string `json:"sourceEventId"`
EventType string `json:"eventType"`
OccurredAt time.Time `json:"occurredAt"`
Location string `json:"location"`
Severity string `json:"severity"`
EvidenceRef *string `json:"evidenceRef,omitempty"`
Attributes map[string]any `json:"attributes"`
}
type Normalized struct {
Command Command
Payload []byte
Digest string
}
func Normalize(command Command) (Normalized, error) {
command.ProducerID = strings.TrimSpace(command.ProducerID)
command.SourceEventID = strings.TrimSpace(command.SourceEventID)
command.EventType = strings.TrimSpace(command.EventType)
command.Location = strings.TrimSpace(command.Location)
command.Severity = strings.ToLower(strings.TrimSpace(command.Severity))
command.OccurredAt = command.OccurredAt.UTC()
if !validText(command.ProducerID, maxProducerID) ||
!validText(command.SourceEventID, maxSourceEventID) ||
!validText(command.EventType, maxEventType) ||
!validText(command.Location, maxLocation) || command.OccurredAt.IsZero() {
return Normalized{}, ErrInvalid
}
switch command.Severity {
case "low", "medium", "high", "critical":
default:
return Normalized{}, ErrInvalid
}
if command.EvidenceRef != nil {
value := strings.TrimSpace(*command.EvidenceRef)
lower := strings.ToLower(value)
if !validText(value, maxEvidenceRef) || strings.Contains(value, "@") ||
strings.Contains(value, "\\") || strings.HasPrefix(lower, "file:") {
return Normalized{}, fmt.Errorf("%w: evidenceRef 必须是安全的逻辑引用", ErrInvalid)
}
command.EvidenceRef = &value
}
if command.Attributes == nil {
command.Attributes = map[string]any{}
}
attributes, err := canonicalJSON(command.Attributes)
if err != nil || len(attributes) > maxAttributes {
return Normalized{}, fmt.Errorf("%w: attributes 无效或过大", ErrInvalid)
}
// Round trip the attributes so map ordering and nested values have one
// deterministic representation before the complete command is hashed.
if err = json.Unmarshal(attributes, &command.Attributes); err != nil {
return Normalized{}, fmt.Errorf("%w: attributes 无效", ErrInvalid)
}
payload, err := json.Marshal(command)
if err != nil {
return Normalized{}, fmt.Errorf("normalize event: %w", err)
}
digest := sha256.Sum256(payload)
return Normalized{Command: command, Payload: payload, Digest: hex.EncodeToString(digest[:])}, nil
}
func canonicalJSON(value any) ([]byte, error) {
raw, err := json.Marshal(value)
if err != nil {
return nil, err
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var normalized any
if err = decoder.Decode(&normalized); err != nil {
return nil, err
}
return json.Marshal(normalized)
}
func validText(value string, max int) bool {
return value != "" && utf8.ValidString(value) && utf8.RuneCountInString(value) <= max &&
!strings.ContainsAny(value, "\x00\r\n")
}
+110
View File
@@ -0,0 +1,110 @@
package event
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"go-admin/app/bell/receipt"
)
type Result struct {
Event Event `json:"event"`
Receipt receipt.Receipt `json:"receipt"`
Duplicate bool `json:"duplicate"`
}
type Service struct{ DB *gorm.DB }
func NewService(db *gorm.DB) Service { return Service{DB: db} }
func (s Service) Ingest(ctx context.Context, command Command, actorID int) (Result, error) {
if s.DB == nil {
return Result{}, errors.New("event database is unavailable")
}
normalized, err := Normalize(command)
if err != nil {
return Result{}, err
}
var result Result
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
lockKey := fmt.Sprintf("%d:%s:%s", len(normalized.Command.ProducerID), normalized.Command.ProducerID, normalized.Command.SourceEventID)
if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtextextended(?, 0))", lockKey).Error; err != nil {
return fmt.Errorf("lock event idempotency key: %w", err)
}
var existing receipt.Receipt
err := tx.Where("producer_id = ? AND source_event_id = ?", normalized.Command.ProducerID, normalized.Command.SourceEventID).
First(&existing).Error
if err == nil {
if existing.PayloadSHA256 != normalized.Digest {
return ErrConflict
}
if err = tx.First(&result.Event, "id = ?", existing.EventID).Error; err != nil {
return fmt.Errorf("load replay event: %w", err)
}
result.Receipt = existing
result.Duplicate = true
return tx.Create(&receipt.IngestAudit{
ProducerID: normalized.Command.ProducerID, SourceEventID: normalized.Command.SourceEventID,
PayloadSHA256: normalized.Digest, Outcome: receipt.OutcomeReplay, ActorID: actorID, CreatedAt: time.Now().UTC(),
}).Error
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("load event receipt: %w", err)
}
now := time.Now().UTC()
result.Event = Event{
ID: uuid.NewString(), ProducerID: normalized.Command.ProducerID,
SourceEventID: normalized.Command.SourceEventID, EventType: normalized.Command.EventType,
OccurredAt: normalized.Command.OccurredAt, Location: normalized.Command.Location,
Severity: normalized.Command.Severity, EvidenceRef: normalized.Command.EvidenceRef,
NormalizedPayload: normalized.Payload, PayloadSHA256: normalized.Digest, ReceivedAt: now,
}
result.Receipt = receipt.Receipt{
ID: uuid.NewString(), EventID: result.Event.ID, ProducerID: result.Event.ProducerID,
SourceEventID: result.Event.SourceEventID, PayloadSHA256: normalized.Digest, AcceptedAt: now,
}
if err = tx.Create(&result.Event).Error; err != nil {
return fmt.Errorf("create event: %w", err)
}
if err = tx.Create(&result.Receipt).Error; err != nil {
return fmt.Errorf("create event receipt: %w", err)
}
return tx.Create(&receipt.IngestAudit{
ProducerID: result.Event.ProducerID, SourceEventID: result.Event.SourceEventID,
PayloadSHA256: normalized.Digest, Outcome: receipt.OutcomeAccepted, ActorID: actorID, CreatedAt: now,
}).Error
})
if errors.Is(err, ErrConflict) {
// The conflict audit must commit independently from the rejected ingest.
auditErr := s.DB.WithContext(ctx).Create(&receipt.IngestAudit{
ProducerID: normalized.Command.ProducerID, SourceEventID: normalized.Command.SourceEventID,
PayloadSHA256: normalized.Digest, Outcome: receipt.OutcomeConflict, ActorID: actorID, CreatedAt: time.Now().UTC(),
}).Error
if auditErr != nil {
return Result{}, fmt.Errorf("record idempotency conflict audit: %w", auditErr)
}
return Result{}, ErrConflict
}
return result, err
}
func (s Service) Get(ctx context.Context, id string) (Event, error) {
if _, err := uuid.Parse(id); err != nil {
return Event{}, ErrNotFound
}
var item Event
if err := s.DB.WithContext(ctx).First(&item, "id = ?", id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return Event{}, ErrNotFound
}
return Event{}, err
}
return item, nil
}
@@ -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
}
+23
View File
@@ -0,0 +1,23 @@
package receipt
import "time"
const (
OutcomeAccepted = "accepted"
OutcomeReplay = "replay"
OutcomeConflict = "conflict"
)
// IngestAudit contains only identifiers, a digest and an outcome. Event payloads,
// credentials and tokens must never be copied into this append-only audit table.
type IngestAudit struct {
ID int64 `json:"id" gorm:"primaryKey;autoIncrement"`
ProducerID string `json:"producerId" gorm:"size:128;not null;index"`
SourceEventID string `json:"sourceEventId" gorm:"size:256;not null;index"`
PayloadSHA256 string `json:"-" gorm:"type:char(64);not null"`
Outcome string `json:"outcome" gorm:"size:16;not null"`
ActorID int `json:"actorId" gorm:"not null"`
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"`
}
func (IngestAudit) TableName() string { return "bell_event_ingest_audits" }
+16
View File
@@ -0,0 +1,16 @@
package receipt
import "time"
// Receipt permanently binds an idempotency key to the accepted Event payload.
// It deliberately has no UpdatedAt or soft-delete fields because it is immutable.
type Receipt struct {
ID string `json:"id" gorm:"type:uuid;primaryKey"`
EventID string `json:"eventId" gorm:"type:uuid;not null;uniqueIndex"`
ProducerID string `json:"producerId" gorm:"size:128;not null;uniqueIndex:bell_receipt_key"`
SourceEventID string `json:"sourceEventId" gorm:"size:256;not null;uniqueIndex:bell_receipt_key"`
PayloadSHA256 string `json:"-" gorm:"type:char(64);not null"`
AcceptedAt time.Time `json:"acceptedAt" gorm:"type:timestamptz;not null"`
}
func (Receipt) TableName() string { return "bell_event_receipts" }
@@ -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/alert_lifecycle"
"go-admin/common/middleware"
)
func init() { registrars = append(registrars, registerAlertLifecycleRouter) }
func registerAlertLifecycleRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
handler := alert_lifecycle.Handler{}
routes := v1.Group("").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
{
routes.GET("/alerts/:id/lifecycle", handler.Get)
routes.POST("/alerts/:id/ack", handler.Ack)
routes.POST("/alerts/:id/close", handler.Close)
}
}
+38
View File
@@ -0,0 +1,38 @@
package router
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/api"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"go-admin/app/bell/event"
)
type eventHandler struct{ api.Api }
func registerEventRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
handler := eventHandler{}
v1.GET("/events/:id", authMiddleware.MiddlewareFunc(), handler.Get)
}
func (h eventHandler) Get(c *gin.Context) {
h.MakeContext(c).MakeOrm()
if h.Errors != nil {
h.Error(http.StatusInternalServerError, errors.New("数据库连接获取失败"), "数据库连接获取失败")
return
}
item, err := event.NewService(h.Orm).Get(c.Request.Context(), c.Param("id"))
if err != nil {
if errors.Is(err, event.ErrNotFound) {
h.Error(http.StatusNotFound, event.ErrNotFound, event.ErrNotFound.Error())
return
}
h.Logger.Errorf("load Bell event failed: %v", err)
h.Error(http.StatusInternalServerError, errors.New("读取事件失败"), "读取事件失败")
return
}
h.OK(item, "查询成功")
}
+38
View File
@@ -0,0 +1,38 @@
package router
import (
"os"
"github.com/gin-gonic/gin"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/go-admin-team/go-admin-core/sdk"
"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/synthetic"
"go-admin/common/middleware"
)
type registrar func(*gin.RouterGroup, *jwt.GinJWTMiddleware)
var registrars = []registrar{registerEventRouter}
func InitRouter() {
engine, ok := sdk.Runtime.GetEngine().(*gin.Engine)
if !ok || engine == nil {
log.Error("Bell business router requires Gin engine")
return
}
authMiddleware, err := middleware.AuthInit()
if err != nil {
log.Errorf("Bell business JWT init error: %v", err)
return
}
v1 := engine.Group("/api/v1/bell")
for _, register := range registrars {
register(v1, authMiddleware)
}
if synthetic.Enabled(config.ApplicationConfig.Mode, os.Getenv) {
registerSyntheticRouter(v1, authMiddleware)
}
}
+31
View File
@@ -0,0 +1,31 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"go-admin/app/bell/alert"
"go-admin/app/bell/rule"
"go-admin/common/middleware"
)
func init() {
registrars = append(registrars, registerRuleAlertRouter)
}
func registerRuleAlertRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
rules := rule.Handler{}
alerts := alert.Handler{}
secured := v1.Group("").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
{
secured.GET("/rules", rules.List)
secured.POST("/rules", rules.Create)
secured.PUT("/rules/:id", rules.Update)
secured.PUT("/rules/:id/enabled", rules.SetEnabled)
secured.GET("/alerts", alerts.List)
secured.GET("/alerts/:id", alerts.Get)
secured.GET("/events", alerts.ListEvents)
secured.GET("/events/:id/rule-results", alerts.EventResults)
}
}
+13
View File
@@ -0,0 +1,13 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"go-admin/app/bell/synthetic"
)
func registerSyntheticRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
handler := synthetic.Handler{}
v1.POST("/synthetic-events", authMiddleware.MiddlewareFunc(), handler.Create)
}
+100
View File
@@ -0,0 +1,100 @@
package rule
import (
"errors"
"net/http"
"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"`
}
func (h Handler) List(c *gin.Context) {
var query PageQuery
h.MakeContext(c).MakeOrm().Bind(&query, binding.Form)
if h.Errors != nil {
h.Error(http.StatusBadRequest, ErrInvalid, "查询条件不正确")
return
}
items, count, err := NewService(h.Orm).List(c.Request.Context(), query)
if err != nil {
h.Logger.Errorf("list Bell rules failed: %v", err)
h.Error(http.StatusInternalServerError, errors.New("读取规则失败"), "读取规则失败")
return
}
page, size := pageValues(query.PageIndex, query.PageSize)
h.PageOK(items, int(count), page, size, "查询成功")
}
func (h Handler) Create(c *gin.Context) {
if !isAdmin(c) {
h.MakeContext(c).Error(http.StatusForbidden, errors.New("仅管理员可修改规则"), "仅管理员可修改规则")
return
}
var input WriteInput
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
if h.Errors != nil {
h.Error(http.StatusBadRequest, ErrInvalid, ErrInvalid.Error())
return
}
item, err := NewService(h.Orm).Create(c.Request.Context(), input, user.GetUserId(c))
h.writeResult(item, err)
}
func (h Handler) Update(c *gin.Context) {
if !isAdmin(c) {
h.MakeContext(c).Error(http.StatusForbidden, errors.New("仅管理员可修改规则"), "仅管理员可修改规则")
return
}
var input WriteInput
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
if h.Errors != nil {
h.Error(http.StatusBadRequest, ErrInvalid, ErrInvalid.Error())
return
}
item, err := NewService(h.Orm).Update(c.Request.Context(), c.Param("id"), input, user.GetUserId(c))
h.writeResult(item, err)
}
func (h Handler) SetEnabled(c *gin.Context) {
if !isAdmin(c) {
h.MakeContext(c).Error(http.StatusForbidden, errors.New("仅管理员可修改规则"), "仅管理员可修改规则")
return
}
var input enabledInput
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
if h.Errors != nil || input.Enabled == nil {
h.Error(http.StatusBadRequest, ErrInvalid, ErrInvalid.Error())
return
}
item, err := NewService(h.Orm).SetEnabled(c.Request.Context(), c.Param("id"), *input.Enabled, user.GetUserId(c))
h.writeResult(item, err)
}
func (h Handler) writeResult(item Rule, err error) {
switch {
case err == nil:
h.OK(item, "保存成功")
case errors.Is(err, ErrInvalid):
h.Error(http.StatusBadRequest, ErrInvalid, ErrInvalid.Error())
case errors.Is(err, ErrNotFound):
h.Error(http.StatusNotFound, ErrNotFound, ErrNotFound.Error())
default:
h.Logger.Errorf("write Bell rule failed: %v", err)
h.Error(http.StatusConflict, errors.New("规则编码已存在或保存失败"), "规则编码已存在或保存失败")
}
}
func isAdmin(c *gin.Context) bool {
claims := jwt.ExtractClaims(c)
role, _ := claims[jwt.RoleKey].(string)
return role == "admin"
}
+22
View File
@@ -0,0 +1,22 @@
package rule
import "time"
// Rule is the current editable rule definition. Historical evaluations keep a
// complete versioned snapshot, so editing this row never rewrites history.
type Rule struct {
ID string `json:"id" gorm:"type:uuid;primaryKey"`
Code string `json:"code" gorm:"size:128;not null;uniqueIndex"`
Name string `json:"name" gorm:"size:128;not null"`
Enabled bool `json:"enabled" gorm:"not null;default:true;index"`
EventType *string `json:"eventType,omitempty" gorm:"size:128"`
MinimumSeverity string `json:"minimumSeverity" gorm:"size:16;not null"`
LocationContains *string `json:"locationContains,omitempty" gorm:"size:128"`
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 (Rule) TableName() string { return "bell_rules" }
+124
View File
@@ -0,0 +1,124 @@
package rule
import (
"context"
"errors"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
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, query PageQuery) ([]Rule, int64, error) {
page, size := pageValues(query.PageIndex, query.PageSize)
db := s.DB.WithContext(ctx).Model(&Rule{})
if name := strings.TrimSpace(query.Name); name != "" {
db = db.Where("name ILIKE ? OR code 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
}
items := make([]Rule, 0)
err := db.Order("created_at DESC, id DESC").Offset((page - 1) * size).Limit(size).Find(&items).Error
return items, count, err
}
func (s Service) Create(ctx context.Context, input WriteInput, actorID int) (Rule, error) {
normalized, err := Normalize(input, true)
if err != nil {
return Rule{}, err
}
now := time.Now().UTC()
item := Rule{
ID: uuid.NewString(), Code: normalized.Code, Name: normalized.Name, Enabled: true,
EventType: normalized.EventType, MinimumSeverity: normalized.MinimumSeverity,
LocationContains: normalized.LocationContains, Version: 1,
CreatedBy: actorID, UpdatedBy: actorID, CreatedAt: now, UpdatedAt: now,
}
if err = s.DB.WithContext(ctx).Create(&item).Error; err != nil {
return Rule{}, err
}
return item, nil
}
func (s Service) Update(ctx context.Context, id string, input WriteInput, actorID int) (Rule, error) {
if _, err := uuid.Parse(id); err != nil {
return Rule{}, ErrNotFound
}
normalized, err := Normalize(input, false)
if err != nil {
return Rule{}, err
}
var item Rule
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&item, "id = ?", id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrNotFound
}
return err
}
updates := map[string]any{
"name": normalized.Name, "event_type": normalized.EventType,
"minimum_severity": normalized.MinimumSeverity, "location_contains": normalized.LocationContains,
"version": item.Version + 1, "updated_by": actorID, "updated_at": time.Now().UTC(),
}
if err := tx.Model(&item).Updates(updates).Error; err != nil {
return err
}
return tx.First(&item, "id = ?", id).Error
})
return item, err
}
func (s Service) SetEnabled(ctx context.Context, id string, enabled bool, actorID int) (Rule, error) {
if _, err := uuid.Parse(id); err != nil {
return Rule{}, ErrNotFound
}
var item Rule
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&item, "id = ?", id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrNotFound
}
return err
}
if item.Enabled == enabled {
return nil
}
if err := tx.Model(&item).Updates(map[string]any{
"enabled": enabled, "version": item.Version + 1,
"updated_by": actorID, "updated_at": time.Now().UTC(),
}).Error; err != nil {
return err
}
return tx.First(&item, "id = ?", id).Error
})
return item, err
}
func pageValues(page, size int) (int, int) {
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
return page, size
}
+70
View File
@@ -0,0 +1,70 @@
package rule
import (
"errors"
"regexp"
"strings"
"unicode/utf8"
)
var (
ErrInvalid = errors.New("规则内容不符合要求")
ErrNotFound = errors.New("规则不存在")
codePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{1,127}$`)
)
type WriteInput struct {
Code string `json:"code"`
Name string `json:"name"`
EventType *string `json:"eventType"`
MinimumSeverity string `json:"minimumSeverity"`
LocationContains *string `json:"locationContains"`
}
func Normalize(input WriteInput, requireCode bool) (WriteInput, error) {
input.Code = strings.ToLower(strings.TrimSpace(input.Code))
input.Name = strings.TrimSpace(input.Name)
input.MinimumSeverity = strings.ToLower(strings.TrimSpace(input.MinimumSeverity))
if requireCode && !codePattern.MatchString(input.Code) {
return WriteInput{}, ErrInvalid
}
if !validText(input.Name, 128) || !validSeverity(input.MinimumSeverity) {
return WriteInput{}, ErrInvalid
}
var err error
if input.EventType, err = optionalText(input.EventType, 128); err != nil {
return WriteInput{}, err
}
if input.LocationContains, err = optionalText(input.LocationContains, 128); err != nil {
return WriteInput{}, err
}
return input, nil
}
func validSeverity(value string) bool {
switch value {
case "low", "medium", "high", "critical":
return true
default:
return false
}
}
func optionalText(value *string, max int) (*string, error) {
if value == nil {
return nil, nil
}
normalized := strings.TrimSpace(*value)
if normalized == "" {
return nil, nil
}
if !validText(normalized, max) {
return nil, ErrInvalid
}
return &normalized, nil
}
func validText(value string, max int) bool {
return value != "" && utf8.ValidString(value) && utf8.RuneCountInString(value) <= max &&
!strings.ContainsAny(value, "\x00\r\n")
}
+19
View File
@@ -0,0 +1,19 @@
package synthetic
import (
"os"
"strings"
)
const EnabledEnv = "BELL_SYNTHETIC_EVENTS_ENABLED"
// Enabled requires both a non-production runtime and an explicit opt-in.
func Enabled(mode string, getenv func(string) string) bool {
if strings.EqualFold(strings.TrimSpace(mode), "prod") || strings.EqualFold(strings.TrimSpace(mode), "production") {
return false
}
value := strings.ToLower(strings.TrimSpace(getenv(EnabledEnv)))
return value == "true" || value == "1"
}
func EnabledFromEnvironment(mode string) bool { return Enabled(mode, os.Getenv) }
+58
View File
@@ -0,0 +1,58 @@
package synthetic
import (
"errors"
"net/http"
"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"
"go-admin/app/bell/event"
)
const maxRequestBytes = 64 * 1024
type Handler struct{ api.Api }
func IsAdministrator(c *gin.Context) bool {
claims := jwt.ExtractClaims(c)
role, _ := claims[jwt.RoleKey].(string)
return role == "admin"
}
func (h Handler) Create(c *gin.Context) {
if !IsAdministrator(c) {
h.MakeContext(c).Error(http.StatusForbidden, errors.New("无权使用合成事件入口"), "无权使用合成事件入口")
return
}
if err := restoreRequestBody(c); err != nil {
h.MakeContext(c).Error(http.StatusBadRequest, errors.New("请求内容格式不正确"), "请求内容格式不正确")
return
}
var request CreateRequest
h.MakeContext(c).MakeOrm().Bind(&request, binding.JSON)
if h.Errors != nil {
h.Error(http.StatusBadRequest, errors.New("请求内容格式不正确"), "请求内容格式不正确")
return
}
result, err := event.NewService(h.Orm).Ingest(c.Request.Context(), request.Command(), user.GetUserId(c))
if err != nil {
switch {
case errors.Is(err, event.ErrInvalid):
h.Error(http.StatusBadRequest, event.ErrInvalid, event.ErrInvalid.Error())
case errors.Is(err, event.ErrConflict):
h.Error(http.StatusConflict, event.ErrConflict, event.ErrConflict.Error())
default:
h.Logger.Errorf("synthetic event ingest failed: %v", err)
h.Error(http.StatusInternalServerError, errors.New("合成事件写入失败"), "合成事件写入失败")
}
return
}
h.OK(result, "合成事件已接收")
// The client already received the full response. Keep only a fixed marker
// for GoAdmin's generic operation audit, which runs after the handler.
c.Set("result", gin.H{"code": http.StatusOK, "data": "<redacted>"})
}
@@ -0,0 +1,53 @@
package synthetic
import (
"bytes"
"errors"
"io"
"net/http"
"github.com/gin-gonic/gin"
)
const (
requestBodyKey = "bell.synthetic.request-body"
requestBodyErrorKey = "bell.synthetic.request-body-error"
)
var redactedRequestBody = []byte(`{"redacted":true}`)
// RedactRequestBody runs before GoAdmin's operation logger. It keeps the real
// body only in the request context for the handler and exposes a fixed marker
// to the generic audit middleware so event payloads never enter sys_opera_log.
func RedactRequestBody() gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.Method != http.MethodPost || c.Request.URL.Path != "/api/v1/bell/synthetic-events" {
c.Next()
return
}
body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxRequestBytes+1))
if err != nil {
c.Set(requestBodyErrorKey, err)
} else if len(body) > maxRequestBytes {
c.Set(requestBodyErrorKey, errors.New("request body too large"))
} else {
c.Set(requestBodyKey, body)
}
_ = c.Request.Body.Close()
c.Request.Body = io.NopCloser(bytes.NewReader(redactedRequestBody))
c.Next()
}
}
func restoreRequestBody(c *gin.Context) error {
if value, ok := c.Get(requestBodyErrorKey); ok {
return value.(error)
}
value, ok := c.Get(requestBodyKey)
if !ok {
return errors.New("synthetic request body was not captured")
}
body := value.([]byte)
c.Request.Body = io.NopCloser(bytes.NewReader(body))
return nil
}
+27
View File
@@ -0,0 +1,27 @@
package synthetic
import (
"time"
"go-admin/app/bell/event"
)
const ProducerID = "bell.synthetic"
type CreateRequest struct {
SourceEventID string `json:"sourceEventId" binding:"required"`
EventType string `json:"eventType" binding:"required"`
OccurredAt time.Time `json:"occurredAt" binding:"required"`
Location string `json:"location" binding:"required"`
Severity string `json:"severity" binding:"required"`
EvidenceRef *string `json:"evidenceRef,omitempty"`
Attributes map[string]any `json:"attributes"`
}
func (r CreateRequest) Command() event.Command {
return event.Command{
ProducerID: ProducerID, SourceEventID: r.SourceEventID, EventType: r.EventType,
OccurredAt: r.OccurredAt, Location: r.Location, Severity: r.Severity,
EvidenceRef: r.EvidenceRef, Attributes: r.Attributes,
}
}
+7 -1
View File
@@ -20,6 +20,9 @@ import (
"go-admin/app/admin/models"
"go-admin/app/admin/router"
"go-admin/app/bell/alert_lifecycle"
bellrouter "go-admin/app/bell/router"
"go-admin/app/bell/synthetic"
"go-admin/common/bellconfig"
"go-admin/common/database"
"go-admin/common/global"
@@ -54,6 +57,7 @@ func init() {
//注册路由 fixme 其他应用的路由,在本目录新建文件放在init方法
AppRouters = append(AppRouters, router.InitRouter)
AppRouters = append(AppRouters, bellrouter.InitRouter)
}
func setup() error {
@@ -178,7 +182,9 @@ func initRouter() {
//r.Use(middleware.Metrics())
r.Use(common.Sentinel()).
Use(common.RequestId(pkg.TrafficKey)).
Use(api.SetRequestLogger)
Use(api.SetRequestLogger).
Use(synthetic.RedactRequestBody()).
Use(alert_lifecycle.RedactRequestBody())
common.InitMiddleware(r)
@@ -0,0 +1,39 @@
package version_local
import (
"runtime"
"gorm.io/gorm"
"go-admin/app/bell/event"
"go-admin/app/bell/receipt"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateBellEventReceipt)
}
func migrateBellEventReceipt(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.AutoMigrate(new(event.Event), new(receipt.Receipt), new(receipt.IngestAudit)); err != nil {
return err
}
statements := []string{
`ALTER TABLE bell_event_receipts ADD CONSTRAINT bell_event_receipts_event_fk FOREIGN KEY (event_id) REFERENCES bell_events(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_events ADD CONSTRAINT bell_events_severity_check CHECK (severity IN ('low','medium','high','critical'))`,
`CREATE OR REPLACE FUNCTION bell_reject_immutable_fact() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'Bell immutable fact cannot be changed' USING ERRCODE = '55000'; END $$`,
`CREATE TRIGGER bell_events_immutable BEFORE UPDATE OR DELETE ON bell_events FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
`CREATE TRIGGER bell_event_receipts_immutable BEFORE UPDATE OR DELETE ON bell_event_receipts FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
`CREATE TRIGGER bell_event_ingest_audits_immutable BEFORE UPDATE OR DELETE ON bell_event_ingest_audits FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
}
for _, statement := range statements {
if err := tx.Exec(statement).Error; err != nil {
return err
}
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,257 @@
package version_local
import (
"fmt"
"runtime"
"gorm.io/gorm"
"go-admin/app/bell/alert"
"go-admin/app/bell/evaluation"
"go-admin/app/bell/rule"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateBellRuleAlert)
}
func migrateBellRuleAlert(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.AutoMigrate(new(rule.Rule), new(evaluation.Evaluation), new(alert.Alert), new(alert.AlertEvent), new(alert.RuleMatch), new(runtimeCasbinRule)); err != nil {
return err
}
for _, statement := range bellRuleAlertSchemaSQL {
if err := tx.Exec(statement).Error; err != nil {
return err
}
}
if err := seedBellRuleAlertAccess(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
var bellRuleAlertSchemaSQL = []string{
`ALTER TABLE bell_rules ADD CONSTRAINT bell_rules_severity_check CHECK (minimum_severity IN ('low','medium','high','critical'))`,
`ALTER TABLE bell_rules ADD CONSTRAINT bell_rules_version_check CHECK (version > 0)`,
`ALTER TABLE bell_alerts ADD CONSTRAINT bell_alerts_status_check CHECK (status IN ('open','acknowledged','closed'))`,
`ALTER TABLE bell_alerts ADD CONSTRAINT bell_alerts_severity_check CHECK (severity IN ('low','medium','high','critical'))`,
`ALTER TABLE bell_rule_evaluations ADD CONSTRAINT bell_rule_evaluations_event_fk FOREIGN KEY (event_id) REFERENCES bell_events(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_rule_evaluations ADD CONSTRAINT bell_rule_evaluations_rule_fk FOREIGN KEY (rule_id) REFERENCES bell_rules(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_alerts ADD CONSTRAINT bell_alerts_rule_fk FOREIGN KEY (primary_rule_id) REFERENCES bell_rules(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_alert_events ADD CONSTRAINT bell_alert_events_alert_fk FOREIGN KEY (alert_id) REFERENCES bell_alerts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_alert_events ADD CONSTRAINT bell_alert_events_event_fk FOREIGN KEY (event_id) REFERENCES bell_events(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_rule_matches ADD CONSTRAINT bell_rule_matches_alert_fk FOREIGN KEY (alert_id) REFERENCES bell_alerts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_rule_matches ADD CONSTRAINT bell_rule_matches_event_fk FOREIGN KEY (event_id) REFERENCES bell_events(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_rule_matches ADD CONSTRAINT bell_rule_matches_rule_fk FOREIGN KEY (rule_id) REFERENCES bell_rules(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`CREATE UNIQUE INDEX bell_alert_open_correlation_idx ON bell_alerts(primary_rule_id, correlation_key) WHERE status = 'open'`,
`CREATE INDEX bell_alert_events_event_idx ON bell_alert_events(event_id, alert_id)`,
`CREATE TRIGGER bell_rule_evaluations_immutable BEFORE UPDATE OR DELETE ON bell_rule_evaluations FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
`CREATE TRIGGER bell_alert_events_immutable BEFORE UPDATE OR DELETE ON bell_alert_events FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
`CREATE TRIGGER bell_rule_matches_immutable BEFORE UPDATE OR DELETE ON bell_rule_matches FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
`CREATE OR REPLACE FUNCTION bell_evaluate_new_event() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
current_rule bell_rules%ROWTYPE;
is_match boolean;
reasons text[];
explanation_text text;
snapshot jsonb;
target_alert_id uuid;
correlation text;
BEGIN
FOR current_rule IN SELECT * FROM bell_rules WHERE enabled = true ORDER BY id LOOP
reasons := ARRAY[]::text[];
IF current_rule.event_type IS NOT NULL AND current_rule.event_type <> NEW.event_type THEN
reasons := array_append(reasons, '事件类型不匹配');
END IF;
IF array_position(ARRAY['low','medium','high','critical'], NEW.severity) <
array_position(ARRAY['low','medium','high','critical'], current_rule.minimum_severity) THEN
reasons := array_append(reasons, '风险等级低于阈值');
END IF;
IF current_rule.location_contains IS NOT NULL AND
position(lower(current_rule.location_contains) in lower(NEW.location)) = 0 THEN
reasons := array_append(reasons, '地点条件不匹配');
END IF;
is_match := cardinality(reasons) = 0;
explanation_text := CASE WHEN is_match THEN '全部条件命中' ELSE array_to_string(reasons, ';') END;
snapshot := jsonb_build_object(
'id', current_rule.id, 'code', current_rule.code, 'name', current_rule.name,
'enabled', current_rule.enabled, 'eventType', current_rule.event_type,
'minimumSeverity', current_rule.minimum_severity,
'locationContains', current_rule.location_contains, 'version', current_rule.version
);
INSERT INTO bell_rule_evaluations(event_id, rule_id, rule_version, rule_snapshot, matched, explanation, evaluated_at)
VALUES(NEW.id, current_rule.id, current_rule.version, snapshot, is_match, explanation_text, now());
IF NOT is_match THEN
CONTINUE;
END IF;
correlation := lower(trim(NEW.location));
INSERT INTO bell_alerts(id, primary_rule_id, correlation_key, status, severity, summary, location, created_at, updated_at)
VALUES(gen_random_uuid(), current_rule.id, correlation, 'open', NEW.severity,
left(current_rule.name || ':' || NEW.event_type, 256), NEW.location, now(), now())
ON CONFLICT(primary_rule_id, correlation_key) WHERE status = 'open'
DO UPDATE SET
updated_at = now(),
severity = CASE
WHEN array_position(ARRAY['low','medium','high','critical'], EXCLUDED.severity) >
array_position(ARRAY['low','medium','high','critical'], bell_alerts.severity)
THEN EXCLUDED.severity ELSE bell_alerts.severity END
RETURNING id INTO target_alert_id;
INSERT INTO bell_alert_events(alert_id, event_id, linked_at)
VALUES(target_alert_id, NEW.id, now());
INSERT INTO bell_rule_matches(event_id, rule_id, alert_id, rule_version, rule_snapshot, explanation, matched_at)
VALUES(NEW.id, current_rule.id, target_alert_id, current_rule.version, snapshot, explanation_text, now());
END LOOP;
RETURN NEW;
END $$`,
`CREATE TRIGGER bell_events_evaluate_rules AFTER INSERT ON bell_events FOR EACH ROW EXECUTE FUNCTION bell_evaluate_new_event()`,
}
type menuSeed struct {
ID int
Permission string
}
type apiSeed struct {
ID int
Path string
Action string
}
// runtimeCasbinRule deliberately matches the table used by the frozen
// go-admin-core gorm adapter. The legacy SysCasbinRule model is not the table
// loaded by middleware.AuthCheckRole in this baseline.
type runtimeCasbinRule struct {
ID uint `gorm:"primaryKey;autoIncrement"`
Ptype string `gorm:"size:100;uniqueIndex:idx_casbin_rule"`
V0 string `gorm:"size:100;uniqueIndex:idx_casbin_rule"`
V1 string `gorm:"size:100;uniqueIndex:idx_casbin_rule"`
V2 string `gorm:"size:100;uniqueIndex:idx_casbin_rule"`
V3 string `gorm:"size:100;uniqueIndex:idx_casbin_rule"`
V4 string `gorm:"size:100;uniqueIndex:idx_casbin_rule"`
V5 string `gorm:"size:100;uniqueIndex:idx_casbin_rule"`
}
func (runtimeCasbinRule) TableName() string { return "casbin_rule" }
func seedBellRuleAlertAccess(tx *gorm.DB) error {
// db.sql contains explicit primary keys, so PostgreSQL sequences can lag
// behind the imported baseline data. Align them before allocating any new
// menu, API or role IDs.
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));
SELECT setval(pg_get_serial_sequence('sys_role','role_id'), GREATEST((SELECT max(role_id) FROM sys_role),1))`).Error; err != nil {
return err
}
root, err := insertMenu(tx, 0, "BellWarning", "预警中心", "warning", "/bell", "M", "", "", "Layout", 1)
if err != nil {
return err
}
alerts, err := insertMenu(tx, root.ID, "BellAlerts", "预警管理", "bell", "alerts", "C", "bell:alert:list", "", "/bell/alerts/index", 1)
if err != nil {
return err
}
events, err := insertMenu(tx, root.ID, "BellEvents", "事件查询", "list", "events", "C", "bell:event:list", "", "/bell/events/index", 2)
if err != nil {
return err
}
rules, err := insertMenu(tx, root.ID, "BellRules", "规则配置", "guide", "rules", "C", "bell:rule:list", "", "/bell/rules/index", 3)
if err != nil {
return err
}
addRule, err := insertMenu(tx, rules.ID, "", "新增规则", "", "", "F", "bell:rule:add", "POST", "", 1)
if err != nil {
return err
}
editRule, err := insertMenu(tx, rules.ID, "", "修改规则", "", "", "F", "bell:rule:edit", "PUT", "", 2)
if err != nil {
return err
}
apiSpecs := []struct{ title, path, action string }{
{"预警列表", "/api/v1/bell/alerts", "GET"}, {"预警详情", "/api/v1/bell/alerts/:id", "GET"},
{"事件列表", "/api/v1/bell/events", "GET"}, {"事件详情", "/api/v1/bell/events/:id", "GET"},
{"事件规则结果", "/api/v1/bell/events/:id/rule-results", "GET"},
{"规则列表", "/api/v1/bell/rules", "GET"}, {"新增规则", "/api/v1/bell/rules", "POST"},
{"修改规则", "/api/v1/bell/rules/:id", "PUT"}, {"启停规则", "/api/v1/bell/rules/:id/enabled", "PUT"},
}
apis := make([]apiSeed, 0, len(apiSpecs))
for _, spec := range apiSpecs {
seed, seedErr := insertAPI(tx, spec.title, spec.path, spec.action)
if seedErr != nil {
return seedErr
}
apis = append(apis, seed)
}
links := map[int][]apiSeed{
alerts.ID: {apis[0], apis[1]}, events.ID: {apis[2], apis[3], apis[4]}, rules.ID: {apis[5]},
addRule.ID: {apis[6]}, editRule.ID: {apis[7], apis[8]},
}
for menuID, menuAPIs := range links {
for _, item := range menuAPIs {
if err := tx.Exec("INSERT INTO sys_menu_api_rule(sys_menu_menu_id, sys_api_id) VALUES(?, ?) ON CONFLICT DO NOTHING", menuID, item.ID).Error; err != nil {
return err
}
}
}
var operatorRoleID 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(&operatorRoleID).Error; err != nil {
return err
}
if operatorRoleID == 0 {
if err := tx.Raw(`INSERT INTO sys_role(role_name,status,role_key,role_sort,flag,remark,admin,data_scope,create_by,update_by,created_at,updated_at)
VALUES('处置员','2','operator',2,'','仅访问 Bell 预警处理入口',false,'',1,1,now(),now())
RETURNING role_id`).Scan(&operatorRoleID).Error; err != nil {
return err
}
}
for _, menu := range []menuSeed{root, alerts, events, rules} {
if err := tx.Exec("INSERT INTO sys_role_menu(role_id, menu_id) VALUES(?, ?) ON CONFLICT DO NOTHING", operatorRoleID, menu.ID).Error; err != nil {
return err
}
}
for _, item := range apis {
if item.Action != "GET" {
continue
}
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 insertMenu(tx *gorm.DB, parentID int, name, title, icon, path, menuType, permission, action, component string, sort int) (menuSeed, 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, parentID, component, sort).Scan(&id).Error
if err != nil {
return menuSeed{}, err
}
paths := fmt.Sprintf("/0/%d", id)
if parentID != 0 {
var parentPaths string
if err = tx.Raw("SELECT paths FROM sys_menu WHERE menu_id = ?", parentID).Scan(&parentPaths).Error; err != nil {
return menuSeed{}, err
}
paths = fmt.Sprintf("%s/%d", parentPaths, id)
}
if err = tx.Exec("UPDATE sys_menu SET paths = ? WHERE menu_id = ?", paths, id).Error; err != nil {
return menuSeed{}, err
}
return menuSeed{ID: id, Permission: permission}, nil
}
func insertAPI(tx *gorm.DB, title, path, action string) (apiSeed, 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 apiSeed{ID: id, Path: path, Action: action}, err
}
@@ -0,0 +1,96 @@
package version_local
import (
"runtime"
"gorm.io/gorm"
"go-admin/app/bell/alert_lifecycle"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateBellAlertLifecycle)
}
func migrateBellAlertLifecycle(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.AutoMigrate(new(alert_lifecycle.Fact), new(alert_lifecycle.RejectionAudit)); err != nil {
return err
}
for _, statement := range alertLifecycleSQL {
if err := tx.Exec(statement).Error; err != nil {
return err
}
}
if err := seedAlertLifecycleAccess(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
var alertLifecycleSQL = []string{
`ALTER TABLE bell_alerts ADD COLUMN acknowledged_by bigint, ADD COLUMN acknowledged_by_name varchar(128), ADD COLUMN acknowledged_at timestamptz, ADD COLUMN closed_by bigint, ADD COLUMN closed_by_name varchar(128), ADD COLUMN closed_at timestamptz, ADD COLUMN close_outcome varchar(32), ADD COLUMN close_note varchar(500)`,
`ALTER TABLE bell_alerts ADD CONSTRAINT bell_alert_ack_user_fk FOREIGN KEY (acknowledged_by) REFERENCES sys_user(user_id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_alerts ADD CONSTRAINT bell_alert_close_user_fk FOREIGN KEY (closed_by) REFERENCES sys_user(user_id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_alerts ADD CONSTRAINT bell_alert_close_outcome_check CHECK (close_outcome IS NULL OR close_outcome IN ('danger_confirmed','false_positive','site_normal','unable_to_confirm'))`,
`ALTER TABLE bell_alert_lifecycle_facts ADD CONSTRAINT bell_alert_lifecycle_alert_fk FOREIGN KEY (alert_id) REFERENCES bell_alerts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_alert_lifecycle_facts ADD CONSTRAINT bell_alert_lifecycle_actor_fk FOREIGN KEY (actor_id) REFERENCES sys_user(user_id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_alert_lifecycle_facts ADD CONSTRAINT bell_alert_lifecycle_transition_check CHECK (transition IN ('acknowledged','closed'))`,
`ALTER TABLE bell_alert_lifecycle_facts ADD CONSTRAINT bell_alert_lifecycle_outcome_check CHECK (outcome IS NULL OR outcome IN ('danger_confirmed','false_positive','site_normal','unable_to_confirm'))`,
`CREATE TRIGGER bell_alert_lifecycle_immutable BEFORE UPDATE OR DELETE ON bell_alert_lifecycle_facts FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
`CREATE TRIGGER bell_alert_lifecycle_rejections_immutable BEFORE UPDATE OR DELETE ON bell_alert_lifecycle_rejections FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
}
func seedAlertLifecycleAccess(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 alertMenuID int
if err := tx.Table("sys_menu").Select("menu_id").Where("permission = ?", "bell:alert:list").Scan(&alertMenuID).Error; err != nil || alertMenuID == 0 {
return gorm.ErrRecordNotFound
}
ackMenu, err := insertMenu(tx, alertMenuID, "", "开始处理", "", "", "F", "bell:alert:ack", "POST", "", 1)
if err != nil {
return err
}
closeMenu, err := insertMenu(tx, alertMenuID, "", "记录结果", "", "", "F", "bell:alert:close", "POST", "", 2)
if err != nil {
return err
}
specs := []struct{ title, path, action string }{{"预警处理时间线", "/api/v1/bell/alerts/:id/lifecycle", "GET"}, {"开始处理预警", "/api/v1/bell/alerts/:id/ack", "POST"}, {"记录结果并完成", "/api/v1/bell/alerts/:id/close", "POST"}}
apis := make([]apiSeed, 0, len(specs))
for _, spec := range specs {
item, itemErr := insertAPI(tx, spec.title, spec.path, spec.action)
if itemErr != nil {
return itemErr
}
apis = append(apis, item)
}
for _, link := range []struct {
menu int
api apiSeed
}{{alertMenuID, apis[0]}, {ackMenu.ID, apis[1]}, {closeMenu.ID, apis[2]}} {
if err := tx.Exec("INSERT INTO sys_menu_api_rule(sys_menu_menu_id,sys_api_id) VALUES(?,?) ON CONFLICT DO NOTHING", link.menu, link.api.ID).Error; err != nil {
return err
}
}
var operatorRoleID int
if err := tx.Table("sys_role").Select("role_id").Where("role_key = ?", "operator").Scan(&operatorRoleID).Error; err != nil || operatorRoleID == 0 {
return gorm.ErrRecordNotFound
}
for _, menuID := range []int{ackMenu.ID, closeMenu.ID} {
if err := tx.Exec("INSERT INTO sys_role_menu(role_id,menu_id) VALUES(?,?) ON CONFLICT DO NOTHING", operatorRoleID, menuID).Error; err != nil {
return err
}
}
for _, item := range apis {
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
}
@@ -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,172 @@
package bell_alert_lifecycle_test
import (
"context"
"os"
"sync"
"sync/atomic"
"testing"
"time"
"gorm.io/driver/postgres"
"gorm.io/gorm"
adminmodels "go-admin/app/admin/models"
"go-admin/app/bell/alert"
"go-admin/app/bell/alert_lifecycle"
"go-admin/app/bell/event"
"go-admin/app/bell/rule"
)
func TestConcurrentLifecycleAndPersistence(t *testing.T) {
dsn := os.Getenv("BELL_ALERT_LIFECYCLE_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("integration database not configured")
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
actors := createActors(t, db)
service := alert_lifecycle.NewService(db)
alertID := createAlert(t, db, "main")
const attempts = 20
var won atomic.Int32
results := make(chan alert_lifecycle.Result, attempts)
var wg sync.WaitGroup
for i := 0; i < attempts; i++ {
wg.Add(1)
actor := actors[i%2]
go func() {
defer wg.Done()
result, _ := service.Ack(ctx, alertID, actor)
if result.Won {
won.Add(1)
}
results <- result
}()
}
wg.Wait()
close(results)
if won.Load() != 1 {
t.Fatalf("ack winners=%d", won.Load())
}
detail, err := service.Get(ctx, alertID)
if err != nil || detail.Projection.Status != alert_lifecycle.StatusAcknowledged || len(detail.Timeline) != 1 {
t.Fatalf("ack projection=%#v err=%v", detail, err)
}
winner := actors[0]
loser := actors[1]
if detail.Projection.AcknowledgedBy == nil || *detail.Projection.AcknowledgedBy != winner.ID {
winner, loser = loser, winner
}
for result := range results {
if result.Detail.Projection.AcknowledgedBy != nil && *result.Detail.Projection.AcknowledgedBy != winner.ID {
t.Fatal("later ack did not report the true winner")
}
}
if _, err = service.Close(ctx, alertID, alert_lifecycle.CloseInput{Outcome: "site_normal"}, loser); err == nil {
t.Fatal("non-owner close succeeded")
}
if _, err = service.Close(ctx, alertID, alert_lifecycle.CloseInput{}, winner); err == nil {
t.Fatal("missing outcome succeeded")
}
closed, err := service.Close(ctx, alertID, alert_lifecycle.CloseInput{Outcome: "site_normal", Note: "现场正常"}, winner)
if err != nil || !closed.Won {
t.Fatalf("close failed: %#v %v", closed, err)
}
replay, err := service.Close(ctx, alertID, alert_lifecycle.CloseInput{Outcome: "site_normal", Note: "现场正常"}, winner)
if err != nil || !replay.Idempotent {
t.Fatalf("close replay=%#v %v", replay, err)
}
if _, err = service.Close(ctx, alertID, alert_lifecycle.CloseInput{Outcome: "false_positive"}, winner); err == nil {
t.Fatal("conflicting close replay succeeded")
}
if err = db.Model(&alert_lifecycle.Fact{}).Where("alert_id = ?", alertID).Update("actor_name", "tampered").Error; err == nil {
t.Fatal("lifecycle fact update succeeded")
}
var facts, rejects int64
db.Model(&alert_lifecycle.Fact{}).Where("alert_id = ?", alertID).Count(&facts)
db.Model(&alert_lifecycle.RejectionAudit{}).Where("alert_id = ?", alertID).Count(&rejects)
if facts != 2 || rejects < 20 {
t.Fatalf("facts=%d rejects=%d", facts, rejects)
}
sqlDB, _ := db.DB()
_ = sqlDB.Close()
reopened, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
after, err := alert_lifecycle.NewService(reopened).Get(ctx, alertID)
if err != nil || after.Projection.Status != alert_lifecycle.StatusClosed || len(after.Timeline) != 2 {
t.Fatalf("restart detail=%#v err=%v", after, err)
}
rollbackID := createAlert(t, reopened, "rollback")
if err = reopened.Exec(`CREATE FUNCTION bell_test_reject_lifecycle() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'forced lifecycle failure'; END $$`).Error; err != nil {
t.Fatal(err)
}
if err = reopened.Exec(`CREATE TRIGGER bell_test_reject_lifecycle BEFORE INSERT ON bell_alert_lifecycle_facts FOR EACH ROW EXECUTE FUNCTION bell_test_reject_lifecycle()`).Error; err != nil {
t.Fatal(err)
}
if _, err = alert_lifecycle.NewService(reopened).Ack(ctx, rollbackID, winner); err == nil {
t.Fatal("forced lifecycle failure succeeded")
}
rollback, _ := alert_lifecycle.NewService(reopened).Get(ctx, rollbackID)
if rollback.Projection.Status != alert_lifecycle.StatusOpen || len(rollback.Timeline) != 0 {
t.Fatal("failed ack left partial projection")
}
if err = reopened.Exec(`DROP TRIGGER bell_test_reject_lifecycle ON bell_alert_lifecycle_facts; DROP FUNCTION bell_test_reject_lifecycle()`).Error; err != nil {
t.Fatal(err)
}
adminCloseID := createAlert(t, reopened, "admin-close")
if _, err = alert_lifecycle.NewService(reopened).Ack(ctx, adminCloseID, winner); err != nil {
t.Fatal(err)
}
administrator := loser
administrator.Role = "admin"
if result, closeErr := alert_lifecycle.NewService(reopened).Close(ctx, adminCloseID, alert_lifecycle.CloseInput{Outcome: "danger_confirmed"}, administrator); closeErr != nil || !result.Won {
t.Fatalf("administrator close failed: %#v %v", result, closeErr)
}
}
func createActors(t *testing.T, db *gorm.DB) []alert_lifecycle.Actor {
t.Helper()
var roleID int
db.Table("sys_role").Select("role_id").Where("role_key='operator'").Scan(&roleID)
result := make([]alert_lifecycle.Actor, 2)
for i := range result {
user := adminmodels.SysUser{Username: "bell_133_operator_" + string(rune('a'+i)), Password: "test-password-133", NickName: "处置员" + string(rune('A'+i)), RoleId: roleID, DeptId: 1, PostId: 1, Status: "2"}
if err := db.Create(&user).Error; err != nil {
t.Fatal(err)
}
result[i] = alert_lifecycle.Actor{ID: user.UserId, Name: user.NickName, Role: "operator"}
}
return result
}
func createAlert(t *testing.T, db *gorm.DB, suffix string) string {
t.Helper()
ctx := context.Background()
eventType := "lifecycle_" + suffix
createdRule, err := rule.NewService(db).Create(ctx, rule.WriteInput{Code: "lifecycle-" + suffix, Name: "生命周期规则", EventType: &eventType, MinimumSeverity: "low"}, 1)
if err != nil {
t.Fatal(err)
}
created, err := event.NewService(db).Ingest(ctx, event.Command{ProducerID: "bell.lifecycle-test", SourceEventID: suffix, EventType: eventType, OccurredAt: time.Date(2026, 8, 29, 0, 0, 0, 0, time.UTC), Location: "测试地点" + suffix, Severity: "high", Attributes: map[string]any{}}, 1)
if err != nil {
t.Fatal(err)
}
items, _, err := alert.NewService(db).List(ctx, alert.PageQuery{PageIndex: 1, PageSize: 100})
if err != nil {
t.Fatal(err)
}
for _, item := range items {
if item.PrimaryRuleID == createdRule.ID {
return item.ID
}
}
t.Fatal("alert not created")
return created.Event.ID
}
@@ -0,0 +1,28 @@
[CmdletBinding()] param([string]$PostgresBin='D:\pgsql17\bin')
Set-StrictMode -Version 3.0
$ErrorActionPreference='Stop'; $started=$false; $server=$null
$root=Join-Path ([IO.Path]::GetTempPath()) ('yovision-bell-133-'+[guid]::NewGuid().ToString('N'))
$data=Join-Path $root 'postgres'; $log=Join-Path $root 'postgres.log'; $serverRoot=(Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path; $serverExe=Join-Path $root 'bell.exe'
function FreePort { $l=[Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback,0); try{$l.Start();return ([Net.IPEndPoint]$l.LocalEndpoint).Port}finally{$l.Stop()} }
function WaitPort([int]$port){for($i=0;$i -lt 120;$i++){try{$c=[Net.Sockets.TcpClient]::new();$ok=$c.ConnectAsync('127.0.0.1',$port).Wait(250)-and$c.Connected;$c.Dispose();if($ok){return}}catch{};Start-Sleep -Milliseconds 250};throw 'PostgreSQL did not start'}
function Login([string]$base,[string]$username,[string]$password){$body=@{username=$username;password=$password;code='0';uuid='0'}|ConvertTo-Json -Compress; $result=Invoke-RestMethod -Method Post -Uri "$base/api/v1/login" -ContentType 'application/json' -Body $body -NoProxy; if([int]$result.code-ne 200){throw "login failed: $username"}; return @{Authorization="Bearer $($result.token)"}}
New-Item -ItemType Directory -Path $root|Out-Null; $port=FreePort
try {
foreach($name in @('initdb.exe','pg_ctl.exe','createdb.exe','psql.exe')){if(-not(Test-Path (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'}
$args="-D `"$data`" -l `"$log`" -o `"-p $port -h 127.0.0.1`" start"; Start-Process (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $args -WindowStyle Hidden|Out-Null; WaitPort $port; $started=$true
& (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $port -U postgres bell_133; if($LASTEXITCODE-ne 0){throw 'createdb failed'}
$bellPort=FreePort; $base="http://127.0.0.1:$bellPort"; $env:GOTOOLCHAIN='go1.26.5'; $env:BELL_DATABASE_URL="host=127.0.0.1 port=$port user=postgres dbname=bell_133 sslmode=disable"; $env:BELL_ALERT_LIFECYCLE_TEST_DATABASE_URL=$env:BELL_DATABASE_URL; $env:BELL_JWT_SECRET=[guid]::NewGuid().ToString('N')+[guid]::NewGuid().ToString('N'); $env:BELL_BOOTSTRAP_USERNAME='bell_133_admin'; $env:BELL_BOOTSTRAP_PASSWORD=[guid]::NewGuid().ToString('N'); $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 $root 'migrate.log'); if($LASTEXITCODE-ne 0){throw "migration failed: $root"}; go test ./tests/bell_alert_lifecycle -count=1 -v; if($LASTEXITCODE-ne 0){throw 'lifecycle test failed'}; go build -o $serverExe . } finally { Pop-Location }
$server=Start-Process $serverExe -ArgumentList @('server','-c','config/settings.demo.yml') -WorkingDirectory $serverRoot -RedirectStandardOutput (Join-Path $root 'server.out') -RedirectStandardError (Join-Path $root 'server.err') -WindowStyle Hidden -PassThru; WaitPort $bellPort
$a=Login $base 'bell_133_operator_a' 'test-password-133'; $b=Login $base 'bell_133_operator_b' 'test-password-133'; $list=Invoke-RestMethod -Uri "$base/api/v1/bell/alerts?status=open" -Headers $a -NoProxy; $id=[string]$list.data.list[0].id; if([string]::IsNullOrWhiteSpace($id)){throw 'open alert missing'}
$ack=Invoke-RestMethod -Method Post -Uri "$base/api/v1/bell/alerts/$id/ack" -Headers $a -ContentType 'application/json' -Body '{}' -NoProxy; $late=Invoke-RestMethod -Method Post -Uri "$base/api/v1/bell/alerts/$id/ack" -Headers $b -ContentType 'application/json' -Body '{}' -NoProxy; if([int]$ack.code-ne 200-or[int]$late.code-ne 409){throw 'ack API semantics failed'}
$forbidden=Invoke-RestMethod -Method Post -Uri "$base/api/v1/bell/alerts/$id/close" -Headers $b -ContentType 'application/json' -Body '{"outcome":"site_normal"}' -NoProxy; $missing=Invoke-RestMethod -Method Post -Uri "$base/api/v1/bell/alerts/$id/close" -Headers $a -ContentType 'application/json' -Body '{}' -NoProxy; if([int]$forbidden.code-ne 403-or[int]$missing.code-ne 400){throw 'close rejection semantics failed'}
$body='{"outcome":"site_normal","note":"现场正常"}'; $closed=Invoke-RestMethod -Method Post -Uri "$base/api/v1/bell/alerts/$id/close" -Headers $a -ContentType 'application/json; charset=utf-8' -Body $body -NoProxy; $replay=Invoke-RestMethod -Method Post -Uri "$base/api/v1/bell/alerts/$id/close" -Headers $a -ContentType 'application/json; charset=utf-8' -Body $body -NoProxy; $timeline=Invoke-RestMethod -Uri "$base/api/v1/bell/alerts/$id/lifecycle" -Headers $a -NoProxy; if([int]$closed.code-ne 200-or-not$replay.data.idempotent-or$timeline.data.detail.timeline.Count-ne 2){throw 'close/timeline API semantics failed'}
$leaks=& (Join-Path $PostgresBin 'psql.exe') -h 127.0.0.1 -p $port -U postgres -d bell_133 -Atc "select count(*) from sys_opera_log where oper_url like '%/bell/alerts/%/close' and ((oper_param <> '' and oper_param not like '%redacted%') or json_result not like '%redacted%');"; if($LASTEXITCODE-ne 0-or[int]$leaks-ne 0){throw 'lifecycle note leaked into GoAdmin operation log'}
Write-Output 'BELL_133_HTTP ack=200 late_ack=409 forbidden_close=403 missing_outcome=400 close=200 replay=true timeline=2'
} finally {
if($null-ne$server-and-not$server.HasExited){Stop-Process -Id $server.Id -Force; $server.WaitForExit(5000)|Out-Null}
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_ALERT_LIFECYCLE_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,7 @@
package bell_alert_lifecycle_test
import "testing"
// Input and state validation are exercised through the PostgreSQL service test;
// this sentinel keeps the package runnable without an integration database.
func TestLifecyclePackageLoadsWithoutDatabase(t *testing.T) {}
@@ -0,0 +1,61 @@
package bell_event_test
import (
"errors"
"testing"
"time"
"go-admin/app/bell/event"
)
func validCommand() event.Command {
return event.Command{
ProducerID: " bell.synthetic ", SourceEventID: " test-001 ", EventType: " danger_area_entered ",
OccurredAt: time.Date(2026, 8, 29, 0, 0, 0, 0, time.FixedZone("CST", 8*60*60)),
Location: " 东门 ", Severity: " HIGH ", Attributes: map[string]any{"z": 2, "a": "first"},
}
}
func TestNormalizeIsDeterministicAndTrimsBusinessFields(t *testing.T) {
first, err := event.Normalize(validCommand())
if err != nil {
t.Fatal(err)
}
secondCommand := validCommand()
secondCommand.Attributes = map[string]any{"a": "first", "z": 2}
second, err := event.Normalize(secondCommand)
if err != nil {
t.Fatal(err)
}
if first.Digest != second.Digest || string(first.Payload) != string(second.Payload) {
t.Fatalf("normalized payload is not deterministic: %s != %s", first.Payload, second.Payload)
}
if first.Command.ProducerID != "bell.synthetic" || first.Command.Severity != "high" {
t.Fatalf("fields were not normalized: %#v", first.Command)
}
if first.Command.OccurredAt.Location() != time.UTC {
t.Fatalf("occurredAt was not converted to UTC: %v", first.Command.OccurredAt)
}
}
func TestNormalizeRejectsUnsafeOrInvalidInput(t *testing.T) {
tests := []struct {
name string
mutate func(*event.Command)
}{
{name: "missing source id", mutate: func(c *event.Command) { c.SourceEventID = " " }},
{name: "unknown severity", mutate: func(c *event.Command) { c.Severity = "urgent" }},
{name: "newline in location", mutate: func(c *event.Command) { c.Location = "东门\nsecret" }},
{name: "unsafe evidence path", mutate: func(c *event.Command) { value := `C:\\secret.jpg`; c.EvidenceRef = &value }},
{name: "oversized attributes", mutate: func(c *event.Command) { c.Attributes = map[string]any{"blob": string(make([]byte, 49*1024))} }},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
command := validCommand()
test.mutate(&command)
if _, err := event.Normalize(command); !errors.Is(err, event.ErrInvalid) {
t.Fatalf("expected ErrInvalid, got %v", err)
}
})
}
}
@@ -0,0 +1,42 @@
package bell_event_test
import (
"testing"
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"go-admin/app/bell/synthetic"
)
func TestSyntheticEnabledRequiresExplicitNonProductionOptIn(t *testing.T) {
getenv := func(name string) string {
if name == synthetic.EnabledEnv {
return "true"
}
return ""
}
if synthetic.Enabled("prod", getenv) || synthetic.Enabled("production", getenv) {
t.Fatal("synthetic route must stay disabled in production even when the flag is set")
}
if !synthetic.Enabled("dev", getenv) {
t.Fatal("explicit development opt-in should enable the synthetic route")
}
if synthetic.Enabled("dev", func(string) string { return "" }) {
t.Fatal("synthetic route must default to disabled")
}
}
func TestSyntheticAdministratorCheckUsesJWTClaims(t *testing.T) {
gin.SetMode(gin.TestMode)
admin, _ := gin.CreateTestContext(nil)
admin.Set(jwt.JwtPayloadKey, jwt.MapClaims{jwt.RoleKey: "admin"})
if !synthetic.IsAdministrator(admin) {
t.Fatal("admin role should be authorized")
}
operator, _ := gin.CreateTestContext(nil)
operator.Set(jwt.JwtPayloadKey, jwt.MapClaims{jwt.RoleKey: "operator"})
if synthetic.IsAdministrator(operator) {
t.Fatal("non-admin role must be rejected")
}
}
@@ -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,214 @@
package bell_rule_alert_test
import (
"context"
"encoding/json"
"fmt"
"os"
"testing"
"time"
"gorm.io/driver/postgres"
"gorm.io/gorm"
adminmodels "go-admin/app/admin/models"
"go-admin/app/bell/alert"
"go-admin/app/bell/evaluation"
"go-admin/app/bell/event"
"go-admin/app/bell/receipt"
"go-admin/app/bell/rule"
)
func TestPostgresRuleEvaluationAndAlertProjection(t *testing.T) {
dsn := os.Getenv("BELL_RULE_ALERT_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set BELL_RULE_ALERT_TEST_DATABASE_URL to run the isolated PostgreSQL test")
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
rules := rule.NewService(db)
events := event.NewService(db)
evaluations := evaluation.NewService(db)
alerts := alert.NewService(db)
assertOperatorAccess(t, db)
createOperatorUser(t, db)
eventType := "danger_area_entered"
location := "东门"
first, err := rules.Create(ctx, rule.WriteInput{Code: "area-high", Name: "高风险区域", EventType: &eventType, MinimumSeverity: "high", LocationContains: &location}, 1)
if err != nil {
t.Fatal(err)
}
second, err := rules.Create(ctx, rule.WriteInput{Code: "all-high", Name: "全局高风险", MinimumSeverity: "high"}, 1)
if err != nil {
t.Fatal(err)
}
otherEventType := "fire_detected"
_, err = rules.Create(ctx, rule.WriteInput{Code: "critical-fire", Name: "仅严重火情", EventType: &otherEventType, MinimumSeverity: "critical"}, 1)
if err != nil {
t.Fatal(err)
}
created := ingest(t, events, "event-001", "东门 A 区", "high")
result, err := evaluations.ForEvent(ctx, created.Event.ID)
if err != nil {
t.Fatal(err)
}
if len(result.Evaluations) != 3 || len(result.Alerts) != 2 {
t.Fatalf("expected 3 evaluations and 2 alerts, got %d and %d", len(result.Evaluations), len(result.Alerts))
}
matched, unmatched := 0, 0
for _, item := range result.Evaluations {
if item.Matched {
matched++
} else if item.Explanation != "" {
unmatched++
}
}
if matched != 2 || unmatched != 1 {
t.Fatalf("unexpected match explanations: matched=%d unmatched=%d", matched, unmatched)
}
replay := ingest(t, events, "event-001", "东门 A 区", "high")
if !replay.Duplicate || replay.Event.ID != created.Event.ID {
t.Fatalf("idempotent replay created another fact: %#v", replay)
}
assertCount(t, db, "bell_rule_evaluations", 3)
assertCount(t, db, "bell_alerts", 2)
secondEvent := ingest(t, events, "event-002", "东门 A 区", "critical")
assertCount(t, db, "bell_alerts", 2)
items, total, err := alerts.List(ctx, alert.PageQuery{PageIndex: 1, PageSize: 20, Status: "open"})
if err != nil || total != 2 || len(items) != 2 {
t.Fatalf("unexpected alert list: total=%d len=%d err=%v", total, len(items), err)
}
for _, item := range items {
if item.EventCount != 2 || item.Status != "open" || item.Severity != "critical" {
t.Fatalf("open alert did not aggregate and escalate: %#v", item)
}
detail, detailErr := alerts.Get(ctx, item.ID)
if detailErr != nil || len(detail.Events) != 2 || len(detail.Matches) != 2 {
t.Fatalf("event-alert navigation is incomplete: events=%d matches=%d err=%v", len(detail.Events), len(detail.Matches), detailErr)
}
}
updated, err := rules.Update(ctx, first.ID, rule.WriteInput{Name: "高风险区域(更新)", EventType: &eventType, MinimumSeverity: "medium", LocationContains: &location}, 1)
if err != nil || updated.Version != 2 {
t.Fatalf("rule version was not incremented: version=%d err=%v", updated.Version, err)
}
if _, err = rules.SetEnabled(ctx, second.ID, false, 1); err != nil {
t.Fatal(err)
}
thirdEvent := ingest(t, events, "event-003", "东门 B 区", "medium")
thirdResults, err := evaluations.ForEvent(ctx, thirdEvent.Event.ID)
if err != nil {
t.Fatal(err)
}
if len(thirdResults.Evaluations) != 2 || len(thirdResults.Alerts) != 1 {
t.Fatalf("disabled rule was evaluated: evaluations=%d alerts=%d", len(thirdResults.Evaluations), len(thirdResults.Alerts))
}
var snapshot struct {
Version int `json:"version"`
}
for _, item := range thirdResults.Evaluations {
if item.RuleID == first.ID {
if err = json.Unmarshal(item.RuleSnapshot, &snapshot); err != nil || item.RuleVersion != 2 || snapshot.Version != 2 {
t.Fatalf("versioned rule snapshot missing: item=%#v snapshot=%#v err=%v", item, snapshot, err)
}
}
}
if err = db.Model(&evaluation.Evaluation{}).Where("event_id = ? AND rule_id = ?", created.Event.ID, first.ID).Update("explanation", "tampered").Error; err == nil {
t.Fatal("immutable evaluation update unexpectedly succeeded")
}
if err = db.Exec(`CREATE FUNCTION bell_test_reject_alert() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'forced alert failure'; END $$`).Error; err != nil {
t.Fatal(err)
}
if err = db.Exec(`CREATE TRIGGER bell_test_reject_alert BEFORE INSERT ON bell_alerts FOR EACH ROW EXECUTE FUNCTION bell_test_reject_alert()`).Error; err != nil {
t.Fatal(err)
}
failedID := "event-rollback"
_, ingestErr := events.Ingest(ctx, eventCommand(failedID, "东门 C 区", "high"), 1)
if ingestErr == nil {
t.Fatal("forced alert failure did not roll back Event ingest")
}
var eventCount, receiptCount int64
db.Model(&event.Event{}).Where("source_event_id = ?", failedID).Count(&eventCount)
db.Model(&receipt.Receipt{}).Where("source_event_id = ?", failedID).Count(&receiptCount)
if eventCount != 0 || receiptCount != 0 {
t.Fatalf("transaction failure left partial facts: events=%d receipts=%d", eventCount, receiptCount)
}
if err = db.Exec(`DROP TRIGGER bell_test_reject_alert ON bell_alerts; DROP FUNCTION bell_test_reject_alert()`).Error; err != nil {
t.Fatal(err)
}
if secondEvent.Event.ID == thirdEvent.Event.ID {
t.Fatal("independent Events unexpectedly share an id")
}
}
func ingest(t *testing.T, service event.Service, sourceID, location, severity string) event.Result {
t.Helper()
result, err := service.Ingest(context.Background(), eventCommand(sourceID, location, severity), 1)
if err != nil {
t.Fatal(err)
}
return result
}
func eventCommand(sourceID, location, severity string) event.Command {
return event.Command{ProducerID: "bell.rule-test", SourceEventID: sourceID, EventType: "danger_area_entered", OccurredAt: time.Date(2026, 8, 29, 0, 0, 0, 0, time.UTC), Location: location, Severity: severity, Attributes: map[string]any{"test": true}}
}
func assertCount(t *testing.T, db *gorm.DB, table string, want int64) {
t.Helper()
var got int64
if err := db.Table(table).Count(&got).Error; err != nil {
t.Fatal(err)
}
if got != want {
t.Fatal(fmt.Sprintf("%s count: got %d want %d", table, got, want))
}
}
func assertOperatorAccess(t *testing.T, db *gorm.DB) {
t.Helper()
var menuCount, readPolicyCount, ruleWritePolicyCount, lifecycleWritePolicyCount 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{"/bell", "alerts", "events", "rules"}).Count(&menuCount).Error; err != nil {
t.Fatal(err)
}
if err := db.Table("casbin_rule").Where("v0 = ? AND v2 = ?", "operator", "GET").Count(&readPolicyCount).Error; err != nil {
t.Fatal(err)
}
if err := db.Table("casbin_rule").Where("v0 = ? AND v2 <> ? AND v1 LIKE ?", "operator", "GET", "/api/v1/bell/rules%").Count(&ruleWritePolicyCount).Error; err != nil {
t.Fatal(err)
}
if err := db.Table("casbin_rule").Where("v0 = ? AND v2 = ? AND v1 IN ?", "operator", "POST", []string{"/api/v1/bell/alerts/:id/ack", "/api/v1/bell/alerts/:id/close"}).Count(&lifecycleWritePolicyCount).Error; err != nil {
t.Fatal(err)
}
if menuCount != 4 || readPolicyCount < 6 || ruleWritePolicyCount != 0 || lifecycleWritePolicyCount > 2 {
t.Fatalf("operator access escaped Bell scope: menus=%d reads=%d rule_writes=%d lifecycle_writes=%d", menuCount, readPolicyCount, ruleWritePolicyCount, lifecycleWritePolicyCount)
}
}
func createOperatorUser(t *testing.T, db *gorm.DB) {
t.Helper()
password := os.Getenv("BELL_RULE_ALERT_OPERATOR_PASSWORD")
if password == "" {
t.Skip("set BELL_RULE_ALERT_OPERATOR_PASSWORD for the HTTP RBAC continuation")
}
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("load 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,143 @@
[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-132-' + [guid]::NewGuid().ToString('N'))
$pgData = Join-Path $testRoot 'postgres'
$pgLog = Join-Path $testRoot 'postgres.log'
$pgCtlOut = Join-Path $testRoot 'pg-ctl.out.log'
$pgCtlErr = Join-Path $testRoot 'pg-ctl.err.log'
$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-FreeTcpPort {
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
try {
$listener.Start()
return ([Net.IPEndPoint]$listener.LocalEndpoint).Port
} finally {
$listener.Stop()
}
}
function Wait-Tcp([int]$Port, [bool]$Open, [int]$Attempts = 120) {
for ($attempt = 0; $attempt -lt $Attempts; $attempt++) {
$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 ($attempt = 0; $attempt -lt 100; $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 endpoint did not become ready'
}
New-Item -ItemType Directory -Path $testRoot | Out-Null
$pgPort = Get-FreeTcpPort
$bellPort = Get-FreeTcpPort
$baseUrl = "http://127.0.0.1:$bellPort"
$database = 'bell_132'
try {
foreach ($required in @('initdb.exe', 'pg_ctl.exe', 'createdb.exe')) {
$path = Join-Path $PostgresBin $required
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Missing PostgreSQL tool: $path" }
}
& (Join-Path $PostgresBin 'initdb.exe') -D $pgData -U postgres -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 $pgCtlOut -RedirectStandardError $pgCtlErr -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 postgres $database
if ($LASTEXITCODE -ne 0) { throw 'isolated Bell database creation failed' }
$env:GOTOOLCHAIN = 'go1.26.5'
$env:BELL_DATABASE_URL = "host=127.0.0.1 port=$pgPort user=postgres dbname=$database sslmode=disable"
$env:BELL_RULE_ALERT_TEST_DATABASE_URL = $env:BELL_DATABASE_URL
$env:BELL_JWT_SECRET = [guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N')
$env:BELL_BOOTSTRAP_USERNAME = 'bell_132_admin'
$env:BELL_BOOTSTRAP_PASSWORD = [guid]::NewGuid().ToString('N')
$env:BELL_RULE_ALERT_OPERATOR_PASSWORD = [guid]::NewGuid().ToString('N')
$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 "Bell migration failed; see $(Join-Path $testRoot 'migrate.log')" }
go test ./tests/bell_rule_alert -count=1 -v
if ($LASTEXITCODE -ne 0) { throw 'Bell rule-alert integration test 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
$unauthorized = Invoke-RestMethod -Uri "$baseUrl/api/v1/bell/rules" -TimeoutSec 5 -NoProxy
if ([int]$unauthorized.code -ne 401) { throw "unauthenticated rule list returned code $($unauthorized.code)" }
$adminLoginBody = @{ username = $env:BELL_BOOTSTRAP_USERNAME; password = $env:BELL_BOOTSTRAP_PASSWORD; code = '0'; uuid = '0' } | ConvertTo-Json -Compress
$adminLogin = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/login" -ContentType 'application/json' -Body $adminLoginBody -TimeoutSec 5 -NoProxy
if ([int]$adminLogin.code -ne 200) { throw 'Bell administrator login failed' }
$adminHeaders = @{ Authorization = "Bearer $($adminLogin.token)" }
$adminRule = @{ code = 'http-admin'; name = '管理员 HTTP 规则'; eventType = $null; minimumSeverity = 'high'; locationContains = $null } | ConvertTo-Json -Compress
$adminWrite = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/rules" -Headers $adminHeaders -ContentType 'application/json; charset=utf-8' -Body $adminRule -TimeoutSec 5 -NoProxy
if ([int]$adminWrite.code -ne 200 -or [int]$adminWrite.data.version -ne 1) { throw 'administrator rule create failed' }
$operatorLoginBody = @{ username = 'bell_132_operator'; password = $env:BELL_RULE_ALERT_OPERATOR_PASSWORD; code = '0'; uuid = '0' } | ConvertTo-Json -Compress
$operatorLogin = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/login" -ContentType 'application/json' -Body $operatorLoginBody -TimeoutSec 5 -NoProxy
if ([int]$operatorLogin.code -ne 200) { throw 'Bell operator login failed' }
$operatorHeaders = @{ Authorization = "Bearer $($operatorLogin.token)" }
foreach ($path in @('/api/v1/bell/rules','/api/v1/bell/events','/api/v1/bell/alerts')) {
$read = Invoke-RestMethod -Uri "$baseUrl$path" -Headers $operatorHeaders -TimeoutSec 5 -NoProxy
if ([int]$read.code -ne 200) { throw "operator read $path returned code $($read.code)" }
}
$operatorWrite = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/rules" -Headers $operatorHeaders -ContentType 'application/json' -Body $adminRule -TimeoutSec 5 -NoProxy
if ([int]$operatorWrite.code -ne 403) { throw "operator rule write returned code $($operatorWrite.code)" }
$menu = Invoke-RestMethod -Uri "$baseUrl/api/v1/menurole" -Headers $operatorHeaders -TimeoutSec 5 -NoProxy
$menuJson = $menu.data | ConvertTo-Json -Depth 20 -Compress
foreach ($title in @('预警中心','预警管理','事件查询','规则配置')) {
if (-not $menuJson.Contains($title)) { throw "operator menu is missing $title" }
}
if ($menuJson.Contains('系统管理') -or $menuJson.Contains('开发工具')) { throw 'operator menu exposed unrelated GoAdmin modules' }
Write-Output 'BELL_132_HTTP unauthenticated=401 admin_write=200 operator_reads=200 operator_write=403 minimal_menu=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 $pgData -m fast stop *> (Join-Path $testRoot 'pg-stop.log')
}
foreach ($name in @('BELL_DATABASE_URL','BELL_RULE_ALERT_TEST_DATABASE_URL','BELL_RULE_ALERT_OPERATOR_PASSWORD','BELL_JWT_SECRET','BELL_BOOTSTRAP_USERNAME','BELL_BOOTSTRAP_PASSWORD','BELL_HOST','BELL_PORT')) {
Remove-Item "Env:$name" -ErrorAction SilentlyContinue
}
Write-Verbose "Bell #132 temporary artifacts: $testRoot"
}
@@ -0,0 +1,55 @@
package bell_rule_alert_test
import (
"errors"
"testing"
"go-admin/app/bell/rule"
)
func TestRuleNormalizeTrimsAndNormalizesFields(t *testing.T) {
eventType := " danger_area_entered "
location := " 东门 "
got, err := rule.Normalize(rule.WriteInput{
Code: " AREA_HIGH ", Name: " 高风险区域 ", EventType: &eventType,
MinimumSeverity: " HIGH ", LocationContains: &location,
}, true)
if err != nil {
t.Fatal(err)
}
if got.Code != "area_high" || got.Name != "高风险区域" || got.MinimumSeverity != "high" {
t.Fatalf("unexpected normalized rule: %#v", got)
}
if got.EventType == nil || *got.EventType != "danger_area_entered" || got.LocationContains == nil || *got.LocationContains != "东门" {
t.Fatalf("optional fields were not normalized: %#v", got)
}
}
func TestRuleNormalizeRejectsInvalidInput(t *testing.T) {
tests := []struct {
name string
input rule.WriteInput
}{
{name: "invalid code", input: rule.WriteInput{Code: "Bad Code", Name: "规则", MinimumSeverity: "low"}},
{name: "missing name", input: rule.WriteInput{Code: "valid-code", Name: " ", MinimumSeverity: "low"}},
{name: "unknown severity", input: rule.WriteInput{Code: "valid-code", Name: "规则", MinimumSeverity: "urgent"}},
{name: "control character", input: rule.WriteInput{Code: "valid-code", Name: "规则\n泄露", MinimumSeverity: "low"}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if _, err := rule.Normalize(test.input, true); !errors.Is(err, rule.ErrInvalid) {
t.Fatalf("expected ErrInvalid, got %v", err)
}
})
}
}
func TestRuleUpdateKeepsImmutableCodeOutsideInput(t *testing.T) {
got, err := rule.Normalize(rule.WriteInput{Code: "ignored invalid code", Name: "更新后规则", MinimumSeverity: "medium"}, false)
if err != nil {
t.Fatal(err)
}
if got.Name != "更新后规则" || got.MinimumSeverity != "medium" {
t.Fatalf("unexpected update normalization: %#v", got)
}
}
@@ -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.'
+204
View File
@@ -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
View File
@@ -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
}
}
+5
View File
@@ -0,0 +1,5 @@
import request from '@/utils/request'
export function getAlertLifecycle(id) { return request({ url: `/api/v1/bell/alerts/${id}/lifecycle`, method: 'get' }) }
export function acknowledgeAlert(id) { return request({ url: `/api/v1/bell/alerts/${id}/ack`, method: 'post' }) }
export function closeAlert(id, data) { return request({ url: `/api/v1/bell/alerts/${id}/close`, method: 'post', data }) }
+9
View File
@@ -0,0 +1,9 @@
import request from '@/utils/request'
export function listAlerts(query) {
return request({ url: '/api/v1/bell/alerts', method: 'get', params: query })
}
export function getAlert(id) {
return request({ url: `/api/v1/bell/alerts/${id}`, method: 'get' })
}
+13
View File
@@ -0,0 +1,13 @@
import request from '@/utils/request'
export function listEvents(query) {
return request({ url: '/api/v1/bell/events', method: 'get', params: query })
}
export function getEvent(id) {
return request({ url: `/api/v1/bell/events/${id}`, method: 'get' })
}
export function getEventRuleResults(id) {
return request({ url: `/api/v1/bell/events/${id}/rule-results`, method: 'get' })
}
+17
View File
@@ -0,0 +1,17 @@
import request from '@/utils/request'
export function listRules(query) {
return request({ url: '/api/v1/bell/rules', method: 'get', params: query })
}
export function createRule(data) {
return request({ url: '/api/v1/bell/rules', method: 'post', data })
}
export function updateRule(id, data) {
return request({ url: `/api/v1/bell/rules/${id}`, method: 'put', data })
}
export function setRuleEnabled(id, enabled) {
return request({ url: `/api/v1/bell/rules/${id}/enabled`, method: 'put', data: { enabled }})
}
@@ -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 },
+1 -1
View File
@@ -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 -1
View File
@@ -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,28 @@
<template>
<div class="lifecycle-actions">
<el-alert v-if="error" :title="error" type="warning" show-icon :closable="false" />
<el-button v-if="lifecycle.canAck" v-permisaction="['bell:alert:ack']" type="primary" :loading="loading" @click="ack">我已看到并开始处理</el-button>
<el-button v-if="lifecycle.canClose" v-permisaction="['bell:alert:close']" type="primary" :loading="loading" @click="dialog=true">记录现场结果并完成</el-button>
<el-dialog v-model="dialog" title="记录现场结果" width="min(520px, calc(100vw - 32px))" append-to-body :close-on-click-modal="false" @closed="reset">
<el-alert title="完成后预警进入已完成状态,原始事件不会被修改。" type="info" :closable="false" class="form-alert" />
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<el-form-item label="现场结果" prop="outcome"><el-radio-group v-model="form.outcome" class="outcome-group"><el-radio value="danger_confirmed">确认有危险</el-radio><el-radio value="false_positive">误报</el-radio><el-radio value="site_normal">现场正常</el-radio><el-radio value="unable_to_confirm">无法确认</el-radio></el-radio-group></el-form-item>
<el-form-item label="补充说明(可选)"><el-input v-model="form.note" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item>
</el-form>
<template #footer><el-button @click="dialog=false">取消</el-button><el-button type="primary" :loading="loading" @click="finish">确认结果并完成</el-button></template>
</el-dialog>
</div>
</template>
<script>
import { acknowledgeAlert, closeAlert } from '@/api/bell/alert-lifecycle'
export default {
name: 'BellLifecycleActions', props: { alertId: { type: String, required: true }, lifecycle: { type: Object, required: true }}, emits: ['changed'],
data() { return { loading: false, error: '', dialog: false, form: { outcome: '', note: '' }, rules: { outcome: [{ required: true, message: '请选择现场结果', trigger: 'change' }] }} },
methods: {
async ack() { this.loading = true; this.error = ''; try { const r = await acknowledgeAlert(this.alertId); this.msgSuccess(r.data.idempotent ? '您已在处理此预警' : '已记录由您开始处理'); this.$emit('changed') } catch (e) { this.error = e.message || '开始处理失败'; this.$emit('changed') } finally { this.loading = false } },
async finish() { try { await this.$refs.formRef.validate(); this.loading = true; this.error = ''; const r = await closeAlert(this.alertId, this.form); this.msgSuccess(r.data.idempotent ? '该结果已记录' : '预警已完成'); this.dialog = false; this.$emit('changed') } catch (e) { if (e && e.message) this.error = e.message } finally { this.loading = false } },
reset() { this.form = { outcome: '', note: '' }; this.$refs.formRef && this.$refs.formRef.clearValidate() }
}
}
</script>
<style scoped>.lifecycle-actions{display:flex;flex-wrap:wrap;gap:12px;margin:16px 0}.lifecycle-actions .el-alert{flex-basis:100%}.form-alert{margin-bottom:16px}.outcome-group{display:grid;gap:10px}</style>
@@ -0,0 +1,2 @@
<template><el-timeline><el-timeline-item v-for="item in items" :key="item.id" :timestamp="parseTime(item.occurredAt)" :type="item.transition==='closed'?'success':'primary'"><strong>{{ item.transition === 'closed' ? '处理完成' : '开始处理' }}</strong> · {{ item.actorName }}<div v-if="item.outcome">现场结果:{{ outcomeName(item.outcome) }}<span v-if="item.note">;{{ item.note }}</span></div></el-timeline-item><el-empty v-if="!items.length" description="尚无处理记录" /></el-timeline></template>
<script>export default { name: 'BellLifecycleTimeline', props: { items: { type: Array, default: () => [] }}, methods: { outcomeName(v) { return { danger_confirmed: '确认有危险', false_positive: '误报', site_normal: '现场正常', unable_to_confirm: '无法确认' }[v] || v } }}</script>
+3
View File
@@ -0,0 +1,3 @@
<template><div><el-descriptions :column="1" border><el-descriptions-item label="发生事项">{{ detail.alert.summary }}</el-descriptions-item><el-descriptions-item label="地点">{{ detail.alert.location }}</el-descriptions-item><el-descriptions-item label="紧急程度">{{ severityName(detail.alert.severity) }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusName(lifecycle.projection.status || detail.alert.status) }}</el-descriptions-item><el-descriptions-item label="处理人">{{ lifecycle.projection.acknowledgedByName || '尚未开始处理' }}</el-descriptions-item><el-descriptions-item v-if="lifecycle.projection.closeOutcome" label="现场结果">{{ outcomeName(lifecycle.projection.closeOutcome) }}</el-descriptions-item><el-descriptions-item v-if="lifecycle.projection.closeNote" label="处理说明">{{ lifecycle.projection.closeNote }}</el-descriptions-item><el-descriptions-item label="命中规则">{{ detail.alert.ruleName }}</el-descriptions-item><el-descriptions-item label="预警编号">{{ detail.alert.id }}</el-descriptions-item></el-descriptions><LifecycleActions :alert-id="detail.alert.id" :lifecycle="lifecycle" @changed="$emit('changed')" /><h3>关联事件</h3><el-table :data="detail.events" border row-key="id"><el-table-column prop="occurredAt" label="发生时间" min-width="180"><template #default="scope">{{ parseTime(scope.row.occurredAt) }}</template></el-table-column><el-table-column prop="eventType" label="事件类型" min-width="150" /><el-table-column label="操作" width="80"><template #default="scope"><el-button link type="primary" @click="$emit('go-event',scope.row.id)">查看</el-button></template></el-table-column></el-table><h3>处理时间线</h3><LifecycleTimeline :items="lifecycle.timeline" /><h3>命中说明</h3><el-timeline><el-timeline-item v-for="match in detail.matches" :key="`${match.eventId}-${match.ruleId}`" :timestamp="parseTime(match.matchedAt)" type="primary">规则 v{{ match.ruleVersion }}:{{ match.explanation }}</el-timeline-item></el-timeline></div></template>
<script>import LifecycleActions from './components/LifecycleActions.vue'; import LifecycleTimeline from './components/LifecycleTimeline.vue'; export default { name: 'BellAlertDetail', components: { LifecycleActions, LifecycleTimeline }, props: { detail: { type: Object, required: true }, lifecycle: { type: Object, required: true }}, emits: ['changed', 'go-event'], methods: { statusName(v) { return { open: '待处理', acknowledged: '处理中', closed: '已完成' }[v] || v }, severityName(v) { return { low: '低', medium: '中', high: '高', critical: '紧急' }[v] || v }, outcomeName(v) { return { danger_confirmed: '确认有危险', false_positive: '误报', site_normal: '现场正常', unable_to_confirm: '无法确认' }[v] || v } }}</script>
<style scoped>h3{font-size:16px;margin:22px 0 10px}</style>
+76
View File
@@ -0,0 +1,76 @@
<template>
<BasicLayout>
<template #wrapper>
<el-card class="box-card">
<template #header><div class="page-heading"><h2>预警管理</h2><p>先开始处理,再记录现场结果完成预警。</p></div></template>
<el-form ref="queryForm" :model="query" :inline="true">
<el-form-item label="状态" prop="status"><el-select v-model="query.status" clearable placeholder="全部状态" style="width:130px"><el-option label="待处理" value="open" /><el-option label="处理中" value="acknowledged" /><el-option label="已完成" value="closed" /></el-select></el-form-item>
<el-form-item label="风险" prop="severity"><el-select v-model="query.severity" clearable placeholder="全部风险" style="width:130px"><el-option v-for="item in severities" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-form-item label="地点" prop="location"><el-input v-model="query.location" clearable placeholder="请输入地点" @keyup.enter="search" /></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-alert" />
<el-table v-loading="loading" :data="items" border row-key="id" @row-dblclick="openDetail">
<el-table-column prop="createdAt" label="创建时间" min-width="180"><template #default="scope">{{ parseTime(scope.row.createdAt) }}</template></el-table-column>
<el-table-column prop="summary" label="预警事项" min-width="200" show-overflow-tooltip />
<el-table-column prop="location" label="地点" min-width="140" show-overflow-tooltip />
<el-table-column label="风险" width="90"><template #default="scope"><el-tag :type="severityType(scope.row.severity)">{{ severityName(scope.row.severity) }}</el-tag></template></el-table-column>
<el-table-column label="状态" width="90"><template #default="scope"><el-tag :type="statusType(scope.row.status)">{{ statusName(scope.row.status) }}</el-tag></template></el-table-column>
<el-table-column prop="eventCount" label="关联事件" width="100" />
<el-table-column label="操作" width="90"><template #default="scope"><el-button type="primary" link @click="openDetail(scope.row)">详情</el-button></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-card>
<el-drawer v-model="drawer" title="预警详情" size="min(720px, 100%)">
<div v-loading="detailLoading">
<el-alert v-if="detailError" :title="detailError" type="error" show-icon :closable="false" class="state-alert" />
<BellAlertDetail v-if="detail && lifecycle" :detail="detail" :lifecycle="lifecycle" @changed="reloadDetail" @go-event="goEvent" />
</div>
</el-drawer>
</template>
</BasicLayout>
</template>
<script>
import { getAlert, listAlerts } from '@/api/bell/alert'
import { getAlertLifecycle } from '@/api/bell/alert-lifecycle'
import BellAlertDetail from './detail.vue'
export default {
name: 'BellAlerts',
components: { BellAlertDetail },
data() {
return {
loading: false, detailLoading: false, error: '', detailError: '', items: [], total: 0,
drawer: false, detail: null, lifecycle: null, activeId: '',
severities: [{ label: '低', value: 'low' }, { label: '中', value: 'medium' }, { label: '高', value: 'high' }, { label: '紧急', value: 'critical' }],
query: { pageIndex: 1, pageSize: 10, status: '', severity: '', location: '' }
}
},
created() { this.load().then(() => { if (this.$route.query.alertId) this.openDetail({ id: this.$route.query.alertId }) }) },
methods: {
async load() {
this.loading = true; this.error = ''
try { const response = await listAlerts(this.query); this.items = response.data.list || []; this.total = response.data.count || 0 } catch (error) { this.error = error.message || '预警加载失败' } finally { this.loading = false }
},
search() { this.query.pageIndex = 1; this.load() },
reset() { this.$refs.queryForm.resetFields(); this.search() },
async openDetail(row) {
this.drawer = true; this.detailLoading = true; this.detailError = ''; this.detail = null; this.lifecycle = null; this.activeId = row.id
try { const [detailResponse, lifecycleResponse] = await Promise.all([getAlert(row.id), getAlertLifecycle(row.id)]); this.detail = detailResponse.data; this.lifecycle = lifecycleResponse.data.detail } catch (error) { this.detailError = error.message || '预警详情加载失败' } finally { this.detailLoading = false }
},
async reloadDetail() { await this.openDetail({ id: this.activeId }); await this.load() },
goEvent(id) { this.drawer = false; this.$router.push({ path: '/bell/events', query: { eventId: id }}) },
severityName(value) { return { low: '低', medium: '中', high: '高', critical: '紧急' }[value] || value },
severityType(value) { return { low: 'info', medium: 'primary', high: 'warning', critical: 'danger' }[value] || 'info' },
statusName(value) { return { open: '待处理', acknowledged: '处理中', closed: '已完成' }[value] || value },
statusType(value) { return { open: 'danger', acknowledged: 'warning', closed: 'success' }[value] || 'info' }
}
}
</script>
<style scoped>
.page-heading h2{margin:0}.page-heading p{margin:6px 0 0;color:var(--el-text-color-secondary)}.state-alert{margin-bottom:16px}h3{font-size:16px;margin:22px 0 10px}
</style>
+103
View File
@@ -0,0 +1,103 @@
<template>
<BasicLayout>
<template #wrapper>
<el-card class="box-card">
<template #header>
<div class="page-heading">
<div><h2>事件查询</h2><p>原始事件只读保存,规则评估和预警不会改写事件。</p></div>
</div>
</template>
<el-form ref="queryForm" :model="query" :inline="true">
<el-form-item label="事件类型" prop="eventType"><el-input v-model="query.eventType" clearable placeholder="请输入事件类型" @keyup.enter="search" /></el-form-item>
<el-form-item label="地点" prop="location"><el-input v-model="query.location" clearable placeholder="请输入地点" @keyup.enter="search" /></el-form-item>
<el-form-item label="风险" prop="severity">
<el-select v-model="query.severity" clearable placeholder="全部风险" style="width: 130px">
<el-option v-for="item in severities" :key="item.value" :label="item.label" :value="item.value" />
</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-alert" />
<el-table v-loading="loading" :data="items" border row-key="id" @row-dblclick="openDetail">
<el-table-column prop="occurredAt" label="发生时间" min-width="180"><template #default="scope">{{ parseTime(scope.row.occurredAt) }}</template></el-table-column>
<el-table-column prop="eventType" label="事件类型" min-width="160" show-overflow-tooltip />
<el-table-column prop="location" label="地点" min-width="150" show-overflow-tooltip />
<el-table-column label="风险" width="90"><template #default="scope"><el-tag :type="severityType(scope.row.severity)">{{ severityName(scope.row.severity) }}</el-tag></template></el-table-column>
<el-table-column prop="alertCount" label="关联预警" width="100" />
<el-table-column label="操作" width="90"><template #default="scope"><el-button type="primary" link @click="openDetail(scope.row)">详情</el-button></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-card>
<el-drawer v-model="drawer" title="事件详情" size="min(680px, 100%)">
<div v-loading="detailLoading">
<el-alert v-if="detailError" :title="detailError" type="error" show-icon :closable="false" class="state-alert" />
<template v-if="selected">
<el-descriptions :column="1" border>
<el-descriptions-item label="发生事项">{{ selected.eventType }}</el-descriptions-item>
<el-descriptions-item label="地点">{{ selected.location }}</el-descriptions-item>
<el-descriptions-item label="发生时间">{{ parseTime(selected.occurredAt) }}</el-descriptions-item>
<el-descriptions-item label="紧急程度">{{ severityName(selected.severity) }}</el-descriptions-item>
<el-descriptions-item label="事件编号">{{ selected.id }}</el-descriptions-item>
<el-descriptions-item label="事件来源">{{ selected.producerId }}</el-descriptions-item>
<el-descriptions-item label="来源事件编号">{{ selected.sourceEventId }}</el-descriptions-item>
<el-descriptions-item label="证据引用">{{ selected.evidenceRef || '无' }}</el-descriptions-item>
</el-descriptions>
<h3>关联预警</h3>
<el-table :data="results.alerts" border row-key="id">
<el-table-column prop="summary" label="预警事项" min-width="180" />
<el-table-column prop="status" label="状态" width="90"><template #default><el-tag type="danger">待处理</el-tag></template></el-table-column>
<el-table-column label="操作" width="80"><template #default="scope"><el-button link type="primary" @click="goAlert(scope.row.id)">查看</el-button></template></el-table-column>
<template #empty><el-empty description="该事件未生成预警" /></template>
</el-table>
<h3>规则评估</h3>
<el-table :data="results.evaluations" border row-key="ruleId">
<el-table-column label="规则" min-width="160"><template #default="scope">{{ scope.row.ruleSnapshot && scope.row.ruleSnapshot.name }}</template></el-table-column>
<el-table-column prop="ruleVersion" label="版本" width="70" />
<el-table-column label="结果" width="90"><template #default="scope"><el-tag :type="scope.row.matched ? 'success' : 'info'">{{ scope.row.matched ? '命中' : '未命中' }}</el-tag></template></el-table-column>
<el-table-column prop="explanation" label="说明" min-width="180" />
<template #empty><el-empty description="接收时没有启用规则" /></template>
</el-table>
</template>
</div>
</el-drawer>
</template>
</BasicLayout>
</template>
<script>
import { getEvent, getEventRuleResults, listEvents } from '@/api/bell/event'
export default {
name: 'BellEvents',
data() {
return {
loading: false, detailLoading: false, error: '', detailError: '', items: [], total: 0,
drawer: false, selected: null, results: { alerts: [], evaluations: [] },
severities: [{ label: '低', value: 'low' }, { label: '中', value: 'medium' }, { label: '高', value: 'high' }, { label: '紧急', value: 'critical' }],
query: { pageIndex: 1, pageSize: 10, eventType: '', location: '', severity: '' }
}
},
created() { this.load().then(() => { if (this.$route.query.eventId) this.openDetail({ id: this.$route.query.eventId }) }) },
methods: {
async load() {
this.loading = true; this.error = ''
try { const response = await listEvents(this.query); this.items = response.data.list || []; this.total = response.data.count || 0 } catch (error) { this.error = error.message || '事件加载失败' } finally { this.loading = false }
},
search() { this.query.pageIndex = 1; this.load() },
reset() { this.$refs.queryForm.resetFields(); this.search() },
async openDetail(row) {
this.drawer = true; this.detailLoading = true; this.detailError = ''; this.selected = null
try { const [eventResponse, resultResponse] = await Promise.all([getEvent(row.id), getEventRuleResults(row.id)]); this.selected = eventResponse.data; this.results = resultResponse.data } catch (error) { this.detailError = error.message || '事件详情加载失败' } finally { this.detailLoading = false }
},
goAlert(id) { this.drawer = false; this.$router.push({ path: '/bell/alerts', query: { alertId: id }}) },
severityName(value) { return { low: '低', medium: '中', high: '高', critical: '紧急' }[value] || value },
severityType(value) { return { low: 'info', medium: 'primary', high: 'warning', critical: 'danger' }[value] || 'info' }
}
}
</script>
<style scoped>
.page-heading h2{margin:0}.page-heading p{margin:6px 0 0;color:var(--el-text-color-secondary)}.state-alert{margin-bottom:16px}h3{font-size:16px;margin:22px 0 10px}
</style>
+96
View File
@@ -0,0 +1,96 @@
<template>
<BasicLayout>
<template #wrapper>
<el-card class="box-card">
<template #header><div class="page-heading"><div><h2>规则配置</h2><p>规则修改会产生新版本,已有事件的命中快照不会改变。</p></div><el-button v-permisaction="['bell:rule:add']" 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:130px"><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-alert" />
<el-table v-loading="loading" :data="items" border row-key="id">
<el-table-column prop="name" label="规则名称" min-width="170" />
<el-table-column prop="code" label="规则编码" min-width="150" />
<el-table-column label="事件类型" min-width="150"><template #default="scope">{{ scope.row.eventType || '全部' }}</template></el-table-column>
<el-table-column label="最低风险" width="100"><template #default="scope"><el-tag :type="severityType(scope.row.minimumSeverity)">{{ severityName(scope.row.minimumSeverity) }}</el-tag></template></el-table-column>
<el-table-column label="地点包含" min-width="130"><template #default="scope">{{ scope.row.locationContains || '不限' }}</template></el-table-column>
<el-table-column prop="version" label="版本" width="70" />
<el-table-column label="状态" width="100"><template #default="scope"><el-switch v-model="scope.row.enabled" :disabled="!canEdit" inline-prompt active-text="启" inactive-text="停" @change="toggle(scope.row)" /></template></el-table-column>
<el-table-column label="操作" width="90"><template #default="scope"><el-button v-permisaction="['bell:rule:edit']" link type="primary" @click="openEdit(scope.row)">编辑</el-button></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-card>
<el-dialog v-model="dialog" :title="editing ? '编辑规则' : '新增规则'" width="min(560px, calc(100vw - 32px))" :close-on-click-modal="false" @closed="clearForm">
<el-alert title="保存后仅影响随后接收的事件,历史命中记录保持原样。" type="info" :closable="false" class="state-alert" />
<el-form ref="ruleForm" :model="form" :rules="formRules" label-position="top">
<el-form-item label="规则编码" prop="code"><el-input v-model.trim="form.code" :disabled="editing" placeholder="如 gate-danger" /></el-form-item>
<el-form-item label="规则名称" prop="name"><el-input v-model.trim="form.name" maxlength="128" show-word-limit /></el-form-item>
<el-form-item label="事件类型"><el-input v-model.trim="form.eventType" placeholder="留空表示全部类型" maxlength="128" /></el-form-item>
<el-form-item label="最低风险" prop="minimumSeverity"><el-select v-model="form.minimumSeverity" style="width:100%"><el-option v-for="item in severities" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-form-item label="地点包含"><el-input v-model.trim="form.locationContains" placeholder="留空表示不限地点" maxlength="128" /></el-form-item>
</el-form>
<template #footer><el-button @click="dialog=false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template>
</el-dialog>
</template>
</BasicLayout>
</template>
<script>
import { createRule, listRules, setRuleEnabled, updateRule } from '@/api/bell/rule'
export default {
name: 'BellRules',
data() {
return {
loading: false, saving: false, error: '', items: [], total: 0, dialog: false, editing: false, editingId: '',
severities: [{ label: '低', value: 'low' }, { label: '中', value: 'medium' }, { label: '高', value: 'high' }, { label: '紧急', value: 'critical' }],
query: { pageIndex: 1, pageSize: 10, name: '', enabled: null },
form: { code: '', name: '', eventType: '', minimumSeverity: 'high', locationContains: '' },
formRules: {
code: [{ required: true, pattern: /^[a-z0-9][a-z0-9_-]{1,127}$/, message: '请输入至少2位小写字母、数字、下划线或短横线', trigger: 'blur' }],
name: [{ required: true, message: '请输入规则名称', trigger: 'blur' }],
minimumSeverity: [{ required: true, message: '请选择最低风险', trigger: 'change' }]
}
}
},
computed: {
canEdit() { const values = this.$store.getters.permisaction || []; return values.includes('*:*:*') || values.includes('bell:rule:edit') }
},
created() { this.load() },
methods: {
async load() {
this.loading = true; this.error = ''
try { const response = await listRules(this.query); this.items = response.data.list || []; this.total = response.data.count || 0 } catch (error) { this.error = error.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.dialog = true },
openEdit(row) {
this.editing = true; this.editingId = row.id
this.form = { code: row.code, name: row.name, eventType: row.eventType || '', minimumSeverity: row.minimumSeverity, locationContains: row.locationContains || '' }
this.dialog = true
},
async save() {
try {
await this.$refs.ruleForm.validate(); this.saving = true
const payload = { ...this.form, eventType: this.form.eventType || null, locationContains: this.form.locationContains || null }
if (this.editing) await updateRule(this.editingId, payload); else await createRule(payload)
this.msgSuccess(this.editing ? '规则已更新并生成新版本' : '规则已创建'); this.dialog = false; await this.load()
} catch (error) { if (error && error.message) this.error = error.message } finally { this.saving = false }
},
async toggle(row) {
try { await setRuleEnabled(row.id, row.enabled); this.msgSuccess(row.enabled ? '规则已启用' : '规则已停用'); await this.load() } catch (error) { row.enabled = !row.enabled; this.error = error.message || '规则状态更新失败' }
},
clearForm() { this.$refs.ruleForm && this.$refs.ruleForm.clearValidate(); this.form = { code: '', name: '', eventType: '', minimumSeverity: 'high', locationContains: '' } },
severityName(value) { return { low: '低', medium: '中', high: '高', critical: '紧急' }[value] || value },
severityType(value) { return { low: 'info', medium: 'primary', high: 'warning', critical: 'danger' }[value] || 'info' }
}
}
</script>
<style scoped>
.page-heading{display:flex;align-items:center;justify-content:space-between;gap:16px}.page-heading h2{margin:0}.page-heading p{margin:6px 0 0;color:var(--el-text-color-secondary)}.state-alert{margin-bottom:16px}
</style>
@@ -0,0 +1,14 @@
import request from '@/utils/request'
import { acknowledgeAlert, closeAlert, getAlertLifecycle } from '@/api/bell/alert-lifecycle'
jest.mock('@/utils/request', () => jest.fn(config => Promise.resolve(config)))
describe('Bell alert lifecycle API', () => {
beforeEach(() => request.mockClear())
it('maps timeline, ack and close to protected Alert routes', async() => {
await getAlertLifecycle('a1'); await acknowledgeAlert('a1'); await closeAlert('a1', { outcome: 'site_normal' })
expect(request.mock.calls.map(call => call[0])).toEqual([
{ url: '/api/v1/bell/alerts/a1/lifecycle', method: 'get' },
{ url: '/api/v1/bell/alerts/a1/ack', method: 'post' },
{ url: '/api/v1/bell/alerts/a1/close', method: 'post', data: { outcome: 'site_normal' }}
])
})
})
@@ -0,0 +1,17 @@
jest.mock('@/api/bell/alert-lifecycle', () => ({
acknowledgeAlert: jest.fn(),
closeAlert: jest.fn(),
getAlertLifecycle: jest.fn()
}))
import AlertDetail from '@/views/bell/alerts/detail.vue'
import LifecycleTimeline from '@/views/bell/alerts/components/LifecycleTimeline.vue'
describe('Bell ordinary-user lifecycle wording', () => {
it('maps technical states and outcomes to familiar wording', () => {
expect(AlertDetail.methods.statusName('open')).toBe('待处理')
expect(AlertDetail.methods.statusName('acknowledged')).toBe('处理中')
expect(AlertDetail.methods.statusName('closed')).toBe('已完成')
expect(AlertDetail.methods.outcomeName('false_positive')).toBe('误报')
expect(LifecycleTimeline.methods.outcomeName('unable_to_confirm')).toBe('无法确认')
})
})
@@ -0,0 +1,41 @@
import request from '@/utils/request'
import { getAlert, listAlerts } from '@/api/bell/alert'
import { getEvent, getEventRuleResults, listEvents } from '@/api/bell/event'
import { createRule, listRules, setRuleEnabled, updateRule } from '@/api/bell/rule'
jest.mock('@/utils/request', () => jest.fn(config => Promise.resolve(config)))
describe('Bell rule-alert API adapters', () => {
beforeEach(() => request.mockClear())
it('maps read operations to the versioned Bell routes', async() => {
await listAlerts({ status: 'open' })
await getAlert('alert-1')
await listEvents({ severity: 'high' })
await getEvent('event-1')
await getEventRuleResults('event-1')
await listRules({ enabled: true })
expect(request.mock.calls.map(call => call[0])).toEqual([
{ url: '/api/v1/bell/alerts', method: 'get', params: { status: 'open' }},
{ url: '/api/v1/bell/alerts/alert-1', method: 'get' },
{ url: '/api/v1/bell/events', method: 'get', params: { severity: 'high' }},
{ url: '/api/v1/bell/events/event-1', method: 'get' },
{ url: '/api/v1/bell/events/event-1/rule-results', method: 'get' },
{ url: '/api/v1/bell/rules', method: 'get', params: { enabled: true }}
])
})
it('maps administrator writes without exposing unrelated operations', async() => {
const payload = { name: '区域规则', minimumSeverity: 'high' }
await createRule(payload)
await updateRule('rule-1', payload)
await setRuleEnabled('rule-1', false)
expect(request.mock.calls.map(call => call[0])).toEqual([
{ url: '/api/v1/bell/rules', method: 'post', data: payload },
{ url: '/api/v1/bell/rules/rule-1', method: 'put', data: payload },
{ url: '/api/v1/bell/rules/rule-1/enabled', method: 'put', data: { enabled: false }}
])
})
})
@@ -0,0 +1,44 @@
import RulesPage from '@/views/bell/rules/index.vue'
import { listRules } from '@/api/bell/rule'
jest.mock('@/api/bell/rule', () => ({
createRule: jest.fn(),
listRules: jest.fn(),
setRuleEnabled: jest.fn(),
updateRule: jest.fn()
}))
function pageContext() {
return {
loading: false,
error: '',
items: [],
total: 0,
query: { pageIndex: 1, pageSize: 10, name: '', enabled: null }
}
}
describe('Bell ordinary warning rule page', () => {
beforeEach(() => jest.clearAllMocks())
it('only enables rule changes for the GoAdmin edit permission', () => {
expect(RulesPage.computed.canEdit.call({ $store: { getters: { permisaction: ['bell:rule:list'] }}})).toBe(false)
expect(RulesPage.computed.canEdit.call({ $store: { getters: { permisaction: ['bell:rule:edit'] }}})).toBe(true)
expect(RulesPage.computed.canEdit.call({ $store: { getters: { permisaction: ['*:*:*'] }}})).toBe(true)
})
it('keeps an empty list as an intentional page state', async() => {
listRules.mockResolvedValue({ data: { list: [], count: 0 }})
const context = pageContext()
await RulesPage.methods.load.call(context)
expect(context).toMatchObject({ loading: false, error: '', items: [], total: 0 })
})
it('surfaces a failed list request instead of leaving a blank page', async() => {
listRules.mockRejectedValue(new Error('网络不可用'))
const context = pageContext()
await RulesPage.methods.load.call(context)
expect(context.loading).toBe(false)
expect(context.error).toBe('网络不可用')
})
})
@@ -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}'`)
})
})
+1 -1
View File
@@ -8,7 +8,7 @@ version = "0.1.0"
description = "Headless inference delivery unit for YoVision"
readme = "README.md"
requires-python = "==3.11.*"
dependencies = []
dependencies = ["cryptography==50.0.1"]
[project.optional-dependencies]
# The wheel backend is selected by the official PyTorch index documented in
@@ -0,0 +1,37 @@
"""Independent service-to-service machine identity for Brain connectors."""
from .token import (
ALLOWED_SKEW_SECONDS,
MAX_KEY_OVERLAP_SECONDS,
MAX_LIFETIME_SECONDS,
VERSION,
Claims,
KeyRecord,
MachineIdentityError,
Registry,
ReplayStore,
Signer,
Verifier,
load_private_key,
load_registry,
bearer_token,
)
from .transport import TransportPolicy
__all__ = [
"ALLOWED_SKEW_SECONDS",
"MAX_KEY_OVERLAP_SECONDS",
"MAX_LIFETIME_SECONDS",
"VERSION",
"Claims",
"KeyRecord",
"MachineIdentityError",
"Registry",
"ReplayStore",
"Signer",
"TransportPolicy",
"Verifier",
"load_private_key",
"load_registry",
"bearer_token",
]
@@ -0,0 +1,336 @@
"""Ed25519 request-bound machine tokens.
This module never accepts browser cookies, GoAdmin JWTs, query tokens, or
shared secrets. HTTP adapters must obtain the compact token exclusively from
the Authorization bearer header and pass the request body unchanged.
"""
from __future__ import annotations
import base64
import hashlib
import json
import posixpath
import re
import secrets
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Iterable, Protocol
from urllib.parse import urlsplit
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Ed25519PublicKey,
)
VERSION = "yovision.machine-identity/v1"
TOKEN_TYPE = "YOVISION-MACHINE+JWT"
MAX_LIFETIME_SECONDS = 300
ALLOWED_SKEW_SECONDS = 30
MAX_KEY_OVERLAP_SECONDS = 24 * 60 * 60
_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"})
_AUDIENCES = frozenset({"yovision-sense", "yovision-brain", "yovision-bell"})
_SCOPES = frozenset({"source-config:write", "runtime-status:write", "events:ingest", "evidence:read"})
_PRINCIPAL = re.compile(r"^yv:(sense|brain|bell):[a-z0-9][a-z0-9.-]{0,62}$")
_KEY_ID = re.compile(r"^[A-Za-z0-9._-]{8,64}$")
_TOKEN_ID = re.compile(r"^[A-Za-z0-9_-]{22,64}$")
class MachineIdentityError(ValueError):
"""A stable, non-secret authentication failure."""
def __init__(self, code: str) -> None:
super().__init__(code)
self.code = code
def bearer_token(authorization: str) -> str:
"""Extract only an Authorization bearer token; there is no cookie/query fallback."""
parts = authorization.split(" ")
if len(parts) != 2 or parts[0].lower() != "bearer" or not parts[1] or any(character in parts[1] for character in " \t\r\n,"):
raise MachineIdentityError("machine_token_missing")
return parts[1]
@dataclass(frozen=True)
class Claims:
ver: str
iss: str
sub: str
aud: str
scope: tuple[str, ...]
iat: int
nbf: int
exp: int
jti: str
htm: str
htu: str
body_sha256: str
@dataclass(frozen=True)
class KeyRecord:
principal: str
key_id: str
public_key: Ed25519PublicKey
audience: str
scopes: frozenset[str]
enabled: bool = True
revoked: bool = False
class Registry:
def __init__(self, records: Iterable[KeyRecord]) -> None:
self._lock = threading.RLock()
self._records: dict[str, KeyRecord] = {}
for record in records:
if not _KEY_ID.fullmatch(record.key_id) or not _PRINCIPAL.fullmatch(record.principal) or record.audience not in _AUDIENCES or not _valid_scopes(record.scopes):
raise ValueError("invalid machine key record")
if record.key_id in self._records:
raise ValueError("duplicate machine key id")
self._records[record.key_id] = record
def lookup(self, key_id: str) -> KeyRecord | None:
with self._lock:
return self._records.get(key_id)
def revoke(self, key_id: str) -> bool:
with self._lock:
record = self._records.get(key_id)
if record is None:
return False
self._records[key_id] = KeyRecord(
principal=record.principal,
key_id=record.key_id,
public_key=record.public_key,
audience=record.audience,
scopes=record.scopes,
enabled=record.enabled,
revoked=True,
)
return True
class ReplayStore:
"""Process-local replay cache for tests or one uninterrupted process."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._used: dict[tuple[str, str], int] = {}
def consume(self, principal: str, token_id: str, expires_at: int, now: int) -> bool:
with self._lock:
self._used = {key: expiry for key, expiry in self._used.items() if expiry > now}
key = (principal, token_id)
if key in self._used:
return False
self._used[key] = expires_at
return True
class ReplayCache(Protocol):
"""Connector implementations provide an atomic durable implementation."""
def consume(self, principal: str, token_id: str, expires_at: int, now: int) -> bool: ...
def load_private_key(path: str | Path) -> Ed25519PrivateKey:
if not str(path).strip():
raise ValueError("machine private key path is required")
try:
raw = Path(path).read_bytes()
key = serialization.load_pem_private_key(raw, password=None)
except (OSError, ValueError, TypeError) as exc:
raise ValueError("invalid machine private key file") from exc
if not isinstance(key, Ed25519PrivateKey):
raise ValueError("machine private key is not Ed25519")
return key
def load_registry(path: str | Path, expected_audience: str) -> Registry:
if not str(path).strip() or expected_audience not in _AUDIENCES:
raise ValueError("machine principal registry path and audience are required")
try:
document = json.loads(Path(path).read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
raise ValueError("invalid machine principal registry") from exc
if not isinstance(document, dict) or set(document) != {"version", "audience", "principals"} or document["version"] != "yovision.machine-principal-registry/v1" or document["audience"] != expected_audience or not isinstance(document["principals"], list) or not document["principals"]:
raise ValueError("invalid machine principal registry")
records: list[KeyRecord] = []
try:
for principal in document["principals"]:
if not isinstance(principal, dict) or set(principal) != {"principal_id", "enabled", "keys"} or not isinstance(principal["enabled"], bool) or not isinstance(principal["keys"], list) or not principal["keys"]:
raise ValueError
for key in principal["keys"]:
if not isinstance(key, dict) or set(key) != {"kid", "public_key_base64url", "status", "scopes"} or key["status"] not in {"active", "revoked"} or not isinstance(key["scopes"], list):
raise ValueError
public_key = Ed25519PublicKey.from_public_bytes(_b64decode(key["public_key_base64url"]))
records.append(KeyRecord(principal["principal_id"], key["kid"], public_key, expected_audience, frozenset(key["scopes"]), principal["enabled"], key["status"] == "revoked"))
except (KeyError, TypeError, ValueError):
raise ValueError("invalid machine principal registry") from None
return Registry(records)
class Signer:
def __init__(
self,
principal: str,
key_id: str,
private_key: Ed25519PrivateKey,
*,
clock: Callable[[], int] | None = None,
) -> None:
if not _PRINCIPAL.fullmatch(principal) or not _KEY_ID.fullmatch(key_id) or not isinstance(private_key, Ed25519PrivateKey):
raise ValueError("incomplete machine signer configuration")
self._principal = principal
self._key_id = key_id
self._private_key = private_key
self._clock = clock or (lambda: int(time.time()))
def mint(self, audience: str, scopes: Iterable[str], method: str, request_path: str, body: bytes) -> str:
normalized_path = _normalize_path(request_path)
normalized_method = method.upper()
scope_values = tuple(scopes)
if audience not in _AUDIENCES or not _valid_scopes(scope_values) or normalized_method not in _METHODS:
raise ValueError("invalid machine token request")
now = int(self._clock())
header = {"alg": "EdDSA", "typ": TOKEN_TYPE, "kid": self._key_id, "ver": VERSION}
claims = {
"ver": VERSION,
"iss": self._principal,
"sub": self._principal,
"aud": audience,
"scope": list(scope_values),
"iat": now,
"nbf": now,
"exp": now + MAX_LIFETIME_SECONDS,
"jti": secrets.token_urlsafe(16),
"htm": normalized_method,
"htu": normalized_path,
"body_sha256": hashlib.sha256(body).hexdigest(),
}
encoded_header = _encode_json(header)
encoded_claims = _encode_json(claims)
signing_input = f"{encoded_header}.{encoded_claims}".encode("ascii")
signature = self._private_key.sign(signing_input)
return f"{encoded_header}.{encoded_claims}.{_b64encode(signature)}"
class Verifier:
def __init__(
self,
registry: Registry,
replay_store: ReplayCache,
*,
clock: Callable[[], int] | None = None,
) -> None:
self._registry = registry
self._replay_store = replay_store
self._clock = clock or (lambda: int(time.time()))
def verify(
self,
token: str,
audience: str,
required_scope: str,
method: str,
request_path: str,
body: bytes,
) -> Claims:
parts = token.split(".")
if len(parts) != 3 or "=" in token:
raise MachineIdentityError("machine_token_invalid")
header = _decode_object(parts[0], {"alg", "typ", "kid", "ver"})
if header.get("alg") != "EdDSA" or header.get("typ") != TOKEN_TYPE or header.get("ver") != VERSION or not isinstance(header.get("kid"), str) or not _KEY_ID.fullmatch(header["kid"]):
raise MachineIdentityError("machine_token_invalid")
record = self._registry.lookup(header["kid"])
if record is None:
raise MachineIdentityError("machine_token_invalid")
try:
record.public_key.verify(_b64decode(parts[2]), f"{parts[0]}.{parts[1]}".encode("ascii"))
except (InvalidSignature, ValueError):
raise MachineIdentityError("machine_token_invalid") from None
if not record.enabled or record.revoked:
raise MachineIdentityError("machine_identity_revoked")
raw = _decode_object(parts[1], {"ver", "iss", "sub", "aud", "scope", "iat", "nbf", "exp", "jti", "htm", "htu", "body_sha256"})
claims = _claims_from_object(raw)
if claims.iss != record.principal or claims.sub != record.principal:
raise MachineIdentityError("machine_token_invalid")
now = int(self._clock())
if claims.exp - claims.iat <= 0 or claims.exp - claims.iat > MAX_LIFETIME_SECONDS or claims.nbf < claims.iat or claims.nbf > claims.exp or claims.iat > now + ALLOWED_SKEW_SECONDS:
raise MachineIdentityError("machine_token_invalid")
if claims.nbf > now + ALLOWED_SKEW_SECONDS or claims.exp < now - ALLOWED_SKEW_SECONDS:
raise MachineIdentityError("machine_token_expired")
if claims.aud != audience or record.audience != audience:
raise MachineIdentityError("machine_audience_denied")
if required_scope not in claims.scope or required_scope not in record.scopes:
raise MachineIdentityError("machine_scope_denied")
if claims.htm != method.upper() or claims.htu != _normalize_path(request_path) or claims.body_sha256 != hashlib.sha256(body).hexdigest():
raise MachineIdentityError("machine_token_invalid")
if not self._replay_store.consume(claims.iss, claims.jti, claims.exp + ALLOWED_SKEW_SECONDS, now):
raise MachineIdentityError("machine_token_replayed")
return claims
def _claims_from_object(value: dict[str, object]) -> Claims:
try:
scope = value["scope"]
if not isinstance(scope, list) or not _valid_scopes(scope):
raise ValueError
integer_fields = ("iat", "nbf", "exp")
if any(not isinstance(value[field], int) or isinstance(value[field], bool) for field in integer_fields):
raise ValueError
string_fields = ("ver", "iss", "sub", "aud", "jti", "htm", "htu", "body_sha256")
if any(not isinstance(value[field], str) for field in string_fields):
raise ValueError
claims = Claims(scope=tuple(scope), **{key: value[key] for key in string_fields + integer_fields})
if claims.ver != VERSION or not _PRINCIPAL.fullmatch(claims.iss) or claims.iss != claims.sub or claims.aud not in _AUDIENCES or not _TOKEN_ID.fullmatch(claims.jti) or claims.htm not in _METHODS or len(claims.body_sha256) != 64:
raise ValueError
bytes.fromhex(claims.body_sha256)
_normalize_path(claims.htu)
return claims
except (KeyError, TypeError, ValueError):
raise MachineIdentityError("machine_token_invalid") from None
def _normalize_path(value: str) -> str:
split = urlsplit(value)
if not value.startswith("/") or split.scheme or split.netloc or split.query or split.fragment or "\\" in split.path or "//" in split.path or posixpath.normpath(split.path) != split.path:
raise ValueError("machine request path must be normalized and contain no query or fragment")
return split.path
def _valid_scopes(scopes: Iterable[str]) -> bool:
values = tuple(scopes)
return 1 <= len(values) <= 4 and len(set(values)) == len(values) and all(scope in _SCOPES for scope in values)
def _encode_json(value: dict[str, object]) -> str:
return _b64encode(json.dumps(value, ensure_ascii=True, separators=(",", ":"), sort_keys=True).encode("utf-8"))
def _decode_object(value: str, expected_keys: set[str]) -> dict[str, object]:
try:
decoded = json.loads(_b64decode(value).decode("utf-8"))
except (UnicodeDecodeError, ValueError, json.JSONDecodeError):
raise MachineIdentityError("machine_token_invalid") from None
if not isinstance(decoded, dict) or set(decoded) != expected_keys:
raise MachineIdentityError("machine_token_invalid")
return decoded
def _b64encode(value: bytes) -> str:
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
def _b64decode(value: str) -> bytes:
if not value or "=" in value:
raise ValueError("invalid base64url")
decoded = base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True)
if _b64encode(decoded) != value:
raise ValueError("non-canonical base64url")
return decoded
@@ -0,0 +1,37 @@
"""Fail-closed HTTPS transport policy for Brain connectors."""
from __future__ import annotations
import ssl
from dataclasses import dataclass
@dataclass(frozen=True)
class TransportPolicy:
tls_min_version: str
verify_certificate: bool
verify_hostname: bool
connect_timeout_ms: int
response_header_timeout_ms: int
request_timeout_ms: int
max_request_bytes: int
def validate(self) -> None:
if (
self.tls_min_version not in {"1.2", "1.3"}
or not self.verify_certificate
or not self.verify_hostname
or not 100 <= self.connect_timeout_ms <= 30_000
or not 100 <= self.response_header_timeout_ms <= 30_000
or not 100 <= self.request_timeout_ms <= 60_000
or not 1 <= self.max_request_bytes <= 10 * 1024 * 1024
):
raise ValueError("machine transport policy is unsafe")
def ssl_context(self) -> ssl.SSLContext:
self.validate()
context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
context.minimum_version = ssl.TLSVersion.TLSv1_3 if self.tls_min_version == "1.3" else ssl.TLSVersion.TLSv1_2
context.check_hostname = True
context.verify_mode = ssl.CERT_REQUIRED
return context
+8
View File
@@ -15,6 +15,14 @@
E2E 入口从 PowerShell 7 调用时会自动转入 Windows PowerShell 5.1 执行本地 HTTP 回归;源码打包仍显式使用冻结要求的 PowerShell 7。这样与 Windows 交付脚本的宿主一致,也避开当前机器 PowerShell 7 HTTP 客户端对本地 Go/MediaMTX 响应的兼容问题。
默认入口只复制 Git 已跟踪的 `Sense/` 源码到系统临时目录,因此正常开发工作区中已有的 `node_modules`、`dist`、本地配置、日志和其他未跟踪文件不会进入验收副本。`-PreparedPackageRoot` 也会先把指定包复制到本次临时目录,运行时配置、浏览器脚本、截图和日志不会写回原包或源码树。
Sense 进程提前退出或 HTTP 就绪超时时,脚本返回非零并输出阶段、退出状态、临时日志位置和经过过滤、截断的日志摘要;数据库连接、密码、token、Cookie、JWT 和 credential key 不得出现在诊断中。默认无论成功或失败都会清理所属进程和临时目录;`-KeepTemporary` 仅用于排错,仍会停止进程,但保留目录可能包含随机运行时秘密,必须限制访问并在排错后安全删除。
为避免 Windows 首次扫描临时复制的 MediaMTX 二进制占用产品固定的就绪窗口,E2E 会先在同一动态端口和临时配置上启动一次包内 MediaMTX,确认 Control API 可用并完全停止,再由 Sense 以 managed 模式启动并完成生命周期验收。预检失败会单独报告 `MediaMTX preflight` 阶段,不会被误报为 Sense HTTP 超时。
HTTP、RTSP、HLS、Control API 和 ONVIF 动态端口使用 TCP 绑定探测;WebRTC 本地 UDP 端口必须使用 UDP socket 实际绑定探测,不得用 TCP 空闲结果代替,避免落入 Windows 的 UDP 排除或占用范围。
## 回归矩阵
| 范围 | 自动化证据 | 判定 |
@@ -0,0 +1,68 @@
package machine_identity
import (
"bytes"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"errors"
"io"
"os"
"strings"
)
type registryDocument struct {
Version string `json:"version"`
Audience string `json:"audience"`
Principals []registryPrincipal `json:"principals"`
}
type registryPrincipal struct {
PrincipalID string `json:"principal_id"`
Enabled bool `json:"enabled"`
Keys []registryKey `json:"keys"`
}
type registryKey struct {
KeyID string `json:"kid"`
PublicKey string `json:"public_key_base64url"`
Status string `json:"status"`
Scopes []string `json:"scopes"`
}
func LoadRegistry(filePath, expectedAudience string) (*Registry, error) {
if strings.TrimSpace(filePath) == "" || !validAudiences[expectedAudience] {
return nil, errors.New("machine principal registry path and audience are required")
}
raw, err := os.ReadFile(filePath)
if err != nil {
return nil, errors.New("read machine principal registry")
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
var document registryDocument
if err = decoder.Decode(&document); err != nil {
return nil, errors.New("invalid machine principal registry")
}
if err = decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return nil, errors.New("invalid machine principal registry")
}
if document.Version != "yovision.machine-principal-registry/v1" || document.Audience != expectedAudience || len(document.Principals) == 0 {
return nil, errors.New("invalid machine principal registry")
}
records := make([]KeyRecord, 0)
for _, principal := range document.Principals {
if len(principal.Keys) == 0 {
return nil, errors.New("invalid machine principal registry")
}
for _, key := range principal.Keys {
publicKey, decodeErr := base64.RawURLEncoding.Strict().DecodeString(key.PublicKey)
if decodeErr != nil || len(publicKey) != ed25519.PublicKeySize || (key.Status != "active" && key.Status != "revoked") {
return nil, errors.New("invalid machine principal registry")
}
records = append(records, KeyRecord{Principal: principal.PrincipalID, KeyID: key.KeyID, PublicKey: ed25519.PublicKey(publicKey), Audience: document.Audience,
Scopes: key.Scopes, Enabled: principal.Enabled, Revoked: key.Status == "revoked"})
}
}
return NewRegistry(records...)
}
@@ -0,0 +1,363 @@
package machine_identity
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/url"
"os"
"path"
"regexp"
"slices"
"strings"
"sync"
"time"
)
var (
principalPattern = regexp.MustCompile(`^yv:(sense|brain|bell):[a-z0-9][a-z0-9.-]{0,62}$`)
keyIDPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{8,64}$`)
tokenIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{22,64}$`)
validAudiences = map[string]bool{"yovision-sense": true, "yovision-brain": true, "yovision-bell": true}
validScopes = map[string]bool{"source-config:write": true, "runtime-status:write": true, "events:ingest": true, "evidence:read": true}
)
const (
Version = "yovision.machine-identity/v1"
TokenType = "YOVISION-MACHINE+JWT"
MaxLifetime = 5 * time.Minute
AllowedSkew = 30 * time.Second
MaxKeyOverlap = 24 * time.Hour
)
type Error struct{ Code string }
func (e *Error) Error() string { return e.Code }
func codeError(code string) error { return &Error{Code: code} }
// BearerToken deliberately has no cookie or query fallback.
func BearerToken(authorization string) (string, error) {
parts := strings.Split(authorization, " ")
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] == "" || strings.ContainsAny(parts[1], " \t\r\n,") {
return "", codeError("machine_token_missing")
}
return parts[1], nil
}
type Claims struct {
Version string `json:"ver"`
Issuer string `json:"iss"`
Subject string `json:"sub"`
Audience string `json:"aud"`
Scopes []string `json:"scope"`
IssuedAt int64 `json:"iat"`
NotBefore int64 `json:"nbf"`
ExpiresAt int64 `json:"exp"`
TokenID string `json:"jti"`
Method string `json:"htm"`
Path string `json:"htu"`
BodySHA256 string `json:"body_sha256"`
}
type protectedHeader struct {
Algorithm string `json:"alg"`
Type string `json:"typ"`
KeyID string `json:"kid"`
Version string `json:"ver"`
}
type KeyRecord struct {
Principal string
KeyID string
PublicKey ed25519.PublicKey
Audience string
Scopes []string
Enabled bool
Revoked bool
}
type Registry struct {
mu sync.RWMutex
keys map[string]KeyRecord
}
func NewRegistry(records ...KeyRecord) (*Registry, error) {
r := &Registry{keys: make(map[string]KeyRecord, len(records))}
for _, record := range records {
if !keyIDPattern.MatchString(record.KeyID) || !principalPattern.MatchString(record.Principal) || !validAudiences[record.Audience] || len(record.PublicKey) != ed25519.PublicKeySize || !validScopeList(record.Scopes) {
return nil, errors.New("invalid machine key record")
}
if _, exists := r.keys[record.KeyID]; exists {
return nil, errors.New("duplicate machine key id")
}
record.PublicKey = slices.Clone(record.PublicKey)
record.Scopes = slices.Clone(record.Scopes)
r.keys[record.KeyID] = record
}
return r, nil
}
func (r *Registry) Lookup(keyID string) (KeyRecord, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
record, ok := r.keys[keyID]
record.PublicKey = slices.Clone(record.PublicKey)
record.Scopes = slices.Clone(record.Scopes)
return record, ok
}
func (r *Registry) Revoke(keyID string) bool {
r.mu.Lock()
defer r.mu.Unlock()
record, ok := r.keys[keyID]
if !ok {
return false
}
record.Revoked = true
r.keys[keyID] = record
return true
}
type ReplayStore struct {
mu sync.Mutex
used map[string]time.Time
}
// ReplayCache must atomically persist accepted (principal, jti) pairs until
// expiry. ReplayStore is process-local and intended for tests or a single
// uninterrupted process; connector implementations inject a durable store.
type ReplayCache interface {
Consume(principal, tokenID string, expiresAt, now time.Time) bool
}
func NewReplayStore() *ReplayStore { return &ReplayStore{used: map[string]time.Time{}} }
func (s *ReplayStore) Consume(principal, tokenID string, expiresAt, now time.Time) bool {
s.mu.Lock()
defer s.mu.Unlock()
for key, expiry := range s.used {
if !expiry.After(now) {
delete(s.used, key)
}
}
key := principal + "\x00" + tokenID
if _, exists := s.used[key]; exists {
return false
}
s.used[key] = expiresAt
return true
}
type Signer struct {
Principal string
KeyID string
PrivateKey ed25519.PrivateKey
Now func() time.Time
}
func LoadPrivateKey(path string) (ed25519.PrivateKey, error) {
if strings.TrimSpace(path) == "" {
return nil, errors.New("machine private key path is required")
}
raw, err := os.ReadFile(path)
if err != nil {
return nil, errors.New("read machine private key")
}
block, rest := pem.Decode(raw)
if block == nil || len(bytes.TrimSpace(rest)) != 0 || block.Type != "PRIVATE KEY" {
return nil, errors.New("machine private key must be one PKCS#8 PEM block")
}
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, errors.New("parse machine private key")
}
key, ok := parsed.(ed25519.PrivateKey)
if !ok || len(key) != ed25519.PrivateKeySize {
return nil, errors.New("machine private key is not Ed25519")
}
return slices.Clone(key), nil
}
func (s Signer) Mint(audience string, scopes []string, method, requestPath string, body []byte) (string, error) {
if !principalPattern.MatchString(s.Principal) || !keyIDPattern.MatchString(s.KeyID) || len(s.PrivateKey) != ed25519.PrivateKeySize || !validAudiences[audience] || !validScopeList(scopes) {
return "", errors.New("incomplete machine signer configuration")
}
normalizedPath, err := normalizePath(requestPath)
if err != nil {
return "", err
}
method = strings.ToUpper(method)
if !allowedMethod(method) {
return "", errors.New("unsupported machine request method")
}
now := time.Now().UTC()
if s.Now != nil {
now = s.Now().UTC()
}
tokenID, err := randomTokenID()
if err != nil {
return "", err
}
digest := sha256.Sum256(body)
claims := Claims{Version: Version, Issuer: s.Principal, Subject: s.Principal, Audience: audience,
Scopes: slices.Clone(scopes), IssuedAt: now.Unix(), NotBefore: now.Unix(), ExpiresAt: now.Add(MaxLifetime).Unix(),
TokenID: tokenID, Method: method, Path: normalizedPath, BodySHA256: hex.EncodeToString(digest[:])}
header := protectedHeader{Algorithm: "EdDSA", Type: TokenType, KeyID: s.KeyID, Version: Version}
headerJSON, _ := json.Marshal(header)
claimsJSON, _ := json.Marshal(claims)
signingInput := rawBase64(headerJSON) + "." + rawBase64(claimsJSON)
signature := ed25519.Sign(s.PrivateKey, []byte(signingInput))
return signingInput + "." + rawBase64(signature), nil
}
type Verifier struct {
Registry *Registry
Replay ReplayCache
Now func() time.Time
}
func (v Verifier) Verify(token, audience, requiredScope, method, requestPath string, body []byte) (Claims, error) {
if v.Registry == nil || v.Replay == nil {
return Claims{}, codeError("machine_token_invalid")
}
parts := strings.Split(token, ".")
if len(parts) != 3 || strings.Contains(token, "=") {
return Claims{}, codeError("machine_token_invalid")
}
headerBytes, err := decodeRaw(parts[0])
if err != nil {
return Claims{}, codeError("machine_token_invalid")
}
var header protectedHeader
if err = decodeClosed(headerBytes, &header); err != nil || header.Algorithm != "EdDSA" || header.Type != TokenType || header.Version != Version || !keyIDPattern.MatchString(header.KeyID) {
return Claims{}, codeError("machine_token_invalid")
}
record, ok := v.Registry.Lookup(header.KeyID)
if !ok {
return Claims{}, codeError("machine_token_invalid")
}
signature, err := decodeRaw(parts[2])
if err != nil || len(signature) != ed25519.SignatureSize || !ed25519.Verify(record.PublicKey, []byte(parts[0]+"."+parts[1]), signature) {
return Claims{}, codeError("machine_token_invalid")
}
if !record.Enabled || record.Revoked {
return Claims{}, codeError("machine_identity_revoked")
}
claimsBytes, err := decodeRaw(parts[1])
if err != nil {
return Claims{}, codeError("machine_token_invalid")
}
var claims Claims
if err = decodeClosed(claimsBytes, &claims); err != nil || !validClaimsShape(claims) || claims.Issuer != record.Principal || claims.Subject != record.Principal {
return Claims{}, codeError("machine_token_invalid")
}
now := time.Now().UTC()
if v.Now != nil {
now = v.Now().UTC()
}
nowUnix := now.Unix()
if claims.ExpiresAt-claims.IssuedAt <= 0 || claims.ExpiresAt-claims.IssuedAt > int64(MaxLifetime/time.Second) ||
claims.NotBefore < claims.IssuedAt || claims.NotBefore > claims.ExpiresAt || claims.IssuedAt > nowUnix+int64(AllowedSkew/time.Second) {
return Claims{}, codeError("machine_token_invalid")
}
if claims.NotBefore > nowUnix+int64(AllowedSkew/time.Second) || claims.ExpiresAt < nowUnix-int64(AllowedSkew/time.Second) {
return Claims{}, codeError("machine_token_expired")
}
if claims.Audience != audience || record.Audience != audience {
return Claims{}, codeError("machine_audience_denied")
}
if !slices.Contains(claims.Scopes, requiredScope) || !slices.Contains(record.Scopes, requiredScope) {
return Claims{}, codeError("machine_scope_denied")
}
normalizedPath, err := normalizePath(requestPath)
digest := sha256.Sum256(body)
if err != nil || claims.Method != strings.ToUpper(method) || claims.Path != normalizedPath || claims.BodySHA256 != hex.EncodeToString(digest[:]) {
return Claims{}, codeError("machine_token_invalid")
}
if !v.Replay.Consume(claims.Issuer, claims.TokenID, time.Unix(claims.ExpiresAt, 0).Add(AllowedSkew), now) {
return Claims{}, codeError("machine_token_replayed")
}
return claims, nil
}
func decodeClosed(raw []byte, target any) error {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
if err == nil {
return errors.New("trailing JSON value")
}
return err
}
return nil
}
func validClaimsShape(claims Claims) bool {
if claims.Version != Version || !principalPattern.MatchString(claims.Issuer) || claims.Subject != claims.Issuer || !validAudiences[claims.Audience] || !tokenIDPattern.MatchString(claims.TokenID) ||
len(claims.Scopes) == 0 || len(claims.Scopes) > 4 || !allowedMethod(claims.Method) || claims.Path == "" || len(claims.BodySHA256) != 64 {
return false
}
if !validScopeList(claims.Scopes) {
return false
}
_, err := hex.DecodeString(claims.BodySHA256)
return err == nil
}
func validScopeList(scopes []string) bool {
if len(scopes) == 0 || len(scopes) > 4 {
return false
}
seen := map[string]bool{}
for _, scope := range scopes {
if !validScopes[scope] || seen[scope] {
return false
}
seen[scope] = true
}
return true
}
func normalizePath(value string) (string, error) {
parsed, err := url.ParseRequestURI(value)
if err != nil || parsed.IsAbs() || parsed.Host != "" || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Path == "" || !strings.HasPrefix(parsed.Path, "/") || strings.Contains(parsed.Path, "\\") || strings.Contains(parsed.Path, "//") || path.Clean(parsed.Path) != parsed.Path {
return "", errors.New("machine request path must be a normalized absolute path without query or fragment")
}
return parsed.EscapedPath(), nil
}
func allowedMethod(method string) bool {
switch method {
case "GET", "POST", "PUT", "PATCH", "DELETE":
return true
default:
return false
}
}
func randomTokenID() (string, error) {
raw := make([]byte, 16)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("generate machine token id: %w", err)
}
return rawBase64(raw), nil
}
func rawBase64(value []byte) string { return base64.RawURLEncoding.EncodeToString(value) }
func decodeRaw(value string) ([]byte, error) {
return base64.RawURLEncoding.Strict().DecodeString(value)
}
@@ -0,0 +1,219 @@
package machine_identity
import (
"crypto/ed25519"
"crypto/rand"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
"time"
)
type crossLanguageVector struct {
PublicKey string `json:"public_key_base64url"`
Token string `json:"token"`
Now int64 `json:"now"`
Audience string `json:"audience"`
Scope string `json:"required_scope"`
Method string `json:"method"`
Path string `json:"path"`
Body string `json:"body_base64"`
}
func testIdentity(t *testing.T) (Signer, *Registry, time.Time) {
t.Helper()
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
now := time.Unix(1_800_000_000, 0).UTC()
registry, err := NewRegistry(KeyRecord{Principal: "yv:sense:site-a", KeyID: "sense-key-0001", PublicKey: publicKey,
Audience: "yovision-brain", Scopes: []string{"source-config:write"}, Enabled: true})
if err != nil {
t.Fatal(err)
}
return Signer{Principal: "yv:sense:site-a", KeyID: "sense-key-0001", PrivateKey: privateKey, Now: func() time.Time { return now }}, registry, now
}
func errorCode(t *testing.T, err error) string {
t.Helper()
var coded *Error
if !errors.As(err, &coded) {
t.Fatalf("expected coded error, got %v", err)
}
return coded.Code
}
func TestMintAndVerifyRequestBoundToken(t *testing.T) {
signer, registry, now := testIdentity(t)
body := []byte(`{"revision":7}`)
token, err := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
if err != nil {
t.Fatal(err)
}
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now }}
claims, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body)
if err != nil {
t.Fatal(err)
}
if claims.Issuer != signer.Principal || claims.Subject != signer.Principal || claims.ExpiresAt-claims.IssuedAt != 300 {
t.Fatalf("unexpected claims: %+v", claims)
}
}
func TestBearerTokenHasNoCookieOrQueryFallback(t *testing.T) {
if token, err := BearerToken("Bearer compact.token.value"); err != nil || token != "compact.token.value" {
t.Fatal(err)
}
for _, value := range []string{"", "compact.token.value", "Bearer", "Bearer one two", "Cookie compact.token.value"} {
if _, err := BearerToken(value); errorCode(t, err) != "machine_token_missing" {
t.Fatalf("accepted %q", value)
}
}
}
func TestRejectsReplayWrongAudienceScopeAndRequest(t *testing.T) {
signer, registry, now := testIdentity(t)
body := []byte(`{"revision":7}`)
mint := func() string {
token, err := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
if err != nil {
t.Fatal(err)
}
return token
}
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now }}
token := mint()
if _, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); err != nil {
t.Fatal(err)
}
if _, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_token_replayed" {
t.Fatal(err)
}
if _, err := verifier.Verify(mint(), "yovision-bell", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_audience_denied" {
t.Fatal(err)
}
if _, err := verifier.Verify(mint(), "yovision-brain", "events:ingest", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_scope_denied" {
t.Fatal(err)
}
if _, err := verifier.Verify(mint(), "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", []byte("changed")); errorCode(t, err) != "machine_token_invalid" {
t.Fatal(err)
}
}
func TestExpiryRevocationAndRotation(t *testing.T) {
signer, registry, now := testIdentity(t)
body := []byte("{}")
token, _ := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
expired := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now.Add(6 * time.Minute) }}
if _, err := expired.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_token_expired" {
t.Fatal(err)
}
oldPublic, oldPrivate, _ := ed25519.GenerateKey(rand.Reader)
newPublic, newPrivate, _ := ed25519.GenerateKey(rand.Reader)
rotation, err := NewRegistry(
KeyRecord{Principal: "yv:brain:node-a", KeyID: "brain-old-0001", PublicKey: oldPublic, Audience: "yovision-sense", Scopes: []string{"runtime-status:write"}, Enabled: true},
KeyRecord{Principal: "yv:brain:node-a", KeyID: "brain-new-0002", PublicKey: newPublic, Audience: "yovision-sense", Scopes: []string{"runtime-status:write"}, Enabled: true},
)
if err != nil {
t.Fatal(err)
}
oldSigner := Signer{Principal: "yv:brain:node-a", KeyID: "brain-old-0001", PrivateKey: oldPrivate, Now: func() time.Time { return now }}
newSigner := Signer{Principal: "yv:brain:node-a", KeyID: "brain-new-0002", PrivateKey: newPrivate, Now: func() time.Time { return now }}
oldToken, _ := oldSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
newToken, _ := newSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
verify := Verifier{Registry: rotation, Replay: NewReplayStore(), Now: func() time.Time { return now }}
if _, err = verify.Verify(oldToken, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); err != nil {
t.Fatal(err)
}
if _, err = verify.Verify(newToken, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); err != nil {
t.Fatal(err)
}
if !rotation.Revoke("brain-old-0001") {
t.Fatal("old key was not revoked")
}
oldAfterRevoke, _ := oldSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
if _, err = verify.Verify(oldAfterRevoke, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); errorCode(t, err) != "machine_identity_revoked" {
t.Fatal(err)
}
}
func TestTransportPolicyRejectsUnsafeTLS(t *testing.T) {
safe := TransportPolicy{TLSMinVersion: tls.VersionTLS12, VerifyCertificate: true, VerifyHostname: true,
ConnectTimeout: time.Second, ResponseHeaderTimeout: time.Second, RequestTimeout: 2 * time.Second, MaxRequestBytes: 1024}
if err := safe.Validate(); err != nil {
t.Fatal(err)
}
unsafe := safe
unsafe.VerifyHostname = false
if err := unsafe.Validate(); err == nil {
t.Fatal("unsafe hostname policy accepted")
}
unsafe = safe
unsafe.TLSMinVersion = tls.VersionTLS11
if err := unsafe.Validate(); err == nil {
t.Fatal("TLS 1.1 accepted")
}
}
func TestVerifiesCrossLanguageVector(t *testing.T) {
vectorPath := filepath.Join("..", "..", "..", "..", "..", "..", "contracts", "tests", "machine-identity-v1", "cross-language-vector.json")
raw, err := os.ReadFile(vectorPath)
if err != nil {
t.Fatal(err)
}
var vector crossLanguageVector
if err = json.Unmarshal(raw, &vector); err != nil {
t.Fatal(err)
}
publicKey, err := base64.RawURLEncoding.DecodeString(vector.PublicKey)
if err != nil {
t.Fatal(err)
}
body, err := base64.StdEncoding.DecodeString(vector.Body)
if err != nil {
t.Fatal(err)
}
registry, err := NewRegistry(KeyRecord{Principal: "yv:brain:vector", KeyID: "brain-vector-0001", PublicKey: ed25519.PublicKey(publicKey), Audience: vector.Audience, Scopes: []string{vector.Scope}, Enabled: true})
if err != nil {
t.Fatal(err)
}
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return time.Unix(vector.Now, 0) }}
claims, err := verifier.Verify(vector.Token, vector.Audience, vector.Scope, vector.Method, vector.Path, body)
if err != nil {
t.Fatal(err)
}
if claims.Issuer != "yv:brain:vector" {
t.Fatalf("unexpected issuer: %s", claims.Issuer)
}
}
func TestLoadsExternalPublicRegistryAndRejectsWrongAudience(t *testing.T) {
publicKey, _, _ := ed25519.GenerateKey(rand.Reader)
document := map[string]any{
"version": "yovision.machine-principal-registry/v1", "audience": "yovision-bell",
"principals": []any{map[string]any{"principal_id": "yv:sense:site-a", "enabled": true, "keys": []any{map[string]any{
"kid": "sense-key-0001", "public_key_base64url": base64.RawURLEncoding.EncodeToString(publicKey), "status": "active", "scopes": []string{"events:ingest"},
}}}},
}
raw, _ := json.Marshal(document)
file := filepath.Join(t.TempDir(), "principals.json")
if err := os.WriteFile(file, raw, 0o600); err != nil {
t.Fatal(err)
}
registry, err := LoadRegistry(file, "yovision-bell")
if err != nil {
t.Fatal(err)
}
if record, ok := registry.Lookup("sense-key-0001"); !ok || record.Principal != "yv:sense:site-a" {
t.Fatal("registry record missing")
}
if _, err = LoadRegistry(file, "yovision-sense"); err == nil {
t.Fatal("wrong registry audience accepted")
}
}

Some files were not shown because too many files have changed in this diff Show More