288 lines
14 KiB
PowerShell
288 lines
14 KiB
PowerShell
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_MEDIAMTX_CAPACITY',
|
|
'SENSE_MEDIAMTX_SHARDS_FILE', '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)(?<key>[A-Za-z_][A-Za-z0-9_]*)=(?<value>''(?:[^'']|'''')*''|"(?:[^"]|"")*"|[^\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')
|
|
$mediaShardsFile = Resolve-SenseConfiguredPath -PackageRoot $PackageRoot -Value (Get-SenseEnvironmentValue -Name 'SENSE_MEDIAMTX_SHARDS_FILE')
|
|
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)."
|
|
}
|
|
if (-not [string]::IsNullOrWhiteSpace($mediaShardsFile) -and -not (Test-Path -LiteralPath $mediaShardsFile -PathType Leaf)) {
|
|
throw 'SENSE_MEDIAMTX_SHARDS_FILE does not exist.'
|
|
}
|
|
|
|
$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')
|
|
[Environment]::SetEnvironmentVariable('SENSE_MEDIAMTX_SHARDS_FILE', $mediaShardsFile, '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: 2592000',
|
|
' 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')
|
|
}
|
|
}
|