[CmdletBinding()] param( # Supply the token at runtime with -Token (the piped quick-install path), # or download a personalized copy from the console ("Download Script"), which # bakes your enrollment token in where the sentinel default is below. [string]$Token = '', # Self-Hosted Relay / air-gapped enrollment: -Relay is the relay's gRPC # endpoint (host or host:port, default port 50051) and -RelayCaFingerprint # is the SHA256 fingerprint (SHA256:<64 lowercase hex chars>) of its # CA/root certificate -- both required together. When set, this script # fetches the CA cert and agent package THROUGH the relay's bootstrap # listener instead of reaching control.tridentstack.com directly (the # endpoint may have zero direct internet access), and the installed agent # routes its own REST calls (registration, auth, inventory, self-update, # policy refresh) through the same relay via an HTTPS CONNECT tunnel. [string]$Relay, [string]$RelayCaFingerprint, # Override for the relay's TLS bootstrap listener address (default: the # -Relay host on port 8443, the relay's fixed bootstrap port -- see # bootstrap_https_port_or_default in the relay). Only needed if the # relay's bootstrap listener is reachable at a different host/port than # its gRPC endpoint. [string]$RelayBootstrap ) $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' # Ensure TLS 1.2 is available for the HTTPS download below. Older Windows # (Server 2012 R2, Windows 7/8.1) and Windows PowerShell 5.1 on .NET Framework # < 4.7 default to TLS 1.0, which the download endpoint refuses, surfacing as # "The request was aborted: Could not create SSL/TLS secure channel." Use the # numeric Tls12 flag (3072) rather than [Net.SecurityProtocolType]::Tls12 so the # line never references an enum member that older .NET may not define, and # bitwise-OR it into the current set so TLS 1.2 is added without clobbering TLS # 1.3 where it is already enabled. The try/catch is a defensive guard; the -bor # itself does not throw on any runtime that can actually negotiate TLS 1.2. try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor 3072 } catch {} $DirectMsiUrl = 'https://control.tridentstack.com/api/agent-packages/latest/download?arch=x64' $MsiPath = Join-Path $env:TEMP 'TridentStack-Control.msi' $LogPath = Join-Path $env:TEMP 'TridentStack-install.log' $Service = 'TridentStack-ControlService' $RelayCaSaveDir = Join-Path $env:ProgramData 'TridentStack Control' $RelayCaSavePath = Join-Path $RelayCaSaveDir 'relay-ca.pem' # Fixed relay ports (not user-configurable): the relay's plaintext CA endpoint # (ca_http_port_or_default) and its TLS bootstrap listener # (bootstrap_https_port_or_default) both default to these values relay-side. $RelayCaHttpPort = 8080 $RelayBootstrapPort = 8443 $totalSteps = 3 function Write-Step { param([int]$N,[string]$Msg) Write-Host "[tridentstack] Step $N/$totalSteps`: $Msg" -ForegroundColor Cyan } function Write-Ok { param([string]$Msg) Write-Host "[tridentstack] $Msg" -ForegroundColor Green } function Write-Err { param([string]$Msg) Write-Host "[tridentstack] ERROR: $Msg" -ForegroundColor Red } function Write-Info { param([string]$Msg) Write-Host "[tridentstack] $Msg" -ForegroundColor DarkGray } # ==================== Relay bootstrap helpers ==================== # Extracts the host portion of a "host" / "host:port" / "[ipv6]" / # "[ipv6]:port" value, dropping any port -- mirrors the Rust agent's # AgentConfig::relay_endpoint() host handling. Used to derive the relay's # fixed-port CA-HTTP and bootstrap-TLS addresses from -Relay regardless of # what port (if any) -Relay itself specifies. function Get-RelayHost { param([Parameter(Mandatory)][string]$RelayValue) if ($RelayValue.StartsWith('[')) { $closeBracket = $RelayValue.IndexOf(']') if ($closeBracket -ge 0) { return $RelayValue.Substring(0, $closeBracket + 1) } return $RelayValue } $lastColon = $RelayValue.LastIndexOf(':') if ($lastColon -ge 0) { return $RelayValue.Substring(0, $lastColon) } return $RelayValue } function Test-RelayCaFingerprintFormat { param([string]$Fingerprint) return $Fingerprint -match '^SHA256:[0-9a-f]{64}$' } # Strips PEM armor and base64-decodes to raw DER bytes. Deliberately manual # (rather than handing the PEM text to X509Certificate2's byte-array # constructor) so this does not depend on Windows CAPI's undocumented # PEM-autodetection -- explicit and portable across PowerShell/.NET versions. function ConvertFrom-PemToDerBytes { param([Parameter(Mandatory)][string]$PemText) $base64 = ($PemText -split '\r?\n' | Where-Object { $_ -and $_ -notmatch '-----BEGIN|-----END' }) -join '' return [Convert]::FromBase64String($base64) } function Get-Sha256HexString { param([Parameter(Mandatory)][byte[]]$Bytes) $sha256 = [System.Security.Cryptography.SHA256]::Create() try { $hash = $sha256.ComputeHash($Bytes) } finally { $sha256.Dispose() } return ([System.BitConverter]::ToString($hash) -replace '-', '').ToLowerInvariant() } # Verifies raw DER bytes against a "SHA256:<64 lowercase hex chars>" # fingerprint. Throws on mismatch -- callers must not swallow this. function Confirm-RelayCaFingerprint { param([Parameter(Mandatory)][byte[]]$DerBytes, [Parameter(Mandatory)][string]$ExpectedFingerprint) $expectedHex = $ExpectedFingerprint.Substring('SHA256:'.Length).ToLowerInvariant() $actualHex = Get-Sha256HexString -Bytes $DerBytes if ($actualHex -ne $expectedHex) { throw "Relay CA fingerprint mismatch. Expected SHA256:$expectedHex, got SHA256:$actualHex. This may indicate a misconfigured relay or a man-in-the-middle -- verify -RelayCaFingerprint against the relay's actual CA before retrying." } } # Builds a ServerCertificateValidationCallback that pins the relay's TLS # bootstrap listener by fingerprint instead of relying on the system trust # store (the relay's cert is self-signed/customer-generated). Mirrors the # Rust agent's own relay CA pinning (rust-core/src/agent/relay_tls.rs): # fingerprints the LAST element of the presented chain (the CA) when a full # chain is sent, falling back to the leaf certificate alone otherwise. # GetNewClosure() freezes $expectedHex into the returned scriptblock so it # resolves correctly when .NET invokes the callback later, after this # function has returned. # # The fingerprint match alone only proves "this is the chain we pinned" -- # it does not rule out a hostname mismatch or a missing certificate on the # connection .NET actually negotiated. So $sslPolicyErrors is also checked: # RemoteCertificateChainErrors is EXPECTED and harmless here (the relay's CA # is never in the system trust store by design), but # RemoteCertificateNameMismatch or RemoteCertificateNotAvailable indicate a # real problem that fingerprint pinning must not paper over, so either one # fails the callback regardless of the fingerprint result. function New-RelayCertValidationCallback { param([Parameter(Mandatory)][string]$ExpectedFingerprint) $expectedHex = $ExpectedFingerprint.Substring('SHA256:'.Length).ToLowerInvariant() return { param($sender, $certificate, $chain, $sslPolicyErrors) # Reject any policy error other than chain-trust errors: a name mismatch # or an absent certificate is not something fingerprint pinning covers. $allowedErrors = [int][System.Net.Security.SslPolicyErrors]::RemoteCertificateChainErrors if (([int]$sslPolicyErrors -band (-bnot $allowedErrors)) -ne 0) { return $false } $target = $certificate if ($chain -and $chain.ChainElements -and $chain.ChainElements.Count -gt 0) { $target = $chain.ChainElements[$chain.ChainElements.Count - 1].Certificate } $sha256 = [System.Security.Cryptography.SHA256]::Create() try { $hash = $sha256.ComputeHash($target.GetRawCertData()) } finally { $sha256.Dispose() } $actualHex = ([System.BitConverter]::ToString($hash) -replace '-', '').ToLowerInvariant() return $actualHex -eq $expectedHex }.GetNewClosure() } # Fetches the relay's CA cert (plaintext HTTP -- no TLS chicken-and-egg here, # see the relay's serve_ca_http), verifies it against -RelayCaFingerprint, # and persists it to $RelayCaSavePath for the MSI's RELAY_CA_CERT_PATH # property (the agent reads this file on every subsequent REST call once # installed, so it must survive after this script exits). function Get-VerifiedRelayCaCert { param([Parameter(Mandatory)][string]$RelayCaHttpAddr, [Parameter(Mandatory)][string]$ExpectedFingerprint) $caUrl = "http://$RelayCaHttpAddr/ca.pem" Write-Info "Fetching relay CA cert from $caUrl" $pemText = (New-Object Net.WebClient).DownloadString($caUrl) $derBytes = ConvertFrom-PemToDerBytes -PemText $pemText Confirm-RelayCaFingerprint -DerBytes $derBytes -ExpectedFingerprint $ExpectedFingerprint Write-Ok 'Relay CA fingerprint verified' New-Item -ItemType Directory -Path $RelayCaSaveDir -Force | Out-Null [System.IO.File]::WriteAllText($RelayCaSavePath, $pemText, (New-Object System.Text.UTF8Encoding $false)) return $RelayCaSavePath } # Preflight: a token must be present (passed via -Token, or baked in when the # script was downloaded from the console). Empty means neither was supplied. if ([string]::IsNullOrWhiteSpace($Token)) { Write-Err 'No enrollment token supplied. Run the Quick Install command from the console, or pass -Token ''''.' exit 1 } # Preflight: relay mode requires both -Relay and -RelayCaFingerprint, and the # fingerprint must be well-formed (fail fast rather than after downloads). if ($Relay -and -not $RelayCaFingerprint) { Write-Err '-Relay requires -RelayCaFingerprint.' exit 1 } if ($RelayCaFingerprint -and -not $Relay) { Write-Err '-RelayCaFingerprint requires -Relay.' exit 1 } if ($RelayBootstrap -and -not $Relay) { Write-Err '-RelayBootstrap requires -Relay.' exit 1 } if ($Relay -and -not (Test-RelayCaFingerprintFormat $RelayCaFingerprint)) { Write-Err "Invalid -RelayCaFingerprint format: '$RelayCaFingerprint'. Expected SHA256:<64 lowercase hex chars>." exit 1 } # Preflight: must be elevated. $principal = [Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent() if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { Write-Err 'This script must be run from an elevated (Administrator) PowerShell.' exit 1 } try { $relayCaCertPath = $null $relayBootstrapAddr = $null if ($Relay) { $relayHost = Get-RelayHost $Relay $relayCaHttpAddr = "$($relayHost):$RelayCaHttpPort" $relayBootstrapAddr = if ($RelayBootstrap) { $RelayBootstrap } else { "$($relayHost):$RelayBootstrapPort" } Write-Step 1 "Bootstrapping via relay $Relay..." $sw = [System.Diagnostics.Stopwatch]::StartNew() $relayCaCertPath = Get-VerifiedRelayCaCert -RelayCaHttpAddr $relayCaHttpAddr -ExpectedFingerprint $RelayCaFingerprint # Every HTTPS call to the relay's TLS bootstrap listener from here on is # pinned by fingerprint instead of the (absent) system trust chain. # Always restored in `finally` so it never leaks into anything else this # process does (including, defensively, the msiexec / service steps below # even though those do not make HTTPS calls of their own). $previousCertCallback = [Net.ServicePointManager]::ServerCertificateValidationCallback [Net.ServicePointManager]::ServerCertificateValidationCallback = (New-RelayCertValidationCallback -ExpectedFingerprint $RelayCaFingerprint) try { $bootstrapConfigUrl = "https://$relayBootstrapAddr/bootstrap-config" Write-Info "Fetching bootstrap config from $bootstrapConfigUrl" $bootstrapConfig = (New-Object Net.WebClient).DownloadString($bootstrapConfigUrl) | ConvertFrom-Json Write-Info "Relay reports relayId=$($bootstrapConfig.relayId) controlUrl=$($bootstrapConfig.controlUrl)" $MsiUrl = "https://$relayBootstrapAddr/agent-package/windows/x64" Write-Info "Downloading installer from relay: $MsiUrl" (New-Object Net.WebClient).DownloadFile($MsiUrl, $MsiPath) } finally { [Net.ServicePointManager]::ServerCertificateValidationCallback = $previousCertCallback } Write-Ok ('Downloaded via relay in {0:N1}s' -f $sw.Elapsed.TotalSeconds) } else { Write-Step 1 'Downloading installer (~12 MB)...' $sw = [System.Diagnostics.Stopwatch]::StartNew() (New-Object Net.WebClient).DownloadFile($DirectMsiUrl, $MsiPath) Write-Ok ('Downloaded in {0:N1}s' -f $sw.Elapsed.TotalSeconds) } <# TODO: No TridentStack code-signing certificate exists yet, so Authenticode verification is disabled for now (do NOT enable this until a real cert is in place -- it would fail every install, direct and relay alike, on a currently-unsigned MSI). Once client MSIs are signed, uncomment this block and fill in $expectedSubject from `Get-AuthenticodeSignature` on a known-good signed MSI. Applies to BOTH install paths above. $sig = Get-AuthenticodeSignature -FilePath $MsiPath if ($sig.Status -ne 'Valid') { Write-Err "MSI signature verification failed: $($sig.Status)" exit 1 } $expectedSubject = "CN=TridentStack*" if ($sig.SignerCertificate.Subject -notlike $expectedSubject) { Write-Err "MSI signed by unexpected publisher: $($sig.SignerCertificate.Subject)" exit 1 } #> Write-Step 2 'Installing MSI (this takes 30-60 seconds)...' $sw.Restart() $msiArgs = @( '/i', $MsiPath, '/qn', '/norestart', "ENROLLMENT_TOKEN=`"$Token`"", '/l*v', $LogPath ) if ($Relay) { $msiArgs += "RELAY_ADDRESS=`"$Relay`"" $msiArgs += "RELAY_CA_FINGERPRINT=`"$RelayCaFingerprint`"" $msiArgs += "RELAY_PROXY=`"$relayBootstrapAddr`"" $msiArgs += "RELAY_CA_CERT_PATH=`"$relayCaCertPath`"" } $proc = Start-Process msiexec -ArgumentList $msiArgs -Wait -PassThru if ($proc.ExitCode -ne 0) { throw "msiexec exited with code $($proc.ExitCode). See log: $LogPath" } Write-Ok ('Installed in {0:N1}s' -f $sw.Elapsed.TotalSeconds) Write-Step 3 'Verifying service...' $svc = Get-Service -Name $Service -ErrorAction SilentlyContinue if (-not $svc) { throw "Service '$Service' not found after install. See log: $LogPath" } if ($svc.Status -ne 'Running') { Start-Service -Name $Service $svc.WaitForStatus('Running', '00:00:30') } Write-Ok 'Service running. Install complete.' } catch { Write-Err $_.Exception.Message Write-Host "Full log: $LogPath" -ForegroundColor Yellow exit 1 } finally { if (Test-Path $LogPath) { try { $content = Get-Content -Raw -LiteralPath $LogPath $redacted = $content -replace [regex]::Escape($Token), '***REDACTED***' [System.IO.File]::WriteAllText($LogPath, $redacted, (New-Object System.Text.UTF8Encoding $false)) } catch { # Non-fatal. Log redaction is best-effort. } } Remove-Item -LiteralPath $MsiPath -ErrorAction SilentlyContinue }