RedFlag/server/internal/services/templates/install/scripts/windows.ps1.tmpl
Fimeg 765ec4188f publish: carry the Windows product into the projection
The tree adds the installer, service and test paths admitted by this source commit. Nothing else changes about what may cross.

Source-Sha: 3e4a3aa4ce092e9e3f51bc78daf3ddc14d9638f8

Policy-Sha: 3e4a3aa4ce092e9e3f51bc78daf3ddc14d9638f8

Tree-Digest: 89d6fce3828a0e0de0c8eeb41ee6750f4462217e2a04b5190297134d7f4a50c3
2026-09-09 12:47:34 -04:00

765 lines
35 KiB
Go Template

# RedFlag Agent Installer - Windows PowerShell
# Generated for agent: {{.AgentID}}
# Platform: {{.Platform}}
# Architecture: {{.Architecture}}
# Version: {{.Version}}
# This script is rendered per-token by the server and meant to be piped straight
# into an elevated PowerShell:
# irm "<server>/api/v1/install/windows?token=XXX" | iex
# That path can't bind script parameters, so the token and server are baked in at
# render time — same model as the Linux installer (curl ... | sudo bash). Env vars
# override for advanced/manual runs. Admin is enforced at runtime below; #Requires
# can't fire under iex, so it's gone.
$RegistrationToken = if ($env:RF_TOKEN) { $env:RF_TOKEN } else { "{{.RegistrationToken}}" }
$ServerUrl = if ($env:RF_SERVER) { $env:RF_SERVER } else { "{{.ServerURL}}" }
$ServerUrl = $ServerUrl.TrimEnd("/")
$SkipServiceInstall = [bool]$env:RF_SKIP_SERVICE_INSTALL
$ErrorActionPreference = "Stop"
function Convert-HexToBytes {
param([Parameter(Mandatory=$true)][string]$Hex)
$CleanHex = $Hex.Trim()
if (($CleanHex.Length % 2) -ne 0) {
throw "invalid hex length"
}
$Bytes = New-Object byte[] ([int]($CleanHex.Length / 2))
for ($i = 0; $i -lt $Bytes.Length; $i++) {
$Bytes[$i] = [Convert]::ToByte($CleanHex.Substring($i * 2, 2), 16)
}
return ,$Bytes
}
# $ErrorActionPreference is Stop, which makes Write-Error terminating: only the
# first diagnostic line would reach the operator, the exit below it would never
# run, and inside a try/catch a refusal would be downgraded to a caught warning.
# Emit every line non-terminatingly, then exit. exit is not catchable, so a
# refusal raised here cannot be swallowed by an enclosing catch.
function Stop-Install {
param([Parameter(Mandatory=$true)][string[]]$Message)
foreach ($Line in $Message) {
Write-Error $Line -ErrorAction Continue
}
exit 1
}
# Cleanup must never replace the failure that brought us here. On Windows a
# scanner routinely holds a transient handle on a freshly downloaded executable,
# so removal can fail on exactly the paths where the real error matters most.
function Remove-InstallerScratchDirectory {
param([Parameter(Mandatory=$true)][string]$Path)
try {
Remove-Item -LiteralPath $Path -Recurse -Force
} catch {
Write-Warning "Could not remove installer staging directory ${Path}: $($_.Exception.Message)"
}
}
function New-InstallerScratchDirectory {
$Scratch = Join-Path ([System.IO.Path]::GetTempPath()) ("redflag-" + [guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Path $Scratch -ErrorAction Stop | Out-Null
& icacls $Scratch /inheritance:r /grant:r "*S-1-5-18:(OI)(CI)F" "*S-1-5-32-544:(OI)(CI)F" | Out-Null
if ($LASTEXITCODE -ne 0) {
Remove-InstallerScratchDirectory -Path $Scratch
throw "Failed to protect installer staging directory"
}
return $Scratch
}
function Test-Ed25519Signature {
param(
[Parameter(Mandatory=$true)][byte[]]$Data,
[Parameter(Mandatory=$true)][byte[]]$Signature,
[Parameter(Mandatory=$true)][byte[]]$PublicKey
)
if ($PublicKey.Length -ne 32) {
throw "invalid Ed25519 public key length"
}
if ($Signature.Length -ne 64) {
throw "invalid Ed25519 signature length"
}
if (-not $script:Ed25519OpenSSL) {
throw "OpenSSL 3 with Ed25519 support must be installed by the operator and available on PATH"
}
$Scratch = New-InstallerScratchDirectory
try {
# RFC 8410 SubjectPublicKeyInfo for a raw 32-byte Ed25519 public key.
[byte[]]$DER = (Convert-HexToBytes "302a300506032b6570032100") + $PublicKey
[System.IO.File]::WriteAllBytes((Join-Path $Scratch "key.der"), $DER)
[System.IO.File]::WriteAllBytes((Join-Path $Scratch "signature.bin"), $Signature)
[System.IO.File]::WriteAllBytes((Join-Path $Scratch "message.bin"), $Data)
# Use the native process exit code, not localized output or a missing
# .NET type. No downloaded executable participates in its own trust.
$Start = New-Object System.Diagnostics.ProcessStartInfo
$Start.FileName = $script:Ed25519OpenSSL
$Start.WorkingDirectory = $Scratch
$Start.Arguments = "pkeyutl -verify -rawin -pubin -keyform DER -inkey key.der -in message.bin -sigfile signature.bin"
$Start.UseShellExecute = $false
$Start.CreateNoWindow = $true
$Process = [System.Diagnostics.Process]::Start($Start)
try {
$Process.WaitForExit()
return ($Process.ExitCode -eq 0)
} finally {
$Process.Dispose()
}
} finally {
Remove-InstallerScratchDirectory -Path $Scratch
}
}
function Assert-Ed25519Verifier {
$Verifier = Get-Command openssl -CommandType Application -ErrorAction Stop | Select-Object -First 1
$script:Ed25519OpenSSL = $Verifier.Source
# RFC 8032 section 7.1, test 2. Prove both acceptance and rejection before
# touching an installed agent; a version string does not prove a provider.
$Public = Convert-HexToBytes "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c"
$Signature = Convert-HexToBytes "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00"
if (-not (Test-Ed25519Signature -Data ([byte[]]@(0x72)) -Signature $Signature -PublicKey $Public)) {
throw "OpenSSL cannot verify Ed25519 signatures"
}
if (Test-Ed25519Signature -Data ([byte[]]@(0x73)) -Signature $Signature -PublicKey $Public) {
throw "Ed25519 verifier accepted tampered data"
}
}
function Set-AgentConfigPermissions {
param(
[Parameter(Mandatory=$true)][string]$ConfigFilePath,
[Parameter(Mandatory=$true)][string]$ConfigDirectoryPath
)
Write-Host "Setting file permissions..." -ForegroundColor Yellow
# Use stable SIDs instead of localized account names:
# S-1-5-18 SYSTEM
# S-1-5-19 LOCAL SERVICE
# S-1-5-32-544 BUILTIN\Administrators
& icacls $ConfigDirectoryPath /inheritance:r /grant:r "*S-1-5-18:(OI)(CI)F" "*S-1-5-19:(OI)(CI)F" "*S-1-5-32-544:(OI)(CI)F" | Out-Null
if ($LASTEXITCODE -ne 0) {
Stop-Install "Failed to set ACL on $ConfigDirectoryPath"
}
& icacls $ConfigFilePath /inheritance:r /grant:r "*S-1-5-18:F" "*S-1-5-19:F" "*S-1-5-32-544:F" | Out-Null
if ($LASTEXITCODE -ne 0) {
Stop-Install "Failed to set ACL on $ConfigFilePath"
}
}
function Repair-AgentConfigAccessIfNeeded {
param(
[Parameter(Mandatory=$true)][string]$ConfigFilePath,
[Parameter(Mandatory=$true)][string]$ConfigDirectoryPath
)
if (-not (Test-Path $ConfigFilePath)) {
return
}
try {
$null = Get-Content -Raw -Path $ConfigFilePath -ErrorAction Stop
} catch {
Write-Host "[WARN] [installer] [permissions] Existing config is not readable; repairing ACL before detection" -ForegroundColor Yellow
Set-AgentConfigPermissions -ConfigFilePath $ConfigFilePath -ConfigDirectoryPath $ConfigDirectoryPath
}
}
function Ensure-LocalApiGroup {
param([Parameter(Mandatory=$true)][string]$GroupName)
Write-Host "Ensuring local API access group..." -ForegroundColor Yellow
$LocalGroupCommandsAvailable = $null -ne (Get-Command Get-LocalGroup -ErrorAction SilentlyContinue) -and
$null -ne (Get-Command New-LocalGroup -ErrorAction SilentlyContinue) -and
$null -ne (Get-Command Add-LocalGroupMember -ErrorAction SilentlyContinue)
if ($LocalGroupCommandsAvailable) {
$Group = Get-LocalGroup -Name $GroupName -ErrorAction SilentlyContinue
if (-not $Group) {
New-LocalGroup -Name $GroupName -Description "RedFlag local agent API access" | Out-Null
Write-Host "✓ Group $GroupName created" -ForegroundColor Green
} else {
Write-Host "✓ Group $GroupName already exists" -ForegroundColor Green
}
$CurrentIdentity = [Security.Principal.WindowsIdentity]::GetCurrent()
if ($CurrentIdentity.User.Value -eq "S-1-5-18") {
Write-Host "[INFO] [installer] [localapi] Running as LocalSystem; no interactive user added to $GroupName" -ForegroundColor Gray
return
}
$CurrentUser = $CurrentIdentity.Name
try {
Add-LocalGroupMember -Group $GroupName -Member $CurrentUser -ErrorAction Stop
Write-Host "✓ Added $CurrentUser to $GroupName" -ForegroundColor Green
Write-Host "[INFO] [installer] [localapi] Sign out and back in if non-elevated local API access is not immediately available" -ForegroundColor Gray
} catch {
if ($_.Exception.Message -match "already.*member|already.*exists") {
Write-Host "✓ $CurrentUser already in $GroupName" -ForegroundColor Green
} else {
throw
}
}
return
}
# Windows PowerShell fallback for hosts without Microsoft.PowerShell.LocalAccounts.
$ComputerName = $env:COMPUTERNAME
try {
$Group = [ADSI]"WinNT://$ComputerName/$GroupName,group"
$null = $Group.Name
Write-Host "✓ Group $GroupName already exists" -ForegroundColor Green
} catch {
$Machine = [ADSI]"WinNT://$ComputerName"
$Group = $Machine.Create("group", $GroupName)
$Group.SetInfo()
$Group.Description = "RedFlag local agent API access"
$Group.SetInfo()
Write-Host "✓ Group $GroupName created" -ForegroundColor Green
}
$CurrentIdentity = [Security.Principal.WindowsIdentity]::GetCurrent()
if ($CurrentIdentity.User.Value -eq "S-1-5-18") {
Write-Host "[INFO] [installer] [localapi] Running as LocalSystem; no interactive user added to $GroupName" -ForegroundColor Gray
return
}
$CurrentUser = $CurrentIdentity.Name
$MemberPath = "WinNT://" + $CurrentUser.Replace("\", "/")
try {
$Group.Add($MemberPath)
Write-Host "✓ Added $CurrentUser to $GroupName" -ForegroundColor Green
Write-Host "[INFO] [installer] [localapi] Sign out and back in if non-elevated local API access is not immediately available" -ForegroundColor Gray
} catch {
if ($_.Exception.Message -match "already.*member|The object already exists") {
Write-Host "✓ $CurrentUser already in $GroupName" -ForegroundColor Green
} else {
throw
}
}
}
# Runtime admin check for better error messaging
if (-NOT ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")) {
Stop-Install @(
"This installer must be run as Administrator.",
"Right-click PowerShell and select 'Run as Administrator', then retry."
)
}
# Prove the Ed25519 verifier accepts and rejects before anything is downloaded
# or installed. A missing or broken verifier stops the install here, untouched.
try {
Assert-Ed25519Verifier
} catch {
Stop-Install "A working operator-installed OpenSSL 3 Ed25519 verifier is required. No agent files or services were changed. Detail: $($_.Exception.Message)"
}
# Detect Windows architecture
$Arch = $env:PROCESSOR_ARCHITECTURE
switch ($Arch) {
"AMD64" { $ArchTag = "amd64" }
"ARM64" { $ArchTag = "arm64" }
default {
Stop-Install "Unsupported architecture: $Arch. Supported: AMD64, ARM64."
}
}
$AgentID = "{{.AgentID}}"
$BinaryURL = "$ServerUrl/api/v1/downloads/windows-${ArchTag}?version={{.Version}}"
$ConfigURL = "{{.ConfigURL}}"
$InstallDir = "C:\Program Files\RedFlag"
$ConfigDir = "C:\ProgramData\RedFlag"
$AgentConfigDir = "C:\ProgramData\RedFlag\agent"
$ServerKeyDir = "C:\ProgramData\RedFlag\server"
$OldConfigDir = "C:\ProgramData\Aggregator"
$ServiceName = "RedFlagAgent"
$LocalApiGroup = "RedFlagLocal"
$Version = "{{.Version}}"
$Timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$BackupDir = Join-Path $ConfigDir "backups\backup.$Timestamp"
Write-Host "=== RedFlag Agent v$Version Installation ===" -ForegroundColor Cyan
Write-Host "Agent ID: $AgentID"
Write-Host "Platform: {{.Platform}}"
Write-Host "Installing to: $InstallDir\redflag-agent.exe"
Write-Host
# Verify the download before changing an installed agent.
Write-Host "Downloading agent binary..." -ForegroundColor Yellow
$BinaryPath = Join-Path $InstallDir "redflag-agent.exe"
$StagingDir = New-InstallerScratchDirectory
$TmpBinary = Join-Path $StagingDir "redflag-agent.exe"
try {
$Response = Invoke-WebRequest -Uri $BinaryURL -OutFile $TmpBinary -UseBasicParsing -PassThru
# --- Cold-start trust: verify the binary against the signed release manifest ---
# Parallels the Linux installer. The manifest lists the expected SHA-256 per
# platform/arch and is Ed25519-signed. Signature and hash checks are mandatory.
$ManifestUrl = "$ServerUrl/api/v1/manifest?version={{.Version}}"
$ServerPubKey = "{{.ServerPublicKey}}"
try {
$ManifestResp = Invoke-WebRequest -Uri $ManifestUrl -UseBasicParsing
} catch {
$ManifestStatus = $null
$ManifestErrorBody = $null
$Resp = $_.Exception.Response
if ($Resp) {
try { $ManifestStatus = [int]$Resp.StatusCode } catch {}
try {
$Stream = $Resp.GetResponseStream()
if ($Stream) {
$Reader = New-Object System.IO.StreamReader($Stream)
$ManifestErrorBody = $Reader.ReadToEnd()
$Reader.Close()
}
} catch {}
}
$Reason = @()
if ($ManifestStatus) {
$Reason += "Failed to fetch release manifest from $ManifestUrl (HTTP $ManifestStatus) - refusing to install."
} else {
$Reason += "Failed to fetch release manifest from $ManifestUrl ($($_.Exception.Message)) - refusing to install."
}
if ($ManifestErrorBody) {
$Reason += "Manifest response: $ManifestErrorBody"
}
Stop-Install $Reason
}
$ManifestBody = $ManifestResp.Content
$ManifestSig = $null
if ($ManifestResp.Headers.ContainsKey("X-Content-Signature")) {
$ManifestSig = $ManifestResp.Headers["X-Content-Signature"]
}
if (-not ($ManifestSig -and $ServerPubKey)) {
Stop-Install "Release manifest is unsigned or no server key embedded - refusing to install."
}
# Establish the authority of the manifest before trusting any artifact hash.
try {
$pk = Convert-HexToBytes $ServerPubKey
$sg = Convert-HexToBytes $ManifestSig
$bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($ManifestBody)
$ok = Test-Ed25519Signature -Data $bodyBytes -Signature $sg -PublicKey $pk
} catch {
Stop-Install "Release manifest signature or public key is malformed - refusing to install."
}
if (-not $ok) {
Stop-Install "Release manifest signature invalid - refusing to install."
} else {
Write-Host "✓ Manifest signature verified (Ed25519)" -ForegroundColor Green
}
# Mandatory hash pin: the downloaded binary must match its manifest entry.
$Manifest = $ManifestBody | ConvertFrom-Json
$ExpectedHash = $null
foreach ($a in $Manifest.artifacts) {
if ($a.platform -eq "windows" -and $a.architecture -eq $ArchTag) {
$ExpectedHash = $a.sha256.ToLower()
break
}
}
if (-not $ExpectedHash) {
Stop-Install "No manifest entry for windows/$ArchTag - refusing to install."
}
$ActualManifestHash = (Get-FileHash -Path $TmpBinary -Algorithm SHA256).Hash.ToLower()
if ($ActualManifestHash -ne $ExpectedHash) {
Stop-Install @(
"Binary hash does not match the signed manifest - possible tampering.",
" expected (signed manifest): $ExpectedHash",
" actual (downloaded): $ActualManifestHash"
)
}
Write-Host "✓ Cold-start trust established: binary matches signed release manifest ($ActualManifestHash)" -ForegroundColor Green
# --- end cold-start manifest verification ---
# Verify checksum if server provided one
$ExpectedChecksum = $null
if ($Response.Headers.ContainsKey("X-Content-SHA256")) {
$ExpectedChecksum = $Response.Headers["X-Content-SHA256"]
}
if ($ExpectedChecksum) {
$ActualHash = (Get-FileHash -Path $TmpBinary -Algorithm SHA256).Hash.ToLower()
if ($ActualHash -ne $ExpectedChecksum) {
Stop-Install @(
"Checksum verification failed",
"Expected: $ExpectedChecksum",
"Actual: $ActualHash"
)
}
Write-Host "Checksum verified: $ActualHash" -ForegroundColor Green
} else {
Write-Host "WARNING: Server did not provide checksum header. Proceeding without verification." -ForegroundColor Yellow
}
# ISSUE-002: Verify binary signature before installation (TOFU model)
$ExpectedSignature = $null
if ($Response.Headers.ContainsKey("X-Content-Signature")) {
$ExpectedSignature = $Response.Headers["X-Content-Signature"]
}
if ($ExpectedSignature -and "{{.ServerPublicKey}}") {
Write-Host "Verifying binary signature..." -ForegroundColor Yellow
try {
$pubKeyBytes = Convert-HexToBytes "{{.ServerPublicKey}}"
$sigBytes = Convert-HexToBytes $ExpectedSignature
$binaryBytes = [System.IO.File]::ReadAllBytes($TmpBinary)
$verified = Test-Ed25519Signature -Data $binaryBytes -Signature $sigBytes -PublicKey $pubKeyBytes
if ($verified) {
Write-Host "✓ Binary signature verified (Ed25519)" -ForegroundColor Green
} else {
throw "Signature verification failed"
}
} catch {
Stop-Install @(
"Binary signature verification failed - possible tampering",
"Detail: $($_.Exception.Message)"
)
}
} else {
Stop-Install "Missing binary signature or server public key - refusing to install."
}
# Step 0: Detect existing installation and migration requirements
Write-Host "Detecting existing RedFlag installations..." -ForegroundColor Yellow
$MigrationNeeded = $false
$CurrentVersion = "unknown"
$ConfigVersion = "0"
$ConfigPath = Join-Path $AgentConfigDir "config.json"
$OldConfigPath = Join-Path $OldConfigDir "config.json"
Repair-AgentConfigAccessIfNeeded -ConfigFilePath $ConfigPath -ConfigDirectoryPath $AgentConfigDir
# Check for existing installation in new location
if (Test-Path $ConfigPath) {
Write-Host "✓ Existing installation detected at $ConfigDir" -ForegroundColor Green
try {
$Config = Get-Content $ConfigPath | ConvertFrom-Json
if ($Config.agent_version) { $CurrentVersion = $Config.agent_version }
if ($Config.version) { $ConfigVersion = $Config.version.ToString() }
} catch {
Write-Host " Warning: Could not parse config.json" -ForegroundColor Yellow
}
Write-Host " Current agent version: $CurrentVersion"
Write-Host " Current config version: $ConfigVersion"
} elseif (Test-Path $OldConfigPath) {
Write-Host "⚠ Old installation detected at $OldConfigDir - MIGRATION REQUIRED" -ForegroundColor Yellow
$MigrationNeeded = $true
try {
$Config = Get-Content $OldConfigPath | ConvertFrom-Json
if ($Config.agent_version) { $CurrentVersion = $Config.agent_version }
if ($Config.version) { $ConfigVersion = $Config.version.ToString() }
} catch {
Write-Host " Warning: Could not parse config.json" -ForegroundColor Yellow
}
Write-Host " Current agent version: $CurrentVersion"
Write-Host " Current config version: $ConfigVersion"
} else {
Write-Host "✓ Fresh installation" -ForegroundColor Green
}
# Determine if migration is needed
if (-not $MigrationNeeded) {
# Check if config version indicates migration is needed
try {
if ([int]$ConfigVersion -lt 4) {
$MigrationNeeded = $true
Write-Host "⚠ Config version $ConfigVersion < v4 - migration required" -ForegroundColor Yellow
}
} catch {
# Config version not a valid number
}
# Check for missing security features
if (Test-Path $ConfigPath) {
$ConfigContent = Get-Content $ConfigPath -Raw
if ($ConfigContent -notmatch "nonce_validation") {
$MigrationNeeded = $true
Write-Host "⚠ Missing security feature: nonce_validation" -ForegroundColor Yellow
}
if ($ConfigContent -notmatch "machine_id") {
$MigrationNeeded = $true
Write-Host "⚠ Missing security feature: machine_id_binding" -ForegroundColor Yellow
}
}
}
# Handle migration if needed
if ($MigrationNeeded) {
Write-Host
Write-Host "=== Migration Required ===" -ForegroundColor Cyan
Write-Host "Agent will migrate on first start. Backing up configuration..." -ForegroundColor Yellow
# Create backup directory
New-Item -ItemType Directory -Force -Path $BackupDir | Out-Null
# Backup old configuration if it exists
if (Test-Path $OldConfigPath) {
Write-Host "Backing up old configuration..." -ForegroundColor Yellow
Copy-Item $OldConfigPath $BackupDir -ErrorAction Stop
}
# Backup current configuration if we're upgrading
if (Test-Path $ConfigPath) {
Write-Host "Backing up current configuration..." -ForegroundColor Yellow
Copy-Item $ConfigPath "$BackupDir\config.json.backup" -ErrorAction Stop
}
Write-Host "Migration will run automatically when agent starts."
Write-Host "View migration logs with: Get-EventLog -LogName Application -Source $ServiceName -Newest 50"
Write-Host
}
# Step 1: Stop existing service if running
$Service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($Service -and $Service.Status -eq 'Running') {
Write-Host "Stopping existing RedFlag agent service..." -ForegroundColor Yellow
Stop-Service -Name $ServiceName -Force
Start-Sleep -Seconds 2
}
# Step 2: Create directories
Write-Host "Creating directories..." -ForegroundColor Yellow
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
New-Item -ItemType Directory -Force -Path $ConfigDir | Out-Null
New-Item -ItemType Directory -Force -Path $AgentConfigDir | Out-Null
New-Item -ItemType Directory -Force -Path $ServerKeyDir | Out-Null
New-Item -ItemType Directory -Force -Path "$ConfigDir\backups" | Out-Null
New-Item -ItemType Directory -Force -Path "$ConfigDir\state" | Out-Null
New-Item -ItemType Directory -Force -Path "$ConfigDir\logs" | Out-Null
Ensure-LocalApiGroup -GroupName $LocalApiGroup
Move-Item -Path $TmpBinary -Destination $BinaryPath -Force
} finally {
Remove-InstallerScratchDirectory -Path $StagingDir
}
# Step 4: Handle configuration
if (Test-Path $ConfigPath) {
# Upgrade - preserve existing config
Write-Host "Upgrade detected - preserving existing configuration" -ForegroundColor Green
Write-Host "Agent will handle migration automatically on first start" -ForegroundColor Green
} else {
# Fresh install - create minimal config template with registration token
Write-Host "Fresh install detected - creating minimal configuration template" -ForegroundColor Green
$ConfigTemplate = @"
{
"version": 5,
"agent_version": "$Version",
"agent_id": "",
"token": "",
"refresh_token": "",
"registration_token": "$RegistrationToken",
"machine_id": "",
"check_in_interval": 300,
"server_url": "$ServerUrl",
"network": {
"timeout": 30000000000,
"retry_count": 3,
"retry_delay": 5000000000,
"max_idle_conn": 10
},
"proxy": {
"enabled": false
},
"tls": {
"enabled": false,
"insecure_skip_verify": false
},
"logging": {
"level": "info",
"max_size": 100,
"max_backups": 3,
"max_age": 28
},
"subsystems": {
"system": {"enabled": true, "timeout": 10000000000, "circuit_breaker": {"enabled": true, "failure_threshold": 3, "failure_window": 600000000000, "open_duration": 1800000000000, "half_open_attempts": 2}},
"filesystem": {"enabled": true, "timeout": 10000000000, "circuit_breaker": {"enabled": true, "failure_threshold": 3, "failure_window": 600000000000, "open_duration": 1800000000000, "half_open_attempts": 2}},
"network": {"enabled": true, "timeout": 30000000000, "circuit_breaker": {"enabled": true, "failure_threshold": 3, "failure_window": 600000000000, "open_duration": 1800000000000, "half_open_attempts": 2}},
"processes": {"enabled": true, "timeout": 30000000000, "circuit_breaker": {"enabled": true, "failure_threshold": 3, "failure_window": 600000000000, "open_duration": 1800000000000, "half_open_attempts": 2}},
"updates": {"enabled": true, "timeout": 30000000000, "circuit_breaker": {"enabled": false, "failure_threshold": 0, "failure_window": 0, "open_duration": 0, "half_open_attempts": 0}},
"storage": {"enabled": true, "timeout": 10000000000, "circuit_breaker": {"enabled": true, "failure_threshold": 3, "failure_window": 600000000000, "open_duration": 1800000000000, "half_open_attempts": 2}}
},
"security": {
"ed25519_verification": true,
"nonce_validation": true,
"machine_id_binding": true
}
}
"@
$ConfigTemplate | Set-Content -Path $ConfigPath -Encoding UTF8
}
# Decide install flow per docs/AGENT_LIFECYCLE.md:
# Fresh Install → no usable local config → call --register
# Upgrade In Place → local config has non-empty refresh_token → skip --register
# Token in URL is ignored on the upgrade path; refresh_token authenticates.
$AgentBinary = Join-Path $InstallDir "redflag-agent.exe"
$ExistingRefreshToken = ""
if (Test-Path $ConfigPath) {
try {
$ExistingConfig = Get-Content -Raw -Path $ConfigPath -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
if ($ExistingConfig.refresh_token) {
$ExistingRefreshToken = [string]$ExistingConfig.refresh_token
}
} catch {
# Malformed config — treat as no credentials present, fall through to register.
Write-Host "[WARN] [installer] [register] Could not parse existing config; treating as fresh install" -ForegroundColor Yellow
}
}
if ($ExistingRefreshToken -ne "") {
Write-Host "[INFO] [installer] [register] Upgrade in place - existing credentials detected, skipping registration" -ForegroundColor Cyan
Write-Host "[INFO] [installer] [register] Token in URL is ignored on the upgrade path; refresh_token authenticates" -ForegroundColor Gray
} elseif ($RegistrationToken -ne "") {
Write-Host "[INFO] [installer] [register] Registering agent with server..." -ForegroundColor Cyan
$RegisterProcess = Start-Process -FilePath $AgentBinary -ArgumentList "--server", "$ServerUrl", "--token", "$RegistrationToken", "--register" -Wait -PassThru -NoNewWindow
if ($RegisterProcess.ExitCode -eq 0) {
Write-Host "[SUCCESS] [installer] [register] Agent registered successfully" -ForegroundColor Green
Write-Host "[INFO] [installer] [register] Agent ID assigned, configuration updated" -ForegroundColor Gray
} else {
Write-Host "[ERROR] [installer] [register] Registration failed - check token validity and server connectivity" -ForegroundColor Red
Write-Host "[WARN] [installer] [register] Agent installed but not registered. Service will not start." -ForegroundColor Yellow
Write-Host ""
Write-Host "[INFO] [installer] [register] To retry registration manually:" -ForegroundColor Gray
Write-Host "[INFO] [installer] [register] $AgentBinary --server $ServerUrl --token YOUR_TOKEN --register" -ForegroundColor Gray
Write-Host "[INFO] [installer] [register] Then start service:" -ForegroundColor Gray
Write-Host "[INFO] [installer] [register] Start-Service -Name $ServiceName" -ForegroundColor Gray
exit 1
}
} else {
Write-Host "[INFO] [installer] [register] No registration token provided - skipping registration" -ForegroundColor Gray
Write-Host "[INFO] [installer] [register] Service will start but agent will exit until registered" -ForegroundColor Gray
Write-Host "[INFO] [installer] [register] To register manually:" -ForegroundColor Gray
Write-Host "[INFO] [installer] [register] $AgentBinary --server $ServerUrl --token YOUR_TOKEN --register" -ForegroundColor Gray
}
# Registration rewrites config.json with agent_id/token/refresh_token, so the
# restrictive ACL must be applied after registration, not before.
Set-AgentConfigPermissions -ConfigFilePath $ConfigPath -ConfigDirectoryPath $AgentConfigDir
# Step 6: Install Windows service (if not skipped)
if (-not $SkipServiceInstall) {
Write-Host "Creating Windows service..." -ForegroundColor Yellow
# Check if service exists and remove it first
$ExistingService = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($ExistingService) {
Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 1
& sc.exe delete $ServiceName | Out-Null
}
# Register service with appropriate credentials
if ([System.Environment]::OSVersion.Version.Major -ge 10) {
# Windows 10/Server 2016+ - can use LocalService
New-Service -Name $ServiceName -BinaryPathName $BinaryPath -DisplayName "RedFlag Security Agent" -Description "RedFlag Security Monitoring Agent" -StartupType Automatic | Out-Null
} else {
# Older Windows - use LocalSystem
New-Service -Name $ServiceName -BinaryPathName $BinaryPath -DisplayName "RedFlag Security Agent" -Description "RedFlag Security Monitoring Agent" -StartupType Automatic | Out-Null
}
Write-Host "Starting service..." -ForegroundColor Yellow
Start-Service -Name $ServiceName
}
# Step 7: Download and install native RedFlag Desktop.
# The desktop binary is optional — the installer proceeds without it if the
# server has no build for this platform.
$DesktopBinary = "redflag-desktop.exe"
$DesktopURL = "$ServerUrl/api/v1/desktop/windows/amd64?version=$Version"
$DesktopBinaryPath = Join-Path $InstallDir $DesktopBinary
Write-Host
Write-Host "Downloading RedFlag Desktop..." -ForegroundColor Yellow
try {
$TmpDesktop = Join-Path $env:TEMP "redflag-desktop-download.exe"
$DesktopResp = Invoke-WebRequest -Uri $DesktopURL -OutFile $TmpDesktop -UseBasicParsing -PassThru
# Verify desktop binary hash against the signed release manifest.
$DesktopHash = (Get-FileHash -Path $TmpDesktop -Algorithm SHA256).Hash.ToLower()
$ManifestDesktop = $Manifest.artifacts | Where-Object { $_.platform -eq "desktop-windows" -and $_.architecture -eq $ArchTag }
if ($ManifestDesktop) {
if ($DesktopHash -ne $ManifestDesktop.sha256.ToLower()) {
Stop-Install @(
"Desktop binary hash does not match signed manifest — refusing to install.",
" expected: $($ManifestDesktop.sha256.ToLower())",
" actual: $DesktopHash"
)
}
Write-Host "✓ Desktop hash verified against signed manifest" -ForegroundColor Green
} else {
Write-Host "[WARN] [installer] [desktop] No manifest entry for desktop-windows/$ArchTag — hash not verified" -ForegroundColor Yellow
}
Move-Item -Path $TmpDesktop -Destination $DesktopBinaryPath -Force
Write-Host "✓ Desktop app installed to $DesktopBinaryPath" -ForegroundColor Green
} catch {
if ($_.Exception.Response -and [int]$_.Exception.Response.StatusCode -eq 404) {
Write-Host " Desktop app not available for this platform — skipping." -ForegroundColor Gray
} else {
Write-Host "[WARN] [installer] [desktop] Download failed: $($_.Exception.Message) — skipping desktop install." -ForegroundColor Yellow
}
}
# Step 8: Register desktop autostart for the interactive user.
# The Run key launches redflag-desktop.exe on interactive logon.
# Must target HKCU (per-user); the installer runs elevated so we resolve the
# original non-elevated user via the calling process chain.
if (Test-Path $DesktopBinaryPath) {
Write-Host "Registering desktop autostart..." -ForegroundColor Yellow
try {
# Walk up the process tree to find the non-elevated caller.
$InteractiveSID = $null
$CurrentPID = [System.Diagnostics.Process]::GetCurrentProcess().Id
$Visited = @{}
while ($CurrentPID -and !$Visited[$CurrentPID]) {
$Visited[$CurrentPID] = $true
try {
$Proc = Get-CimInstance Win32_Process -Filter "ProcessId = $CurrentPID" -ErrorAction Stop
if ($Proc.Name -in @('explorer.exe', 'pwsh.exe', 'powershell.exe') -and $Proc.SessionId -ne 0) {
$InteractiveSID = ([System.Security.Principal.NTAccount]$Proc.GetOwner().User).Translate([System.Security.Principal.SecurityIdentifier]).Value
break
}
$CurrentPID = $Proc.ParentProcessId
} catch { break }
}
if (-not $InteractiveSID) {
# Fallback: use the SID of the active console session user.
$ConsoleSession = (Get-CimInstance Win32_ComputerSystem).UserName
if ($ConsoleSession) {
$InteractiveSID = ([System.Security.Principal.NTAccount]$ConsoleSession).Translate([System.Security.Principal.SecurityIdentifier]).Value
}
}
if ($InteractiveSID) {
$RunKey = "registry::HKEY_USERS\$InteractiveSID\Software\Microsoft\Windows\CurrentVersion\Run"
New-Item -Path $RunKey -Force | Out-Null
Set-ItemProperty -Path $RunKey -Name "RedFlagDesktop" -Value "`"$DesktopBinaryPath`"" -Type String
Write-Host "✓ Desktop autostart registered for interactive user" -ForegroundColor Green
} else {
Write-Host "[WARN] [installer] [desktop] Could not resolve interactive user SID — autostart not registered." -ForegroundColor Yellow
Write-Host " To launch manually: $DesktopBinaryPath" -ForegroundColor Gray
}
} catch {
Write-Host "[WARN] [installer] [desktop] Autostart registration failed: $($_.Exception.Message)" -ForegroundColor Yellow
Write-Host " To launch manually: $DesktopBinaryPath" -ForegroundColor Gray
}
}
Write-Host
Write-Host "✓ Installation complete!" -ForegroundColor Green
Write-Host "Agent is running. Check status with: Get-Service $ServiceName"
Write-Host "View logs with: Get-Content $ConfigDir\logs\agent.log -Tail 100 -Wait"
if (Test-Path $DesktopBinaryPath) {
Write-Host "Desktop tray app installed. Launch it from the Start Menu or reboot."
}