<# .SYNOPSIS Installs (or upgrades) a UAP Finder capture head on this Windows PC in one step. .DESCRIPTION One command, from any PowerShell: irm https://uapfinder.com/install.ps1 | iex It relaunches itself elevated if it has to, installs FFmpeg when it is missing (winget), downloads the latest capture-service release from the PUBLIC feed and verifies it against the SHA-256 the feed publishes (the same check every installed head makes before it self-updates), unpacks it to C:\UAPFinder\svc, registers the Windows service UAPFinderCapture (auto-start, restart on crash, the two firewall rules and the desktop shortcut - via the install-service.ps1 that ships in the package), asks for the camera's address and login the first time, and opens the dashboard. No account is needed to install: the feed is public. Signing in happens afterwards, on the dashboard, and is what pairs the camera with you. Running it again on a machine that already has a head UPGRADES it in place. appsettings.Local.json - your camera login, capture folder, location, account link - is never rewritten once it exists, and neither are head-cert.pfx or installation-id.txt; the same three files the self-updater preserves. This script carries no secrets and nothing about any particular machine. Anything it needs to know about yours it asks for, or takes from a parameter. .PARAMETER InstallDir Where the service lives. Default C:\UAPFinder\svc. .PARAMETER CameraHost The camera's IP address or host name (RTSP). Prompted for when omitted and no Local config exists. .PARAMETER CameraUser The camera's RTSP user. Default admin. .PARAMETER CameraPassword The camera's RTSP password. Pass a SecureString to keep it out of your shell history; a plain string is accepted with a warning. .PARAMETER CapturesDir Where clips and event records go. Default %ProgramData%\UAPFinder\captures. Pick a drive with room. .PARAMETER Schedule Night (capture only when the sun is below the horizon at the camera - needs a location, which you set from the phone or the website afterwards) or Always. Default Night. .PARAMETER DryRun Print everything the script would do - the release it would install, the files it would write, the service actions - and change nothing. Does not need elevation. .PARAMETER NoBrowser Do not open the dashboard at the end (unattended installs). .PARAMETER NonInteractive Never prompt. Anything not supplied as a parameter is left for the dashboard's Camera panel. .EXAMPLE irm https://uapfinder.com/install.ps1 | iex .EXAMPLE # Unattended, with parameters. iex cannot take arguments, so bind the text to a script block: & ([scriptblock]::Create((irm https://uapfinder.com/install.ps1))) -CameraHost 192.168.1.50 ` -CameraUser admin -CameraPassword (Read-Host -AsSecureString "camera password") ` -CapturesDir D:\UAPFinder\captures -Schedule Night -NoBrowser .EXAMPLE .\install-capture-head.ps1 -DryRun #> [CmdletBinding()] param( [string]$InstallDir = 'C:\UAPFinder\svc', [string]$CameraHost, [string]$CameraUser = 'admin', [object]$CameraPassword, [string]$CapturesDir, [ValidateSet('Night', 'Always')] [string]$Schedule = 'Night', [string]$FeedUrl = 'https://api.uapfinder.com/api/releases/capture-service.json', [string]$ScriptUrl = 'https://uapfinder.com/install.ps1', [string]$ServiceName = 'UAPFinderCapture', [string]$DashboardUrl = 'http://localhost:5199', [switch]$DryRun, [switch]$NoBrowser, [switch]$NonInteractive ) $ErrorActionPreference = 'Stop' if ($PSVersionTable.PSVersion.Major -lt 6) { # Windows PowerShell 5.1 defaults to TLS 1.0 and cannot reach the feed without this. [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } $ProgressPreference = 'SilentlyContinue' # Invoke-WebRequest's progress bar makes 5.1 downloads crawl $Preserve = @('appsettings.Local.json', 'head-cert.pfx', 'installation-id.txt') if (-not $CapturesDir) { $CapturesDir = Join-Path $env:ProgramData 'UAPFinder\captures' } function Say($m) { Write-Host $m } function Step($m) { Write-Host ""; Write-Host "== $m" -ForegroundColor Cyan } function Plan($m) { if ($DryRun) { Write-Host " [dry run] would $m" -ForegroundColor Yellow } } function Fail($m) { Write-Host ""; Write-Host "STOPPED: $m" -ForegroundColor Red; exit 1 } # --- elevation --------------------------------------------------------------------------------------- $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = New-Object Security.Principal.WindowsPrincipal($identity) $elevated = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) if (-not $elevated -and -not $DryRun) { Step "Administrator rights are needed to install a Windows service - relaunching elevated" # Under `irm | iex` there is no file to relaunch, so the script is saved first. Its own text is # preferred; the published copy is the fallback. $self = $PSCommandPath if (-not $self) { $self = Join-Path ([IO.Path]::GetTempPath()) 'uapfinder-install-capture-head.ps1' $text = $null try { $text = $MyInvocation.MyCommand.ScriptBlock.ToString() } catch { } if ($text -and $text.Contains('UAPFinderCapture')) { Set-Content -Path $self -Value $text -Encoding UTF8 } else { Invoke-WebRequest -Uri $ScriptUrl -OutFile $self -UseBasicParsing } } $forward = @() foreach ($k in $PSBoundParameters.Keys) { $v = $PSBoundParameters[$k] if ($v -is [switch]) { if ($v) { $forward += "-$k" }; continue } if ($k -eq 'CameraPassword') { # A password cannot cross the elevation boundary safely on a command line; the elevated # copy asks for it again. Write-Warning "The camera password will be asked for again in the elevated window." continue } $forward += "-$k"; $forward += "`"$v`"" } $shell = if ($PSVersionTable.PSVersion.Major -ge 6) { 'pwsh' } else { 'powershell' } $argList = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-NoExit', '-File', "`"$self`"") + $forward Start-Process -FilePath $shell -Verb RunAs -ArgumentList $argList Say "Continue in the elevated window." return } if ($DryRun) { Say "DRY RUN - nothing on this machine will change." if (-not $elevated) { Say "(not elevated; a real run would relaunch as administrator first)" } } # --- FFmpeg ----------------------------------------------------------------------------------------- # The service runs as LocalSystem, which does not see a user's PATH, so whatever is found is written as an # ABSOLUTE path into the head's own config. Bare "ffmpeg" works in a console and fails as a service. function Find-Ffmpeg { $roots = @( (Join-Path $env:ProgramFiles 'WinGet\Packages'), (Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Packages'), 'C:\ffmpeg\bin', 'C:\ProgramData\chocolatey\bin' ) $cmd = Get-Command ffmpeg.exe -ErrorAction SilentlyContinue if ($cmd) { $p = $cmd.Source # winget's Links dir holds a reparse point; the service wants the real file behind it. try { $item = Get-Item $p; if ($item.Target) { $p = [string]$item.Target } } catch { } if (Test-Path $p) { return $p } } foreach ($r in $roots) { if (-not (Test-Path $r)) { continue } $hit = Get-ChildItem $r -Recurse -Filter ffmpeg.exe -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if ($hit) { return $hit.FullName } } return $null } Step "FFmpeg" $ffmpeg = Find-Ffmpeg if ($ffmpeg) { Say " found: $ffmpeg" } else { $winget = Get-Command winget -ErrorAction SilentlyContinue if (-not $winget) { Fail "FFmpeg is not installed and winget is not available. Install FFmpeg (https://www.gyan.dev/ffmpeg/builds/ - the 'essentials' build), put its bin folder on PATH or note where it is, then run this again." } Plan "run: winget install Gyan.FFmpeg --scope machine --accept-source-agreements --accept-package-agreements" if (-not $DryRun) { Say " installing Gyan.FFmpeg with winget (machine scope, so the service account can see it)..." & winget install --id Gyan.FFmpeg --exact --scope machine --accept-source-agreements --accept-package-agreements --disable-interactivity | Out-Host $ffmpeg = Find-Ffmpeg if (-not $ffmpeg) { Fail "winget did not leave an ffmpeg.exe where this script can find it. Install FFmpeg by hand (https://www.gyan.dev/ffmpeg/builds/), then run this again; or set Camera:FfmpegPath / Camera:FfprobePath in $InstallDir\appsettings.Local.json yourself." } Say " installed: $ffmpeg" } } $ffprobe = $null if ($ffmpeg) { $ffprobe = Join-Path (Split-Path $ffmpeg) 'ffprobe.exe' if (-not (Test-Path $ffprobe)) { Fail "ffprobe.exe is not beside $ffmpeg - the service needs both. Install a full FFmpeg build." } } # --- the .NET runtime the package runs on -------------------------------------------------------------- # The release is framework-dependent (small, and it self-updates without re-shipping a runtime), so the # machine needs the ASP.NET Core 10 shared runtime. Without it the service is installed fine and then # dies on first start with Windows' own dialog, which nobody sees under LocalSystem. Step ".NET runtime" function Test-AspNetRuntime { $dotnet = Get-Command dotnet -ErrorAction SilentlyContinue if (-not $dotnet) { return $false } $list = & $dotnet.Source --list-runtimes 2>$null return [bool]($list | Where-Object { $_ -match '^Microsoft\.AspNetCore\.App 10\.' }) } if (Test-AspNetRuntime) { Say " found: Microsoft.AspNetCore.App 10.x" } else { $winget = Get-Command winget -ErrorAction SilentlyContinue if (-not $winget) { Fail "The ASP.NET Core 10 runtime is not installed and winget is not available. Install it from https://dotnet.microsoft.com/download/dotnet/10.0 (the 'ASP.NET Core Runtime' Hosting Bundle or x64 installer), then run this again." } Plan "run: winget install Microsoft.DotNet.AspNetCore.10 --accept-source-agreements --accept-package-agreements" if (-not $DryRun) { Say " installing the ASP.NET Core 10 runtime with winget..." & winget install --id Microsoft.DotNet.AspNetCore.10 --exact --accept-source-agreements --accept-package-agreements --disable-interactivity | Out-Host # A fresh install lands on PATH for new processes only; look in the default location too. $env:Path = [Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [Environment]::GetEnvironmentVariable('Path', 'User') if (-not (Test-AspNetRuntime)) { Fail "winget finished but 'dotnet --list-runtimes' still shows no Microsoft.AspNetCore.App 10.x. Install it from https://dotnet.microsoft.com/download/dotnet/10.0, then run this again." } Say " installed." } } # --- the latest release, verified -------------------------------------------------------------------- Step "Latest release" try { $manifest = Invoke-RestMethod -Uri $FeedUrl -UseBasicParsing -TimeoutSec 60 } catch { Fail "Could not read the release feed at $FeedUrl : $($_.Exception.Message)" } foreach ($k in 'version', 'commit', 'url', 'sha256', 'publishedUtc') { if (-not $manifest.$k) { Fail "The release feed is missing '$k'; refusing to install from it." } } $pkgUri = [Uri]$manifest.url if ($pkgUri.Scheme -ne 'https' -and -not $pkgUri.IsLoopback) { Fail "The package URL is not https ($($manifest.url)); refusing." } if ($manifest.sha256 -notmatch '^[0-9a-fA-F]{64}$') { Fail "The release publishes no usable SHA-256, so the download could not be verified; refusing." } Say " version: $($manifest.version)" Say " commit: $($manifest.commit)" Say " published: $($manifest.publishedUtc)" if ($manifest.notes) { Say " notes: $($manifest.notes)" } $installedRecord = Join-Path $InstallDir 'installed-update.json' $exe = Join-Path $InstallDir 'UAPFinder.CaptureService.exe' $alreadyCurrent = $false if (Test-Path $installedRecord) { try { $rec = Get-Content $installedRecord -Raw | ConvertFrom-Json if ($rec.Commit -and ([string]$manifest.commit).StartsWith([string]$rec.Commit, [StringComparison]::OrdinalIgnoreCase)) { $alreadyCurrent = $true } } catch { } } if ($alreadyCurrent -and (Test-Path $exe)) { Say " $InstallDir already runs this release; the files will be left alone." } $work = Join-Path ([IO.Path]::GetTempPath()) "uapfinder-install-$([IO.Path]::GetRandomFileName())" $zip = Join-Path $work 'package.zip' $stage = Join-Path $work 'package' function Extract-Safely($zipPath, $destination) { # Refuse any entry that would land outside the destination (a zip is a program here). Add-Type -AssemblyName System.IO.Compression.FileSystem $root = (New-Item -ItemType Directory -Path $destination -Force).FullName.TrimEnd('\') + '\' $archive = [IO.Compression.ZipFile]::OpenRead($zipPath) try { foreach ($entry in $archive.Entries) { if (-not $entry.Name) { continue } # directory entry $target = [IO.Path]::GetFullPath((Join-Path $root $entry.FullName)) if (-not $target.StartsWith($root, [StringComparison]::OrdinalIgnoreCase)) { throw "The package contains an entry that escapes its folder ($($entry.FullName)); refusing it." } New-Item -ItemType Directory -Path (Split-Path $target) -Force | Out-Null [IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $target, $true) } } finally { $archive.Dispose() } } if (-not ($alreadyCurrent -and (Test-Path $exe))) { Plan "download $($manifest.url), verify SHA-256 $($manifest.sha256), unpack to $InstallDir (keeping $($Preserve -join ', '))" if (-not $DryRun) { New-Item -ItemType Directory -Path $work -Force | Out-Null Say " downloading $($manifest.url)" Invoke-WebRequest -Uri $manifest.url -OutFile $zip -UseBasicParsing -TimeoutSec 600 $actual = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant() if ($actual -ne ([string]$manifest.sha256).ToLowerInvariant()) { Remove-Item $work -Recurse -Force -ErrorAction SilentlyContinue Fail "The download hashes to $actual but the feed published $($manifest.sha256). Nothing was unpacked." } Say " SHA-256 verified" Extract-Safely $zip $stage if (-not (Test-Path (Join-Path $stage 'UAPFinder.CaptureService.exe'))) { Fail "The package has no UAPFinder.CaptureService.exe in it." } } } # --- stop the service if it is running ----------------------------------------------------------------- Step "Windows service $ServiceName" $svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue $needsFiles = -not ($alreadyCurrent -and (Test-Path $exe)) if ($svc) { Say " exists (status $($svc.Status))" if ($needsFiles -and $svc.Status -ne 'Stopped') { Plan "stop $ServiceName while its files are replaced" if (-not $DryRun) { Stop-Service $ServiceName -Force for ($i = 0; $i -lt 30 -and (Get-Service $ServiceName).Status -ne 'Stopped'; $i++) { Start-Sleep -Seconds 1 } Start-Sleep -Seconds 3 # file locks outlive the stopped status for a moment } } } else { Say " not installed yet" } # --- files --------------------------------------------------------------------------------------------- if ($needsFiles) { Plan "copy the package over $InstallDir (a copy of the previous build goes to $InstallDir\..\previous)" if (-not $DryRun) { New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null if (Test-Path $exe) { $previous = Join-Path (Split-Path $InstallDir) 'previous' if (Test-Path $previous) { Remove-Item $previous -Recurse -Force } New-Item -ItemType Directory -Path $previous -Force | Out-Null Copy-Item (Join-Path $InstallDir '*') $previous -Recurse -Force } $stageRoot = (Get-Item $stage).FullName Get-ChildItem $stage -Recurse -File | ForEach-Object { $rel = $_.FullName.Substring($stageRoot.Length).TrimStart('\', '/') if ($Preserve -contains $rel) { return } $dest = Join-Path $InstallDir $rel New-Item -ItemType Directory -Path (Split-Path $dest) -Force | Out-Null Copy-Item $_.FullName $dest -Force } # The same record the self-updater writes, so its next check orders against THIS release instead # of treating the install-day build as unknown. [ordered]@{ Version = $manifest.version; Commit = $manifest.commit PublishedUtc = $manifest.publishedUtc; InstalledUtc = [DateTimeOffset]::UtcNow.ToString('o') } | ConvertTo-Json | Set-Content -Path $installedRecord -Encoding UTF8 Remove-Item $work -Recurse -Force -ErrorAction SilentlyContinue Say " installed to $InstallDir" } } # --- appsettings.Local.json: written once, never rewritten --------------------------------------------- Step "Head configuration ($InstallDir\appsettings.Local.json)" $localPath = Join-Path $InstallDir 'appsettings.Local.json' if (Test-Path $localPath) { Say " exists - left exactly as it is (camera login, capture folder, location and account link are yours)." } else { $askable = -not $NonInteractive -and -not $DryRun if (-not $CameraHost -and $askable) { $CameraHost = Read-Host " Camera IP address or host name (leave blank to fill it in on the dashboard later)" } if ($CameraHost -and $askable) { $u = Read-Host " Camera RTSP user [$CameraUser]" if ($u) { $CameraUser = $u } if ($null -eq $CameraPassword) { $CameraPassword = Read-Host " Camera RTSP password" -AsSecureString } } if ($askable -and -not $PSBoundParameters.ContainsKey('CapturesDir')) { $d = Read-Host " Capture folder (needs tens of GB; pick a drive with room) [$CapturesDir]" if ($d) { $CapturesDir = $d } } if ($askable -and -not $PSBoundParameters.ContainsKey('Schedule')) { $s = Read-Host " Schedule: Night (only after dark; needs the location you set later) or Always [$Schedule]" if ($s -match '^always$') { $Schedule = 'Always' } elseif ($s -match '^night$') { $Schedule = 'Night' } } $plainPassword = '' if ($CameraPassword -is [securestring]) { $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($CameraPassword) try { $plainPassword = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } } elseif ($null -ne $CameraPassword) { Write-Warning "The camera password was passed as plain text; it is now in this shell's history. Prefer -CameraPassword (Read-Host -AsSecureString)." $plainPassword = [string]$CameraPassword } $camera = [ordered]@{} if ($CameraHost) { $camera['Host'] = $CameraHost; $camera['Username'] = $CameraUser; $camera['Password'] = $plainPassword } if ($ffmpeg) { $camera['FfmpegPath'] = $ffmpeg; $camera['FfprobePath'] = $ffprobe } $local = [ordered]@{ '//' = 'Written by install-capture-head.ps1 on first install. This file is yours: the dashboard edits it, updates never touch it.' 'Camera' = $camera 'Storage' = [ordered]@{ 'OutputDirectory' = $CapturesDir } 'Schedule' = [ordered]@{ 'Mode' = $Schedule } } $json = $local | ConvertTo-Json -Depth 5 if ($plainPassword) { Plan "write (password hidden here):`n$($json.Replace($plainPassword, '********'))" } else { Plan "write:`n$json" } if (-not $DryRun) { New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null New-Item -ItemType Directory -Path $CapturesDir -Force | Out-Null Set-Content -Path $localPath -Value $json -Encoding UTF8 # The file holds the camera password: administrators and the service account only. try { & icacls $localPath /inheritance:r /grant:r 'SYSTEM:F' 'Administrators:F' | Out-Null } catch { Write-Warning "Could not tighten permissions on $localPath; it is readable by other users of this PC." } Say " written" } } # --- register or start the service ------------------------------------------------------------------- Step "Start" $installScript = Join-Path $InstallDir 'install-service.ps1' if (-not $svc) { Plan "run $installScript -InstallDir $InstallDir (New-Service auto-start + sc failure restart/5s, firewall rules for TCP 5199 private + 5443, desktop and Start Menu shortcut), which also starts it" if (-not $DryRun) { if (-not (Test-Path $installScript)) { Fail "$installScript is not in the package." } & $installScript -InstallDir $InstallDir -ServiceName $ServiceName -Url $DashboardUrl } } else { if ((Get-Service $ServiceName).Status -eq 'Running' -and -not $needsFiles) { Say " already running on the current release." } else { Plan "start $ServiceName" if (-not $DryRun) { Start-Service $ServiceName } } } if (-not $DryRun) { $health = "$DashboardUrl/health" $ok = $false $deadline = (Get-Date).AddSeconds(90) while ((Get-Date) -lt $deadline) { try { $r = Invoke-RestMethod -Uri $health -TimeoutSec 5 -UseBasicParsing; if ($r) { $ok = $true; break } } catch { } Start-Sleep -Seconds 3 } if ($ok) { Say " $health answers - the head is up." } else { Write-Warning "The service started but $health did not answer within 90 s. Check the log under $CapturesDir\logs." } } # --- what next ----------------------------------------------------------------------------------------- Write-Host "" Write-Host "Done. What to do next:" -ForegroundColor Green Say " 1. Dashboard: $DashboardUrl (also the 'UAP Finder Camera' shortcut on the desktop)." if (-not $CameraHost -and -not (Test-Path $localPath)) { Say " Camera panel: enter the camera's address and login, press Test connection, save." } Say " 2. Account panel: sign in with your UAP Finder account. That is the whole pairing step - the phone app" Say " and uapfinder.com/cameras find this camera through it. Signing in shares nothing publicly." Say " 3. Set the camera's LOCATION from the phone app (My Cameras) or uapfinder.com/cameras. Night mode captures" Say " continuously until it knows where the sun is; satellite and aircraft matching need it too." Say " 4. On the dashboard: set the mount AIM (star-calibrate on the first clear night) and paint IGNORE ZONES over" Say " treetops, roof lines and any burned-in clock. Both need the picture in front of you, so they stay there." Say " Running this script again later upgrades the head in place; it also updates itself every 6 hours." if (-not $DryRun -and -not $NoBrowser) { try { Start-Process $DashboardUrl } catch { } }