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: 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') } }