diff --git a/Sense/.gitignore b/Sense/.gitignore index bbd6acd..31c0408 100644 --- a/Sense/.gitignore +++ b/Sense/.gitignore @@ -3,5 +3,8 @@ server/*.exe server/*.db ui/node_modules/ ui/dist/ +dist/ +!scripts/build/ +!scripts/build/** .env *.local.yml diff --git a/Sense/README-WINDOWS.md b/Sense/README-WINDOWS.md new file mode 100644 index 0000000..cb4295d --- /dev/null +++ b/Sense/README-WINDOWS.md @@ -0,0 +1,157 @@ +# Sense Windows 安装与运行 + +本说明适用于 `sense-windows-amd64` 交付包。Sense 后端和管理网页来自冻结的 GoAdmin/go-admin-ui 基线;启动仍使用 GoAdmin Cobra 的 `migrate` 与 `server` 命令。Brain、Bell 不需要启动。 + +## 1. 准备环境 + +- Windows 10/11 或 Windows Server 2019 及以上,amd64。 +- PostgreSQL 17;先由数据库管理员创建独立的 Sense 数据库和最小权限账号。 +- 已审核版本与许可证的 Windows amd64 `mediamtx.exe`,放到 `bin\mediamtx.exe`。 +- 备份、恢复时还需要 PostgreSQL 客户端的 `pg_dump.exe`、`pg_restore.exe`;可把目录加入 PATH,或配置 `SENSE_POSTGRES_BIN`。 + +交付包不包含 PostgreSQL、数据库数据、管理员默认密码、摄像头密码或客户配置。不要把包解压到所有用户都可写的共享目录。 + +## 2. 配置 production + +编辑 `config\sense.env`。脚本只按 `NAME=value` 读取白名单字段,不会执行文件内容。值中可以包含 `#`、`&`、`;`、空格或 `=`;如首尾使用成对单/双引号,外层引号会被移除。 + +至少填写: + +```dotenv +SENSE_DATABASE_URL=host=127.0.0.1 port=5432 user=sense password=请替换 dbname=sense sslmode=disable +SENSE_JWT_SECRET=请替换为至少32字符的随机值 +SENSE_MEDIAMTX_MODE=managed +SENSE_MEDIAMTX_BINARY=bin\mediamtx.exe +SENSE_MEDIAMTX_CONFIG=config\mediamtx.yml +``` + +用 PowerShell 生成随机值,不要把输出写入工单、Wiki 或 Git: + +```powershell +$bytes = New-Object byte[] 48 +[Security.Cryptography.RandomNumberGenerator]::Fill($bytes) +[Convert]::ToBase64String($bytes) +``` + +同名的非空进程环境变量优先于 `sense.env`。这便于由服务管理器或秘密管理工具注入值;空进程变量不会覆盖文件值。脚本不会打印数据库连接串、JWT secret、Bootstrap token 或摄像头密钥。 + +运行启动前检查: + +```bat +check-sense.bat +``` + +它会检查配置格式、HTTP 端口、PostgreSQL TCP 连接、数据库名、JWT 长度、网页文件和 MediaMTX 模式。`managed` 模式要求二进制与配置文件存在;`external` 模式要求本机 Control API 已可连接。production 不允许 `disabled`。 + +## 3. 启动、迁移与停止 + +首次及日常启动: + +```bat +start-sense.bat +``` + +脚本先执行 `sense.exe migrate -c data\runtime\settings.yml`,成功后再执行 `sense.exe server -c ...`。迁移失败时不会启动 HTTP 服务。迁移会检查 PostgreSQL 数据库是否存在;脚本不会自动创建生产数据库。 + +`config\db.sql` 与 `config\pg.sql` 是冻结 GoAdmin 首次初始化所需的无秘密基线数据,必须和 `sense.exe` 同版本保留;删除它们会导致空库首次迁移失败。 + +浏览器访问 `http://127.0.0.1:18080/`。当前窗口按 `Ctrl+C` 可让 Sense 优雅停止,并请求停止由它启动的 MediaMTX。也可在另一管理员终端运行: + +```bat +stop-sense.bat +``` + +停止脚本只会强制停止监听配置端口、且可执行文件确实位于当前交付包的 Sense 进程树;端口属于其他程序时会拒绝操作。日常维护优先在启动窗口按 `Ctrl+C` 完成优雅停止,窗口丢失或进程失去响应时再使用停止脚本。 + +只执行迁移或禁用启动时自动迁移: + +```bat +migrate-sense.bat +start-sense.bat -SkipMigration +``` + +只有已完成备份并明确掌握版本状态时才使用 `-SkipMigration`。也可把 `SENSE_AUTO_MIGRATE=false` 放到外部进程环境中。 + +## 4. 创建首个管理员与修改密码 + +Sense 不提供生产默认管理员。首次初始化: + +1. 生成至少 32 字符的一次性随机值,临时填入 `SENSE_BOOTSTRAP_TOKEN`。 +2. 启动 Sense。 +3. 在另一个终端运行 `initialize-admin.bat -Username admin`,按隐藏提示输入至少 6 位密码。 +4. 成功后立即清空 `SENSE_BOOTSTRAP_TOKEN` 并重启 Sense。 + +Bootstrap 只允许在用户表为空时执行一次,token 通过请求头传递,不放在 JSON 或命令行中。不要把密码作为 bat 参数。 + +管理员登录后,在右上角头像进入“个人中心 → 修改密码”。密码至少 6 个字符;修改成功后重新登录。其他管理员的密码重置只能由授权管理员通过 GoAdmin 用户管理入口完成并形成审计记录。 + +## 5. 备份与恢复 + +创建 PostgreSQL custom-format 备份: + +```bat +backup-sense.bat +backup-sense.bat -OutputDirectory D:\SenseBackups +``` + +默认写入包外可单独保护的 `backups` 目录。脚本从连接串移除密码后再构造 `pg_dump` 命令,密码只通过子进程环境传递。 + +恢复会清理并替换目标库中的对象,必须先停止 Sense、备份当前库,并两次确认数据库名: + +```bat +restore-sense.bat -BackupFile D:\SenseBackups\sense-sense-20260815-120000.dump -ConfirmDatabaseName sense +migrate-sense.bat +``` + +恢复脚本还会交互要求输入 `RESTORE-数据库名`;名称不完全一致时拒绝执行。不要对来源不明或版本不匹配的备份执行恢复。 + +## 6. Demo 隔离 + +Demo 使用独立的 `config\sense.demo.env` 和 `SENSE_DEMO_DATABASE_URL`: + +```bat +start-sense.bat demo +``` + +数据库名必须包含 `demo` 或 `test`,且不会回退到 production 的 `SENSE_DATABASE_URL`。默认 HTTP 端口为 18081、MediaMTX 为 disabled。Demo 数据不属于生产数据,不得迁入生产库或用于客户交付。 + +Demo 启动窗口按 `Ctrl+C` 停止;窗口不可用时执行 `stop-sense.bat -Mode demo`。 + +## 7. 日志与排错 + +- Sense 文件日志:`logs\` +- 运行时生成的 GoAdmin YAML:`data\runtime\settings.yml`(包含秘密,不得复制到工单或发送给无权限人员) +- MediaMTX 日志:由 Sense 启动窗口和 MediaMTX 自身输出提供 +- 包完整性:`MANIFEST.sha256` + +常见错误: + +- `SENSE_DATABASE_URL is required`:编辑当前包的 `config\sense.env`,或设置非空进程变量。 +- `PostgreSQL is unreachable`:确认服务、地址、端口和防火墙;数据库不存在会在迁移阶段明确失败。 +- `port ... already in use`:先运行 `stop-sense.bat`,或确认占用者后修改 `SENSE_PORT`。 +- `Managed MediaMTX binary not found`:把已审核的 `mediamtx.exe` 放入 `bin`,不要只复制配置文件。 +- `External MediaMTX Control API is unreachable`:启动外部实例并确认 API 只监听回环地址。 +- `migration failed`:不要跳过;先备份,保留错误输出,核对数据库账号权限和版本。 +- 网页返回 404:检查 `web\index.html` 与 `SENSE_WEB_ROOT=web`,不要把源码目录或 `node_modules` 放进包。 +- 网页返回 200 但白屏:在浏览器开发者工具检查 JS/CSS 是否 404;正式包的构建审计会逐项核对 `web\index.html` 引用的本地资源,缺失时拒绝生成交付包。 + +## 8. 构建交付包 + +开发机在仓库根目录执行: + +```bat +Sense\scripts\build\build-windows.bat +``` + +若要把已审核的 MediaMTX 一并放入包: + +```powershell +Sense\scripts\build\build-windows.ps1 -MediaMTXPath D:\approved\mediamtx.exe +``` + +构建严格检查 Go 1.26.5、Node 22.22.1 和 pnpm 9.15.1,生成: + +- `Sense\dist\sense-windows-amd64\` +- `Sense\dist\sense-windows-amd64.zip` + +构建末尾会审计包内容:逐项核对 `web\index.html` 引用的本地 JS/CSS,并拒绝 `node_modules`、嵌套 `dist`、Git/缓存目录、数据库/备份文件、非空秘密字段、常见默认密码和私钥标记。`dist` 为可重建产物,不提交 Git。 diff --git a/Sense/config/mediamtx.yml b/Sense/config/mediamtx.yml new file mode 100644 index 0000000..5d59df8 --- /dev/null +++ b/Sense/config/mediamtx.yml @@ -0,0 +1,5 @@ +logLevel: info +api: true +apiAddress: 127.0.0.1:9997 +metrics: false +paths: {} diff --git a/Sense/config/sense.demo.env.example b/Sense/config/sense.demo.env.example new file mode 100644 index 0000000..42884c1 --- /dev/null +++ b/Sense/config/sense.demo.env.example @@ -0,0 +1,18 @@ +# Demo mode must use a disposable PostgreSQL database whose name contains +# "demo" or "test". It never falls back to SENSE_DATABASE_URL. +SENSE_MODE=demo +SENSE_HOST=127.0.0.1 +SENSE_PORT=18081 +SENSE_DEMO_DATABASE_URL= +SENSE_JWT_SECRET= +SENSE_BOOTSTRAP_TOKEN= +SENSE_CREDENTIAL_KEY= +SENSE_ONVIF_DISCOVERY_IP= +SENSE_ONVIF_ALLOWED_CIDRS= +SENSE_MEDIAMTX_MODE=disabled +SENSE_MEDIAMTX_BINARY= +SENSE_MEDIAMTX_CONFIG= +SENSE_MEDIAMTX_API=http://127.0.0.1:9997 +SENSE_WEB_ROOT=web +SENSE_AUTO_MIGRATE=true +SENSE_POSTGRES_BIN= diff --git a/Sense/config/sense.env.example b/Sense/config/sense.env.example new file mode 100644 index 0000000..1bfef50 --- /dev/null +++ b/Sense/config/sense.env.example @@ -0,0 +1,18 @@ +# Sense production configuration. Copy this file as config\sense.env. +# Values are parsed as data; this file is never executed as a script. +SENSE_MODE=production +SENSE_HOST=127.0.0.1 +SENSE_PORT=18080 +SENSE_DATABASE_URL= +SENSE_JWT_SECRET= +SENSE_BOOTSTRAP_TOKEN= +SENSE_CREDENTIAL_KEY= +SENSE_ONVIF_DISCOVERY_IP= +SENSE_ONVIF_ALLOWED_CIDRS= +SENSE_MEDIAMTX_MODE=managed +SENSE_MEDIAMTX_BINARY=bin\mediamtx.exe +SENSE_MEDIAMTX_CONFIG=config\mediamtx.yml +SENSE_MEDIAMTX_API=http://127.0.0.1:9997 +SENSE_WEB_ROOT=web +SENSE_AUTO_MIGRATE=true +SENSE_POSTGRES_BIN= diff --git a/Sense/package/README-MEDIAMTX.txt b/Sense/package/README-MEDIAMTX.txt new file mode 100644 index 0000000..93e8b93 --- /dev/null +++ b/Sense/package/README-MEDIAMTX.txt @@ -0,0 +1,5 @@ +Place the approved Windows amd64 mediamtx.exe in this package's bin directory, +or pass -MediaMTXPath to scripts\build\build-windows.ps1. + +Sense does not redistribute MediaMTX automatically. Verify its version, +license, checksum, and customer approval before delivery. diff --git a/Sense/scripts/build/assert-web-assets.ps1 b/Sense/scripts/build/assert-web-assets.ps1 new file mode 100644 index 0000000..2a9b0b1 --- /dev/null +++ b/Sense/scripts/build/assert-web-assets.ps1 @@ -0,0 +1,37 @@ +param([Parameter(Mandatory = $true)][string]$WebRoot) +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' + +$root = [IO.Path]::GetFullPath($WebRoot) +$indexPath = Join-Path $root 'index.html' +if (-not (Test-Path -LiteralPath $indexPath -PathType Leaf)) { + throw "Web index not found: $indexPath" +} + +$rootPrefix = $root.TrimEnd('\') + '\' +$html = Get-Content -LiteralPath $indexPath -Raw +$references = [regex]::Matches($html, '(?i)(?:src|href)\s*=\s*["''](?[^"'']+)["'']') +$checked = 0 +foreach ($match in $references) { + $assetReference = $match.Groups['path'].Value.Trim() + if (-not $assetReference -or $assetReference.StartsWith('//') -or $assetReference -match '^[a-z][a-z0-9+.-]*:') { + continue + } + $assetPath = ($assetReference -split '[?#]', 2)[0] + if ([IO.Path]::GetExtension($assetPath).ToLowerInvariant() -notin @('.js', '.css')) { + continue + } + $relative = [Uri]::UnescapeDataString($assetPath).TrimStart('/').Replace('/', '\') + if (-not $relative) { throw "Web index contains an empty local asset path: $assetReference" } + $resolved = [IO.Path]::GetFullPath((Join-Path $root $relative)) + if (-not $resolved.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw "Web index asset escapes the web root: $assetReference" + } + if (-not (Test-Path -LiteralPath $resolved -PathType Leaf)) { + throw "Web index references missing local asset: $assetReference" + } + $checked++ +} + +if ($checked -eq 0) { throw 'Web index does not reference any local JavaScript or CSS assets.' } +Write-Host "Sense web asset audit passed: $checked local references." diff --git a/Sense/scripts/build/build-windows.bat b/Sense/scripts/build/build-windows.bat new file mode 100644 index 0000000..df753f3 --- /dev/null +++ b/Sense/scripts/build/build-windows.bat @@ -0,0 +1,3 @@ +@echo off +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0build-windows.ps1" %* +exit /b %errorlevel% diff --git a/Sense/scripts/build/build-windows.ps1 b/Sense/scripts/build/build-windows.ps1 new file mode 100644 index 0000000..a08194f --- /dev/null +++ b/Sense/scripts/build/build-windows.ps1 @@ -0,0 +1,117 @@ +param([string]$MediaMTXPath = '') +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' + +$senseRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..')) +$serverRoot = Join-Path $senseRoot 'server' +$uiRoot = Join-Path $senseRoot 'ui' +$distRoot = Join-Path $senseRoot 'dist' +$target = Join-Path $distRoot 'sense-windows-amd64' +$archive = Join-Path $distRoot 'sense-windows-amd64.zip' +$staging = Join-Path $distRoot ('.sense-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-SenseFileSha256([string]$Path) { + $sha256 = [Security.Cryptography.SHA256]::Create() + $stream = [IO.File]::OpenRead($Path) + try { + return ([BitConverter]::ToString($sha256.ComputeHash($stream))).Replace('-', '') + } finally { + $stream.Dispose() + $sha256.Dispose() + } +} + +Assert-ChildPath $senseRoot $distRoot +Assert-ChildPath $distRoot $target +Assert-ChildPath $distRoot $archive +Assert-ChildPath $distRoot $staging + +$goVersion = '' +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; module toolchain reported $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; corepack reported $pnpmVersion." } + +$hadNodeModules = Test-Path -LiteralPath (Join-Path $uiRoot 'node_modules') +$hadUIDist = Test-Path -LiteralPath (Join-Path $uiRoot 'dist') +try { + 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 'Sense UI production build failed.' } + } 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 'sense.exe') . + if ($LASTEXITCODE -ne 0) { throw 'Sense 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 'bin') | Out-Null + Copy-Item -Path (Join-Path $senseRoot 'scripts\runtime\*.ps1') -Destination (Join-Path $staging 'scripts\runtime') + foreach ($name in @('start-sense', 'stop-sense', 'check-sense', 'migrate-sense', 'backup-sense', 'restore-sense', 'initialize-admin')) { + Copy-Item -LiteralPath (Join-Path $senseRoot "scripts\runtime\$name.bat") -Destination (Join-Path $staging "$name.bat") + } + Copy-Item -LiteralPath (Join-Path $senseRoot 'config\sense.env.example') -Destination (Join-Path $staging 'config\sense.env.example') + Copy-Item -LiteralPath (Join-Path $senseRoot 'config\sense.env.example') -Destination (Join-Path $staging 'config\sense.env') + Copy-Item -LiteralPath (Join-Path $senseRoot 'config\sense.demo.env.example') -Destination (Join-Path $staging 'config\sense.demo.env.example') + Copy-Item -LiteralPath (Join-Path $senseRoot 'config\sense.demo.env.example') -Destination (Join-Path $staging 'config\sense.demo.env') + Copy-Item -LiteralPath (Join-Path $senseRoot 'config\mediamtx.yml') -Destination (Join-Path $staging 'config\mediamtx.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 $senseRoot 'README-WINDOWS.md') -Destination (Join-Path $staging 'README-WINDOWS.md') + Copy-Item -LiteralPath (Join-Path $senseRoot 'package\README-MEDIAMTX.txt') -Destination (Join-Path $staging 'bin\README-MEDIAMTX.txt') + Copy-Item -LiteralPath (Join-Path $senseRoot 'LICENSES') -Destination (Join-Path $staging 'LICENSES') -Recurse + if (-not [string]::IsNullOrWhiteSpace($MediaMTXPath)) { + $mediaSource = [IO.Path]::GetFullPath($MediaMTXPath) + if (-not (Test-Path -LiteralPath $mediaSource -PathType Leaf)) { throw "MediaMTX binary not found: $mediaSource" } + Copy-Item -LiteralPath $mediaSource -Destination (Join-Path $staging 'bin\mediamtx.exe') + } + $commit = (& git -C (Split-Path $senseRoot -Parent) 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 'Sense package audit failed.' } + $manifest = foreach ($file in Get-ChildItem -LiteralPath $staging -Recurse -File | Sort-Object FullName) { + $relative = $file.FullName.Substring($staging.Length + 1).Replace('\', '/') + "$(Get-SenseFileSha256 -Path $file.FullName) $relative" + } + [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 "Sense Windows package: $target" + Write-Host "Sense Windows archive: $archive" +} finally { + if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force } + if (-not $hadUIDist -and (Test-Path -LiteralPath (Join-Path $uiRoot 'dist'))) { Remove-Item -LiteralPath (Join-Path $uiRoot 'dist') -Recurse -Force } + if (-not $hadNodeModules -and (Test-Path -LiteralPath (Join-Path $uiRoot 'node_modules'))) { Remove-Item -LiteralPath (Join-Path $uiRoot 'node_modules') -Recurse -Force } +} diff --git a/Sense/scripts/build/test-package.ps1 b/Sense/scripts/build/test-package.ps1 new file mode 100644 index 0000000..2035141 --- /dev/null +++ b/Sense/scripts/build/test-package.ps1 @@ -0,0 +1,37 @@ +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 = @( + 'sense.exe', 'start-sense.bat', 'stop-sense.bat', 'check-sense.bat', + 'migrate-sense.bat', 'backup-sense.bat', 'restore-sense.bat', + 'initialize-admin.bat', 'README-WINDOWS.md', 'config\sense.env', + 'config\sense.env.example', 'config\sense.demo.env', + 'config\mediamtx.yml', 'config\db.sql', 'config\pg.sql', + 'web\index.html', 'scripts\runtime\sense-common.ps1' +) +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)" } +$configFiles = @((Join-Path $root 'config\sense.env'), (Join-Path $root 'config\sense.demo.env')) +foreach ($configFile in $configFiles) { + $content = Get-Content -LiteralPath $configFile -Raw + foreach ($secret in @('SENSE_DATABASE_URL', 'SENSE_DEMO_DATABASE_URL', 'SENSE_JWT_SECRET', 'SENSE_BOOTSTRAP_TOKEN', 'SENSE_CREDENTIAL_KEY')) { + if ($content -match "(?m)^$secret[ \t]*=[ \t]*[^ \t\r\n]") { throw "Package contains a non-empty secret field: $secret" } + } +} +$textExtensions = @('.md', '.txt', '.env', '.example', '.ps1', '.bat', '.yml', '.yaml', '.json', '.html', '.js', '.css', '.sql') +foreach ($file in Get-ChildItem -LiteralPath $root -Recurse -File | Where-Object { $textExtensions -contains $_.Extension.ToLowerInvariant() }) { + $content = 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 "Sense package audit passed: $root" diff --git a/Sense/scripts/runtime/backup-sense.bat b/Sense/scripts/runtime/backup-sense.bat new file mode 100644 index 0000000..39831f5 --- /dev/null +++ b/Sense/scripts/runtime/backup-sense.bat @@ -0,0 +1,3 @@ +@echo off +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\backup-sense.ps1" %* +exit /b %errorlevel% diff --git a/Sense/scripts/runtime/backup-sense.ps1 b/Sense/scripts/runtime/backup-sense.ps1 new file mode 100644 index 0000000..6302169 --- /dev/null +++ b/Sense/scripts/runtime/backup-sense.ps1 @@ -0,0 +1,22 @@ +param( + [ValidateSet('production', 'demo')][string]$Mode = 'production', + [string]$OutputDirectory = '' +) +. (Join-Path $PSScriptRoot 'sense-common.ps1') + +try { + $root = Get-SensePackageRoot + $state = Initialize-SenseRuntime -PackageRoot $root -Mode $Mode -AllowOccupiedPort + $pgDump = Get-SensePostgresTool -Name 'pg_dump' + if ([string]::IsNullOrWhiteSpace($OutputDirectory)) { $OutputDirectory = Join-Path $root 'backups' } + $OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory) + New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $output = Join-Path $OutputDirectory "sense-$($state.Database.Database)-$stamp.dump" + Invoke-SensePostgresTool -Tool $pgDump -Database $state.Database -Arguments @('--dbname', $state.Database.Sanitized, '--format=custom', '--no-owner', '--file', $output) + Write-Host "Sense backup created: $output" + exit 0 +} catch { + Write-Error $_.Exception.Message + exit 1 +} diff --git a/Sense/scripts/runtime/check-sense.bat b/Sense/scripts/runtime/check-sense.bat new file mode 100644 index 0000000..6e16275 --- /dev/null +++ b/Sense/scripts/runtime/check-sense.bat @@ -0,0 +1,3 @@ +@echo off +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\check-sense.ps1" %* +exit /b %errorlevel% diff --git a/Sense/scripts/runtime/check-sense.ps1 b/Sense/scripts/runtime/check-sense.ps1 new file mode 100644 index 0000000..1654d7f --- /dev/null +++ b/Sense/scripts/runtime/check-sense.ps1 @@ -0,0 +1,19 @@ +param( + [ValidateSet('production', 'demo')][string]$Mode = 'production', + [switch]$Running +) +. (Join-Path $PSScriptRoot 'sense-common.ps1') + +try { + $root = Get-SensePackageRoot + $state = Initialize-SenseRuntime -PackageRoot $root -Mode $Mode -AllowOccupiedPort:$Running + if ($Running -and -not (Test-SenseTcpEndpoint -HostName $state.Host -Port $state.Port)) { + throw "Sense is not accepting TCP connections at $($state.Host):$($state.Port)." + } + Write-Host "Sense $Mode configuration check passed." + Write-Host "PostgreSQL endpoint: reachable; MediaMTX mode: $($state.MediaMode); HTTP port: $($state.Port)." + exit 0 +} catch { + Write-Error $_.Exception.Message + exit 1 +} diff --git a/Sense/scripts/runtime/initialize-admin.bat b/Sense/scripts/runtime/initialize-admin.bat new file mode 100644 index 0000000..85e2772 --- /dev/null +++ b/Sense/scripts/runtime/initialize-admin.bat @@ -0,0 +1,3 @@ +@echo off +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\initialize-admin.ps1" %* +exit /b %errorlevel% diff --git a/Sense/scripts/runtime/initialize-admin.ps1 b/Sense/scripts/runtime/initialize-admin.ps1 new file mode 100644 index 0000000..bedaa5d --- /dev/null +++ b/Sense/scripts/runtime/initialize-admin.ps1 @@ -0,0 +1,29 @@ +param( + [string]$Username = '', + [ValidateSet('production', 'demo')][string]$Mode = 'production' +) +. (Join-Path $PSScriptRoot 'sense-common.ps1') + +$passwordPointer = [IntPtr]::Zero +try { + $root = Get-SensePackageRoot + $state = Initialize-SenseRuntime -PackageRoot $root -Mode $Mode -AllowOccupiedPort + $token = Get-SenseEnvironmentValue -Name 'SENSE_BOOTSTRAP_TOKEN' + if ($token.Length -lt 32) { throw 'Set a temporary random SENSE_BOOTSTRAP_TOKEN of at least 32 characters, then restart Sense.' } + if ([string]::IsNullOrWhiteSpace($Username)) { $Username = Read-Host 'Administrator username' } + $securePassword = Read-Host 'Administrator password (at least 6 characters)' -AsSecureString + $passwordPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($securePassword) + $password = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($passwordPointer) + $body = @{ username = $Username; password = $password; nickName = $Username } | ConvertTo-Json -Compress + $headers = @{ 'X-Sense-Bootstrap-Token' = $token } + $uri = "http://$($state.Host):$($state.Port)/api/v1/public/bootstrap" + Invoke-RestMethod -Method Post -Uri $uri -Headers $headers -ContentType 'application/json; charset=utf-8' -Body $body | Out-Null + Write-Host 'Sense administrator created. Remove SENSE_BOOTSTRAP_TOKEN from config/sense.env and restart Sense now.' + exit 0 +} catch { + Write-Error $_.Exception.Message + exit 1 +} finally { + if ($passwordPointer -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($passwordPointer) } + Remove-Variable password -ErrorAction SilentlyContinue +} diff --git a/Sense/scripts/runtime/migrate-sense.bat b/Sense/scripts/runtime/migrate-sense.bat new file mode 100644 index 0000000..fabdcb5 --- /dev/null +++ b/Sense/scripts/runtime/migrate-sense.bat @@ -0,0 +1,3 @@ +@echo off +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\migrate-sense.ps1" %* +exit /b %errorlevel% diff --git a/Sense/scripts/runtime/migrate-sense.ps1 b/Sense/scripts/runtime/migrate-sense.ps1 new file mode 100644 index 0000000..4d1e5e6 --- /dev/null +++ b/Sense/scripts/runtime/migrate-sense.ps1 @@ -0,0 +1,19 @@ +param([ValidateSet('production', 'demo')][string]$Mode = 'production') +. (Join-Path $PSScriptRoot 'sense-common.ps1') + +try { + $root = Get-SensePackageRoot + $state = Initialize-SenseRuntime -PackageRoot $root -Mode $Mode -AllowOccupiedPort + $sense = Join-Path $root 'sense.exe' + Write-Host 'Applying pending Sense database migrations...' + Push-Location $root + try { + & $sense migrate -c $state.SettingsPath + if ($LASTEXITCODE -ne 0) { throw 'Sense database migration failed.' } + } finally { Pop-Location } + Write-Host 'Sense database migration completed.' + exit 0 +} catch { + Write-Error $_.Exception.Message + exit 1 +} diff --git a/Sense/scripts/runtime/restore-sense.bat b/Sense/scripts/runtime/restore-sense.bat new file mode 100644 index 0000000..6c31ec0 --- /dev/null +++ b/Sense/scripts/runtime/restore-sense.bat @@ -0,0 +1,3 @@ +@echo off +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\restore-sense.ps1" %* +exit /b %errorlevel% diff --git a/Sense/scripts/runtime/restore-sense.ps1 b/Sense/scripts/runtime/restore-sense.ps1 new file mode 100644 index 0000000..87dd8c7 --- /dev/null +++ b/Sense/scripts/runtime/restore-sense.ps1 @@ -0,0 +1,27 @@ +param( + [Parameter(Mandatory = $true)][string]$BackupFile, + [Parameter(Mandatory = $true)][string]$ConfirmDatabaseName, + [ValidateSet('production', 'demo')][string]$Mode = 'production', + [string]$Confirmation = '' +) +. (Join-Path $PSScriptRoot 'sense-common.ps1') + +try { + $root = Get-SensePackageRoot + $state = Initialize-SenseRuntime -PackageRoot $root -Mode $Mode -AllowOccupiedPort + $backup = [IO.Path]::GetFullPath($BackupFile) + if (-not (Test-Path -LiteralPath $backup -PathType Leaf)) { throw "Backup file not found: $backup" } + if ($ConfirmDatabaseName -cne $state.Database.Database) { + throw 'Restore confirmation does not exactly match the configured database name.' + } + $pgRestore = Get-SensePostgresTool -Name 'pg_restore' + Write-Warning "Restoring will replace objects in database '$ConfirmDatabaseName'. Stop Sense before continuing." + $answer = if ([string]::IsNullOrWhiteSpace($Confirmation)) { Read-Host "Type RESTORE-$ConfirmDatabaseName to continue" } else { $Confirmation } + if ($answer -cne "RESTORE-$ConfirmDatabaseName") { throw 'Restore cancelled.' } + Invoke-SensePostgresTool -Tool $pgRestore -Database $state.Database -Arguments @('--dbname', $state.Database.Sanitized, '--clean', '--if-exists', '--no-owner', '--exit-on-error', $backup) + Write-Host 'Sense database restore completed. Run migrate-sense.bat before starting Sense.' + exit 0 +} catch { + Write-Error $_.Exception.Message + exit 1 +} diff --git a/Sense/scripts/runtime/sense-common.ps1 b/Sense/scripts/runtime/sense-common.ps1 new file mode 100644 index 0000000..e4cd351 --- /dev/null +++ b/Sense/scripts/runtime/sense-common.ps1 @@ -0,0 +1,281 @@ +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' + +$script:SenseAllowedEnvironment = @( + 'SENSE_MODE', 'SENSE_HOST', 'SENSE_PORT', 'SENSE_DATABASE_URL', + 'SENSE_DEMO_DATABASE_URL', 'SENSE_JWT_SECRET', 'SENSE_BOOTSTRAP_TOKEN', + 'SENSE_CREDENTIAL_KEY', 'SENSE_ONVIF_DISCOVERY_IP', + 'SENSE_ONVIF_ALLOWED_CIDRS', 'SENSE_MEDIAMTX_MODE', + 'SENSE_MEDIAMTX_BINARY', 'SENSE_MEDIAMTX_CONFIG', + 'SENSE_MEDIAMTX_API', 'SENSE_WEB_ROOT', 'SENSE_AUTO_MIGRATE', + 'SENSE_POSTGRES_BIN' +) + +function Get-SensePackageRoot { + param([string]$ScriptDirectory = $PSScriptRoot) + return [System.IO.Path]::GetFullPath((Join-Path $ScriptDirectory '..\..')) +} + +function Import-SenseEnvironment { + param([Parameter(Mandatory = $true)][string]$Path) + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "Sense 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 Sense configuration at line $lineNumber. Expected NAME=value." + } + $name = $line.Substring(0, $separator).Trim() + if ($script:SenseAllowedEnvironment -notcontains $name) { + throw "Unsupported Sense 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) + } + } + $existing = [Environment]::GetEnvironmentVariable($name, 'Process') + if ([string]::IsNullOrWhiteSpace($existing)) { + [Environment]::SetEnvironmentVariable($name, $value, 'Process') + } + } +} + +function Get-SenseEnvironmentValue { + param([Parameter(Mandatory = $true)][string]$Name, [string]$Default = '') + $value = [Environment]::GetEnvironmentVariable($Name, 'Process') + if ([string]::IsNullOrWhiteSpace($value)) { return $Default } + return $value +} + +function ConvertTo-SenseYamlString { + param([AllowEmptyString()][string]$Value) + return ($Value | ConvertTo-Json -Compress) +} + +function Resolve-SenseConfiguredPath { + param([Parameter(Mandatory = $true)][string]$PackageRoot, [AllowEmptyString()][string]$Value) + if ([string]::IsNullOrWhiteSpace($Value)) { return '' } + if ([System.IO.Path]::IsPathRooted($Value)) { + return [System.IO.Path]::GetFullPath($Value) + } + return [System.IO.Path]::GetFullPath((Join-Path $PackageRoot $Value)) +} + +function Get-SenseDatabaseInfo { + param([Parameter(Mandatory = $true)][string]$Connection) + $result = @{ Host = '127.0.0.1'; Port = 5432; Database = ''; Sanitized = $Connection; Password = '' } + if ($Connection -match '^postgres(?:ql)?://') { + $uri = [Uri]$Connection + $result.Host = $uri.Host + if (-not $uri.IsDefaultPort) { $result.Port = $uri.Port } + $result.Database = $uri.AbsolutePath.TrimStart('/') + if ($uri.UserInfo) { + $parts = $uri.UserInfo.Split(':', 2) + $user = [Uri]::UnescapeDataString($parts[0]) + if ($parts.Count -eq 2) { $result.Password = [Uri]::UnescapeDataString($parts[1]) } + $builder = [UriBuilder]$uri + $builder.UserName = $user + $builder.Password = '' + $result.Sanitized = $builder.Uri.AbsoluteUri + } + return $result + } + + $matches = [regex]::Matches($Connection, '(?:^|\s)(?[A-Za-z_][A-Za-z0-9_]*)=(?''(?:[^'']|'''')*''|"(?:[^"]|"")*"|[^\s]+)') + $sanitized = New-Object System.Collections.Generic.List[string] + foreach ($match in $matches) { + $key = $match.Groups['key'].Value + $raw = $match.Groups['value'].Value + $value = $raw + if ($raw.Length -ge 2 -and (($raw[0] -eq "'" -and $raw[$raw.Length - 1] -eq "'") -or ($raw[0] -eq '"' -and $raw[$raw.Length - 1] -eq '"'))) { + $value = $raw.Substring(1, $raw.Length - 2) + } + if ($key.ToLowerInvariant() -eq 'password') { + $result.Password = $value + continue + } + switch ($key.ToLowerInvariant()) { + 'host' { $result.Host = $value } + 'port' { $result.Port = [int]$value } + 'dbname' { $result.Database = $value } + } + $sanitized.Add("$key=$raw") + } + if ($matches.Count -eq 0) { throw 'SENSE_DATABASE_URL must be a PostgreSQL URI or keyword connection string.' } + $result.Sanitized = $sanitized -join ' ' + return $result +} + +function Test-SenseTcpEndpoint { + param([Parameter(Mandatory = $true)][string]$HostName, [Parameter(Mandatory = $true)][int]$Port, [int]$TimeoutMilliseconds = 2000) + $client = New-Object System.Net.Sockets.TcpClient + try { + $task = $client.ConnectAsync($HostName, $Port) + if (-not $task.Wait($TimeoutMilliseconds)) { return $false } + return $client.Connected + } catch { + return $false + } finally { + $client.Dispose() + } +} + +function Test-SenseListenPortAvailable { + param([Parameter(Mandatory = $true)][string]$HostName, [Parameter(Mandatory = $true)][int]$Port) + $ip = if ($HostName -eq '0.0.0.0') { [Net.IPAddress]::Any } elseif ($HostName -eq 'localhost') { [Net.IPAddress]::Loopback } else { [Net.IPAddress]::Parse($HostName) } + $listener = New-Object Net.Sockets.TcpListener($ip, $Port) + try { $listener.Start(); return $true } catch { return $false } finally { try { $listener.Stop() } catch {} } +} + +function Initialize-SenseRuntime { + param( + [Parameter(Mandatory = $true)][string]$PackageRoot, + [ValidateSet('production', 'demo')][string]$Mode = 'production', + [switch]$AllowOccupiedPort + ) + $configName = if ($Mode -eq 'demo') { 'sense.demo.env' } else { 'sense.env' } + Import-SenseEnvironment -Path (Join-Path $PackageRoot "config\$configName") + + $hostName = Get-SenseEnvironmentValue -Name 'SENSE_HOST' -Default '127.0.0.1' + $portText = Get-SenseEnvironmentValue -Name 'SENSE_PORT' -Default '18080' + $port = 0 + if (-not [int]::TryParse($portText, [ref]$port) -or $port -lt 1 -or $port -gt 65535) { + throw 'SENSE_PORT must be an integer between 1 and 65535.' + } + if ($hostName -notin @('127.0.0.1', '0.0.0.0', 'localhost')) { + throw 'SENSE_HOST must be 127.0.0.1, localhost, or 0.0.0.0.' + } + if (-not $AllowOccupiedPort -and -not (Test-SenseListenPortAvailable -HostName $hostName -Port $port)) { + throw "Sense HTTP port $hostName`:$port is already in use. Stop the existing process or change SENSE_PORT." + } + + $databaseVariable = if ($Mode -eq 'demo') { 'SENSE_DEMO_DATABASE_URL' } else { 'SENSE_DATABASE_URL' } + $databaseURL = Get-SenseEnvironmentValue -Name $databaseVariable + if ([string]::IsNullOrWhiteSpace($databaseURL)) { throw "$databaseVariable is required." } + $database = Get-SenseDatabaseInfo -Connection $databaseURL + if ([string]::IsNullOrWhiteSpace($database.Database)) { throw "$databaseVariable must name a database." } + if ($Mode -eq 'demo' -and $database.Database -notmatch '(?i)demo|test') { + throw 'Demo mode requires a database name containing demo or test; production data must never be reused as demo data.' + } + if (-not (Test-SenseTcpEndpoint -HostName $database.Host -Port $database.Port)) { + throw "PostgreSQL is unreachable at $($database.Host):$($database.Port). Start PostgreSQL and verify the database connection." + } + + $jwtSecret = Get-SenseEnvironmentValue -Name 'SENSE_JWT_SECRET' + if ($Mode -eq 'production' -and $jwtSecret.Trim().Length -lt 32) { + throw 'SENSE_JWT_SECRET must contain at least 32 characters in production.' + } + if ([string]::IsNullOrWhiteSpace($jwtSecret)) { throw 'SENSE_JWT_SECRET is required.' } + + $mediaMode = (Get-SenseEnvironmentValue -Name 'SENSE_MEDIAMTX_MODE' -Default $(if ($Mode -eq 'demo') { 'disabled' } else { 'managed' })).ToLowerInvariant() + if ($mediaMode -notin @('managed', 'external', 'disabled')) { throw 'SENSE_MEDIAMTX_MODE must be managed, external, or disabled.' } + if ($Mode -eq 'production' -and $mediaMode -eq 'disabled') { throw 'MediaMTX cannot be disabled in production.' } + $mediaAPI = Get-SenseEnvironmentValue -Name 'SENSE_MEDIAMTX_API' -Default 'http://127.0.0.1:9997' + $apiUri = [Uri]$mediaAPI + if ($apiUri.Scheme -ne 'http' -or $apiUri.Host -notin @('127.0.0.1', 'localhost', '::1')) { + throw 'SENSE_MEDIAMTX_API must be an HTTP loopback URL.' + } + $mediaBinary = Resolve-SenseConfiguredPath -PackageRoot $PackageRoot -Value (Get-SenseEnvironmentValue -Name 'SENSE_MEDIAMTX_BINARY') + $mediaConfig = Resolve-SenseConfiguredPath -PackageRoot $PackageRoot -Value (Get-SenseEnvironmentValue -Name 'SENSE_MEDIAMTX_CONFIG') + if ($mediaMode -eq 'managed') { + if (-not (Test-Path -LiteralPath $mediaBinary -PathType Leaf)) { throw 'Managed MediaMTX binary not found. Set SENSE_MEDIAMTX_BINARY to mediamtx.exe.' } + if (-not (Test-Path -LiteralPath $mediaConfig -PathType Leaf)) { throw 'Managed MediaMTX configuration not found. Set SENSE_MEDIAMTX_CONFIG.' } + } + if ($mediaMode -eq 'external' -and -not (Test-SenseTcpEndpoint -HostName $apiUri.Host -Port $apiUri.Port)) { + throw "External MediaMTX Control API is unreachable at $($apiUri.Host):$($apiUri.Port)." + } + + $webRoot = Resolve-SenseConfiguredPath -PackageRoot $PackageRoot -Value (Get-SenseEnvironmentValue -Name 'SENSE_WEB_ROOT' -Default 'web') + if (-not (Test-Path -LiteralPath (Join-Path $webRoot 'index.html') -PathType Leaf)) { throw 'Sense web assets are missing. Rebuild or replace the delivery package.' } + [Environment]::SetEnvironmentVariable('SENSE_WEB_ROOT', $webRoot, 'Process') + [Environment]::SetEnvironmentVariable('SENSE_MEDIAMTX_MODE', $mediaMode, 'Process') + if ($mediaMode -eq 'managed') { + [Environment]::SetEnvironmentVariable('SENSE_MEDIAMTX_BINARY', $mediaBinary, 'Process') + [Environment]::SetEnvironmentVariable('SENSE_MEDIAMTX_CONFIG', $mediaConfig, 'Process') + } else { + [Environment]::SetEnvironmentVariable('SENSE_MEDIAMTX_BINARY', '', 'Process') + [Environment]::SetEnvironmentVariable('SENSE_MEDIAMTX_CONFIG', '', 'Process') + } + [Environment]::SetEnvironmentVariable('SENSE_MEDIAMTX_API', $mediaAPI, 'Process') + + $runtimeDir = Join-Path $PackageRoot 'data\runtime' + $logDir = Join-Path $PackageRoot 'logs' + New-Item -ItemType Directory -Force -Path $runtimeDir, $logDir | Out-Null + $settingsPath = Join-Path $runtimeDir 'settings.yml' + $applicationMode = if ($Mode -eq 'production') { 'prod' } else { 'test' } + $lines = @( + 'settings:', + ' application:', + " mode: $applicationMode", + " host: $(ConvertTo-SenseYamlString $hostName)", + ' name: sense', + " port: $port", + ' readtimeout: 10', + ' writertimeout: 20', + ' enabledp: false', + ' logger:', + " path: $(ConvertTo-SenseYamlString $logDir)", + " stdout: ''", + ' level: info', + ' enableddb: false', + ' jwt:', + " secret: $(ConvertTo-SenseYamlString $jwtSecret)", + ' timeout: 3600', + ' database:', + ' driver: postgres', + " source: $(ConvertTo-SenseYamlString $databaseURL)", + ' gen:', + " dbname: $(ConvertTo-SenseYamlString $database.Database)", + " frontpath: ''", + ' extend:', + ' demo:', + ' name: data', + ' cache:', + " memory: ''", + ' queue:', + ' memory:', + ' poolSize: 100', + ' locker:', + ' redis:' + ) + [IO.File]::WriteAllLines($settingsPath, $lines, (New-Object Text.UTF8Encoding($false))) + return @{ PackageRoot = $PackageRoot; SettingsPath = $settingsPath; Host = $hostName; Port = $port; Database = $database; Mode = $Mode; MediaMode = $mediaMode } +} + +function Get-SensePostgresTool { + param([Parameter(Mandatory = $true)][string]$Name) + $configured = Get-SenseEnvironmentValue -Name 'SENSE_POSTGRES_BIN' + if (-not [string]::IsNullOrWhiteSpace($configured)) { + $candidate = Join-Path $configured "$Name.exe" + if (Test-Path -LiteralPath $candidate -PathType Leaf) { return $candidate } + } + $command = Get-Command "$Name.exe" -ErrorAction SilentlyContinue + if ($command) { return $command.Source } + throw "$Name.exe was not found. Install PostgreSQL client tools or set SENSE_POSTGRES_BIN." +} + +function Invoke-SensePostgresTool { + param( + [Parameter(Mandatory = $true)][string]$Tool, + [Parameter(Mandatory = $true)][hashtable]$Database, + [Parameter(Mandatory = $true)][string[]]$Arguments + ) + $oldPassword = [Environment]::GetEnvironmentVariable('PGPASSWORD', 'Process') + try { + if (-not [string]::IsNullOrEmpty($Database.Password)) { + [Environment]::SetEnvironmentVariable('PGPASSWORD', $Database.Password, 'Process') + } + & $Tool @Arguments + if ($LASTEXITCODE -ne 0) { throw "PostgreSQL tool failed with exit code $LASTEXITCODE." } + } finally { + [Environment]::SetEnvironmentVariable('PGPASSWORD', $oldPassword, 'Process') + } +} diff --git a/Sense/scripts/runtime/start-sense.bat b/Sense/scripts/runtime/start-sense.bat new file mode 100644 index 0000000..766e7a2 --- /dev/null +++ b/Sense/scripts/runtime/start-sense.bat @@ -0,0 +1,3 @@ +@echo off +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\start-sense.ps1" %* +exit /b %errorlevel% diff --git a/Sense/scripts/runtime/start-sense.ps1 b/Sense/scripts/runtime/start-sense.ps1 new file mode 100644 index 0000000..ddc4cd4 --- /dev/null +++ b/Sense/scripts/runtime/start-sense.ps1 @@ -0,0 +1,31 @@ +param( + [ValidateSet('production', 'demo')][string]$Mode = 'production', + [switch]$SkipMigration +) +. (Join-Path $PSScriptRoot 'sense-common.ps1') + +try { + $root = Get-SensePackageRoot + $state = Initialize-SenseRuntime -PackageRoot $root -Mode $Mode + $sense = Join-Path $root 'sense.exe' + if (-not (Test-Path -LiteralPath $sense -PathType Leaf)) { throw "Sense executable not found: $sense" } + $autoMigrate = (Get-SenseEnvironmentValue -Name 'SENSE_AUTO_MIGRATE' -Default 'true').ToLowerInvariant() + Push-Location $root + try { + if (-not $SkipMigration -and $autoMigrate -notin @('false', '0', 'no')) { + Write-Host 'Applying pending Sense database migrations...' + & $sense migrate -c $state.SettingsPath + if ($LASTEXITCODE -ne 0) { throw 'Sense database migration failed. Review the error above and the PostgreSQL connection.' } + } + if ($Mode -eq 'demo') { + Write-Warning 'Sense is running in isolated demo mode. Demo data must not be used as production data.' + } + Write-Host "Starting Sense at http://$($state.Host):$($state.Port)/ ..." + Write-Host 'Press Ctrl+C in this window to stop Sense and its managed MediaMTX process.' + & $sense server -c $state.SettingsPath + exit $LASTEXITCODE + } finally { Pop-Location } +} catch { + Write-Error $_.Exception.Message + exit 1 +} diff --git a/Sense/scripts/runtime/stop-sense.bat b/Sense/scripts/runtime/stop-sense.bat new file mode 100644 index 0000000..b5fa021 --- /dev/null +++ b/Sense/scripts/runtime/stop-sense.bat @@ -0,0 +1,3 @@ +@echo off +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\runtime\stop-sense.ps1" %* +exit /b %errorlevel% diff --git a/Sense/scripts/runtime/stop-sense.ps1 b/Sense/scripts/runtime/stop-sense.ps1 new file mode 100644 index 0000000..0a0c7ab --- /dev/null +++ b/Sense/scripts/runtime/stop-sense.ps1 @@ -0,0 +1,24 @@ +param([ValidateSet('production', 'demo')][string]$Mode = 'production') +. (Join-Path $PSScriptRoot 'sense-common.ps1') + +try { + $root = Get-SensePackageRoot + $configName = if ($Mode -eq 'demo') { 'sense.demo.env' } else { 'sense.env' } + $config = Join-Path $root "config\$configName" + Import-SenseEnvironment -Path $config + $port = [int](Get-SenseEnvironmentValue -Name 'SENSE_PORT' -Default '18080') + $connection = Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $connection) { Write-Host "Sense is not listening on port $port."; exit 0 } + $process = Get-CimInstance Win32_Process -Filter "ProcessId = $($connection.OwningProcess)" + $expected = [IO.Path]::GetFullPath((Join-Path $root 'sense.exe')) + if (-not $process -or [IO.Path]::GetFullPath($process.ExecutablePath) -ne $expected) { + throw "Port $port belongs to another process; it was not stopped." + } + & taskkill.exe /PID $process.ProcessId /T /F | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'Failed to stop the Sense process tree.' } + Write-Host 'Sense and its managed child processes were stopped.' + exit 0 +} catch { + Write-Error $_.Exception.Message + exit 1 +} diff --git a/Sense/server/app/sense/media/runtime.go b/Sense/server/app/sense/media/runtime.go index defbf45..e34fc14 100644 --- a/Sense/server/app/sense/media/runtime.go +++ b/Sense/server/app/sense/media/runtime.go @@ -3,6 +3,8 @@ package media import ( "context" "errors" + "os" + "strings" "sync" "time" @@ -16,6 +18,10 @@ var runtimeState struct { } func StartRuntime(parent context.Context, db *gorm.DB) error { + mode := strings.ToLower(strings.TrimSpace(os.Getenv("SENSE_MEDIAMTX_MODE"))) + if mode == "disabled" { + return nil + } if db == nil { return errors.New("Sense database is unavailable for MediaMTX runtime") } @@ -29,6 +35,12 @@ func StartRuntime(parent context.Context, db *gorm.DB) error { } service := NewService(db, controller, NewSupervisor(config.Binary, config.ConfigPath), config) ctx, cancel := context.WithCancel(parent) + if mode == "managed" || mode == "external" { + if err = service.ensureControl(ctx); err != nil { + cancel() + return err + } + } runtimeState.Lock() if runtimeState.cancel != nil { runtimeState.cancel() diff --git a/Sense/server/app/sense/media/runtime_test.go b/Sense/server/app/sense/media/runtime_test.go new file mode 100644 index 0000000..a608577 --- /dev/null +++ b/Sense/server/app/sense/media/runtime_test.go @@ -0,0 +1,22 @@ +package media + +import ( + "context" + "strings" + "testing" +) + +func TestStartRuntimeAllowsExplicitDemoDisable(t *testing.T) { + t.Setenv("SENSE_MEDIAMTX_MODE", "disabled") + if err := StartRuntime(context.Background(), nil); err != nil { + t.Fatalf("disabled demo runtime must not require MediaMTX or a database: %v", err) + } +} + +func TestStartRuntimeManagedModeRequiresDatabase(t *testing.T) { + t.Setenv("SENSE_MEDIAMTX_MODE", "managed") + err := StartRuntime(context.Background(), nil) + if err == nil || !strings.Contains(err.Error(), "database") { + t.Fatalf("managed runtime must fail before HTTP startup without a database: %v", err) + } +} diff --git a/Sense/server/cmd/api/server.go b/Sense/server/cmd/api/server.go index 1f0af69..bc39b37 100644 --- a/Sense/server/cmd/api/server.go +++ b/Sense/server/cmd/api/server.go @@ -92,7 +92,7 @@ func run() error { for _, db := range sdk.Runtime.GetDb() { runtimeDBFound = true if err := media.StartRuntime(runtimeCtx, db); err != nil { - log.Errorf("MediaMTX runtime unavailable: %v", err) + return fmt.Errorf("MediaMTX runtime unavailable: %w", err) } break } @@ -196,5 +196,6 @@ func initRouter() { Use(api.SetRequestLogger) common.InitMiddleware(r) + configureWebUI(r) } diff --git a/Sense/server/cmd/api/web.go b/Sense/server/cmd/api/web.go new file mode 100644 index 0000000..70d15a2 --- /dev/null +++ b/Sense/server/cmd/api/web.go @@ -0,0 +1,70 @@ +package api + +import ( + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/gin-gonic/gin" +) + +// configureWebUI adds an optional SPA fallback to the existing GoAdmin Gin +// engine. API and framework routes keep their normal handlers; the fallback is +// enabled only for Windows delivery packages that set SENSE_WEB_ROOT. +func configureWebUI(r *gin.Engine) { + root := strings.TrimSpace(os.Getenv("SENSE_WEB_ROOT")) + if root == "" { + return + } + absRoot, err := filepath.Abs(root) + if err != nil { + return + } + index := filepath.Join(absRoot, "index.html") + if info, statErr := os.Stat(index); statErr != nil || info.IsDir() { + return + } + + r.NoRoute(func(c *gin.Context) { + if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead { + c.Status(http.StatusNotFound) + return + } + if isBackendPath(c.Request.URL.Path) { + c.Status(http.StatusNotFound) + return + } + + requested := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(c.Request.URL.Path, "/"))) + if requested == "." { + requested = "" + } + candidate := filepath.Join(absRoot, requested) + if withinRoot(absRoot, candidate) { + if info, statErr := os.Stat(candidate); statErr == nil && !info.IsDir() { + c.File(candidate) + return + } + } + if filepath.Ext(requested) != "" { + c.Status(http.StatusNotFound) + return + } + c.File(index) + }) +} + +func withinRoot(root, candidate string) bool { + rel, err := filepath.Rel(root, candidate) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +func isBackendPath(path string) bool { + for _, prefix := range []string{"/api/", "/swagger/", "/static/", "/form-generator/"} { + if strings.HasPrefix(path, prefix) { + return true + } + } + return false +} diff --git a/Sense/server/cmd/api/web_test.go b/Sense/server/cmd/api/web_test.go new file mode 100644 index 0000000..437fad7 --- /dev/null +++ b/Sense/server/cmd/api/web_test.go @@ -0,0 +1,54 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestConfigureWebUIServesAssetsAndSPAFallback(t *testing.T) { + gin.SetMode(gin.TestMode) + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "index.html"), []byte("sense-index"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(root, "js"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "js", "app.js"), []byte("sense-app"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("SENSE_WEB_ROOT", root) + r := gin.New() + configureWebUI(r) + + for _, tc := range []struct { + path string + code int + body string + }{ + {path: "/", code: http.StatusOK, body: "sense-index"}, + {path: "/device/list", code: http.StatusOK, body: "sense-index"}, + {path: "/js/app.js", code: http.StatusOK, body: "sense-app"}, + {path: "/js/missing.js", code: http.StatusNotFound}, + {path: "/api/v1/missing", code: http.StatusNotFound}, + } { + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + res := httptest.NewRecorder() + r.ServeHTTP(res, req) + if res.Code != tc.code || (tc.body != "" && res.Body.String() != tc.body) { + t.Fatalf("%s: got %d %q", tc.path, res.Code, res.Body.String()) + } + } +} + +func TestWithinRootRejectsTraversal(t *testing.T) { + root := t.TempDir() + if withinRoot(root, filepath.Join(root, "..", "secret.txt")) { + t.Fatal("path traversal must be rejected") + } +} diff --git a/Sense/tests/package/run-tests.bat b/Sense/tests/package/run-tests.bat new file mode 100644 index 0000000..073b799 --- /dev/null +++ b/Sense/tests/package/run-tests.bat @@ -0,0 +1,3 @@ +@echo off +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0run-tests.ps1" +exit /b %errorlevel% diff --git a/Sense/tests/package/run-tests.ps1 b/Sense/tests/package/run-tests.ps1 new file mode 100644 index 0000000..3b2c269 --- /dev/null +++ b/Sense/tests/package/run-tests.ps1 @@ -0,0 +1,97 @@ +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' + +$senseRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..')) +. (Join-Path $senseRoot 'scripts\runtime\sense-common.ps1') +$script:passed = 0 + +function Assert-True([bool]$Condition, [string]$Message) { + if (-not $Condition) { throw "ASSERT FAILED: $Message" } + $script:passed++ +} +function Assert-Equal($Expected, $Actual, [string]$Message) { + if ($Expected -cne $Actual) { throw "ASSERT FAILED: $Message; expected [$Expected], got [$Actual]" } + $script:passed++ +} +function Assert-Throws([scriptblock]$Action, [string]$Pattern, [string]$Message) { + try { & $Action; throw "ASSERT FAILED: $Message; no error was raised" } catch { + if ($_.Exception.Message -notmatch $Pattern) { throw "ASSERT FAILED: $Message; unexpected error: $($_.Exception.Message)" } + } + $script:passed++ +} + +$temporary = Join-Path ([IO.Path]::GetTempPath()) ("sense-package-tests-" + [guid]::NewGuid().ToString('N')) +$listener = $null +$oldValues = @{} +foreach ($name in $script:SenseAllowedEnvironment) { + $oldValues[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') + [Environment]::SetEnvironmentVariable($name, $null, 'Process') +} +try { + New-Item -ItemType Directory -Path (Join-Path $temporary 'config'), (Join-Path $temporary 'web'), (Join-Path $temporary 'web\js'), (Join-Path $temporary 'bin') | Out-Null + $webIndex = '
' + [IO.File]::WriteAllText((Join-Path $temporary 'web\index.html'), $webIndex, (New-Object Text.UTF8Encoding($false))) + [IO.File]::WriteAllText((Join-Path $temporary 'web\js\runtime.fixture.js'), 'fixture', (New-Object Text.UTF8Encoding($false))) + [IO.File]::WriteAllText((Join-Path $temporary 'bin\mediamtx.exe'), 'fixture', (New-Object Text.UTF8Encoding($false))) + [IO.File]::WriteAllText((Join-Path $temporary 'config\mediamtx.yml'), 'api: true', (New-Object Text.UTF8Encoding($false))) + + $webAssetAudit = Join-Path $senseRoot 'scripts\build\assert-web-assets.ps1' + & $webAssetAudit -WebRoot (Join-Path $temporary 'web') + Assert-True $true 'web asset audit must accept existing local references' + Remove-Item -LiteralPath (Join-Path $temporary 'web\js\runtime.fixture.js') + Assert-Throws { & $webAssetAudit -WebRoot (Join-Path $temporary 'web') } 'missing local asset' 'web asset audit must reject missing runtime files' + [IO.File]::WriteAllText((Join-Path $temporary 'web\js\runtime.fixture.js'), 'fixture', (New-Object Text.UTF8Encoding($false))) + + $listener = New-Object Net.Sockets.TcpListener([Net.IPAddress]::Loopback, 0) + $listener.Start() + $dbPort = ([Net.IPEndPoint]$listener.LocalEndpoint).Port + $marker = Join-Path $temporary 'must-not-exist.txt' + $envText = @( + 'SENSE_HOST=127.0.0.1', + 'SENSE_PORT=18070', + "SENSE_DATABASE_URL=host=127.0.0.1 port=$dbPort user=sense password=p#&;=x dbname=sense sslmode=disable", + "SENSE_JWT_SECRET=`$(Set-Content -LiteralPath '$marker' hacked)-literal-secret-1234567890", + 'SENSE_MEDIAMTX_MODE=managed', + 'SENSE_MEDIAMTX_BINARY=bin\mediamtx.exe', + 'SENSE_MEDIAMTX_CONFIG=config\mediamtx.yml', + 'SENSE_MEDIAMTX_API=http://127.0.0.1:9997', + 'SENSE_WEB_ROOT=web' + ) -join "`n" + [IO.File]::WriteAllText((Join-Path $temporary 'config\sense.env'), $envText, (New-Object Text.UTF8Encoding($false))) + [IO.File]::WriteAllText((Join-Path $temporary 'config\sense.demo.env'), $envText.Replace('SENSE_DATABASE_URL=', 'SENSE_DEMO_DATABASE_URL=').Replace('dbname=sense ', 'dbname=sense_demo '), (New-Object Text.UTF8Encoding($false))) + + [Environment]::SetEnvironmentVariable('SENSE_PORT', '18071', 'Process') + $state = Initialize-SenseRuntime -PackageRoot $temporary -Mode production + Assert-Equal 18071 $state.Port 'non-empty process environment must override sense.env' + Assert-True (-not (Test-Path -LiteralPath $marker)) 'sense.env content must never execute' + $generatedSettings = Get-Content -LiteralPath $state.SettingsPath -Raw + Assert-True ((Get-SenseEnvironmentValue -Name 'SENSE_DATABASE_URL').Contains('p#&;=x')) 'special characters must survive env parsing' + Assert-True ($generatedSettings.Contains('password=p#\u0026;=x')) 'special characters must be safely JSON-escaped in YAML' + Assert-True (-not ((Get-SenseDatabaseInfo (Get-SenseEnvironmentValue -Name 'SENSE_DATABASE_URL')).Sanitized.Contains('password='))) 'PostgreSQL tool arguments must not contain password' + + [Environment]::SetEnvironmentVariable('SENSE_PORT', $null, 'Process') + foreach ($name in @('SENSE_DATABASE_URL','SENSE_JWT_SECRET','SENSE_MEDIAMTX_MODE','SENSE_MEDIAMTX_BINARY','SENSE_MEDIAMTX_CONFIG','SENSE_MEDIAMTX_API','SENSE_WEB_ROOT')) { [Environment]::SetEnvironmentVariable($name, $null, 'Process') } + $demo = Initialize-SenseRuntime -PackageRoot $temporary -Mode demo + Assert-Equal 'sense_demo' $demo.Database.Database 'demo must use its dedicated database variable' + + $bad = Join-Path $temporary 'config\bad.env' + [IO.File]::WriteAllText($bad, 'SENSE_UNKNOWN=value', (New-Object Text.UTF8Encoding($false))) + Assert-Throws { Import-SenseEnvironment -Path $bad } 'Unsupported Sense configuration key' 'unknown keys must be rejected' + [IO.File]::WriteAllText((Join-Path $temporary 'config\sense.demo.env'), $envText.Replace('SENSE_DATABASE_URL=', 'SENSE_DEMO_DATABASE_URL='), (New-Object Text.UTF8Encoding($false))) + foreach ($name in $script:SenseAllowedEnvironment) { [Environment]::SetEnvironmentVariable($name, $null, 'Process') } + Assert-Throws { Initialize-SenseRuntime -PackageRoot $temporary -Mode demo } 'database name containing demo or test' 'demo must reject production database names' + + foreach ($file in Get-ChildItem -LiteralPath (Join-Path $senseRoot 'scripts') -Recurse -Filter '*.ps1') { + [void][scriptblock]::Create((Get-Content -LiteralPath $file.FullName -Raw)) + $script:passed++ + } + Write-Host "Sense package tests passed: $script:passed assertions." +} finally { + if ($listener) { $listener.Stop() } + foreach ($name in $script:SenseAllowedEnvironment) { [Environment]::SetEnvironmentVariable($name, $oldValues[$name], 'Process') } + if (Test-Path -LiteralPath $temporary) { + $resolved = [IO.Path]::GetFullPath($temporary) + if (-not $resolved.StartsWith([IO.Path]::GetTempPath(), [StringComparison]::OrdinalIgnoreCase)) { throw "Unsafe temporary test path: $resolved" } + Remove-Item -LiteralPath $resolved -Recurse -Force + } +} diff --git a/Sense/tests/package/smoke-start-stop.ps1 b/Sense/tests/package/smoke-start-stop.ps1 new file mode 100644 index 0000000..b177554 --- /dev/null +++ b/Sense/tests/package/smoke-start-stop.ps1 @@ -0,0 +1,32 @@ +param([Parameter(Mandatory = $true)][string]$PackageRoot) +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' + +$root = [IO.Path]::GetFullPath($PackageRoot) +$start = Join-Path $root 'start-sense.bat' +$stop = Join-Path $root 'stop-sense.bat' +$port = [int]$env:SENSE_PORT +$launcher = Start-Process -FilePath 'cmd.exe' -ArgumentList @('/d', '/c', "`"$start`" -SkipMigration") -WorkingDirectory (Split-Path $root -Parent) -WindowStyle Hidden -PassThru +try { + $ready = $false + for ($attempt = 0; $attempt -lt 60; $attempt++) { + $client = New-Object Net.Sockets.TcpClient + try { + $task = $client.ConnectAsync('127.0.0.1', $port) + if ($task.Wait(500) -and $client.Connected) { $ready = $true; break } + } catch {} finally { $client.Dispose() } + Start-Sleep -Milliseconds 500 + } + if (-not $ready) { throw 'start-sense.bat did not open the configured HTTP port.' } + & $stop + if ($LASTEXITCODE -ne 0) { throw 'stop-sense.bat failed.' } + Start-Sleep -Seconds 1 + $probe = New-Object Net.Sockets.TcpClient + try { + $task = $probe.ConnectAsync('127.0.0.1', $port) + if ($task.Wait(500) -and $probe.Connected) { throw 'Sense port is still open after stop-sense.bat.' } + } catch [Net.Sockets.SocketException] {} finally { $probe.Dispose() } + Write-Host 'Sense start/stop wrapper smoke passed from an external working directory.' +} finally { + if (-not $launcher.HasExited) { & taskkill.exe /PID $launcher.Id /T /F | Out-Null } +} diff --git a/Sense/tests/package/smoke-windows-package.ps1 b/Sense/tests/package/smoke-windows-package.ps1 new file mode 100644 index 0000000..c2e0b1f --- /dev/null +++ b/Sense/tests/package/smoke-windows-package.ps1 @@ -0,0 +1,53 @@ +param([Parameter(Mandatory = $true)][string]$PackageRoot) +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' + +$root = [IO.Path]::GetFullPath($PackageRoot) +$port = [int]$env:SENSE_PORT +$mediaUri = [Uri]$env:SENSE_MEDIAMTX_API +$stdout = Join-Path ([IO.Path]::GetTempPath()) ("sense-smoke-$PID.out") +$stderr = Join-Path ([IO.Path]::GetTempPath()) ("sense-smoke-$PID.err") +$server = $null +$succeeded = $false +try { + & (Join-Path $root 'migrate-sense.bat') + if ($LASTEXITCODE -ne 0) { throw 'Package migration entry failed.' } + if (-not [IO.Path]::IsPathRooted($env:SENSE_MEDIAMTX_BINARY)) { $env:SENSE_MEDIAMTX_BINARY = [IO.Path]::GetFullPath((Join-Path $root $env:SENSE_MEDIAMTX_BINARY)) } + if (-not [IO.Path]::IsPathRooted($env:SENSE_MEDIAMTX_CONFIG)) { $env:SENSE_MEDIAMTX_CONFIG = [IO.Path]::GetFullPath((Join-Path $root $env:SENSE_MEDIAMTX_CONFIG)) } + if (-not [IO.Path]::IsPathRooted($env:SENSE_WEB_ROOT)) { $env:SENSE_WEB_ROOT = [IO.Path]::GetFullPath((Join-Path $root $env:SENSE_WEB_ROOT)) } + $settings = Join-Path $root 'data\runtime\settings.yml' + $server = Start-Process -FilePath (Join-Path $root 'sense.exe') -ArgumentList 'server', '-c', $settings -WorkingDirectory $root -RedirectStandardOutput $stdout -RedirectStandardError $stderr -WindowStyle Hidden -PassThru + $response = $null + for ($attempt = 0; $attempt -lt 80; $attempt++) { + $server.Refresh() + if ($server.HasExited) { + Get-Content -LiteralPath $stdout -Tail 120 -ErrorAction SilentlyContinue | Out-Host + Get-Content -LiteralPath $stderr -Tail 120 -ErrorAction SilentlyContinue | Out-Host + throw "Sense exited before HTTP readiness with code $($server.ExitCode)." + } + try { + $response = Invoke-WebRequest -UseBasicParsing -Uri "http://127.0.0.1:$port/" -TimeoutSec 1 + if ($response.StatusCode -eq 200 -and $response.Content.Contains('')) { break } + } catch {} + Start-Sleep -Milliseconds 500 + } + if (-not $response -or $response.StatusCode -ne 200 -or -not $response.Content.Contains('<title>')) { + throw 'Sense package did not serve the GoAdmin UI before the smoke timeout.' + } + $media = $null + for ($attempt = 0; $attempt -lt 20; $attempt++) { + try { + $media = Invoke-RestMethod -Uri "http://$($mediaUri.Host):$($mediaUri.Port)/v3/config/global/get" -TimeoutSec 1 + if ($null -ne $media) { break } + } catch {} + Start-Sleep -Milliseconds 500 + } + if ($null -eq $media) { throw 'MediaMTX Control API returned no data.' } + Write-Host "Sense package smoke passed: web=200, SPA=true, MediaMTX=true, port=$port." + $succeeded = $true +} finally { + if ($server -and -not $server.HasExited) { + & taskkill.exe /PID $server.Id /T /F | Out-Null + } + Remove-Item -LiteralPath $stdout, $stderr -Force -ErrorAction SilentlyContinue +} diff --git a/Sense/ui/vue.config.js b/Sense/ui/vue.config.js index 6589e04..aa01667 100644 --- a/Sense/ui/vue.config.js +++ b/Sense/ui/vue.config.js @@ -107,14 +107,6 @@ module.exports = { config .when(process.env.NODE_ENV !== 'development', config => { - config - .plugin('ScriptExtHtmlWebpackPlugin') - .after('html') - .use('script-ext-html-webpack-plugin', [{ - // `runtime` must same as runtimeChunk name. default is `runtime` - inline: /runtime\..*\.js$/ - }]) - .end() config .optimization.splitChunks({ chunks: 'all', @@ -145,6 +137,13 @@ module.exports = { }, css: { loaderOptions: { + css: { + // Preserve GoAdmin's :export variables as JavaScript values with css-loader 6. + // ICSS mode does not rename ordinary global or component class selectors. + modules: { + mode: 'icss' + } + }, less: { modifyVars: { // less vars,customize ant design theme diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 5d659c3..5013571 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Architecture-and-Code-Map.- -wiki_revision: 0ba2909431bd04320a9b31121126b59b1824e3dc -synchronized_at: 2026-08-15T01:13:02Z +wiki_revision: a95b7692bc0c6bf4d05f8c57088a83379e15a08f +synchronized_at: 2026-08-15T03:01:57Z <!-- gitea-wiki-mirror:end --> # 架构与代码地图 @@ -130,3 +130,13 @@ ONVIF 支持 Basic 与 MD5/SHA-256 Digest challenge,Profile 与无凭据 Strea 认证 API 为 `/api/v1/area/configurations` 及其版本子资源,接入 GoAdmin JWT、Casbin、动态菜单和操作权限。API 只返回设备/Profile 展示字段、规格、归一化坐标和版本信息,不返回 RTSP URI、摄像头凭据或 MediaMTX 内部路径。 <!-- sense-area:end --> + +<!-- sense-windows-delivery:start --> +## Sense Windows 交付运行链 + +工单 #70 在 GoAdmin 派生入口上建立 Windows amd64 交付链。构建入口为 `Sense/scripts/build/build-windows.ps1`;运行包入口为 `start-sense.bat`,它先调用现有 `sense.exe migrate -c data\runtime\settings.yml`,迁移成功后再调用 `sense.exe server -c ...`。前端生产构建复制到包内 `web/`,后端只在显式配置 `SENSE_WEB_ROOT` 时提供同源静态资源和 SPA fallback,API 与健康检查不会被 fallback 覆盖。 + +运行脚本将 `config\sense.env` 当作数据解析,只接受白名单 `SENSE_*` 字段,不执行文件内容;同名非空进程环境变量优先。生成的 `data\runtime\settings.yml` 含运行秘密,只能留在部署目录。production 固定使用 PostgreSQL,要求至少 32 字符 JWT secret,且 MediaMTX 只能为 `managed` 或 `external`;`managed` 在 HTTP 启动前拉起包内二进制,`external` 在 HTTP 启动前确认回环 Control API 可达。Demo 使用独立配置和名称含 demo/test 的隔离数据库,默认禁用 MediaMTX,绝不回退 production 数据库。 + +交付包同时提供检查、迁移、管理员初始化、停止、备份和恢复入口。停止脚本只操作当前包且监听配置端口的 Sense 进程树;备份密码只进入子进程环境;恢复要求数据库名和二次短语确认。包不包含 PostgreSQL、生产数据、默认管理员、默认密码或客户秘密。 +<!-- sense-windows-delivery:end --> diff --git a/docs/delivery/README.md b/docs/delivery/README.md index 443efcf..e8ec8cf 100644 --- a/docs/delivery/README.md +++ b/docs/delivery/README.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Delivery-Documentation-Guide wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Delivery-Documentation-Guide.- -wiki_revision: 45c86f2a0e4d3252e8042df5ee725e633dd497c1 -synchronized_at: 2026-08-14T09:47:31Z +wiki_revision: 3d95c9d392e81cf59d2af1f6f21d8e67f580b68f +synchronized_at: 2026-08-15T03:02:47Z <!-- gitea-wiki-mirror:end --> # 交付文档指南 @@ -97,12 +97,16 @@ Sense 面向网管、实施人员和非技术现场人员,菜单按日常任 <!-- sense-windows-package:start --> ## Sense Windows 运行包交付 -交付对象为实施和运维人员。`sense-windows-amd64.zip` 包含后端程序、已构建前端、空值示例配置、上游许可证、启动脚本与包内说明;不包含 PostgreSQL、MediaMTX、Windows 服务、生产数据或秘密。 +交付对象为实施和运维人员。以 `sense-windows-amd64.zip` 交付后端程序、已构建前端、空值示例配置、迁移基线、许可证、启动/检查/停止/备份/恢复脚本与包内说明;不包含 PostgreSQL、生产数据、默认管理员、默认密码或客户秘密。可按交付决定是否包含已审核的 `bin\mediamtx.exe`。 -- 临时查看必须显式运行 `start-sense.bat demo`,其内存数据在进程结束后丢失,不能当作生产部署。 -- 生产配置可由运维写入解压目录的 `config\sense.env`,或通过 Windows 进程环境安全注入;进程环境优先。先运行 `start-sense.bat check` 检查必填项,再运行 `start-sense.bat`。 -- 交付时记录 ZIP SHA-256,并至少验证 `/healthz` 与首页;真实 PostgreSQL、MediaMTX、摄像机和目标浏览器仍需在获准环境验收。 -- 包内 `README-WINDOWS.md` 是现场操作入口;真实 `config\sense.env` 只留在具体部署目录,不得提交 Git 或重新打入交付 ZIP,交付 ZIP 只保留 `sense.env.example`。 +- production 先复制 `config\sense.env.example` 为 `config\sense.env`,填写 PostgreSQL、至少 32 字符 JWT secret、凭据密钥、获准 ONVIF 网络和 MediaMTX 模式。进程环境中的同名非空值优先,脚本不执行 env 文件内容,也不打印秘密。 +- 先运行 `check-sense.bat`,再运行 `start-sense.bat`。启动会先迁移,失败时不会开放 HTTP;`managed` MediaMTX 在 Sense HTTP 前启动,`external` 必须已有可达的回环 Control API,production 禁止 `disabled`。 +- 首位管理员通过至少 32 字符的一次性 bootstrap token 和 `initialize-admin.bat -Username admin` 创建,密码由隐藏提示输入;成功后立即清空 token 并重启。仓库与交付包均无默认账号密码。 +- 临时演示必须显式运行 `start-sense.bat demo`,使用独立 `config\sense.demo.env` 和名称含 demo/test 的数据库;不会回退 production 数据库,也不能作为生产部署。 +- 日常停止优先在启动窗口按 Ctrl+C;窗口丢失或进程无响应时使用 `stop-sense.bat`。脚本会验证端口与可执行文件归属,拒绝停止其他程序。 +- 备份使用 `backup-sense.bat` 生成 PostgreSQL custom-format 文件。恢复前停止服务并再次备份,执行 `restore-sense.bat` 时需确认目标数据库名及 `RESTORE-数据库名`,随后重新迁移。 +- 交付时保存 ZIP 和 `MANIFEST.sha256` 的 SHA-256,至少验证首页、`/healthz`、数据库迁移、MediaMTX API、启动/停止和备份/恢复。真实摄像机、目标浏览器与客户数据库账号仍在授权现场验证。 +- 包内 `README-WINDOWS.md` 是现场事实入口;真实 `config\sense.env`、运行生成的 `data\runtime\settings.yml`、日志和备份不得提交 Git 或重新打入 ZIP。 <!-- sense-windows-package:end --> <!-- sense-device-ledger:start --> diff --git a/docs/task/70-Sense-Windows配置启动与打包交付.md b/docs/task/70-Sense-Windows配置启动与打包交付.md new file mode 100644 index 0000000..562b99b --- /dev/null +++ b/docs/task/70-Sense-Windows配置启动与打包交付.md @@ -0,0 +1,110 @@ +<!-- gitea-wiki-mirror:start --> +generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) +wiki_page: Task-70-Sense-Windows配置启动与打包交付 +wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-70-Sense-Windows%E9%85%8D%E7%BD%AE%E5%90%AF%E5%8A%A8%E4%B8%8E%E6%89%93%E5%8C%85%E4%BA%A4%E4%BB%98.- +wiki_revision: 9eb390dfd691acc089656a838dd3f12e24f97884 +synchronized_at: 2026-08-16T11:33:28Z +<!-- gitea-wiki-mirror:end --> + +# 70 Sense Windows配置启动与打包交付 + +- 类型:需求 +- 所属 Epic:#7 +- 所属 MVP / 版本:#8 +- 状态:已完成 +- 日期:2026-08-15 +- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/70 +- Wiki 页面:Task-70-Sense-Windows配置启动与打包交付 +- Wiki revision:见本地镜像头 + +## 背景与目标 + +在 #61–#69 完成冻结 GoAdmin 基线及 Sense 独立业务纵切后,重建 Windows amd64 前后端单包交付能力。交付必须继续使用 GoAdmin Cobra 的迁移与服务入口,并覆盖包内配置、PostgreSQL、MediaMTX、管理员初始化、停止、备份和恢复;不得回用 explore 中的自研运行框架,也不得包含默认密码或客户秘密。 + +## 最终方案 + +- `Sense/scripts/build/build-windows.ps1` 严格检查 Go 1.26.5、Node 22.22.1、pnpm 9.15.1,执行前后端生产构建、许可证和迁移基线复制、内容审计并生成目录与 ZIP。 +- `Sense/scripts/runtime` 提供白名单 env 解析、check/start/stop/migrate/bootstrap/backup/restore。配置文件只作为数据读取,同名非空进程环境优先,日志不打印秘密。 +- production 只接受 PostgreSQL,要求至少 32 字符 JWT secret,MediaMTX 使用 managed 或 external;启动前完成迁移与媒体服务就绪检查,失败不开放 HTTP。Demo 使用独立配置和名称含 demo/test 的数据库。 +- 继续调用现有 `sense.exe migrate -c ...` 与 `sense.exe server -c ...`。在现有 Gin Engine 上增加可选同源 SPA fallback;未配置 `SENSE_WEB_ROOT` 时保持上游行为。MediaMTX 显式模式增加启动前就绪门禁,未配置模式保留既有惰性行为。 +- 管理员初始化无默认账户密码;密码经隐藏提示输入。停止脚本验证端口与可执行文件归属;备份密码只通过子进程环境;恢复要求目标库名称和二次短语确认。 +- 空白 PostgreSQL 17 已成功执行完整迁移,未复现 #67 曾记录的旧 `sys_config` 字段长度问题,因此未修改上游迁移。 + +## 修改文件 + +- `Sense/scripts/build/**`、`Sense/tests/package/**`:固定工具链构建、包审计与配置/失败路径自动化。 +- `Sense/scripts/runtime/**`:Windows 配置、检查、迁移、启动停止、初始化、备份恢复入口。 +- `Sense/config/**`、`Sense/package/**`、`Sense/README-WINDOWS.md`:空值示例、MediaMTX 基线和现场说明。 +- `Sense/server/cmd/api/server.go`、`web.go`、`web_test.go`:现有 GoAdmin Gin 服务的可选 SPA 托管。 +- `Sense/server/app/sense/media/runtime.go`、`runtime_test.go`:显式 MediaMTX 模式的启动前就绪门禁。 +- `Sense/.gitignore`:忽略可重建交付产物并允许版本化构建脚本。 +- Wiki `Architecture-and-Code-Map`、`Local-Development-and-Verification`、`Delivery-Documentation-Guide` 及对应 `docs/` 镜像:运行链、构建验证和现场交付说明。 + +## 验收结果 + +| 验收标准 | 结果 | +|---|---| +| 干净环境按单一命令生成 Windows amd64 交付包 | 通过;固定工具链构建目录和 ZIP | +| 包不包含 node_modules、构建缓存、真实秘密或客户数据 | 通过;包审计及源码残留检查通过 | +| start 读取 config/sense.env,进程环境优先且不执行内容 | 通过;自动化覆盖优先级、特殊字符和注入非执行 | +| production 检查 PostgreSQL、迁移、端口和 MediaMTX | 通过;隔离 PostgreSQL 17 与 MediaMTX 包 smoke 通过 | +| demo 与 production 明确隔离 | 通过;独立配置且数据库名必须含 demo/test | +| 提供安全初始化、密码修改、停止、备份和恢复步骤 | 通过;脚本、包内说明与长期 Wiki 已更新 | + +最终 ZIP:`Sense/dist/sense-windows-amd64.zip`,大小 56,455,903 字节,SHA-256 `B500982BD566005DC8876A418C26A75BCABE41F498D10E3DAD286A71C92F0241`。包内 `VERSION.txt` 记录实现提交 `b66c39724c42b9e63891d06a41fb4a87c8c3f6c7`,包含 #92、#95 的旧库兼容修复、白屏修复及已验收 #97 的免验证码登录。 + +## 测试 + +- `go test ./...`、`go vet ./...`、`go build ./...`:通过。 +- `go test -race ./app/sense/media ./cmd/api`:通过。 +- PowerShell 包测试:21 项断言通过,覆盖配置注入不执行、特殊字符、环境优先级、demo 数据库隔离、不支持字段拒绝、HTML 本地资源正反例及全部脚本语法。 +- `Sense/scripts/build/build-windows.ps1 -MediaMTXPath <已审核本机路径>`:通过;前端剩余 4 条非阻塞构建 warning;导致启动失败的 runtime 与 SCSS 导出 warning 已消除。 +- `Sense/scripts/build/test-package.ps1 -PackageRoot Sense/dist/sense-windows-amd64`:通过。 +- 隔离 PostgreSQL 17:空库 8 个迁移通过;首页、SPA fallback、`/healthz`、MediaMTX Control API、包外目录启动停止通过;119,630 字节 custom-format 备份及恢复到另一数据库通过。 +- `git diff --check`:通过。 +- `python dev_scripts/check_harness.py --strict`:未通过,原因仅为既存 `docs/task/66`、`docs/task/67` 缺少当前模板要求的“修改文件/未验证”章节;#70 未修改这两个既有归档,也未发现 #70 新增问题。 +- **未验证部分**:尚未在全新客户 Windows 机器、客户生产 PostgreSQL 账号、目标浏览器和真实获准摄像机上验收;Windows 服务化不在本工单范围。 + +## 旧库交付回归 + +- 真实迁移前已生成仓库外 PostgreSQL custom-format 备份与配置副本,备份通过 `pg_restore --list` 校验。 +- #92 成功把旧设备 `capabilities` 转为 JSONB;#95 继续兼容旧媒体路由 `path` 唯一约束和缺失运行态列。 +- 真实库 2 条媒体路由完整保留,运行态列无空值,`idx_sense_media_routes_path` 与设备/Profile 组合唯一索引均有效,媒体迁移版本已登记。 +- Web 首页、SPA、`/healthz`、MediaMTX Control API 均返回 200;停止脚本成功且 Sense/MediaMTX 监听端口全部清空。 +- PowerShell `Invoke-WebRequest` 在本机受代理环境影响而无法访问 loopback;使用明确绕过代理的本机 HTTP 客户端确认服务正常,该现象不属于 Sense 服务失败。 + + +## 白屏验收反馈修复 + +- 用户运行发布包后访问生产入口出现白屏。只读诊断确认首页 HTML 返回 200,但现代浏览器请求的 `runtime.daef9028.js` 不在包内并返回 404;修复 runtime 内联配置后,浏览器继续暴露 GoAdmin `:export` 主题变量在 css-loader 6 下没有 JavaScript 导出的启动错误。 +- 删除不可靠的 runtime 内联插件配置,使现代与 legacy runtime 都作为独立文件进入产物;为 css-loader 启用不改写普通类名的 ICSS mode,保留 GoAdmin 原有 SCSS `:export` 变量模式。 +- 新增 `assert-web-assets.ps1`,构建阶段逐项核对 `index.html` 引用的本地 JS/CSS;缺失 runtime 的反例会直接使包构建失败。 +- Chromium 最终打开 `http://127.0.0.1:18080/` 并进入账号登录页;首屏 7 个 JS/CSS 全部返回 200,白屏和阻止 Vue 挂载的错误消失。测试完成后停止 Sense 与 MediaMTX,18080 无监听。 +- 浏览器仍观察到不阻塞首屏的既有 `/api/v1/app-config` 404 和上游默认百度统计请求;不属于本次白屏修复范围,未混入当前提交。 + +## 遗留问题 + +- Harness 严格检查的 #66/#67 既有归档格式问题需独立处理,不阻塞 #70 产品代码和交付包验证。 +- 客户环境验收需由实施人员使用脱敏测试账户和获准设备完成。 + +## 相关提交 + +- `6b79478` 建立 Sense Windows 交付包。 +- `e4544a0` 记录 Sense Windows 交付流程。 +- `4ca4abf` 修复 Windows 包白屏并增加静态资源闭环审计。 + + +## #97 集成与最终重打包(2026-08-16) + +- 将 `dev@116318df748ff0d46d3fe5f8a4f41a6507567eec` 合入 #70 分支,发布包现已包含 #97 的账号密码直接登录;登录页和登录载荷不再包含验证码字段或请求,兼容 captcha API 保留。 +- Windows PowerShell 构建在生成清单时暴露 `Get-FileHash` 模块自动加载依赖;改用 .NET `SHA256` 流式计算,避免客户构建环境因模块加载差异失败。实现提交:`b66c39724c42b9e63891d06a41fb4a87c8c3f6c7`。 +- 固定工具链构建通过;21 项包测试、Go 全量 test/vet、65 个 ZIP 清单文件逐项哈希、模板配置/无 node_modules 审计通过。 +- 使用仓库外配置备份完成真实 PostgreSQL 迁移、首页/SPA、MediaMTX、外部目录 start/stop smoke;测试后 18080/9997 无监听。本地解压目录恢复现场 `sense.env`,ZIP 内仍只含无秘密模板。 +- 首次在 PowerShell 7 下调用 smoke 的 `Invoke-WebRequest` 出现 loopback 超时;同一服务用 curl 返回 200,按交付目标的 Windows PowerShell 5.1 正式入口复测全部通过,确认不是 Sense 服务阻塞。 +- 新 ZIP:56,455,903 字节;SHA-256 `B500982BD566005DC8876A418C26A75BCABE41F498D10E3DAD286A71C92F0241`。 + + +## 人工验收 + +- 2026-08-16:用户明确验收通过 #70。 +- #95 已先合入 `dev`,随后按依赖顺序合并 PR #89;`main` 保持不变。 diff --git a/wiki-docs.json b/wiki-docs.json index 3012802..3fb80b4 100644 --- a/wiki-docs.json +++ b/wiki-docs.json @@ -132,6 +132,10 @@ "page": "Task-69-Sense多边形区域与方向警戒线配置", "path": "docs/task/69-Sense多边形区域与方向警戒线配置.md" }, + { + "page": "Task-70-Sense-Windows配置启动与打包交付", + "path": "docs/task/70-Sense-Windows配置启动与打包交付.md" + }, { "page": "Task-95-Sense旧媒体路由唯一约束兼容迁移", "path": "docs/task/95-Sense旧媒体路由唯一约束兼容迁移.md"