#Requires -Version 5.1 <# .SYNOPSIS BoltOS Windows Agent installer / uninstaller (idempotent, run as Administrator). .DESCRIPTION Lays out C:\BoltOS\agent, downloads node.exe (official LTS zip, sha256 checked against nodejs.org SHASUMS256.txt), SumatraPDF (portable), and winagent-.zip (sha256 checked against manifest.json), writes data\config.json, registers the "BoltOS Agent" Scheduled Task (logon trigger, restart-on-failure, no time limit) and starts it. .EXAMPLE irm https://boltos-winagent.pages.dev/winagent/install.ps1 -OutFile install.ps1 .\install.ps1 -DeviceId win-test01 -MqttUser win-test01 -MqttPass .EXAMPLE $env:WINAGENT_DEVICE_ID='win-test01'; $env:WINAGENT_MQTT_USER='win-test01'; $env:WINAGENT_MQTT_PASS='' irm https://boltos-winagent.pages.dev/winagent/install.ps1 | iex .EXAMPLE .\install.ps1 -Uninstall # stop + unregister task, remove program files, keep data\ .\install.ps1 -Uninstall -Purge # also remove data\ (config, logs, ledgers) #> [CmdletBinding()] param( [string]$DeviceId, [string]$MqttUser, [string]$MqttPass, [string]$Broker = 'wss://mqtt.powerbolt.co.th:443/mqtt', [string]$Version, [string]$ManifestUrl = 'https://boltos-winagent.pages.dev/winagent/manifest.json', [string]$Root = 'C:\BoltOS\agent', [switch]$Uninstall, [switch]$Purge ) Set-StrictMode -Version 1.0 $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } catch {} $TaskName = 'BoltOS Agent' $NodeVer = 'v22.22.0' $NodeZip = "node-$NodeVer-win-x64.zip" $NodeBase = "https://nodejs.org/dist/$NodeVer" $SumatraUrl = 'https://www.sumatrapdfreader.org/dl/rel/3.5.2/SumatraPDF-3.5.2-64.exe' $IssuerPem = @" -----BEGIN PUBLIC KEY----- MCowBQYDK2VwAyEABtVjTAzZeqbZo6WOF3nvgfW7kUpAm5cFBAXZ7h/ZI8Y= -----END PUBLIC KEY----- "@ $Utf8NoBom = New-Object System.Text.UTF8Encoding($false) function Log([string]$msg) { Write-Host ("[boltos] {0}" -f $msg) } function Warn([string]$msg) { Write-Warning ("[boltos] {0}" -f $msg) } function Write-TextFile([string]$path, [string]$text) { # Node reads these files: they must be UTF-8 WITHOUT BOM (JSON.parse rejects a BOM). [IO.File]::WriteAllText($path, $text, $Utf8NoBom) } function Get-Sha256([string]$path) { (Get-FileHash -Algorithm SHA256 -Path $path).Hash.ToLowerInvariant() } function Get-Download([string]$url, [string]$dest, [int]$attempts = 3) { for ($i = 1; $i -le $attempts; $i++) { try { Log ("download {0} (attempt {1}/{2})" -f $url, $i, $attempts) $tmp = "$dest.part" if (Test-Path $tmp) { Remove-Item $tmp -Force } Invoke-WebRequest -Uri $url -OutFile $tmp -UseBasicParsing -MaximumRedirection 5 Move-Item -Path $tmp -Destination $dest -Force return } catch { if ($i -eq $attempts) { throw ("download failed: {0} ({1})" -f $url, $_.Exception.Message) } Start-Sleep -Seconds (3 * $i) } } } function Get-TaskOrNull() { return Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue } function Stop-AgentTask() { $task = Get-TaskOrNull if ($null -eq $task) { return } try { Stop-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue } catch {} # Stop-ScheduledTask ends the task but a child node.exe launched from $Root may linger; end it explicitly. Get-CimInstance Win32_Process -Filter "Name = 'wscript.exe'" -ErrorAction SilentlyContinue | Where-Object { $_.CommandLine -and $_.CommandLine -match 'run-hidden\.vbs' } | ForEach-Object { try { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } catch {} } Get-CimInstance Win32_Process -Filter "Name = 'node.exe'" -ErrorAction SilentlyContinue | Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith($Root, [StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { try { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } catch {} } Start-Sleep -Seconds 1 } function Test-IsAdmin() { $id = [Security.Principal.WindowsIdentity]::GetCurrent() return (New-Object Security.Principal.WindowsPrincipal($id)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } # ---------------------------------------------------------------- uninstall if ($Uninstall) { Log "uninstall: stopping + unregistering task '$TaskName'" Stop-AgentTask if (Get-TaskOrNull) { Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false } if (Test-Path $Root) { Get-ChildItem -Path $Root -Force | ForEach-Object { if ($_.Name -ieq 'data' -and -not $Purge) { Log "keeping $($_.FullName) (use -Purge to remove)"; return } Remove-Item -Path $_.FullName -Recurse -Force } if ($Purge -or -not (Test-Path (Join-Path $Root 'data'))) { Remove-Item -Path $Root -Recurse -Force -ErrorAction SilentlyContinue $parent = Split-Path $Root -Parent if ((Test-Path $parent) -and -not (Get-ChildItem $parent -Force)) { Remove-Item $parent -Force -ErrorAction SilentlyContinue } } } try { Remove-MpPreference -ExclusionPath (Split-Path $Root -Parent) -ErrorAction SilentlyContinue } catch {} Log "uninstall complete" return } # ---------------------------------------------------------------- install if (-not (Test-IsAdmin)) { Warn "not running as Administrator: Defender exclusion will be skipped and task registration may fail" } # env fallbacks (for the `irm ... | iex` form) if (-not $DeviceId -and $env:WINAGENT_DEVICE_ID) { $DeviceId = $env:WINAGENT_DEVICE_ID } if (-not $MqttUser -and $env:WINAGENT_MQTT_USER) { $MqttUser = $env:WINAGENT_MQTT_USER } if (-not $MqttPass -and $env:WINAGENT_MQTT_PASS) { $MqttPass = $env:WINAGENT_MQTT_PASS } if ($env:WINAGENT_BROKER_URL) { $Broker = $env:WINAGENT_BROKER_URL } if (-not $Version -and $env:WINAGENT_VERSION) { $Version = $env:WINAGENT_VERSION } $DataDir = Join-Path $Root 'data' $ReleasesDir = Join-Path $Root 'releases' $LogsDir = Join-Path $DataDir 'logs' $DlDir = Join-Path $Root 'downloads' $ConfigPath = Join-Path $DataDir 'config.json' foreach ($d in @($Root, $ReleasesDir, $DataDir, $LogsDir, $DlDir, (Join-Path $Root 'src'))) { if (-not (Test-Path $d)) { New-Item -ItemType Directory -Path $d -Force | Out-Null } } # config: first install needs DeviceId + MqttPass; re-runs may omit them and keep the existing config $existing = $null if (Test-Path $ConfigPath) { try { $existing = Get-Content -Path $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json } catch { Warn "existing config.json unreadable, it will be rewritten" } } if (-not $DeviceId) { if ($existing -and $existing.device_id) { $DeviceId = $existing.device_id } else { throw "-DeviceId is required (e.g. win-test01)" } } if ($DeviceId -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$') { throw "-DeviceId '$DeviceId' has invalid characters" } if (-not $MqttUser) { if ($existing -and $existing.mqtt_user) { $MqttUser = $existing.mqtt_user } else { $MqttUser = $DeviceId } } if (-not $MqttPass) { if ($existing -and $existing.mqtt_pass) { $MqttPass = $existing.mqtt_pass } else { throw "-MqttPass is required on first install (never printed; stored only in data\config.json)" } } Log "device_id=$DeviceId mqtt_user=$MqttUser broker=$Broker" # node.exe $NodeExe = Join-Path $Root 'node.exe' $haveNode = $false if (Test-Path $NodeExe) { try { $haveNode = ((& $NodeExe -v) -eq $NodeVer) } catch { $haveNode = $false } } if ($haveNode) { Log "node.exe $NodeVer present" } else { $zipPath = Join-Path $DlDir $NodeZip Get-Download "$NodeBase/$NodeZip" $zipPath $sums = (Invoke-WebRequest -Uri "$NodeBase/SHASUMS256.txt" -UseBasicParsing).Content $line = ($sums -split "`n") | Where-Object { $_ -match ("\s+{0}\s*$" -f [regex]::Escape($NodeZip)) } | Select-Object -First 1 if (-not $line) { throw "SHASUMS256.txt has no entry for $NodeZip" } $expected = ($line -split '\s+')[0].ToLowerInvariant() $actual = Get-Sha256 $zipPath if ($actual -ne $expected) { throw "node zip sha256 mismatch (expected $expected got $actual)" } Add-Type -AssemblyName System.IO.Compression.FileSystem $zip = [IO.Compression.ZipFile]::OpenRead($zipPath) try { $entry = $zip.Entries | Where-Object { $_.FullName -ieq "node-$NodeVer-win-x64/node.exe" } | Select-Object -First 1 if (-not $entry) { throw "node.exe not found inside $NodeZip" } if (Test-Path $NodeExe) { Remove-Item $NodeExe -Force } [IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $NodeExe, $true) } finally { $zip.Dispose() } Remove-Item $zipPath -Force -ErrorAction SilentlyContinue Log ("node.exe {0} installed (sha256 verified)" -f (& $NodeExe -v)) } # SumatraPDF (portable, used with -silent for PDF printing) $SumatraExe = Join-Path $Root 'SumatraPDF.exe' if (Test-Path $SumatraExe) { Log "SumatraPDF.exe present" } else { Get-Download $SumatraUrl $SumatraExe Log "SumatraPDF.exe installed" } # manifest + release zip Log "manifest $ManifestUrl" $manifest = Invoke-RestMethod -Uri $ManifestUrl -UseBasicParsing if (-not $Version) { $Version = [string]$manifest.version } if ($Version -notmatch '^[A-Za-z0-9._-]+$') { throw "invalid version '$Version'" } if ([string]$manifest.version -eq $Version) { $zipUrl = [string]$manifest.url; $zipSha = ([string]$manifest.sha256).ToLowerInvariant() } else { # pinned older/newer version: same directory as the manifest, no sha available -> refuse unless manifest lists it $listed = $null if ($manifest.PSObject.Properties.Name -contains 'versions') { $listed = $manifest.versions | Where-Object { [string]$_.version -eq $Version } | Select-Object -First 1 } if (-not $listed) { throw "version $Version is not listed in the manifest (manifest.version=$($manifest.version))" } $zipUrl = [string]$listed.url; $zipSha = ([string]$listed.sha256).ToLowerInvariant() } if ($zipSha -notmatch '^[0-9a-f]{64}$') { throw "manifest sha256 is not a 64-hex digest" } if ($manifest.PSObject.Properties.Name -contains 'sumatra_sha256' -and $manifest.sumatra_sha256) { $sActual = Get-Sha256 $SumatraExe if ($sActual -ne ([string]$manifest.sumatra_sha256).ToLowerInvariant()) { throw "SumatraPDF.exe sha256 mismatch ($sActual)" } Log "SumatraPDF.exe sha256 verified" } $ReleaseDir = Join-Path $ReleasesDir $Version $marker = Join-Path $ReleaseDir '.sha256' $haveRelease = (Test-Path (Join-Path $ReleaseDir 'src\main.js')) -and (Test-Path $marker) -and ((Get-Content $marker -Raw).Trim() -eq $zipSha) if ($haveRelease) { Log "release $Version already extracted (sha256 match)" } else { $relZip = Join-Path $DlDir "winagent-$Version.zip" Get-Download $zipUrl $relZip $actual = Get-Sha256 $relZip if ($actual -ne $zipSha) { Remove-Item $relZip -Force -ErrorAction SilentlyContinue; throw "winagent zip sha256 mismatch (expected $zipSha got $actual)" } if (Test-Path $ReleaseDir) { Remove-Item $ReleaseDir -Recurse -Force } New-Item -ItemType Directory -Path $ReleaseDir -Force | Out-Null Add-Type -AssemblyName System.IO.Compression.FileSystem [IO.Compression.ZipFile]::ExtractToDirectory($relZip, $ReleaseDir) if (-not (Test-Path (Join-Path $ReleaseDir 'src\main.js'))) { throw "bad build: src\main.js missing in zip" } Write-TextFile $marker "$zipSha`n" Remove-Item $relZip -Force -ErrorAction SilentlyContinue Log "release $Version extracted (sha256 verified)" } # stable launcher (Scheduled Task entry point) + current.txt Copy-Item -Path (Join-Path $ReleaseDir 'src\launcher.js') -Destination (Join-Path $Root 'src\launcher.js') -Force Write-TextFile (Join-Path $Root 'current.txt') "$Version`n" # hidden launcher (Scheduled Task runs wscript.exe -> run-hidden.vbs -> node.exe with NO console window) $vbsSrc = Join-Path $ReleaseDir 'installer\run-hidden.vbs' if (-not (Test-Path $vbsSrc) -and $PSScriptRoot) { $vbsSrc = Join-Path $PSScriptRoot 'run-hidden.vbs' } if (-not (Test-Path $vbsSrc)) { throw "run-hidden.vbs not found (expected in release zip installer\)" } $vbs = Get-Content -Path $vbsSrc -Raw if ($Root -ne 'C:\BoltOS\agent') { $vbs = $vbs -replace [regex]::Escape('C:\BoltOS\agent'), $Root } [IO.File]::WriteAllText((Join-Path $Root 'run-hidden.vbs'), $vbs, [Text.Encoding]::ASCII) Log "hidden launcher written: $(Join-Path $Root 'run-hidden.vbs')" # data\config.json (never echoed) $cfg = [ordered]@{} if ($existing) { foreach ($p in $existing.PSObject.Properties) { $cfg[$p.Name] = $p.Value } } $cfg['device_id'] = $DeviceId $cfg['broker_url'] = $Broker $cfg['mqtt_user'] = $MqttUser $cfg['mqtt_pass'] = $MqttPass $cfg['sumatra_path'] = $SumatraExe if (-not $cfg.Contains('issuer_pubkey_pem') -or -not $cfg['issuer_pubkey_pem']) { $cfg['issuer_pubkey_pem'] = $IssuerPem.Trim() } Write-TextFile $ConfigPath (($cfg | ConvertTo-Json -Depth 5) + "`n") Log "wrote $ConfigPath" # Defender exclusion (best effort) try { Add-MpPreference -ExclusionPath (Split-Path $Root -Parent) -ErrorAction Stop; Log "Defender exclusion added for $(Split-Path $Root -Parent)" } catch { Warn ("Defender exclusion skipped: {0}" -f $_.Exception.Message) } # Scheduled Task from XML (%USERNAME% -> the logged-on account that will own printers + overlay) $xmlPath = Join-Path $ReleaseDir 'installer\BoltOSAgent.task.xml' if (-not (Test-Path $xmlPath) -and $PSScriptRoot) { $xmlPath = Join-Path $PSScriptRoot 'BoltOSAgent.task.xml' } if (-not (Test-Path $xmlPath)) { throw "BoltOSAgent.task.xml not found (expected in release zip installer\)" } $account = "$env:USERDOMAIN\$env:USERNAME" $xml = (Get-Content -Path $xmlPath -Raw) -replace '%USERNAME%', [Security.SecurityElement]::Escape($account) if ($Root -ne 'C:\BoltOS\agent') { $xml = $xml -replace [regex]::Escape('C:\BoltOS\agent'), $Root } Stop-AgentTask Register-ScheduledTask -TaskName $TaskName -Xml $xml -Force | Out-Null Log "task '$TaskName' registered for $account" Start-ScheduledTask -TaskName $TaskName Log "task started; waiting up to 60 s for the agent's first heartbeat" $logFile = Join-Path $LogsDir 'agent.log' $deadline = (Get-Date).AddSeconds(60) $healthy = $false while ((Get-Date) -lt $deadline) { Start-Sleep -Seconds 3 if (Test-Path $logFile) { $recent = Get-Content -Path $logFile -Tail 50 -ErrorAction SilentlyContinue if ($recent | Where-Object { $_ -match ' INFO heartbeat' }) { $healthy = $true; break } } } $state = (Get-TaskOrNull).State if ($healthy) { Log "agent is online (heartbeat logged); task state: $state" } else { Warn "no heartbeat within 60 s; task state: $state (see log tail below)" } Log "---- last 20 lines of $logFile ----" if (Test-Path $logFile) { Get-Content -Path $logFile -Tail 20 } else { Warn "agent.log not created yet" } Log "---- verify from the city: retained topic boltos/v1/device/$DeviceId/status should show online:true; send a test command with bin/winagent-cmd.sh $DeviceId status"