# AMerc Outpost installer -- one line, and the amerc page can watch it. # # powershell -ExecutionPolicy Bypass -c "irm https://amerc.ai/install.ps1 | iex" # # The install opens a small HTTP control service on 127.0.0.1 BEFORE it has any # credentials, and that is the whole design. The amerc page the user is already # signed in to finds the service there, hands it a one-time install key, watches # every step, and answers when a step fails -- so nobody types a password into a # console and nobody pastes a token. Run it with no page open and the same # install happens as a console TUI instead. # # Everything printed here is ASCII on purpose: this runs on Chinese Windows # consoles (GBK) where a stray box-drawing character turns the log into noise. param( [string]$Base = 'https://nuget.lessokaji.com', [string]$Token = '', [string]$Label = '', [switch]$NoService ) $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' $InstallerVersion = '1.1.52-reuse' # Placeholders survive when the raw asset is run straight from a checkout. if ($Base -like '@@*') { $Base = 'https://amerc.ai' } if ($Token -like '@@*') { $Token = '' } if ($Label -like '@@*') { $Label = '' } if ($InstallerVersion -like '@@*') { $InstallerVersion = 'dev' } $Base = $Base.TrimEnd('/') if (-not $Label) { $Label = $env:COMPUTERNAME + '-outpost' } $AllowedOrigins = @('https://nuget.lessokaji.com', 'https://amerc.lessokaji.com', 'https://us.amerc.ai', 'https://amerc.ai', 'https://uz.amerc.ai') if ($AllowedOrigins.Count -eq 1 -and $AllowedOrigins[0] -like '@@*') { $AllowedOrigins = @('https://amerc.ai') } if ($AllowedOrigins -notcontains $Base) { $AllowedOrigins += $Base } $ControlPorts = @(51781, 51782, 51783) if ($ControlPorts.Count -eq 1 -and "$($ControlPorts[0])" -like '@@*') { $ControlPorts = @(51781, 51782, 51783) } try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls11 } catch {} $Root = Join-Path $env:LOCALAPPDATA 'AMerc\Outpost' # --------------------------------------------------------------------------- # Shared state. The install runs on this thread; the control service runs on # another and only ever reads this, plus writes the few fields the page is # allowed to set (token, base, answer, cancel). # --------------------------------------------------------------------------- $State = [hashtable]::Synchronized(@{ installerId = ([guid]::NewGuid().ToString('N')) version = $InstallerVersion platform = 'windows' base = $Base label = $Label token = $Token machine = $env:COMPUTERNAME phase = 'starting' needs = '' detail = '' percent = 0 error = '' prompt = $null answer = '' cancelled = $false done = $false outpostId = '' outpostPort = 0 port = 0 served = 0 serviceError = '' serving = $true startedAt = (Get-Date).ToUniversalTime().ToString('o') steps = [System.Collections.ArrayList]::Synchronized((New-Object System.Collections.ArrayList)) origins = $AllowedOrigins listener = $null }) function Now-Iso { (Get-Date).ToUniversalTime().ToString('o') } function Set-Phase([string]$phase, [string]$needs) { $State.phase = $phase $State.needs = $needs } # Every step is printed locally AND kept in the state the page reads, AND # streamed to amerc when we have an install key. Three readers, one call. function Save-StateFile { try { $path = Join-Path $env:TEMP ('amerc-installer-' + $State.installerId + '.json') $snapshot = @{ phase = $State.phase; needs = $State.needs; detail = $State.detail; error = $State.error port = $State.port; served = $State.served; serviceError = $State.serviceError done = $State.done; outpostId = $State.outpostId; version = $State.version at = (Get-Date).ToUniversalTime().ToString('o') } Set-Content -LiteralPath $path -Value ($snapshot | ConvertTo-Json -Compress) -Encoding ASCII -Force } catch {} } function Step([string]$message) { $line = [string]$message Write-Host ('[amerc] ' + $line) -ForegroundColor Cyan [void]$State.steps.Add(@{ at = (Now-Iso); text = $line }) while ($State.steps.Count -gt 200) { $State.steps.RemoveAt(0) } $State.detail = $line Save-StateFile if ($State.token) { try { $body = @{ step = $line } | ConvertTo-Json -Compress Invoke-RestMethod -Uri ($State.base + '/api/outpost/install-progress/' + $State.token) -Method Post ` -TimeoutSec 3 -ContentType 'application/json' -Body $body -ErrorAction SilentlyContinue | Out-Null } catch {} } } function Write-Rule { Write-Host ('-' * 66) -ForegroundColor DarkGray } # --------------------------------------------------------------------------- # The control service. # # A raw TcpListener rather than HttpListener: HttpListener needs a URL ACL # reservation and refuses to start for a non-elevated user, and this installer # deliberately never asks for administrator. # --------------------------------------------------------------------------- $ServiceScript = { param($State) $enc = [Text.Encoding]::UTF8 # The service is one thread, so the cost of a socket that says nothing is the # cost of the whole service. Browsers PRECONNECT: they open a socket to an # origin they expect to use and send nothing at all. At six seconds each, # against a page polling every second and a half, those silent sockets put the # loop permanently behind and the page sees a port that accepts and never # answers. Everything here is bounded in the low hundreds of milliseconds; a # real request on loopback arrives in one packet, immediately. function Read-Request($stream) { $bytes = New-Object System.Collections.Generic.List[byte] $buffer = New-Object byte[] 4096 $headerEnd = -1 $deadline = (Get-Date).AddSeconds(3) while ((Get-Date) -lt $deadline) { if ($stream.DataAvailable -or $bytes.Count -eq 0) { $read = 0 try { $read = $stream.Read($buffer, 0, $buffer.Length) } catch { break } if ($read -le 0) { break } for ($i = 0; $i -lt $read; $i++) { $bytes.Add($buffer[$i]) } } else { Start-Sleep -Milliseconds 5; continue } if ($headerEnd -lt 0) { for ($i = 3; $i -lt $bytes.Count; $i++) { if ($bytes[$i - 3] -eq 13 -and $bytes[$i - 2] -eq 10 -and $bytes[$i - 1] -eq 13 -and $bytes[$i] -eq 10) { $headerEnd = $i; break } } } if ($headerEnd -ge 0) { $headerText = $enc.GetString($bytes.ToArray(), 0, $headerEnd + 1) $length = 0 foreach ($line in ($headerText -split "`r`n")) { if ($line -match '^(?i)content-length:\s*(\d+)\s*$') { $length = [int]$matches[1] } } if ($bytes.Count -ge $headerEnd + 1 + $length) { $body = '' if ($length -gt 0) { $body = $enc.GetString($bytes.ToArray(), $headerEnd + 1, $length) } return @{ header = $headerText; body = $body } } } if ($bytes.Count -gt 262144) { break } } return $null } function Send-Response($stream, [int]$status, [string]$reason, [hashtable]$headers, [string]$body) { $payload = $enc.GetBytes([string]$body) $text = "HTTP/1.1 $status $reason`r`n" foreach ($key in $headers.Keys) { $text += ('' + $key + ': ' + $headers[$key] + "`r`n") } $text += ('Content-Length: ' + $payload.Length + "`r`n") $text += "Connection: close`r`n`r`n" $head = $enc.GetBytes($text) $stream.Write($head, 0, $head.Length) if ($payload.Length -gt 0) { $stream.Write($payload, 0, $payload.Length) } $stream.Flush() } function Cors-Headers([string]$origin) { $headers = @{ 'Vary' = 'Origin, Access-Control-Request-Private-Network' 'Cache-Control' = 'no-store' 'X-Content-Type-Options' = 'nosniff' 'X-Amerc-Installer' = $State.installerId } if ($origin -and ($State.origins -contains $origin)) { $headers['Access-Control-Allow-Origin'] = $origin $headers['Access-Control-Expose-Headers'] = 'x-amerc-installer' } return $headers } function Snapshot-Json { $steps = @() foreach ($item in $State.steps.ToArray()) { $steps += (New-Object psobject -Property $item) } $snapshot = New-Object psobject -Property @{ ok = $true service = 'amerc-installer' installerId = $State.installerId version = $State.version platform = $State.platform machine = $State.machine label = $State.label base = $State.base phase = $State.phase needs = $State.needs detail = $State.detail percent = $State.percent error = $State.error prompt = $State.prompt done = $State.done cancelled = $State.cancelled outpostId = $State.outpostId outpostPort = $State.outpostPort port = $State.port served = $State.served serviceError = $State.serviceError startedAt = $State.startedAt hasCredential = [bool]$State.token steps = $steps } return ($snapshot | ConvertTo-Json -Depth 5 -Compress) } while ($State.serving) { try { if (-not $State.listener.Pending()) { Start-Sleep -Milliseconds 25; continue } $client = $State.listener.AcceptTcpClient() } catch { Start-Sleep -Milliseconds 100; continue } $State.served = [int]$State.served + 1 try { $client.ReceiveTimeout = 900 $client.SendTimeout = 3000 $client.NoDelay = $true $stream = $client.GetStream() $request = Read-Request $stream if (-not $request) { $client.Close(); continue } $lines = $request.header -split "`r`n" $parts = $lines[0] -split ' ' $method = [string]$parts[0] $target = [string]$parts[1] $origin = '' $host_ = '' $type = '' foreach ($line in $lines) { if ($line -match '^(?i)origin:\s*(.+?)\s*$') { $origin = $matches[1] } elseif ($line -match '^(?i)host:\s*(.+?)\s*$') { $host_ = $matches[1] } elseif ($line -match '^(?i)content-type:\s*(.+?)\s*$') { $type = $matches[1] } } $path = ($target -split '\?')[0] $headers = Cors-Headers $origin # A page that is not amerc must not be able to drive an install, and a # name that is not loopback must not resolve here (DNS rebinding). $hostName = ($host_ -split ':')[0] if ($hostName -and $hostName -ne '127.0.0.1' -and $hostName -ne 'localhost' -and $hostName -ne '[::1]') { Send-Response $stream 421 'Misdirected Request' $headers '{"error":"bad_host"}' $client.Close(); continue } if ($method -eq 'OPTIONS') { if (-not $headers.ContainsKey('Access-Control-Allow-Origin')) { Send-Response $stream 403 'Forbidden' @{ 'Cache-Control' = 'no-store' } 'origin not allowed' $client.Close(); continue } $headers['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS' $headers['Access-Control-Allow-Headers'] = 'content-type' $headers['Access-Control-Max-Age'] = '600' foreach ($line in $lines) { if ($line -match '^(?i)access-control-request-private-network:\s*true\s*$') { $headers['Access-Control-Allow-Private-Network'] = 'true' } } Send-Response $stream 204 'No Content' $headers '' $client.Close(); continue } $headers['Content-Type'] = 'application/json; charset=utf-8' if ($method -eq 'GET' -and ($path -eq '/api/installer/health' -or $path -eq '/api/installer/status')) { Send-Response $stream 200 'OK' $headers (Snapshot-Json) $client.Close(); continue } if ($method -eq 'POST') { # A cross-origin POST that skips the preflight can only carry a simple # content type. Requiring JSON means every write was preflighted, and # the preflight is where the origin allowlist is enforced. if ($type -notmatch '(?i)application/json') { Send-Response $stream 415 'Unsupported Media Type' $headers '{"error":"json_required"}' $client.Close(); continue } if (-not $headers.ContainsKey('Access-Control-Allow-Origin') -and $origin) { Send-Response $stream 403 'Forbidden' $headers '{"error":"origin_not_allowed"}' $client.Close(); continue } $payload = $null try { if ($request.body) { $payload = $request.body | ConvertFrom-Json } } catch { $payload = $null } if ($path -eq '/api/installer/credential') { $newBase = '' $newToken = '' if ($payload) { if ($payload.PSObject.Properties.Name -contains 'base') { $newBase = [string]$payload.base } if ($payload.PSObject.Properties.Name -contains 'token_reg') { $newToken = [string]$payload.token_reg } if (-not $newToken -and $payload.PSObject.Properties.Name -contains 'token') { $newToken = [string]$payload.token } if ($payload.PSObject.Properties.Name -contains 'label' -and $payload.label) { $State.label = [string]$payload.label } } if ($newBase -match '^https?://[A-Za-z0-9._:-]+$') { $State.base = $newBase.TrimEnd('/') } if ($newToken -notmatch '^[a-f0-9]{48}$') { Send-Response $stream 400 'Bad Request' $headers '{"error":"install_key_invalid","hint":"token_reg must be the 48-hex install key from build_outpost"}' $client.Close(); continue } $State.token = $newToken $State.answer = 'credential' Send-Response $stream 200 'OK' $headers (Snapshot-Json) $client.Close(); continue } if ($path -eq '/api/installer/answer' -or $path -eq '/api/installer/action') { $value = '' if ($payload) { if ($payload.PSObject.Properties.Name -contains 'action') { $value = [string]$payload.action } if (-not $value -and $payload.PSObject.Properties.Name -contains 'value') { $value = [string]$payload.value } if ($payload.PSObject.Properties.Name -contains 'base' -and [string]$payload.base -match '^https?://[A-Za-z0-9._:-]+$') { $State.base = ([string]$payload.base).TrimEnd('/') } } $value = $value.ToLower().Trim() if ($value -notin @('retry', 'skip', 'continue', 'cancel', 'shutdown')) { Send-Response $stream 400 'Bad Request' $headers '{"error":"unknown_action","actions":["retry","skip","continue","cancel","shutdown"]}' $client.Close(); continue } if ($value -eq 'cancel' -or $value -eq 'shutdown') { $State.cancelled = $true } $State.answer = $value Send-Response $stream 200 'OK' $headers (Snapshot-Json) $client.Close(); continue } } Send-Response $stream 404 'Not Found' $headers '{"error":"unknown_route","routes":["GET /api/installer/health","GET /api/installer/status","POST /api/installer/credential","POST /api/installer/answer","POST /api/installer/action"]}' $client.Close() } catch { try { $client.Close() } catch {} } } try { $State.listener.Stop() } catch {} $State.serving = $false } function Start-ControlService { if ($NoService) { return $false } foreach ($port in $ControlPorts) { try { $listener = New-Object System.Net.Sockets.TcpListener -ArgumentList ([System.Net.IPAddress]::Loopback), ([int]$port) $listener.Start() $State.listener = $listener $State.port = [int]$port break } catch { $State.listener = $null } } if (-not $State.listener) { return $false } $runspace = [runspacefactory]::CreateRunspace() $runspace.ApartmentState = 'MTA' $runspace.ThreadOptions = 'ReuseThread' $runspace.Open() $shell = [powershell]::Create() $shell.Runspace = $runspace [void]$shell.AddScript($ServiceScript.ToString()).AddArgument($State) $script:ServiceHandle = $shell.BeginInvoke() $script:ServiceShell = $shell return $true } # A dead service thread is indistinguishable, from the page, from a machine that # never ran the command -- the OS still completes the TCP handshake out of the # listen backlog. Notice it here and say so, rather than letting the page wait. function Test-ControlService { if (-not $script:ServiceHandle) { return } if (-not $script:ServiceHandle.IsCompleted -and $State.serving) { return } $reason = '' try { if ($script:ServiceShell -and $script:ServiceShell.Streams.Error.Count -gt 0) { $reason = [string]$script:ServiceShell.Streams.Error[0] } } catch {} $State.serviceError = if ($reason) { $reason } else { 'the control service stopped' } Write-Host ('[amerc] control service stopped: ' + $State.serviceError) -ForegroundColor Yellow } function Stop-ControlService { $State.serving = $false Start-Sleep -Milliseconds 200 try { if ($script:ServiceShell) { $script:ServiceShell.Stop() } } catch {} try { if ($State.listener) { $State.listener.Stop() } } catch {} } # --------------------------------------------------------------------------- # Console TUI. The keyboard is a second, equal way to answer anything the page # can answer -- an install must never be stuck because no browser showed up. # --------------------------------------------------------------------------- $script:Interactive = $true try { $null = [Console]::KeyAvailable } catch { $script:Interactive = $false } function Read-Key { if (-not $script:Interactive) { return '' } try { if (-not [Console]::KeyAvailable) { return '' } } catch { $script:Interactive = $false; return '' } try { return ([Console]::ReadKey($true).KeyChar.ToString().ToLower()) } catch { return '' } } function Show-Banner { Write-Host '' Write-Rule Write-Host (' AMerc Outpost installer ' + $InstallerVersion) -ForegroundColor White Write-Rule Write-Host (' site : ' + $State.base) Write-Host (' machine : ' + $State.machine) Write-Host (' install to : ' + $Root) if ($State.port -gt 0) { Write-Host (' control service: http://127.0.0.1:' + $State.port + ' (the amerc page drives this install)') } else { Write-Host ' control service: not started (ports busy) -- this install is keyboard-only' -ForegroundColor Yellow } Write-Rule Write-Host '' } # One place where both answer channels meet: the page POSTs /answer, the user # presses a key, and whichever arrives first wins. function Wait-Answer([string[]]$allowed, [int]$timeoutSeconds, [string]$hint) { $deadline = (Get-Date).AddSeconds($timeoutSeconds) $State.answer = '' if ($hint) { Write-Host $hint -ForegroundColor Yellow } while ((Get-Date) -lt $deadline) { if ($State.cancelled) { return 'cancel' } if ($State.answer) { $value = [string]$State.answer $State.answer = '' # A key pushed while a step is blocked means "try that again with this". if ($value -eq 'credential' -and $allowed -contains 'retry') { return 'retry' } if ($allowed -contains $value) { return $value } } $key = Read-Key if ($key) { switch ($key) { 'r' { if ($allowed -contains 'retry') { return 'retry' } } 's' { if ($allowed -contains 'skip') { return 'skip' } } 'c' { if ($allowed -contains 'continue') { return 'continue' } } 'm' { if ($allowed -contains 'manual') { return 'manual' } } 'q' { return 'cancel' } } } Start-Sleep -Milliseconds 150 } return 'timeout' } # --------------------------------------------------------------------------- # Credentials. # # The guided path never sees a password: the page mints a one-time install key # server-side and POSTs it here. The manual path asks for the account and mints # the same key itself. Both end holding a 48-hex key and nothing else. # --------------------------------------------------------------------------- function Request-InstallKeyInteractively { Write-Host '' Write-Host ' Manual sign-in (nothing is stored; only a one-time install key is kept).' -ForegroundColor White $accountUser = Read-Host ' AMerc account username' $secure = Read-Host ' AMerc account password' -AsSecureString $accountPassword = [Net.NetworkCredential]::new('', $secure).Password try { $session = New-Object Microsoft.PowerShell.Commands.WebRequestSession Invoke-WebRequest -UseBasicParsing -WebSession $session -Method Post -Uri ($State.base + '/login') ` -ContentType 'application/x-www-form-urlencoded' -Body @{ username = $accountUser; password = $accountPassword } | Out-Null $body = @{ platform = 'windows'; label = $State.label; servesAgents = $true } | ConvertTo-Json -Depth 4 $built = Invoke-RestMethod -WebSession $session -Method Post -Uri ($State.base + '/api/outposts/build') ` -ContentType 'application/json' -Body $body $key = '' if ($built.downloadUrl -match '([a-f0-9]{48})') { $key = $matches[1] } if (-not $key) { throw 'AMerc did not return an install key.' } $State.token = $key if ($built.outpostId) { $State.outpostId = [string]$built.outpostId } Step 'Signed in and minted a one-time install key.' return $true } catch { Write-Host (' Sign-in failed: ' + $_.Exception.Message) -ForegroundColor Red return $false } finally { $accountPassword = $null $secure = $null } } function Wait-ForCredential { if ($State.token) { return $true } Set-Phase 'awaiting_credential' 'credential' Step 'Waiting for the amerc page to configure this install.' Write-Host '' Write-Host ' Keep the amerc tab open -- it configures this install automatically.' -ForegroundColor White Write-Host ' No browser? Press [M] to sign in here instead. [Q] quit' -ForegroundColor DarkGray Write-Host '' $spin = '|/-\' $tick = 0 $deadline = (Get-Date).AddMinutes(20) while ((Get-Date) -lt $deadline) { if ($State.token) { Write-Host '' Step 'The amerc page supplied a one-time install key.' return $true } if ($State.cancelled) { return $false } $key = Read-Key if ($key -eq 'm') { Write-Host '' if (Request-InstallKeyInteractively) { return $true } Write-Host ' Still waiting for the amerc page. [M] try again [Q] quit' -ForegroundColor DarkGray } elseif ($key -eq 'q') { return $false } $tick++ if ($tick % 20 -eq 0) { Test-ControlService; Save-StateFile } # Show the request counter next to the spinner. "waiting" with a counter # that never moves is a control service nobody is reaching; "waiting" with # a counter that climbs is a page that found it and has not configured it # yet. Those are different problems and they used to look the same. Write-Host ([char]13 + ' waiting ' + $spin[$tick % 4] + ' (' + $State.served + ' requests served) ') -NoNewline -ForegroundColor DarkGray Start-Sleep -Milliseconds 250 } Write-Host '' return $false } # --------------------------------------------------------------------------- # Steps that can fail, ask, and be answered. # --------------------------------------------------------------------------- function Invoke-Step([string]$name, [scriptblock]$work, [switch]$Skippable) { while ($true) { if ($State.cancelled) { throw 'cancelled' } try { Step $name & $work $State.error = '' $State.prompt = $null return $true } catch { $message = [string]$_.Exception.Message $State.error = $message $allowed = @('retry', 'cancel') if ($Skippable) { $allowed += 'skip' } $State.prompt = New-Object psobject -Property @{ id = ([guid]::NewGuid().ToString('N').Substring(0, 12)) kind = 'step_failed' step = $name text = ($name + ' failed: ' + $message) options = $allowed } $previousPhase = $State.phase Set-Phase 'blocked' 'answer' Write-Host '' Write-Host (' FAILED: ' + $name) -ForegroundColor Red Write-Host (' ' + $message) -ForegroundColor Red $hintKeys = if ($Skippable) { ' [R] retry [S] skip this step [Q] quit' } else { ' [R] retry [Q] quit' } $answer = Wait-Answer $allowed 900 ($hintKeys + ' (the amerc page can answer this too)') $State.prompt = $null Set-Phase $previousPhase '' if ($answer -eq 'retry') { Write-Host ' retrying...' -ForegroundColor Yellow; continue } if ($answer -eq 'skip') { Step ($name + ' -- skipped on request.'); return $false } throw $message } } } function Download-File([string]$url, [string]$destination, [string]$label) { try { $request = [Net.HttpWebRequest]::Create($url) $request.Timeout = 60000 $request.ReadWriteTimeout = 120000 $response = $request.GetResponse() $total = [int64]$response.ContentLength $stream = $response.GetResponseStream() $file = [IO.File]::Create($destination) $buffer = New-Object byte[] 262144 $read = 0 $sum = [int64]0 $lastPercent = -1 while (($read = $stream.Read($buffer, 0, $buffer.Length)) -gt 0) { $file.Write($buffer, 0, $read) $sum += $read if ($total -gt 0) { $percent = [int](($sum / $total) * 100) if ($percent -ne $lastPercent) { $lastPercent = $percent $State.percent = $percent $State.detail = ($label + ' ' + $percent + '%') $mb = [math]::Round($sum / 1MB, 1) $totalMb = [math]::Round($total / 1MB, 1) Write-Host ([char]13 + '[amerc] ' + $label + ' ' + $mb + '/' + $totalMb + ' MB (' + $percent + '%) ') -NoNewline -ForegroundColor Cyan } } } Write-Host '' $file.Close(); $stream.Close(); $response.Close() $State.percent = 100 } catch { if (Test-Path $destination) { Remove-Item $destination -Force -ErrorAction SilentlyContinue } throw } } function Stop-RunningOutpost { Get-Process -Name node -ErrorAction SilentlyContinue | ForEach-Object { try { $line = (Get-CimInstance Win32_Process -Filter ("ProcessId=" + $_.Id) -ErrorAction SilentlyContinue).CommandLine if ($line -and $line -like '*outpost.mjs*') { Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue } } catch {} } } function Install-Outpost { $payloadUrl = $State.base + '/api/outpost/payload/' + $State.token $manifestUrl = $State.base + '/downloads/outpost-components/manifest.json' $zip = Join-Path $env:TEMP ('amerc-core-' + $State.installerId + '.zip') Set-Phase 'preparing' '' Invoke-Step 'Preparing the install directory' { Stop-RunningOutpost New-Item -ItemType Directory -Force -Path $Root | Out-Null } | Out-Null Set-Phase 'downloading_core' '' Invoke-Step 'Downloading the Outpost core (your credentials are baked in)' { Download-File $payloadUrl $zip 'Outpost core' if (-not (Test-Path $zip)) { throw 'the core did not arrive' } $size = (Get-Item $zip).Length if ($size -lt 1024) { throw ('the core is only ' + $size + ' bytes -- the install key has probably expired') } # A proxy or an error page answering 200 is the failure mode that otherwise # surfaces one step later as an unhelpful Expand-Archive error. $head = New-Object byte[] 2 $probe = [IO.File]::OpenRead($zip) [void]$probe.Read($head, 0, 2) $probe.Dispose() if ($head[0] -ne 0x50 -or $head[1] -ne 0x4B) { throw 'the download is not an Outpost package (install key expired?)' } } | Out-Null Set-Phase 'extracting' '' Invoke-Step 'Extracting the Outpost core' { Step ('Core downloaded: ' + [math]::Round((Get-Item $zip).Length / 1KB, 0) + ' KB.') Expand-Archive -LiteralPath $zip -DestinationPath $Root -Force Remove-Item $zip -Force -ErrorAction SilentlyContinue $configPath = Join-Path $Root 'outpost.config.json' if (Test-Path $configPath) { try { $config = Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json if ($config.outpostId) { $State.outpostId = [string]$config.outpostId } } catch {} } } | Out-Null Set-Phase 'runtime' '' $script:NodeExe = $null # A runtime this machine already fetched is still here after a reinstall: the # core archive is unpacked over the install dir and never deletes bins\node. # Checking costs nothing and saves a customer re-downloading 31 MB to reinstall. $existing = Join-Path $Root 'bins\node\node.exe' if (Test-Path -LiteralPath $existing) { try { $have = (& $existing -v) -replace 'v', '' if ([int]($have.Split('.')[0]) -ge 18) { $script:NodeExe = $existing Step ('Reusing the Node runtime already installed here (' + $have + ')') } } catch {} } $system = Get-Command node -ErrorAction SilentlyContinue if (-not $script:NodeExe -and $system) { try { $version = (& $system.Source -v) -replace 'v', '' if ([int]($version.Split('.')[0]) -ge 18) { $script:NodeExe = $system.Source Step ('Reusing system Node ' + $version) } } catch {} } if (-not $script:NodeExe) { Invoke-Step 'Fetching the verified Node runtime component' { $manifest = Invoke-RestMethod -UseBasicParsing -Uri $manifestUrl -TimeoutSec 30 $component = $manifest.components | Where-Object { $_.platform -eq 'windows' } | Select-Object -First 1 if (-not $component) { throw 'no Windows Node runtime component is published' } Step ('Downloading Node ' + $component.version + ' (' + [math]::Round($component.downloadBytes / 1MB, 1) + ' MB compressed)') $gz = Join-Path $env:TEMP 'amerc-node.exe.gz' Download-File $component.url $gz 'Node runtime' $nodeDir = Join-Path $Root 'bins\node' New-Item -ItemType Directory -Force -Path $nodeDir | Out-Null $target = Join-Path $nodeDir 'node.exe' Step 'Decompressing the Node runtime' $inStream = [IO.File]::OpenRead($gz) $outStream = [IO.File]::Create($target) $gzip = New-Object IO.Compression.GzipStream -ArgumentList $inStream, ([IO.Compression.CompressionMode]::Decompress) $gzip.CopyTo($outStream) $gzip.Dispose(); $outStream.Dispose(); $inStream.Dispose() Remove-Item $gz -Force -ErrorAction SilentlyContinue if ($component.sha256) { Step 'Verifying the runtime checksum' $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $target).Hash.ToLower() if ($hash -ne ([string]$component.sha256).ToLower()) { throw 'Node runtime checksum mismatch' } } $script:NodeExe = $target } | Out-Null } Set-Phase 'registering' '' $gui = Join-Path $Root 'outpost-gui.ps1' $powershellExe = '' try { $powershellExe = (Get-Command powershell.exe -ErrorAction Stop).Source } catch {} if (-not $powershellExe) { $powershellExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' } $launchCmd = Join-Path $Root 'run-amerc-outpost.cmd' Invoke-Step 'Registering the logon task' { Set-Content -LiteralPath $launchCmd -Encoding ASCII -Value @( '@echo off', ('cd /d "' + $Root + '"'), ('start "" "' + $powershellExe + '" -NoLogo -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "' + $gui + '" -StartMinimized') ) $action = New-ScheduledTaskAction -Execute $launchCmd $trigger = New-ScheduledTaskTrigger -AtLogOn Register-ScheduledTask -TaskName 'AMerc Outpost' -Action $action -Trigger $trigger -Force -ErrorAction Stop | Out-Null } -Skippable | Out-Null Set-Phase 'starting_outpost' '' Invoke-Step 'Starting the Outpost' { if (Test-Path -LiteralPath $gui) { Start-Process -FilePath $powershellExe -ArgumentList '-NoLogo', '-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', $gui, '-StartMinimized' -WorkingDirectory $Root } else { Start-Process -FilePath $script:NodeExe -ArgumentList ('"' + (Join-Path $Root 'outpost.mjs') + '"') -WindowStyle Hidden -WorkingDirectory $Root } } | Out-Null Invoke-Step 'Adding the desktop shortcut' { $desktop = [Environment]::GetFolderPath('Desktop') $icon = Join-Path $Root 'assets\amerc.ico' $cli = Join-Path $Root 'outpost-cli.cmd' $shell = New-Object -ComObject WScript.Shell $link = $shell.CreateShortcut((Join-Path $desktop 'AMerc Outpost.lnk')) $link.TargetPath = $cli $link.Arguments = 'menu' $link.WorkingDirectory = $Root $link.IconLocation = ($icon + ',0') $link.Description = 'Open the AMerc Outpost control CLI' $link.Save() Remove-Item -LiteralPath (Join-Path $desktop 'AMerc Outpost.url') -Force -ErrorAction SilentlyContinue } -Skippable | Out-Null } # The install is not finished when the script ends -- it is finished when the # Outpost answers on loopback. Waiting here is what lets the page say "online" # instead of "the script exited". function Wait-ForOutpostOnline { Set-Phase 'verifying' '' Step 'Waiting for the Outpost to answer on this machine.' $ports = @(51771, 51772, 51773, 51774, 51775) $deadline = (Get-Date).AddSeconds(120) while ((Get-Date) -lt $deadline) { foreach ($port in $ports) { try { $health = Invoke-RestMethod -Uri ('http://127.0.0.1:' + $port + '/api/health') -TimeoutSec 2 if ($health) { $id = '' if ($health.PSObject.Properties.Name -contains 'outpostId') { $id = [string]$health.outpostId } elseif ($health.PSObject.Properties.Name -contains 'id') { $id = [string]$health.id } if (-not $State.outpostId -or $id -eq $State.outpostId) { $State.outpostPort = [int]$port if ($id) { $State.outpostId = $id } Step ('Outpost is answering on 127.0.0.1:' + $port) return $true } } } catch {} } Start-Sleep -Milliseconds 1500 } Step 'The Outpost did not answer on loopback yet; it may still be starting.' return $false } # --------------------------------------------------------------------------- # Run. # --------------------------------------------------------------------------- $served = Start-ControlService Show-Banner if ($served) { Step ('Control service listening on http://127.0.0.1:' + $State.port) } try { if (-not (Wait-ForCredential)) { Set-Phase 'cancelled' '' $State.cancelled = $true Write-Host '' Write-Host ' Install cancelled. Nothing was changed.' -ForegroundColor Yellow Stop-ControlService return } Install-Outpost $online = Wait-ForOutpostOnline Set-Phase 'done' '' $State.done = $true $State.percent = 100 Step 'Done.' Write-Host '' Write-Rule Write-Host ' AMerc Outpost is installed.' -ForegroundColor Green Write-Host (' install dir : ' + $Root) if ($State.outpostId) { Write-Host (' outpost id : ' + $State.outpostId) } if ($online) { Write-Host (' local api : http://127.0.0.1:' + $State.outpostPort) } Write-Host ' desktop : "AMerc Outpost" opens the control CLI (no login).' Write-Rule Write-Host ' Keep the amerc page open -- it takes over from here.' -ForegroundColor White Write-Host '' } catch { Set-Phase 'failed' '' $State.error = [string]$_.Exception.Message Write-Host '' Write-Host (' Install stopped: ' + $State.error) -ForegroundColor Red Write-Host ' The amerc page can see this. Ask it to retry, or run the one-liner again.' -ForegroundColor Yellow Write-Host '' } finally { # Stay reachable for a little while after the last step so the page can read # the final state -- an installer that vanishes the instant it finishes looks # exactly like an installer that crashed. if ($served) { $linger = (Get-Date).AddSeconds(120) while ((Get-Date) -lt $linger -and -not $State.cancelled) { Start-Sleep -Milliseconds 250 } } Stop-ControlService # The one-time install key is spent: the node revokes it the moment the # Outpost connects and it expires anyway. We do NOT self-delete this script -- # a PowerShell file that erases itself is a classic antivirus heuristic and # got an earlier installer flagged. The Outpost removes it once it is up. try { if ($PSCommandPath -and (Test-Path $Root)) { Set-Content -LiteralPath (Join-Path $Root '.installer-cleanup') -Value $PSCommandPath -Encoding UTF8 -Force } } catch {} }