# ============================================================================= # tsupdate - one-line bootstrap for the Auggie Troubleshooting GUI updater # # irm https://tsupdate.airprodiag.com | iex # # Served as plain text from a Cloudflare Worker. See tools/bootstrap/README.md. # # ----------------------------------------------------------------------------- # What this does, and what it deliberately does not # # It does almost nothing itself. It asks the update service for the current # release's copy of Update-TsGui.ps1 and runs it. All the real work - version # comparison, the payload download, closing the GUI, backup, rollback, version # stamping - stays in the updater. # # It needs no credential. The update service holds one GitHub token on behalf of # the whole fleet, which is what lets a tech run this on any unit with nothing to # paste and nothing to remember. A unit cannot keep a secret, so the answer was # never to hide one better on the unit; it was to not put one there. # # That split is the point. An earlier draft had the bootstrap download the # release zip itself and hand it to the updater with -SourcePath, which would # have meant a second copy of the GitHub redirect-handling code. That code is # exactly what shipped broken in v1.0.0 (it worked on PowerShell 7 and threw on # the 5.1 that units actually run), and duplicating it into a file served to the # whole fleet is asking for the same bug twice, in the harder place to fix. # # So the only network call here is one plain GET against the update service: # # /updater -> src/Update-TsGui.ps1 as it stood at the current release's tag # # A side effect worth having: because the updater is fetched fresh every time, # this one-liner works even on a unit whose installed updater is broken. The # updater is the one component that cannot update itself out of a bad state, and # this is the way back in. # # ----------------------------------------------------------------------------- # Because this is piped into iex # # iex runs the text in the caller's scope, so there is no param() block to bind # to - a param() block would break the plain `irm | iex` form outright. # Configuration therefore comes from the environment: # # $env:TSGUI_TOKEN Optional. Setting one makes the UPDATER go straight at # api.github.com instead of through the service - the RMM # path. Nothing here asks for one or needs one. # $env:TSGUI_VERSION Pin a version, e.g. '1.0.1'. Default: latest release. # $env:TSGUI_FORCE Any non-empty value reinstalls even if already current. # $env:TSGUI_RESTART Any non-empty value relaunches the GUI afterwards. # # Everything lives in one function so the tech's session is not left holding a # scope full of this script's variables. # ============================================================================= function Invoke-TsGuiBootstrap { [CmdletBinding()] param() $BootstrapVersion = '1.1.0' # Substituted by the Worker with the origin this script was actually served # from, so a relaunch re-fetches from the same place - workers.dev during # testing, the custom domain in production. Hardcoding it meant testing on # one URL and having the elevation hop silently go to the other. It is also # handed to the updater as -UpdateUrl, so the payload comes from the same # place this script did. $BootstrapUrl = 'https://tsupdate.airprodiagnostics.com' function Write-Step { param([string]$m) Write-Host ("[{0:HH:mm:ss}] {1}" -f (Get-Date), $m) -ForegroundColor Cyan } function Write-Detail { param([string]$m) Write-Host (" {0}" -f $m) } Write-Host "============================================================" Write-Host " Auggie Troubleshooting GUI - update bootstrap $BootstrapVersion" Write-Host "============================================================" # ---------------------------------------------------------------- TLS # .NET 4.7+ defaults SecurityProtocol to SystemDefault and negotiates TLS 1.2 # on its own, which is why the `irm` that fetched this script worked at all. # Older frameworks default to Ssl3|Tls and would fail against GitHub, so this # is set explicitly rather than assumed for everything from here on. try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } catch { } $interactive = $false try { $interactive = [Environment]::UserInteractive -and ($null -ne $Host.UI.RawUI) } catch { } # ---------------------------------------------------------- elevation # Checked before anything else, so a unit that is going to need a UAC prompt # gets it before any time is spent on the network. $identity = $null $isAdmin = $false try { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $isAdmin = ([Security.Principal.WindowsPrincipal]$identity).IsInRole( [Security.Principal.WindowsBuiltInRole]::Administrator) } catch { Write-Warning "Could not determine whether this session is elevated: $($_.Exception.Message)" return 1 } if (-not $isAdmin) { if (-not $interactive) { Write-Warning "Not elevated, and not an interactive session, so there is no way to ask. Run this as SYSTEM from the RMM, or from an elevated PowerShell." return 1 } Write-Step "Not elevated - requesting administrator rights" Write-Detail "Windows will show a UAC prompt. Over a remote session that appears" Write-Detail "on the secure desktop, so look for it there if nothing seems to happen." Write-Detail "" Write-Detail "The update continues in the new elevated window. Nothing to type there." # An unsubstituted placeholder means this was run as a file rather than # served, so there is no URL to re-fetch from and no way to relaunch. if ($BootstrapUrl -like '*__BOOTSTRAP*URL__*') { Write-Warning "This copy was run directly rather than fetched from the update URL, so it cannot relaunch itself. Reopen PowerShell as administrator and run it again." return 1 } # Relaunching re-fetches this same one-liner rather than trying to write a # copy of itself to disk: iex leaves no $PSCommandPath to copy from, and # re-fetching is a few hundred bytes. # -Verb RunAs does not inherit the environment, so any configuration set # for this run has to be written into the command the elevated window # executes or it is silently lost. Installing latest when somebody asked # for 1.0.1 is exactly the kind of quiet wrong answer worth these lines. # # TSGUI_TOKEN is deliberately NOT among them. It is not needed on this # path, and a credential on a command line is readable by anything that # can enumerate processes. If one is set here, the elevated window simply # does without it and goes through the service. $carry = '' foreach ($name in 'TSGUI_VERSION', 'TSGUI_FORCE', 'TSGUI_RESTART') { $value = [Environment]::GetEnvironmentVariable($name) if ($value) { $carry += ("`$env:{0}='{1}'; " -f $name, ($value -replace "'", "''")) } } $relaunch = $carry + "irm $BootstrapUrl | iex" $psHost = 'powershell.exe' try { $psHost = (Get-Process -Id $PID).Path } catch { } try { Start-Process -FilePath $psHost ` -ArgumentList @('-NoProfile', '-NoExit', '-ExecutionPolicy', 'Bypass', '-Command', $relaunch) ` -Verb RunAs | Out-Null } catch { Write-Warning "Elevation was refused or failed: $($_.Exception.Message)" Write-Warning "Reopen PowerShell as administrator and run the one-liner again." return 1 } Write-Step "Handed off to the elevated window. Nothing more happens in this one." return 0 } Write-Detail "Elevated: yes" Write-Detail ("Host: {0}" -f $env:COMPUTERNAME) # ------------------------------------------------------- the updater # # One plain GET, no credential. The update service resolves the current # release and hands back src/Update-TsGui.ps1 as it stood at that tag. # # Fetching the updater rather than carrying a copy is what makes this # one-liner the way back in on a unit whose INSTALLED updater is broken. The # updater is the one component that cannot update itself out of a bad state. # # It comes from the same origin that served this script, which is not a new # thing to trust: anyone who can change what that origin returns can already # replace this bootstrap, and this bootstrap is running as administrator. # # Always the LATEST release's updater, even when a version is pinned. The # updater is the tool; the pin is the payload, and it travels as -Version # instead. Fetching the updater at the pinned tag - which is what this did # first - makes a rollback run old updater code: pinning to 1.0.0 would have # fetched the updater whose download is broken on every unit, so the rollback # could not complete. It also breaks any pin to a release older than the # parameters this script passes. $pin = '' $updaterUri = "$BootstrapUrl/updater" if ($env:TSGUI_VERSION) { $pin = $env:TSGUI_VERSION.Trim() -replace '^[vV]', '' Write-Step "Pinned to $pin (the updater itself is still the current one)" } Write-Step "Fetching the updater" Write-Detail $updaterUri $updaterText = $null try { $updaterText = Invoke-RestMethod -Uri $updaterUri ` -Headers @{ 'User-Agent' = "AuggieTsGuiBootstrap/$BootstrapVersion" } ` -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop } catch { $status = $null try { $status = [int]$_.Exception.Response.StatusCode } catch { } # The service answers failures with a plain-text explanation, and that # text is usually the whole diagnosis. PowerShell 7 has already read it # into ErrorDetails; 5.1 leaves it on the response as a one-shot stream. $detail = '' try { if ($_.ErrorDetails -and $_.ErrorDetails.Message) { $detail = $_.ErrorDetails.Message } elseif ($_.Exception.Response) { $reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream()) try { $detail = $reader.ReadToEnd() } finally { $reader.Dispose() } } } catch { } if ($detail) { $detail = ($detail -replace '[\r\n]+', ' ').Trim() } if ($status -eq 503) { Write-Warning "The update service cannot reach GitHub - its own token is missing or expired. $detail" Write-Warning "There is nothing to fix on this unit. Whoever owns the Worker needs to run:" Write-Warning " npx wrangler secret put GITHUB_TOKEN" return 2 } if ($status -eq 404) { Write-Warning "The update service has no such release. $detail" return 3 } Write-Warning ("Could not fetch the updater from {0}{1}: {2} {3}" -f $updaterUri, ` $(if ($status) { " (HTTP $status)" } else { '' }), $_.Exception.Message, $detail) return 3 } if ($updaterText -isnot [string]) { $updaterText = [string]$updaterText } if ($updaterText.Length -lt 2000 -or $updaterText -notmatch 'Update-TsGui') { Write-Warning "What came back does not look like the updater ($($updaterText.Length) chars). Refusing to run it." return 3 } Write-Detail ("Got {0:N0} chars" -f $updaterText.Length) # -------------------------------------------------------------- run it # Written to a temp file and run as a child process rather than turned into a # scriptblock, for three reasons: the updater's exit code is the contract and # a child process is how you get it reliably; -ExecutionPolicy Bypass makes # the unit's policy irrelevant; and a child process inherits this one's # environment, which is how a token gets to the updater on the RMM path # without ever appearing on a command line. $temp = Join-Path ([System.IO.Path]::GetTempPath()) ("Update-TsGui-boot-{0}.ps1" -f $PID) [System.IO.File]::WriteAllText($temp, $updaterText, (New-Object System.Text.UTF8Encoding $false)) # -UpdateUrl points the updater at the same service this script came from, so # the payload and the updater are served by one origin rather than two. # # Passed only to an updater that actually has the parameter. PowerShell fails # a -File call with an unknown parameter outright, so handing -UpdateUrl to a # pre-1.1.0 updater is not a degraded run, it is no run at all - and that is # exactly what gets fetched in the window between deploying this Worker and # cutting the first release that contains the new updater. An older one falls # back to its own default and asks for a token, which is a bad afternoon but # not a broken one. $updaterArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $temp) if ($updaterText -match '(?m)^\s*\[string\]\$UpdateUrl') { $updaterArgs += @('-UpdateUrl', $BootstrapUrl) } else { Write-Detail "This updater predates -UpdateUrl, so it will use its own default and may ask for a token." } if ($pin) { $updaterArgs += @('-Version', $pin) } if ($env:TSGUI_FORCE) { $updaterArgs += '-Force' } if ($env:TSGUI_RESTART) { $updaterArgs += '-Restart' } $psHost = 'powershell.exe' try { $psHost = (Get-Process -Id $PID).Path } catch { } # No token juggling here any more. If TSGUI_TOKEN or GITHUB_TOKEN happens to # be set in this session, the child inherits it and the updater goes straight # at GitHub instead of through the service. If neither is set - the normal # case, and the case on every unit - the updater uses the service and needs # no credential at all. Write-Step "Running the updater" Write-Host "------------------------------------------------------------" try { # Out-Host, not a bare call. A native command's stdout inside a function # goes to that function's SUCCESS stream, so without this the updater's # entire console output is returned to the caller alongside the exit code # - and `switch` iterates an array, so the summary below printed once per # line of output. Same trap as Write-Output-inside-a-function, different # hat. Out-Host sends it to the console and leaves the pipeline empty; # $LASTEXITCODE is still set. & $psHost @updaterArgs | Out-Host $code = $LASTEXITCODE } finally { Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue } Write-Host "------------------------------------------------------------" if ($null -eq $code) { $code = 1 } return [int]$code } $TsuResult = Invoke-TsGuiBootstrap Remove-Item function:\Invoke-TsGuiBootstrap -ErrorAction SilentlyContinue # Belt and braces on top of the Out-Host fix above. If anything inside that # function ever leaks to the success stream again, take the last value rather # than letting an array reach the switch below - `switch` iterates arrays, so an # array here means the summary prints once per leaked line and the exit code is # nonsense. Cheap insurance against a bug that has now appeared twice. $TsuExit = if ($TsuResult -is [array]) { [int]($TsuResult[-1]) } else { [int]$TsuResult } switch ($TsuExit) { 0 { Write-Host "Done. The GUI is up to date." -ForegroundColor Green } 10 { Write-Host "An update is available (nothing was installed)." -ForegroundColor Yellow } 5 { Write-Host "The install failed and the previous version was restored. The unit is usable." -ForegroundColor Yellow } 6 { Write-Host "The install failed AND the rollback failed. This unit needs hands on it - see C:\Auggie\Logs\Setup.log." -ForegroundColor Red } default { if ($TsuExit -ne 0) { Write-Host "Finished with exit code $TsuExit. Details in C:\Auggie\Logs\Setup.log." -ForegroundColor Red } } } # `exit` would close the window a tech is reading, so it is only used when # nothing is watching - which is also the only case where the exit code is what # somebody is reading instead of the output. if ([Environment]::UserInteractive) { Write-Host "" Write-Host "Exit code: $TsuExit" } else { exit $TsuExit }