From 6b79478414a31b28b855f4f36742dc24c9d0e76e Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 15 Aug 2026 10:56:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BB=BA=E7=AB=8B=20Sense=20Windows=20?= =?UTF-8?q?=E4=BA=A4=E4=BB=98=E5=8C=85=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sense/.gitignore | 3 + Sense/README-WINDOWS.md | 156 ++++++++++ Sense/config/mediamtx.yml | 5 + Sense/config/sense.demo.env.example | 18 ++ Sense/config/sense.env.example | 18 ++ Sense/package/README-MEDIAMTX.txt | 5 + Sense/scripts/build/build-windows.bat | 3 + Sense/scripts/build/build-windows.ps1 | 106 +++++++ Sense/scripts/build/test-package.ps1 | 36 +++ Sense/scripts/runtime/backup-sense.bat | 3 + Sense/scripts/runtime/backup-sense.ps1 | 22 ++ Sense/scripts/runtime/check-sense.bat | 3 + Sense/scripts/runtime/check-sense.ps1 | 19 ++ Sense/scripts/runtime/initialize-admin.bat | 3 + Sense/scripts/runtime/initialize-admin.ps1 | 29 ++ Sense/scripts/runtime/migrate-sense.bat | 3 + Sense/scripts/runtime/migrate-sense.ps1 | 19 ++ Sense/scripts/runtime/restore-sense.bat | 3 + Sense/scripts/runtime/restore-sense.ps1 | 27 ++ Sense/scripts/runtime/sense-common.ps1 | 281 ++++++++++++++++++ Sense/scripts/runtime/start-sense.bat | 3 + Sense/scripts/runtime/start-sense.ps1 | 31 ++ Sense/scripts/runtime/stop-sense.bat | 3 + Sense/scripts/runtime/stop-sense.ps1 | 24 ++ Sense/server/app/sense/media/runtime.go | 12 + Sense/server/app/sense/media/runtime_test.go | 22 ++ Sense/server/cmd/api/server.go | 3 +- Sense/server/cmd/api/web.go | 70 +++++ Sense/server/cmd/api/web_test.go | 54 ++++ Sense/tests/package/run-tests.bat | 3 + Sense/tests/package/run-tests.ps1 | 88 ++++++ Sense/tests/package/smoke-start-stop.ps1 | 32 ++ Sense/tests/package/smoke-windows-package.ps1 | 53 ++++ 33 files changed, 1159 insertions(+), 1 deletion(-) create mode 100644 Sense/README-WINDOWS.md create mode 100644 Sense/config/mediamtx.yml create mode 100644 Sense/config/sense.demo.env.example create mode 100644 Sense/config/sense.env.example create mode 100644 Sense/package/README-MEDIAMTX.txt create mode 100644 Sense/scripts/build/build-windows.bat create mode 100644 Sense/scripts/build/build-windows.ps1 create mode 100644 Sense/scripts/build/test-package.ps1 create mode 100644 Sense/scripts/runtime/backup-sense.bat create mode 100644 Sense/scripts/runtime/backup-sense.ps1 create mode 100644 Sense/scripts/runtime/check-sense.bat create mode 100644 Sense/scripts/runtime/check-sense.ps1 create mode 100644 Sense/scripts/runtime/initialize-admin.bat create mode 100644 Sense/scripts/runtime/initialize-admin.ps1 create mode 100644 Sense/scripts/runtime/migrate-sense.bat create mode 100644 Sense/scripts/runtime/migrate-sense.ps1 create mode 100644 Sense/scripts/runtime/restore-sense.bat create mode 100644 Sense/scripts/runtime/restore-sense.ps1 create mode 100644 Sense/scripts/runtime/sense-common.ps1 create mode 100644 Sense/scripts/runtime/start-sense.bat create mode 100644 Sense/scripts/runtime/start-sense.ps1 create mode 100644 Sense/scripts/runtime/stop-sense.bat create mode 100644 Sense/scripts/runtime/stop-sense.ps1 create mode 100644 Sense/server/app/sense/media/runtime_test.go create mode 100644 Sense/server/cmd/api/web.go create mode 100644 Sense/server/cmd/api/web_test.go create mode 100644 Sense/tests/package/run-tests.bat create mode 100644 Sense/tests/package/run-tests.ps1 create mode 100644 Sense/tests/package/smoke-start-stop.ps1 create mode 100644 Sense/tests/package/smoke-windows-package.ps1 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..1ece1fe --- /dev/null +++ b/Sense/README-WINDOWS.md @@ -0,0 +1,156 @@ +# 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` 放进包。 + +## 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` + +构建末尾会审计包内容:拒绝 `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/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..b084199 --- /dev/null +++ b/Sense/scripts/build/build-windows.ps1 @@ -0,0 +1,106 @@ +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" + } +} + +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-FileHash -LiteralPath $file.FullName -Algorithm SHA256 | Select-Object -ExpandProperty Hash) $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..442fa91 --- /dev/null +++ b/Sense/scripts/build/test-package.ps1 @@ -0,0 +1,36 @@ +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" } +} +$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..9195279 --- /dev/null +++ b/Sense/tests/package/run-tests.ps1 @@ -0,0 +1,88 @@ +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 'bin') | Out-Null + [IO.File]::WriteAllText((Join-Path $temporary 'web\index.html'), 'sense', (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))) + + $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 +}