#Requires -Version 5
# GameServersHub agent installer + auto-updater for Windows Server.
# install: download the SIGNED gsh-install.ps1, verify its Authenticode
# signature, then run it (the panel shows the exact command).
# update: runs hourly as GSHAgentUpdater from a local, signature-verified
# gsh-update.ps1 that self-refreshes from the signed copy each run.
$script:GshDownloadBase = 'https://get.gsh-servers.com'
$script:GshSiteUrl = 'https://gameserverspanel.com'
$script:GshBrokerOrigin = 'https://broker.gsh-servers.com'
$script:GshUpdateKeyXml = 'oBQ7by4pVSEyIBBxSJlmHirx7LUkixhIpWtRGTiWV/9KuPjtF7IWqgmSy8hWzfqHSeTc9mURymzQmMlOlLrlU4ZNu6dzv6KBFaQtghHFCcbtj2pacm/5ceYmCzpa77ObRTKbaBdreLYVereRJXxUk6ictfiU5GXZORZI51aRjdg3FZkHSeZpux+s55ez/xkZ9mGug/KpEG2oARhpxmz89PQN8JkY74pGM6Xmh3LSG6JOz8VnJ+KoaQoSqgn33am2CCKqhlQJ8/G9JZeegqSwxg3vsbJBZH5Gxz4lfaW9Pw28ZVfpvB4ZoXQwz1n8+Lo6/mhEWOWaAbVai6tyQGkDzjzqs73+Q9g64qajirwwYostrhB6SFFYrJp5B0Nr8ACyBTPOqntKr7uvKlV/bamsOvPHJ4AZYd8s94MWU0/KmOyyU9jhmZEQoJosxj07/hQbZHm+Yhyf2AH9UFbjUf0jdw01T0eCUTs0Qf90oJE9qd8gyud5tTRu44eV120XxEebzOFa0WS9OxFGVh0wzOXnVEQN1b3nqdk0T2F7EVqN736g99+tYKDWiT6T0FjTqtaAf4a3poPzLE8iJX0f5p5TFT4TRSj9H6R2V2/+yLo0IHVugtjkuiGE24j6LWWhp/GwEImZm6I+QZC6dFBdfWfnqPGflTiUddCW5Ku26Qc0xys=AQAB'
$script:GshCodeSignSubject = 'GameServersHub'
$script:GshCodeSignEku = '1.3.6.1.4.1.311.97.408016492.281262343.806447839.709468821'
$script:GshTask = 'GSHAgent'
$script:GshUpdaterTask = 'GSHAgentUpdater'
# The privilege helper task (ADR-0043). Present + running ONLY on nodes whose
# agent.json opts in with "useHelper": true; _gsh_sync_helper_task reconciles.
$script:GshHelperTask = 'GSHHelper'
# Version from the last successfully verified signed manifest. Set by
# _gsh_verified_hash and read by the post-swap health check so it can confirm the
# newly-installed agent reports the version we actually published. Empty when
# signing is unconfigured (legacy hash file) or verification failed.
$script:GshLastManifestVersion = ''
# Update-channel override for THIS run only (set by gsh-install -Channel). The
# persisted channel lives in installer-policy.json; see _gsh_channel.
$script:GshChannelOverride = ''
function _gsh_tls {
# Ensure TLS 1.2 is enabled for Invoke-WebRequest on PowerShell 5.1 / older .NET
# (Server 2016/2012R2 default to a weaker set). Do NOT reference Tls13: that
# enum is absent on older .NET Framework and assigning it throws.
try {
[Net.ServicePointManager]::SecurityProtocol =
[Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
} catch {}
}
function _gsh_paths {
$installDir = Join-Path $env:ProgramFiles 'GSH'
[pscustomobject]@{
Dir = $installDir
Exe = Join-Path $installDir 'gsh-agent.exe'
Config = Join-Path (Join-Path $env:ProgramData 'GSH') 'agent.json'
}
}
function _gsh_require_admin {
$u = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
if (-not $u.IsInRole([Security.Principal.WindowsBuiltinRole]::Administrator)) {
throw 'Run this in an elevated PowerShell window (Run as Administrator).'
}
}
# Serialize every binary swap behind a machine-global mutex. The hourly
# GSHAgentUpdater task, a dashboard-triggered self-update, and a manual run can
# otherwise fire at the same moment and collide on the same download/swap, which
# leaves a locked gsh-agent.exe(.new) and the telltale
# Invoke-WebRequest: "...because it is being used by another process."
# With the lock, exactly one update touches the binary at a time; the rest skip
# cleanly instead of clobbering each other. AbandonedMutexException means a prior
# holder crashed without releasing — we still acquire ownership and proceed.
function _gsh_with_lock {
param([scriptblock] $Body)
$mtx = New-Object System.Threading.Mutex($false, 'Global\GSHAgentUpdate')
$owned = $false
try {
try { $owned = $mtx.WaitOne(0) }
catch [System.Threading.AbandonedMutexException] { $owned = $true }
if (-not $owned) {
# Log to updater.log too: the scheduled task's console is invisible, and a
# silently-skipping run (e.g. a prior run stuck on a hung download holding
# this mutex) is exactly what an operator needs to see when diagnosing a
# node that refuses to update (2026-06-10 incident).
_gsh_update_log 'check: another update run holds the lock; skipping (if this repeats, Stop-ScheduledTask GSHAgentUpdater and retry)'
_gsh_write_update_status -Result 'skipped' -Detail 'another update run holds the lock'
_gsh_detail 'Another agent update is already running; skipping this run.'
return
}
& $Body
} finally {
if ($owned) { try { $mtx.ReleaseMutex() } catch {} }
try { $mtx.Dispose() } catch {}
}
}
# Replace the agent binary safely. Order matters: stop the agent FIRST (so the
# download->swap window is tiny and the exe is unlocked), then download, then
# VERIFY the new file exists + hashes before touching the installed exe, then
# swap with rollback. This guarantees the box is never left without a working
# gsh-agent.exe even if the download is missing (e.g. antivirus quarantine).
function _gsh_swap_binary {
param([string] $Exe, [string] $ExpectedHash, [bool] $SkipDefenderExclusion = $false)
# Exclude our dirs from Defender real-time scanning, since an unsigned binary can
# be quarantined mid-swap otherwise. This widens the host's attack surface (those
# folders stop being scanned), so it is transparent and can be declined with
# -NoDefenderExclusion. Once the binary is Authenticode-signed this is no longer
# needed. Best-effort; ignore if Defender is absent.
# Skip the exclusion when the caller opted out OR when code signing is enforced:
# a validly Authenticode-signed binary is not quarantined, so the exclusion (and
# the attack surface it creates) is no longer needed.
$skipDefender = $SkipDefenderExclusion -or (_gsh_codesign_enforced)
if (-not $skipDefender) {
$excl = @((Split-Path $Exe -Parent), (Split-Path (_gsh_paths).Config -Parent))
try { Add-MpPreference -ExclusionPath $excl -ErrorAction SilentlyContinue } catch {}
_gsh_detail ('Defender real-time scanning excluded for: ' + ($excl -join ', '))
} else {
_gsh_detail 'Skipping Windows Defender exclusions; the agent must be signed or explicitly allowed.'
}
# 1. Stop the agent first. Prefer the graceful sentinel handshake (the agent
# watches this file and saves game worlds before exiting); Task Scheduler's
# Stop is a hard kill, used only as a fallback when the agent overruns.
# ADR-0043: stop the privilege helper BEFORE the sentinel wait. It runs from
# this same gsh-agent.exe but ignores the sentinel (it holds no game state),
# so it would pin the wait below to its full deadline; and a Task Scheduler
# stop does NOT trigger the task's restart-on-failure, while the force-kill
# fallback below WOULD - relaunching the helper mid-swap and re-locking the
# image. The post-swap _gsh_sync_helper_task brings it back on the new binary.
Stop-ScheduledTask -TaskName $script:GshHelperTask -ErrorAction SilentlyContinue | Out-Null
$dataDir = Split-Path (_gsh_paths).Config -Parent
$sentinel = Join-Path $dataDir 'shutdown.request'
New-Item -ItemType File -Path $sentinel -Force | Out-Null
$deadline = (Get-Date).AddSeconds(40)
while ((Get-Date) -lt $deadline -and (Get-Process -Name 'gsh-agent' -ErrorAction SilentlyContinue)) {
Start-Sleep -Milliseconds 500
}
if (Get-Process -Name 'gsh-agent' -ErrorAction SilentlyContinue) {
Stop-ScheduledTask -TaskName $script:GshTask -ErrorAction SilentlyContinue | Out-Null
# Wait for the process to ACTUALLY exit before swapping. A running agent holds
# gsh-agent.exe open, so renaming/moving it fails with "Cannot create a file
# when that file already exists." Poll, then force-kill as a last resort.
$killDeadline = (Get-Date).AddSeconds(20)
while ((Get-Date) -lt $killDeadline -and (Get-Process -Name 'gsh-agent' -ErrorAction SilentlyContinue)) {
Start-Sleep -Milliseconds 500
}
Get-Process -Name 'gsh-agent' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
Start-Sleep -Milliseconds 500
}
Remove-Item $sentinel -Force -ErrorAction SilentlyContinue
# 2. Download the new binary to a UNIQUE temp name. Using a per-run name (not a
# fixed gsh-agent.exe.new) means a locked leftover from a previously killed
# or crashed run can never block this download with "being used by another
# process". Sweep any such stale temp files first (best-effort); the mutex
# above guarantees none of them belong to a live run.
Get-ChildItem -Path (Split-Path $Exe -Parent) -Filter 'gsh-agent.exe*.new' -ErrorAction SilentlyContinue |
Remove-Item -Force -ErrorAction SilentlyContinue
$tmp = $Exe + '.' + ([guid]::NewGuid().ToString('N')) + '.new'
# -TimeoutSec: Invoke-WebRequest has NO default timeout, and a hung download
# here holds the machine-global update mutex with the agent already STOPPED —
# every later update attempt then silently skips while the node sits offline
# (2026-06-10 incident). 10 minutes is generous for the binary on a slow link.
Invoke-WebRequest ((_gsh_channel_base) + '/gsh-agent.exe') -OutFile $tmp -UseBasicParsing -TimeoutSec 600
# 3. Verify BEFORE touching the installed exe.
if (-not (Test-Path $tmp)) {
throw 'Downloaded binary is missing after download. Antivirus may have removed it. Exclude C:\Program Files\GSH and retry.'
}
_gsh_verify_authenticode -Path $tmp
if (-not [string]::IsNullOrWhiteSpace($ExpectedHash)) {
$got = (Get-FileHash $tmp -Algorithm SHA256).Hash
if ($got -ine $ExpectedHash) { Remove-Item $tmp -Force -ErrorAction SilentlyContinue; throw 'Downloaded binary failed hash check.' }
}
# 4. Swap with rollback: move the live exe aside, drop the new one in, and
# restore the old one if the move fails.
$old = $Exe + '.old'
Remove-Item $old -Force -ErrorAction SilentlyContinue
if (Test-Path $Exe) { Rename-Item -Path $Exe -NewName 'gsh-agent.exe.old' -ErrorAction SilentlyContinue }
try {
Move-Item -Path $tmp -Destination $Exe -Force -ErrorAction Stop
} catch {
if ((-not (Test-Path $Exe)) -and (Test-Path $old)) {
Rename-Item -Path $old -NewName 'gsh-agent.exe' -ErrorAction SilentlyContinue
}
throw
}
# NOTE: keep "$Exe.old"; the caller deletes it only after a post-start health
# check confirms the new binary actually runs (see _gsh_health_check_or_rollback).
}
# Shared success tail for the health check: the new binary is good, so drop the
# rollback copy and report healthy.
function _gsh_health_pass {
param([string] $Old)
Remove-Item $Old -Force -ErrorAction SilentlyContinue
return $true
}
# After a swap + Start, confirm the new agent actually came up AND reached the
# broker. "Healthy" means the new binary wrote a FRESH agent.ready marker (it
# only does so once connected + authenticated), not merely that a process named
# gsh-agent exists: a build that launches but crash-loops or can't reach the
# broker would otherwise pass and we'd discard the known-good rollback. If the
# marker never goes fresh within the window, roll back to the previous binary and
# restart it. Returns $true when healthy.
#
# Structured marker (agent 0.20.4+): agent.ready is JSON
# {status,version,pid,brokerConnected,authenticated,at}. We require brokerConnected
# = true AND, when we know the version the signed manifest published
# ($script:GshLastManifestVersion), that the agent reports exactly that version, so
# a build that comes up but is the wrong version is caught. A version mismatch does
# NOT brick the fleet: the binary hash already matched the signed manifest, so we
# fail OPEN at the deadline (accept + log loudly) rather than roll back forever.
#
# Backward-compat (both directions):
# - Agent 0.19.2..0.20.3 writes a bare-timestamp marker (not JSON). A fresh marker
# with no brokerConnected field is accepted on freshness, exactly as before.
# - Agent older than 0.19.2 never writes a marker; we detect "process up, no marker
# all window" and fall back to the legacy process-exists signal.
# - An OLDER updater (already on the fleet) reads only the marker's mtime, so the
# new agent's JSON body is transparent to it.
function _gsh_health_check_or_rollback {
param([string] $Exe)
$old = $Exe + '.old'
$marker = Join-Path (Split-Path (_gsh_paths).Config -Parent) 'agent.ready'
# Only a marker written AFTER we begin watching proves THIS (new) binary
# connected; a stale marker from the pre-swap binary has an older timestamp.
$watchFrom = Get-Date
# 120s default, not 30s: a brand-new binary can incur a one-time Windows
# reputation / Defender scan on first launch, and the broker connect itself
# takes a moment. Operators with slow boxes can override via an optional
# healthTimeoutSeconds field in installer-policy.json (admin-locked), clamped
# to 60..600 so a tampered or fat-fingered value can't gut or hang the check.
$timeoutS = 120
try {
$polPath = _gsh_policy_path
if (Test-Path $polPath) {
$pol = (Get-Content -Raw -Path $polPath) | ConvertFrom-Json
$t = 0
if ([int]::TryParse(('' + $pol.healthTimeoutSeconds), [ref]$t) -and $t -ge 60 -and $t -le 600) { $timeoutS = $t }
}
} catch {}
$deadline = (Get-Date).AddSeconds($timeoutS)
$expectedVer = $script:GshLastManifestVersion
$up = $false
$markerSeen = $false
$brokerConnectedSeen = $false
$mismatchLogged = $false
while ((Get-Date) -lt $deadline) {
Start-Sleep -Seconds 2
$proc = [bool](Get-Process -Name 'gsh-agent' -ErrorAction SilentlyContinue)
if ($proc) { $up = $true }
if (Test-Path $marker) {
$markerSeen = $true
$mtime = (Get-Item $marker -ErrorAction SilentlyContinue).LastWriteTime
if ($mtime -and $mtime -gt $watchFrom) {
# Fresh marker from the NEW binary. Try to read it as the structured JSON
# marker (agent 0.20.4+). Parsing can transiently fail if we catch a write
# mid-flight, or yield a bare number for an older agent's timestamp marker;
# both cases fall through to the freshness acceptance below (mtime alone
# already proves the new binary is up and writing).
$body = ''
try { $body = (Get-Content -Raw -Path $marker -ErrorAction SilentlyContinue) } catch {}
$info = $null
if (-not [string]::IsNullOrWhiteSpace($body)) { try { $info = $body | ConvertFrom-Json } catch { $info = $null } }
if ($info -and $info.PSObject.Properties['brokerConnected']) {
# Structured marker: require the agent to report it actually reached the
# broker, and (when we know the published version) that it matches.
if ($info.brokerConnected -eq $true) {
$reportedVer = ('' + $info.version).Trim()
if ([string]::IsNullOrWhiteSpace($expectedVer) -or ($reportedVer -ieq $expectedVer)) {
return _gsh_health_pass $old
}
# Connected, but the reported version is not the one the signed manifest
# published. Keep watching (an in-flight marker from the pre-swap binary
# can briefly show the old version); fail open at the deadline rather
# than brick, since the binary hash already matched the signed manifest.
$brokerConnectedSeen = $true
if (-not $mismatchLogged) {
_gsh_update_log ('health: agent connected but reports version ' + $reportedVer + ', expected ' + $expectedVer + '; still watching')
$mismatchLogged = $true
}
}
} else {
# Legacy bare-timestamp marker (agent 0.19.2..0.20.3): freshness == healthy.
return _gsh_health_pass $old
}
}
}
}
# Deadline reached with the agent broker-connected but reporting a version that
# never matched the signed manifest. Channel-aware strictness (review item,
# 2026-06-10): STABLE fails CLOSED, rolling back to the previous binary, because
# a hash-matches-but-version-differs state on the fleet channel means either a
# mis-published manifest (publisher error: fix and republish; nodes retry
# hourly) or signing-key compromise, and neither should stay live. beta/canary
# fail OPEN with a loud log so staged testing of an in-flight release is never
# blocked by a label mistake.
if ($brokerConnectedSeen) {
if ((_gsh_channel) -eq 'stable') {
_gsh_update_log ('health: STABLE refusing the version mismatch (manifest says ' + $expectedVer + '); rolling back to the previous binary. Fix the published manifest version; nodes retry hourly.')
} else {
_gsh_update_log ('health: channel=' + (_gsh_channel) + ' accepted on broker-connected readiness despite a version mismatch (hash already matched the signed manifest); check the published manifest version')
return _gsh_health_pass $old
}
}
# Legacy fallback: a pre-0.19.2 binary that's up but never writes a marker.
if ($up -and -not $markerSeen) {
_gsh_detail 'Agent is running (older build without a readiness signal); accepting.'
return _gsh_health_pass $old
}
if (Test-Path $old) {
Stop-ScheduledTask -TaskName $script:GshTask -ErrorAction SilentlyContinue | Out-Null
# ADR-0043: a relaunched helper would hold the new (refused) image mapped,
# making the Remove/Rename below fail SILENTLY - the node would then report
# rolled-back while still running the refused binary, and stay there (the
# hash compare says up-to-date next hour). Task Scheduler stop = no
# restart-on-failure retrigger; the caller's post-rollback sync restarts it
# on the restored binary.
Stop-ScheduledTask -TaskName $script:GshHelperTask -ErrorAction SilentlyContinue | Out-Null
Start-Sleep -Milliseconds 800
Remove-Item $Exe -Force -ErrorAction SilentlyContinue
Rename-Item -Path $old -NewName 'gsh-agent.exe' -ErrorAction SilentlyContinue
Start-ScheduledTask -TaskName $script:GshTask -ErrorAction SilentlyContinue | Out-Null
}
return $false
}
# ── Limited core account (ADR-0044 Phase 4a) ─────────────────────────────────
# Phase 4 runs the agent core as a LIMITED local account (gsh-core) instead of
# SYSTEM, so an RCE in the core lands low-privilege. This block creates + manages
# that account and decides when its ACL grants activate. Everything is opt-in
# (policy.limitedCore) and SID-pinned (we only ever trust an account WE created),
# so a node that has not opted in behaves exactly as the SYSTEM-only era.
$script:GshCoreUser = 'gsh-core'
# Marker kept <= 48 chars: New-LocalUser -Description rejects longer strings. It is
# the provenance tag that lets _gsh_ensure_core_account tell an account WE created
# apart from an unrelated same-named one (which it refuses to adopt).
$script:GshCoreMarker = 'GameServersHub limited agent core (ADR-0044)'
# Read installer-policy.json as an object, or $null when absent/unreadable.
function _gsh_read_policy {
try {
$path = _gsh_policy_path
if (Test-Path $path) { return ((Get-Content -Raw -Path $path) | ConvertFrom-Json) }
} catch {}
return $null
}
# $true only when this node opted into the limited-core model. Fail-closed on any
# error (missing/odd policy) so a node never silently grows the gsh-core surface.
function _gsh_limited_core_enabled {
try {
$pol = _gsh_read_policy
if ($pol -and $null -ne $pol.PSObject.Properties['limitedCore']) { return ($pol.limitedCore -eq $true) }
} catch {}
return $false
}
# The gsh-core SID the installer recorded in policy when it created the account
# (the provenance anchor), or '' if none recorded.
function _gsh_policy_core_sid {
try { $pol = _gsh_read_policy; if ($pol) { $s = ('' + $pol.coreSid).Trim(); if ($s) { return $s } } } catch {}
return ''
}
# Resolve the live SID of the gsh-core account, or '' if it does not exist.
function _gsh_core_account_sid {
try {
return (New-Object System.Security.Principal.NTAccount($script:GshCoreUser)).Translate([System.Security.Principal.SecurityIdentifier]).Value
} catch { return '' }
}
# The SID to grant gsh-core on protected resources (config Read) AND to pass as the
# helper pipe's -core-sid, or '' to grant nothing / use the SYSTEM placeholder.
# Returns the live SID ONLY when (a) the node opted in, (b) the account exists, and
# (c) its live SID MATCHES the SID the installer recorded when it created the
# account. The provenance match means a same-named account created out-of-band
# (e.g. by other software) can never inherit the grant.
function _gsh_core_grant_sid {
if (-not (_gsh_limited_core_enabled)) { return '' }
$live = _gsh_core_account_sid
if ([string]::IsNullOrWhiteSpace($live)) { return '' }
$recorded = _gsh_policy_core_sid
if ([string]::IsNullOrWhiteSpace($recorded) -or ($recorded -ne $live)) { return '' }
return $live
}
# Generate a strong random password (never persisted): a fixed complexity prefix
# plus URL-safe base64 of 24 random bytes, mirroring the agent's randomPassword so
# it satisfies Windows complexity and can never be mistaken for a switch.
function _gsh_random_password {
$bytes = New-Object byte[] 24
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
try { $rng.GetBytes($bytes) } finally { $rng.Dispose() }
return ('Aa1!' + ([Convert]::ToBase64String($bytes)).Replace('+','-').Replace('/','_').Replace('=',''))
}
# Create the limited gsh-core account if missing (marked with our Description so we
# can later tell it apart from an unrelated same-named account) and add it to the
# built-in Users group; return its SID. If it ALREADY exists: adopt it ONLY when it
# carries our marker (never trust an out-of-band account), and NEVER reset its
# password (once a task runs as this account in step 3 the credential lives in the
# LSA secret, and a reset would invalidate it and stop the agent). Best-effort and
# idempotent; returns '' on any failure so callers fail closed to the SYSTEM model.
function _gsh_ensure_core_account {
try {
$existing = Get-LocalUser -Name $script:GshCoreUser -ErrorAction SilentlyContinue
if ($existing) {
if (('' + $existing.Description) -ne $script:GshCoreMarker) {
_gsh_update_log 'limited-core: an account named gsh-core exists but is NOT installer-managed (description mismatch); refusing to adopt it'
return ''
}
try { Enable-LocalUser -Name $script:GshCoreUser -ErrorAction SilentlyContinue } catch {}
} else {
$sec = ConvertTo-SecureString (_gsh_random_password) -AsPlainText -Force
New-LocalUser -Name $script:GshCoreUser -Password $sec -FullName 'GSH Agent (limited core)' -Description $script:GshCoreMarker -PasswordNeverExpires -UserMayNotChangePassword -AccountNeverExpires -ErrorAction Stop | Out-Null
_gsh_update_log 'limited-core: created the gsh-core account'
}
# Baseline limited-user membership (BUILTIN\Users via its well-known SID for
# locale safety). Idempotent; a games-as-gsh-core launch needs the standard base.
try {
$usersGrp = Get-LocalGroup -SID 'S-1-5-32-545' -ErrorAction SilentlyContinue
if ($usersGrp) { Add-LocalGroupMember -Group $usersGrp -Member $script:GshCoreUser -ErrorAction SilentlyContinue }
} catch {}
return (_gsh_core_account_sid)
} catch {
_gsh_update_log ('limited-core: ensure account failed (' + $_.Exception.Message + '); staying on the SYSTEM model')
return ''
}
}
# Remove the gsh-core account (uninstall / explicit opt-out). Best-effort.
function _gsh_remove_core_account {
try {
if (Get-LocalUser -Name $script:GshCoreUser -ErrorAction SilentlyContinue) {
Remove-LocalUser -Name $script:GshCoreUser -ErrorAction SilentlyContinue
_gsh_update_log 'limited-core: removed the gsh-core account'
}
} catch {}
}
# Converge the limited core account with policy. Enabled: ensure the account exists
# and record its SID in the admin-locked policy so _gsh_core_grant_sid trusts it.
# Not enabled: no-op (removal is explicit via -DisableLimitedCore / uninstall, so a
# transient policy-read glitch can never delete a live account). Never throws into
# the install / update path that calls it.
function _gsh_reconcile_core_account {
try {
if (-not (_gsh_limited_core_enabled)) { return }
$sid = _gsh_ensure_core_account
if ([string]::IsNullOrWhiteSpace($sid)) { return }
if ((_gsh_policy_core_sid) -ne $sid) {
_gsh_write_policy -AllowDefenderExclusion (-not (_gsh_skip_defender)) -Channel (_gsh_channel) -LimitedCore $true -CoreSid $sid
_gsh_update_log ('limited-core: account ready, recorded SID ' + $sid)
}
# Grant the account access to the game subtrees so it can actually run + save
# games once the core is flipped to it (step 3). Runs every reconcile so dirs
# created later (a node's first ARK/mod install) get the grant. Inert while the
# core is still SYSTEM. The config Read + state-dir Modify grants live elsewhere
# (_gsh_secure_config / the helper's LockControlDir).
_gsh_grant_core_data_access
} catch {
_gsh_update_log ('limited-core: reconcile failed (' + $_.Exception.Message + '); the agent is unaffected')
}
}
# Mirror the agent's validDataDir (cmd/agent/main.go) so the grant targets EXACTLY
# where the agent installs games: an operator dataDir that is a bare volume root or
# sits inside a protected OS folder is REJECTED by the agent (it falls back to the
# config dir), so this must reject it too, or the two resolvers diverge and we would
# stamp gsh-core ACEs in a location the agent never uses.
function _gsh_valid_data_root {
param([string] $Path)
try {
$clean = [System.IO.Path]::GetFullPath($Path)
$root = [System.IO.Path]::GetPathRoot($clean)
if (($clean.TrimEnd('\','/')) -ieq ($root.TrimEnd('\','/'))) { return $false } # bare volume root
# Use .Replace (literal), NOT -replace (regex): in the JS template a double
# backslash renders to a SINGLE backslash here, and -replace with one backslash
# is an invalid regex (dangling escape) that throws, so the catch returned false
# for EVERY path -- a custom dataDir was always rejected and the gsh-core grants
# landed on the wrong (config-dir) tree. .Replace takes a literal backslash fine.
$lower = ($clean.Replace('\','/')).ToLowerInvariant()
foreach ($bad in @('/windows','/program files','/program files (x86)','/system32')) {
if ($lower.Contains($bad)) { return $false }
}
return $true
} catch { return $false }
}
# Resolve the game data root the agent actually uses, mirroring its
# resolveServersRoot: the operator's dataDir from agent.json when set + absolute +
# valid, else the config's own dir (C:\ProgramData\GSH). Read-only probe; defaults
# safely on any error.
function _gsh_core_data_root {
$cfgPath = (_gsh_paths).Config
$fallback = Split-Path $cfgPath -Parent
try {
if (Test-Path $cfgPath) {
$cfg = Get-Content $cfgPath -Raw | ConvertFrom-Json
$dd = ('' + $cfg.dataDir).Trim()
if ($dd -and [System.IO.Path]::IsPathRooted($dd) -and (_gsh_valid_data_root $dd)) { return $dd }
}
} catch {}
return $fallback
}
# Grant the VERIFIED gsh-core SID access to the game subtrees under the data root so
# a flipped core (step 3) can install, launch, and SAVE games. Modify on the
# writable trees (servers/clusters/workshop), Read+Execute on the execute-only tool
# trees (runtimes/alderon — Modify there would let a compromised core swap the JRE).
# Granted on the SUBTREES, NEVER the root, so when dataDir is empty (root ==
# C:\ProgramData\GSH) the identity files (agent.json, agent.version, agent.ready,
# installer-policy.json) are never touched. Additive + inheritable (NO /T, NO
# /inheritance:r): existing children inherit via the OS, reparse points stay safe,
# and /grant:r keeps it idempotent (exactly one gsh-core ACE per tree). SID-pinned,
# so it is a no-op until the account is verified; inert while the core is SYSTEM.
function _gsh_grant_core_data_access {
$sid = _gsh_core_grant_sid
if ([string]::IsNullOrWhiteSpace($sid)) { return }
$root = _gsh_core_data_root
# Modify on the writable trees: game installs (servers), ARK cluster transfers
# (clusters), Steam workshop mods (workshop), the running-state dir (state, holds
# running.json), per-server console logs (logs), and SteamCMD itself (steamcmd:
# it writes its own self-updates, appcache, registry.vdf and logs INTO its dir,
# and crashes with 0xc00000fd on every run when it cannot - the 2026-06-12
# fleet-wide install/mod failure that began the moment nodes flipped to
# gsh-core). state + logs were the gap that broke the first flip (running.json
# read/write + console logs were Access-denied for the limited core). state is
# ALSO locked+granted by the helper's LockControlDir; granting here too removes
# the boot-order chicken-and-egg.
foreach ($d in @('servers','clusters','workshop','state','logs','steamcmd')) {
$p = Join-Path $root $d
try { New-Item -ItemType Directory -Force -Path $p | Out-Null } catch {}
try { icacls $p /grant:r ('*' + $sid + ':(OI)(CI)M') /Q | Out-Null }
catch { _gsh_update_log ('limited-core: grant Modify on ' + $p + ' failed (' + $_.Exception.Message + ')') }
}
foreach ($d in @('runtimes','alderon')) {
$p = Join-Path $root $d
try { New-Item -ItemType Directory -Force -Path $p | Out-Null } catch {}
try { icacls $p /grant:r ('*' + $sid + ':(OI)(CI)RX') /Q | Out-Null }
catch { _gsh_update_log ('limited-core: grant RX on ' + $p + ' failed (' + $_.Exception.Message + ')') }
}
# The clustering sidecar's home (syncthing) needs Modify too, but it is NOT one of
# the game subtrees above and its files PRE-EXIST from the SYSTEM era, so a plain
# inheritable dir grant would miss them. The limited core both EXECUTES the engine
# AND must WRITE its SQLite index (the index-v2 dir), certs, config.xml and log
# there; with only the inherited Users RX it can launch the exe but cannot open
# its database, so the engine dies before binding its API and cross-machine
# transfers silently stop the moment a node is flipped to gsh-core (observed
# fleet-wide 2026-06-13: "syncthing REST did not come up ... actively refused it").
# The tree is tiny, so re-grant it recursively every reconcile (covers existing
# files that don't inherit a new parent ACE) rather than gating it behind the
# one-time game-tree migration. No-op when the node never clustered (dir absent).
$stHome = Join-Path $root 'syncthing'
if (Test-Path $stHome) { _gsh_grant_tree_existing -Path $stHome -Sid $sid -Access 'M' }
_gsh_grant_core_identity_markers $sid
}
# Recursively apply an icacls grant to EXISTING files + dirs under a subtree,
# SKIPPING reparse points (junctions/symlinks) so a planted junction can never
# redirect the grant outside the tree. This is the SAFE alternative to icacls /T
# (which follows reparse points by name -- the exact risk the 2026-06-11 security
# review flagged). Needed because files installed while the agent ran as SYSTEM
# (server.jar, running.json, console logs, ...) carry their own ACLs and do NOT
# pick up the parent's inheritable ACE. Best-effort per item.
# One icacls grant with LOUD failure accounting. icacls is an exe: it does NOT
# throw on failure, so a try/catch around it swallows every error -- which is how
# the running.json grant failed invisibly on the 2026-06-11 flip while every
# check upstream said the migration ran. Failures bump $script:GshGrantFailures
# (the migration reads it to decide whether to write its done-marker) and the
# first few log the path + icacls output so the updater.log says WHY.
function _gsh_grant_one {
param([string] $Path, [string] $Ace)
# EAP=Continue for the native call: under the updater's $ErrorActionPreference
# = 'Stop' (it dynamically scopes into callees), 2>&1 converts icacls stderr
# into a THROW mid-pipeline, which can leave $LASTEXITCODE stale from the
# PREVIOUS (successful) command -- a failed grant would then read as success,
# the exact invisible-failure bug this function exists to kill. Resetting
# LASTEXITCODE also stops an exe-never-launched throw from inheriting a stale 0.
$eap = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
$global:LASTEXITCODE = 0
$out = ''
$threw = $false
try { $out = ((icacls $Path /grant:r $Ace /Q 2>&1) | Out-String).Trim() } catch { $out = $_.Exception.Message; $threw = $true }
$ErrorActionPreference = $eap
if ($threw -or ($LASTEXITCODE -ne 0)) {
$script:GshGrantFailures = [int]$script:GshGrantFailures + 1
if ([int]$script:GshGrantFailures -le 5) {
_gsh_update_log ('limited-core: grant FAILED on ' + $Path + ' (' + $out + ')')
}
}
}
function _gsh_grant_tree_existing {
param([string] $Path, [string] $Sid, [string] $Access)
$item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
if (-not $item) { return }
$isReparse = [bool]($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint)
if ($item.PSIsContainer -and (-not $isReparse)) {
# Directory: inheritable grant ((OI)(CI) so its NEW children inherit), then
# recurse into real subdirectories (never into a junction/symlink).
_gsh_grant_one -Path $Path -Ace ('*' + $Sid + ':(OI)(CI)' + $Access)
foreach ($child in (Get-ChildItem -LiteralPath $Path -Force -ErrorAction SilentlyContinue)) {
_gsh_grant_tree_existing -Path $child.FullName -Sid $Sid -Access $Access
}
} else {
# File (or a reparse point we must NOT descend): grant WITHOUT inheritance
# flags -- (OI)(CI) is INVALID on a file and icacls rejects it (the bug that
# left running.json + server.jar Access-denied on the second node flip).
_gsh_grant_one -Path $Path -Ace ('*' + $Sid + ':' + $Access)
}
}
# One-time migration: give the limited core access to files that ALREADY existed
# from the SYSTEM era (NEW files inherit the dir grants in _gsh_grant_core_data_access,
# but pre-existing + individually-locked files like running.json don't). Marker-gated
# by the recorded SID so the potentially-slow tree walk runs once per account, not
# every hourly reconcile, and re-runs if the account (SID) was recreated. Modify on
# the writable trees, RX on the execute-only tool trees.
function _gsh_migrate_core_data_acls {
$sid = _gsh_core_grant_sid
if ([string]::IsNullOrWhiteSpace($sid)) { return }
$root = _gsh_core_data_root
$marker = Join-Path $root '.gsh-core-acls'
$done = ''
if (Test-Path $marker) { try { $done = (Get-Content -Raw -Path $marker).Trim() } catch {} }
if ($done -eq $sid) { return }
_gsh_update_log 'limited-core: applying gsh-core ACLs to existing files (one-time; may take a minute on large installs)'
$script:GshGrantFailures = 0
foreach ($d in @('servers','clusters','workshop')) {
$p = Join-Path $root $d
if (Test-Path $p) { _gsh_grant_tree_existing -Path $p -Sid $sid -Access 'M' }
}
foreach ($d in @('runtimes','alderon')) {
$p = Join-Path $root $d
if (Test-Path $p) { _gsh_grant_tree_existing -Path $p -Sid $sid -Access 'RX' }
}
$nonGating = [int]$script:GshGrantFailures
foreach ($d in @('state','logs')) {
$p = Join-Path $root $d
if (Test-Path $p) { _gsh_grant_tree_existing -Path $p -Sid $sid -Access 'M' }
}
# Marker policy: failures in the FLIP-GATING trees (state + logs -- running.json
# read/replace and console logs are what broke the node flips) withhold the
# done-marker so the hourly reconcile retries. Failures confined to the game
# trees (e.g. one over-MAX_PATH workshop file) are logged but do NOT block the
# marker: the dir-level inheritable grants already propagated, and an eternal
# hourly full-tree re-walk over one unfixable path helps nobody.
$gating = [int]$script:GshGrantFailures - $nonGating
if ($gating -gt 0) {
_gsh_update_log ('limited-core: ' + $gating + ' grant(s) FAILED in the state/logs trees during the ACL migration; done-marker withheld so the next reconcile retries')
return
}
if ($nonGating -gt 0) {
_gsh_update_log ('limited-core: ' + $nonGating + ' grant(s) failed outside the state/logs trees (see above); marker written -- the flip-gating trees are clean')
}
try {
Set-Content -Path $marker -Value $sid -Encoding ascii
icacls $marker /inheritance:r /grant:r 'SYSTEM:F' 'Administrators:F' /Q | Out-Null
} catch {}
_gsh_update_log 'limited-core: existing-file ACL migration complete'
}
# Wait (bounded) for the OLD core agent process to fully exit. The helper is the
# SAME exe, so a name-only wait would never converge while it runs -- filter on
# the '-role core' argv. Best-effort: on timeout we proceed anyway (the regrant
# below is idempotent and the next reconcile retries).
function _gsh_wait_core_exit {
param([int] $Seconds = 30)
$deadline = (Get-Date).AddSeconds($Seconds)
while ((Get-Date) -lt $deadline) {
$core = $null
try {
$core = Get-CimInstance Win32_Process -Filter "Name='gsh-agent.exe'" -ErrorAction Stop |
Where-Object { ('' + $_.CommandLine) -like '*-role core*' }
} catch {
_gsh_update_log 'limited-core: could not watch for the old core process (WMI unavailable); continuing without the wait'
return
}
if (-not $core) { return }
Start-Sleep -Milliseconds 500
}
_gsh_update_log ('limited-core: old core agent still running after ' + $Seconds + 's; regranting anyway (the flip health-check is the backstop)')
}
# Re-grant the state + logs trees IMMEDIATELY before starting the flipped agent.
# The OLD agent's stop-time persist re-locks running.json SYSTEM+Admins-only
# (lockStateFile on agents < 0.20.15), which silently undoes a grant the
# migration applied while it was still running -- so the flipped core's first
# boot found its own state file Access-denied (2026-06-11 node run). Cheap (a
# handful of files), un-marker-gated, idempotent.
function _gsh_regrant_state_tree {
$sid = _gsh_core_grant_sid
if ([string]::IsNullOrWhiteSpace($sid)) { return }
$root = _gsh_core_data_root
# Reset so the per-walk failure-log cap in _gsh_grant_one applies to this walk.
$script:GshGrantFailures = 0
foreach ($d in @('state','logs')) {
$p = Join-Path $root $d
if (Test-Path $p) { _gsh_grant_tree_existing -Path $p -Sid $sid -Access 'M' }
}
}
# Remove the gsh-core ACE residue from the state tree when the agent reverts to
# SYSTEM. /inheritance:r + /grant:r only replace ACEs for NAMED trustees, so the
# explicit core ACE (and Modify on the RCON-password-carrying running.json)
# would otherwise outlive the account's tenure as the run-as principal.
# Best-effort and scoped to the state tree: ACEs elsewhere become harmless
# orphans when the account is removed.
function _gsh_remove_core_state_acl {
$sid = _gsh_policy_core_sid
if ([string]::IsNullOrWhiteSpace($sid)) { return }
$root = _gsh_core_data_root
$state = Join-Path $root 'state'
foreach ($p in @($state, (Join-Path $state 'running.json'))) {
if (Test-Path $p) { try { icacls $p /remove ('*' + $sid) /Q 2>$null | Out-Null } catch {} }
}
}
# The flipped core writes exactly TWO files in the SYSTEM-locked identity dir
# (C:\ProgramData\GSH): agent.ready (the readiness marker the updater health-check
# reads) and agent.log. Grant gsh-core Modify on JUST those two files (per-file,
# never the dir), so the identity/integrity files beside them -- agent.json (key,
# Read-only), agent.version (anti-rollback), installer-policy.json -- stay
# SYSTEM+Admins-only. Both files are written in place (WriteFile truncate / O_APPEND,
# never delete+recreate), so the per-file ACE persists across the agent's rewrites.
# Created here only if absent (never truncating a live file); the SYSTEM agent
# normally creates them first. Idempotent + inert while the core is SYSTEM.
function _gsh_grant_core_identity_markers {
param([string] $sid)
if ([string]::IsNullOrWhiteSpace($sid)) { return }
$dir = Split-Path (_gsh_paths).Config -Parent
foreach ($name in @('agent.ready','agent.log')) {
$f = Join-Path $dir $name
try { if (-not (Test-Path $f)) { New-Item -ItemType File -Path $f -Force | Out-Null } } catch {}
try { icacls $f /grant:r ('*' + $sid + ':(M)') /Q | Out-Null }
catch { _gsh_update_log ('limited-core: grant on ' + $f + ' failed (' + $_.Exception.Message + ')') }
}
}
# ── The run-as flip (ADR-0044 Phase 4a step 3) ───────────────────────────────
# Should GSHAgent run as the limited gsh-core account instead of SYSTEM? Requires
# ALL of: (1) the account is opted-in + verified (_gsh_core_grant_sid), (2) the node
# routes privileged ops through the SYSTEM helper (useHelper) -- a limited core
# CANNOT lock its own state dir or edit the firewall in-process, so without the
# helper it would be broken; (3) NOT a sandbox node -- a limited core can't
# launch/kill the per-server sandbox users' cross-user processes (that stays SYSTEM
# until ADR-0044 4c). Any of these false => stay SYSTEM.
function _gsh_should_run_core_limited {
if ([string]::IsNullOrWhiteSpace((_gsh_core_grant_sid))) { return $false }
if (-not (_gsh_helper_enabled)) { return $false }
$sandbox = $false
try {
$cfg = Get-Content (_gsh_paths).Config -Raw | ConvertFrom-Json
if ($null -ne $cfg.PSObject.Properties['sandbox']) { $sandbox = ($cfg.sandbox -eq $true) }
} catch {
# Fail closed: an unreadable/unparseable config must NOT let a sandbox node flip
# (a limited core can't manage the per-server sandbox users' cross-user
# processes). Matches _gsh_helper_enabled's deliberate strictness.
return $false
}
return (-not $sandbox)
}
# Is GSHAgent CURRENTLY registered to run as the limited core (vs SYSTEM)?
function _gsh_agent_principal_is_core {
try {
$t = Get-ScheduledTask -TaskName $script:GshTask -ErrorAction SilentlyContinue
if (-not $t) { return $false }
$uid = '' + $t.Principal.UserId
if ($uid -eq 'SYSTEM' -or $uid -eq 'S-1-5-18' -or $uid -like '*\SYSTEM') { return $false }
# Anchored match (NOT a substring regex): the principal is either DOMAIN\gsh-core,
# the bare name, or the pinned SID. Avoids matching an unrelated *gsh-core* name.
$recorded = _gsh_policy_core_sid
return (($uid -like ('*\' + $script:GshCoreUser)) -or ($uid -eq $script:GshCoreUser) -or (($recorded -ne '') -and ($uid -eq $recorded)))
} catch { return $false }
}
# Grant the gsh-core account the rights it needs as a limited task principal:
# SeBatchLogonRight (REQUIRED -- a limited account CANNOT run as a scheduled-task
# principal without it; registration does NOT reliably auto-grant it), plus deny
# interactive/network/remote logon (defense-in-depth: it should only ever be a batch
# principal). Uses the LsaAddAccountRights Win32 API, which ADDS a right to ONE
# account without touching any other account or privilege -- truly append-safe and
# far more reliable than a secedit INF (which proved flaky on a real node). Returns
# $true only if the grant succeeded; the flip aborts on $false so a node that can't
# get the batch right is never half-flipped.
function _gsh_grant_core_logon_rights {
$sidStr = _gsh_core_account_sid
if ([string]::IsNullOrWhiteSpace($sidStr)) { return $false }
$rights = @('SeBatchLogonRight','SeDenyInteractiveLogonRight','SeDenyNetworkLogonRight','SeDenyRemoteInteractiveLogonRight')
try {
if (-not ([System.Management.Automation.PSTypeName]'GshLsa.Policy').Type) {
Add-Type -Namespace GshLsa -Name Policy -MemberDefinition @'
[StructLayout(LayoutKind.Sequential)]
struct LSA_UNICODE_STRING { public ushort Length; public ushort MaximumLength; public IntPtr Buffer; }
[StructLayout(LayoutKind.Sequential)]
struct LSA_OBJECT_ATTRIBUTES { public int Length; public IntPtr RootDirectory; public IntPtr ObjectName; public uint Attributes; public IntPtr SecurityDescriptor; public IntPtr SecurityQualityOfService; }
[DllImport("advapi32.dll", SetLastError=true)]
static extern uint LsaOpenPolicy(IntPtr SystemName, ref LSA_OBJECT_ATTRIBUTES ObjectAttributes, uint AccessMask, out IntPtr PolicyHandle);
[DllImport("advapi32.dll", SetLastError=true)]
static extern uint LsaAddAccountRights(IntPtr PolicyHandle, byte[] AccountSid, LSA_UNICODE_STRING[] UserRights, uint CountOfRights);
[DllImport("advapi32.dll")]
static extern uint LsaClose(IntPtr PolicyHandle);
[DllImport("advapi32.dll")]
static extern int LsaNtStatusToWinError(uint Status);
public static void Grant(byte[] sid, string[] rights) {
var oa = new LSA_OBJECT_ATTRIBUTES();
IntPtr ph;
uint st = LsaOpenPolicy(IntPtr.Zero, ref oa, 0x00000810, out ph);
if (st != 0) throw new Exception("LsaOpenPolicy " + LsaNtStatusToWinError(st));
try {
var arr = new LSA_UNICODE_STRING[rights.Length];
for (int i = 0; i < rights.Length; i++) {
arr[i].Buffer = Marshal.StringToHGlobalUni(rights[i]);
arr[i].Length = (ushort)(rights[i].Length * 2);
arr[i].MaximumLength = (ushort)((rights[i].Length + 1) * 2);
}
uint r = LsaAddAccountRights(ph, sid, arr, (uint)rights.Length);
if (r != 0) throw new Exception("LsaAddAccountRights " + LsaNtStatusToWinError(r));
} finally { LsaClose(ph); }
}
'@
}
$sidObj = New-Object System.Security.Principal.SecurityIdentifier($sidStr)
$sidBytes = New-Object byte[] $sidObj.BinaryLength
$sidObj.GetBinaryForm($sidBytes, 0)
[GshLsa.Policy]::Grant($sidBytes, $rights)
_gsh_update_log 'limited-core: granted gsh-core SeBatchLogonRight + deny interactive/network/remote logon'
return $true
} catch {
_gsh_update_log ('limited-core: granting logon rights failed (' + $_.Exception.Message + ')')
return $false
}
}
# (Re)register the GSHAgent task. -AsCore runs it as gsh-core at RunLevel Limited
# (a standard user cannot run Highest) and resets the password to a fresh value,
# registering with it in ONE step so the LSA-stored credential is always in sync --
# this is the ONLY place that resets an in-use account's password, atomic with the
# registration; the hourly reconcile never does. The caller must have already
# granted SeBatchLogonRight (_gsh_grant_core_logon_rights) or the task won't start.
# Without -AsCore it runs as SYSTEM (the original principal). Stops the task first so
# the re-register is clean. Returns $true on success.
function _gsh_register_agent_task {
param([string] $Exe, [switch] $AsCore)
$a1 = New-ScheduledTaskAction -Execute $Exe -Argument '-role core'
$t1 = New-ScheduledTaskTrigger -AtStartup
$s1 = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -RestartCount 9999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero)
try { Stop-ScheduledTask -TaskName $script:GshTask -ErrorAction SilentlyContinue | Out-Null } catch {}
if ($AsCore) {
$pw = _gsh_random_password
try { Set-LocalUser -Name $script:GshCoreUser -Password (ConvertTo-SecureString $pw -AsPlainText -Force) -ErrorAction Stop }
catch { _gsh_update_log ('limited-core: could not set gsh-core password (' + $_.Exception.Message + '); NOT flipping'); return $false }
try {
Register-ScheduledTask -TaskName $script:GshTask -Action $a1 -Trigger $t1 -Settings $s1 -User $script:GshCoreUser -Password $pw -RunLevel Limited -Force | Out-Null
return $true
} catch { _gsh_update_log ('limited-core: registering GSHAgent as gsh-core failed (' + $_.Exception.Message + ')'); return $false }
}
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
Register-ScheduledTask -TaskName $script:GshTask -Action $a1 -Trigger $t1 -Principal $principal -Settings $s1 -Force | Out-Null
return $true
}
# Converge GSHAgent's run-as with policy. Flips SYSTEM->gsh-core when the node now
# qualifies, with a health-check + REVERT-TO-SYSTEM rollback so a bad flip can never
# strand the node offline (the updater task stays SYSTEM as the ultimate safety
# net). Flips gsh-core->SYSTEM when the node no longer qualifies (opt-out, sandbox
# turned on, or useHelper turned off). Non-disruptive: re-registering stops the
# agent, which DETACHES running games (ADR-0040) so they survive and the restarted
# core re-adopts them. Best-effort; never throws into the caller.
function _gsh_flip_core_runas {
param([string] $Exe)
try {
$wantCore = _gsh_should_run_core_limited
$isCore = _gsh_agent_principal_is_core
if ($wantCore -and -not $isCore) {
if (-not (_gsh_grant_core_logon_rights)) {
_gsh_update_log 'limited-core: could not grant gsh-core its logon rights; staying on SYSTEM (not flipping)'
return
}
# Give the account access to pre-existing (SYSTEM-era) game files before it
# starts, or it cannot read running.json / write console logs / replace
# server.jar (all Access-denied on the first node flip).
_gsh_migrate_core_data_acls
if (_gsh_register_agent_task -Exe $Exe -AsCore) {
# The old agent's stop-time persist re-locks running.json AFTER the
# migration granted it (pre-0.20.15 lockStateFile is SYSTEM+Admins-only).
# Wait for it to exit, then re-grant the state tree, THEN start the core.
_gsh_wait_core_exit
_gsh_regrant_state_tree
Start-ScheduledTask -TaskName $script:GshTask -ErrorAction SilentlyContinue
if (_gsh_health_check_or_rollback -Exe $Exe) {
_gsh_update_log 'limited-core: FLIPPED GSHAgent to run as gsh-core (health OK)'
} else {
_gsh_update_log 'limited-core: gsh-core agent FAILED its health check; reverting to SYSTEM'
_gsh_write_update_status -Result 'limited-core-rollback' -Detail 'gsh-core agent did not come online; reverted to SYSTEM'
_gsh_register_agent_task -Exe $Exe | Out-Null
Start-ScheduledTask -TaskName $script:GshTask -ErrorAction SilentlyContinue
}
}
} elseif ((-not $wantCore) -and $isCore) {
_gsh_register_agent_task -Exe $Exe | Out-Null
Start-ScheduledTask -TaskName $script:GshTask -ErrorAction SilentlyContinue
_gsh_remove_core_state_acl
_gsh_update_log 'limited-core: reverted GSHAgent to run as SYSTEM (no longer qualifies)'
}
} catch {
_gsh_update_log ('limited-core: run-as flip failed (' + $_.Exception.Message + '); leaving the agent as-is')
}
}
# Lock the agent config (it carries the machine's agent identity) to SYSTEM +
# Administrators only, so a non-admin local user can't read or tamper with it.
# File-level only, so it does not change directory traversal and the per-server
# sandbox users are unaffected. On a limited-core node the VERIFIED gsh-core SID is
# ALSO granted Read (NOT write) so the Phase-4 core can load its identity/key; the
# core never rewrites config after enrollment, so read is sufficient. SID-pinned
# (see _gsh_core_grant_sid), so an unrelated same-named account is never granted.
function _gsh_secure_config {
param([string] $Config)
if (Test-Path $Config) {
$sid = _gsh_core_grant_sid
if (-not [string]::IsNullOrWhiteSpace($sid)) {
try { icacls $Config /inheritance:r /grant:r 'SYSTEM:F' 'Administrators:F' ('*' + $sid + ':R') /Q | Out-Null } catch {}
} else {
try { icacls $Config /inheritance:r /grant:r 'SYSTEM:F' 'Administrators:F' /Q | Out-Null } catch {}
}
}
}
# Lock the control/state directory (C:\ProgramData\GSH) so only SYSTEM and
# Administrators can WRITE there; everyone else is read-only. ProgramData is
# created world-writable-for-authenticated-users by default, which let any local
# non-admin (or a low-privilege sandboxed game-server process) forge the files
# the agent and updater trust: a fresh agent.ready marker to defeat the update
# health check / anti-rollback, a shutdown.request file to keep the agent down,
# or tampered agent.version / installer-policy.json. Locking the directory closes
# all three at once (INST-H1/H2/H3, 2026-06-09 audit). Both legitimate writers,
# the agent and the hourly updater, run as SYSTEM, so the graceful shutdown plus
# readiness handshake is unaffected.
#
# Directory-level only (no /T): existing files keep their own ACLs, so this never
# loosens agent.json (which _gsh_secure_config locks to SYSTEM+Admins). The
# inheritable (OI)(CI) grants apply to NEW files; agent.json is re-tightened by
# _gsh_secure_config regardless. Users keep Read+Execute (strictly no looser than
# the historical default) so nothing that worked before, including the per-server
# sandbox users, loses read access; only the dangerous write/create is removed.
#
# This locks C:\ProgramData\GSH (the agent's identity/marker dir: agent.json,
# agent.version, agent.ready, logs) — NOT the game data root. The Phase-4 limited
# core (ADR-0044) gets NO blanket grant here on purpose: this dir holds the machine
# identity + the update-integrity markers, so it stays SYSTEM+Admins-write. The core
# reads agent.json via the narrow gsh-core:R grant in _gsh_secure_config, and it
# writes its running state (running.json) under \state, a SEPARATE tree the
# agent locks via the helper's LockControlDir (which grants the core SID Modify).
# Whatever the flipped core must write under here (e.g. agent.ready) is granted
# per-file at the flip step, never as a dir-wide Modify over the identity markers.
function _gsh_secure_data_dir {
param([string] $Dir)
if (Test-Path $Dir) {
try { icacls $Dir /inheritance:r /grant:r 'SYSTEM:(OI)(CI)F' 'Administrators:(OI)(CI)F' 'Users:(OI)(CI)RX' /C /Q | Out-Null } catch {}
}
}
# NOTE: a C:\Program Files\GSH ACL lock was tried here and REVERTED (2026-06-09):
# applying it with icacls /T recursively stamped folder-inheritance flags onto
# gsh-agent.exe, which mangled the binary's ACL and made it non-executable even by
# SYSTEM/Administrators — bricking both the agent task AND the updater task on the
# node, with no self-heal path. Program Files is admin-only-write by Windows
# DEFAULT (TrustedInstaller/Admins/SYSTEM write, Users read+execute), so the
# binary is already protected; an explicit lock here is marginal and not worth the
# risk. If ever re-added, do it directory-level ONLY (no /T, mirror
# _gsh_secure_data_dir) and validate on a throwaway box first — never the fleet.
# Scrub secret-looking values from any text before it is shown to the operator or
# written to a log (INST item 5). Belt-and-braces: the agent already keeps the
# enrollment token off its command line, but if any tool ever echoes a
# token/secret/password/key/authorization value we replace the value with
# [redacted]. Matches key=value, key: value, and "Bearer xxx" forms.
function _gsh_redact {
param([string] $Text)
if ([string]::IsNullOrEmpty($Text)) { return $Text }
# Enumerate the secret-bearing key names explicitly rather than a bare 'key'
# (which over-redacted harmless fields like 'public_key' / 'key_id'). Order
# matters: redact JSON quoted values first, then bare key=value / key: value,
# then bearer + the hyphenated header form that the key class above won't catch.
$names = 'token|secret|password|passwd|pwd|authorization|api[-_]?key|access[-_]?key|secret[-_]?key|client[-_]?secret'
# 1. JSON / quoted: "token": "value" secret = "value" (value may contain spaces)
$t = $Text -replace ('(?i)("?(?:' + $names + ')"?\s*[:=]\s*)"[^"]*"'), '$1"[redacted]"'
# 2. Bare: token=value password: value (value runs to whitespace/comma/;/}/quote)
$t = $t -replace ('(?i)((?:' + $names + ')\s*[:=]\s*)[^\s,;}"]+'), '$1[redacted]'
# 3. Authorization: Bearer xxx / Bearer xxx
$t = $t -replace '(?i)(bearer)\s+\S+', '$1 [redacted]'
# 4. x-api-key: xxx (hyphenated header key, not matched by the alternation above)
$t = $t -replace '(?i)(x-api-key\s*[:=]\s*)\S+', '$1[redacted]'
return $t
}
# Append-only updater log under an ACL-locked logs dir, so a silently-failing
# hourly SYSTEM update leaves a diagnosable trail (INST item 10). NEVER logs
# secrets — callers pass only versions, hashes, and outcomes, and every line is
# run through _gsh_redact as a backstop. Best-effort and size-capped so it can
# never fill the disk or break the update.
function _gsh_log_dir { Join-Path (Split-Path (_gsh_paths).Config -Parent) 'logs' }
function _gsh_update_log {
param([string] $Message)
try {
$dir = _gsh_log_dir
if (-not (Test-Path $dir)) {
New-Item -ItemType Directory -Force -Path $dir | Out-Null
try { icacls $dir /inheritance:r /grant:r 'SYSTEM:(OI)(CI)F' 'Administrators:(OI)(CI)F' /Q | Out-Null } catch {}
}
$logFile = Join-Path $dir 'updater.log'
if ((Test-Path $logFile) -and ((Get-Item $logFile).Length -gt 262144)) {
try { (Get-Content -Path $logFile -Tail 500) | Set-Content -Path $logFile -Encoding ascii } catch {}
}
$stamp = (Get-Date).ToString('yyyy-MM-ddTHH:mm:ssK')
Add-Content -Path $logFile -Value ($stamp + ' ' + (_gsh_redact $Message)) -Encoding ascii -ErrorAction SilentlyContinue
} catch {}
}
# Machine-readable outcome of the LAST update run (update-status.json in the
# locked control dir). One small JSON object, overwritten each run: when the
# updater silently skips or rolls back, this is the one file that says why
# without tailing updater.log. A future agent release reports it to the panel
# so /nodes can show last-check time + result (review item, 2026-06-10).
# Best-effort and never secret: detail passes through _gsh_redact.
function _gsh_write_update_status {
param([string] $Result, [string] $Detail)
try {
$path = Join-Path (Split-Path (_gsh_paths).Config -Parent) 'update-status.json'
(@{
at = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
channel = (_gsh_channel)
result = $Result
detail = (_gsh_redact $Detail)
manifestVersion = $script:GshLastManifestVersion
} | ConvertTo-Json) | Set-Content -Path $path -Encoding ascii
try { icacls $path /inheritance:r /grant:r 'SYSTEM:F' 'Administrators:F' /Q | Out-Null } catch {}
} catch {}
}
# Persisted installer choices (admin-only JSON next to the config). Today it
# records whether Defender exclusions are allowed, so the operator's install-time
# choice survives into every future hourly update instead of being silently
# re-applied. Add future install policy here rather than re-deriving from flags.
function _gsh_policy_path { Join-Path (Split-Path (_gsh_paths).Config -Parent) 'installer-policy.json' }
function _gsh_write_policy {
param([bool] $AllowDefenderExclusion, [string] $Channel = '', $LimitedCore = $null, $CoreSid = $null)
try {
$path = _gsh_policy_path
$existing = _gsh_read_policy
# Do NOT clobber a policy file that EXISTS but couldn't be read this instant (a
# transient lock / mid-write / AV scan, or corruption): _gsh_read_policy returns
# $null for both "absent" and "unreadable", and rewriting from defaults here
# would silently demote the node (drop a beta/canary channel back to stable AND
# wipe an opted-in node's limitedCore/coreSid). Skip this run; it self-heals on
# the next one once the file reads again. A genuinely absent file (fresh install)
# falls through and is created normally.
if ((Test-Path $path) -and ($null -eq $existing)) { return }
# No channel passed: preserve the node's existing channel, so a plain
# reinstall never silently moves a beta/canary node back to stable.
if ([string]::IsNullOrWhiteSpace($Channel)) {
$Channel = 'stable'
if ($existing) {
$c = ('' + $existing.channel).Trim().ToLowerInvariant()
if ((_gsh_valid_channels) -contains $c) { $Channel = $c }
}
}
# Preserve the limited-core fields (ADR-0044) across routine channel/defender
# writes; $null means "preserve existing", an explicit value overrides. This
# keeps a plain reinstall/update from wiping an opted-in node's gsh-core state.
# Both fields use the same property-existence guard for clarity.
$lc = $false
if ($existing -and $null -ne $existing.PSObject.Properties['limitedCore']) { $lc = ($existing.limitedCore -eq $true) }
if ($null -ne $LimitedCore) { $lc = ($LimitedCore -eq $true) }
$cs = ''
if ($existing -and $null -ne $existing.PSObject.Properties['coreSid']) { $cs = ('' + $existing.coreSid).Trim() }
if ($null -ne $CoreSid) { $cs = ('' + $CoreSid).Trim() }
New-Item -ItemType Directory -Force -Path (Split-Path $path -Parent) | Out-Null
(@{ allowDefenderExclusion = $AllowDefenderExclusion; channel = $Channel; limitedCore = $lc; coreSid = $cs } | ConvertTo-Json) | Set-Content -Path $path -Encoding ascii
try { icacls $path /inheritance:r /grant:r 'SYSTEM:F' 'Administrators:F' /Q | Out-Null } catch {}
} catch {}
}
# $true when Defender exclusions should be SKIPPED (operator opted out at install).
# Defaults to $false when no policy file exists, matching historical behavior.
function _gsh_skip_defender {
try {
$path = _gsh_policy_path
if (Test-Path $path) {
$pol = (Get-Content -Raw -Path $path) | ConvertFrom-Json
return (-not $pol.allowDefenderExclusion)
}
} catch {}
return $false
}
# ── Update channels (stable | beta | canary) ────────────────────────────────
# A node's channel decides WHICH signed manifest + binary the hourly updater
# follows, so a release can roll to the owner's machines (canary), then beta
# nodes, then the whole fleet. stable is the default AND the pre-channel layout:
# it reads the exact same URLs as today (manifest.json, gsh-agent.exe at the
# origin root), so every existing install keeps working untouched. beta/canary
# read from channels// under the SAME origin, published by the SAME signed
# pipeline — every channel build is manifest-signed and Authenticode-verified
# identically, and the shared anti-rollback marker still applies. The channel is
# persisted in installer-policy.json (locked to SYSTEM+Administrators), so a
# non-admin can never move a node onto another channel. Unknown or corrupt
# values fail safe to stable. The updater SCRIPT itself stays single-track by
# design: it is signed maintenance logic, not an agent build.
#
# DOWNGRADE BEHAVIOR (intentional, ADR-0042): the anti-rollback marker
# (agent.version) is shared across channels, so moving a node from beta/canary
# back to stable keeps the NEWER binary until stable catches up; the updater
# refuses the older stable manifest. That is the anti-rollback working, not a
# bug. Emergency downgrade: as admin, delete C:\ProgramData\GSH\agent.version
# (the floor fails open when absent) and run gsh-update; the binary is still
# signature- and hash-verified against the signed stable manifest.
function _gsh_valid_channels { @('stable','beta','canary') }
function _gsh_channel {
if (-not [string]::IsNullOrWhiteSpace($script:GshChannelOverride)) { return $script:GshChannelOverride }
try {
$path = _gsh_policy_path
if (Test-Path $path) {
$pol = (Get-Content -Raw -Path $path) | ConvertFrom-Json
$c = ('' + $pol.channel).Trim().ToLowerInvariant()
if ((_gsh_valid_channels) -contains $c) { return $c }
}
} catch {}
return 'stable'
}
# Download base for this node's channel. stable uses the origin root
# (byte-for-byte the pre-channel layout); other channels live under channels/.
function _gsh_channel_base {
$c = _gsh_channel
if ($c -eq 'stable') { return $script:GshDownloadBase }
return ($script:GshDownloadBase + '/channels/' + $c)
}
# $true when any Authenticode enforcement is configured (subject and/or EKU pin).
function _gsh_codesign_enforced {
return (-not [string]::IsNullOrWhiteSpace($script:GshCodeSignSubject)) -or
(-not [string]::IsNullOrWhiteSpace($script:GshCodeSignEku))
}
# Verify the downloaded binary is Authenticode-signed by us. Enforced only when a
# publisher pin is baked in (AGENT_CODE_SIGN_SUBJECT / AGENT_CODE_SIGN_EKU); until
# then it is a no-op so unsigned dev builds still install. When enforced it fails
# closed. The STRONG pin is the per-identity EKU OID: Azure Artifact Signing
# rotates the signing cert daily (72h validity), so pinning the leaf thumbprint
# or public key would break within days (Microsoft documents this), and subject
# DNs can legitimately change. The EKU (prefix 1.3.6.1.4.1.311.97.) is unique to
# our validated identity and stable across every rotation. The RFC 3161 timestamp
# countersignature is required too: it is what keeps an already-downloaded binary
# valid after its 72-hour signing cert expires.
function _gsh_verify_authenticode {
param([string] $Path)
if (-not (_gsh_codesign_enforced)) { return }
$sig = Get-AuthenticodeSignature -FilePath $Path
if ($sig.Status -ne 'Valid') {
throw ('Refusing to install: code signature is ' + $sig.Status + ' (expected Valid). The download may be tampered or unsigned.')
}
$cert = $sig.SignerCertificate
if (-not $cert) {
throw 'Refusing to install: signature reports Valid but carries no signer certificate.'
}
if (-not $sig.TimeStamperCertificate) {
throw 'Refusing to install: the signature has no RFC 3161 timestamp countersignature.'
}
if (-not [string]::IsNullOrWhiteSpace($script:GshCodeSignEku)) {
$ekuOk = $false
# A hostile cert can carry an extension tagged 2.5.29.37 whose bytes don't
# parse as a valid EKU sequence; reading .EnhancedKeyUsages then throws a
# CryptographicException. Contain it per-extension so a malformed cert yields
# our explicit refusal below, not an opaque stack trace (still fails closed:
# $ekuOk stays $false).
foreach ($ext in $cert.Extensions) {
if ($ext.Oid.Value -eq '2.5.29.37') {
try {
foreach ($eku in ([System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]$ext).EnhancedKeyUsages) {
if ($eku.Value -eq $script:GshCodeSignEku) { $ekuOk = $true; break }
}
} catch {}
}
if ($ekuOk) { break }
}
if (-not $ekuOk) {
throw ('Refusing to install: the signer certificate does not carry our publisher identity [' + [string]$cert.Subject + '].')
}
}
if (-not [string]::IsNullOrWhiteSpace($script:GshCodeSignSubject)) {
$subject = [string]$cert.Subject
if ($subject -notlike ('*' + $script:GshCodeSignSubject + '*')) {
throw ('Refusing to install: unexpected code-signing publisher [' + $subject + '].')
}
}
_gsh_detail ('Code signature verified: ' + [string]$cert.Subject)
}
# One-time cleanup for machines installed BEFORE code signing went live: those
# installs excluded our folders from Defender real-time scanning so an unsigned
# binary would not be quarantined mid-swap. With signature-verified binaries the
# exclusions are pure attack surface (an unscanned folder), so remove ours once
# signing is enforced. The STATE is the exclusion list itself, not a marker file:
# we query Defender each run and remove only our paths if still present. This is
# deliberately not gated on a marker — a marker under ProgramData (writable by
# non-admins by default) could be pre-planted to permanently skip the cleanup,
# leaving the unscanned folder in place. Re-querying is cheap and self-healing.
function _gsh_remove_defender_exclusions {
if (-not (_gsh_codesign_enforced)) { return }
try {
$p = _gsh_paths
$ours = @($p.Dir, (Split-Path $p.Config -Parent))
$current = @()
try { $current = @((Get-MpPreference -ErrorAction Stop).ExclusionPath) } catch { return }
$present = @($ours | Where-Object { $current -contains $_ })
if ($present.Count -gt 0) {
Remove-MpPreference -ExclusionPath $present -ErrorAction SilentlyContinue
_gsh_detail ('Removed Windows Defender exclusions that signed updates no longer need: ' + ($present -join ', '))
}
} catch {}
}
function _gsh_remote_hash {
try {
return ((Invoke-WebRequest ((_gsh_channel_base) + '/gsh-agent.exe.sha256') -UseBasicParsing -TimeoutSec 60).Content).Trim().Split(' ')[0]
} catch { return '' }
}
function _gsh_signing_enabled { return -not [string]::IsNullOrWhiteSpace($script:GshUpdateKeyXml) }
# Resolve the expected binary hash. When an update-signing public key is baked in,
# fetch the SIGNED manifest (manifest.json + manifest.json.sig), verify the RSA
# signature with that public key, and return the manifest's sha256. An attacker
# who controls the download origin cannot forge an update: they lack the private
# key (it lives only in the release pipeline). This is a self-managed RSA key, so
# it needs no paid certificate authority. Returns '' on ANY verification failure,
# which callers treat as "do not update" (fail closed). With no key configured it
# falls back to the legacy same-origin hash file.
function _gsh_verified_hash {
# Reset the carried manifest version each call so a prior run's value never leaks
# into this run's health check; it is set again only on a fully verified manifest.
$script:GshLastManifestVersion = ''
if (-not (_gsh_signing_enabled)) { return _gsh_remote_hash }
$rsa = $null
try {
# Invoke-WebRequest returns .Content as a STRING for text/* responses but as
# a BYTE ARRAY for octet-stream; an R2/CDN content-type drift must not break
# signature verification fleet-wide, so normalize both shapes explicitly.
$manifestRaw = (Invoke-WebRequest ((_gsh_channel_base) + '/manifest.json') -UseBasicParsing -TimeoutSec 60).Content
$sigRaw = (Invoke-WebRequest ((_gsh_channel_base) + '/manifest.json.sig') -UseBasicParsing -TimeoutSec 60).Content
if ($manifestRaw -is [byte[]]) { $bytes = [byte[]]$manifestRaw; $manifest = [System.Text.Encoding]::UTF8.GetString($bytes) }
else { $manifest = [string]$manifestRaw; $bytes = [System.Text.Encoding]::UTF8.GetBytes($manifest) }
if ($sigRaw -is [byte[]]) { $sigB64 = [System.Text.Encoding]::ASCII.GetString([byte[]]$sigRaw).Trim() }
else { $sigB64 = ([string]$sigRaw).Trim() }
if ([string]::IsNullOrWhiteSpace($manifest) -or [string]::IsNullOrWhiteSpace($sigB64)) { return '' }
$sig = [Convert]::FromBase64String($sigB64)
$rsa = New-Object System.Security.Cryptography.RSACryptoServiceProvider
$rsa.PersistKeyInCsp = $false
$rsa.FromXmlString($script:GshUpdateKeyXml)
if (-not $rsa.VerifyData($bytes, 'SHA256', $sig)) { return '' }
$obj = $manifest | ConvertFrom-Json
# The signed sha256 must be exactly 64 hex chars; reject anything else so a
# malformed-but-signed manifest can't slip a junk value past the hash gate.
$sha = ('' + $obj.sha256).Trim()
if ($sha -notmatch '^[0-9a-fA-F]{64}$') { return '' }
# Anti-rollback (INST-M1, hardened 2026-06-09). In SIGNED mode the version is
# REQUIRED and must parse as a dotted-numeric: a signed manifest with a missing
# or unparsable version is malformed and REJECTED (fail closed), because the
# rollback floor relies on it. CLAUDE.md §17 guarantees CI emits a dotted
# numeric, so this never rejects a legitimate release; it only stalls (never
# bricks) on a genuinely broken manifest, and the binary still wouldn't swap
# without a matching signed hash anyway.
$ver = ('' + $obj.version).Trim()
$rv = $null
if ([string]::IsNullOrWhiteSpace($ver) -or -not [Version]::TryParse($ver, [ref]$rv)) { return '' }
# Carry the verified version to the post-swap health check (it confirms the new
# agent reports exactly this version once it connects). Safe: the binary hash is
# also gated against this same signed manifest below.
$script:GshLastManifestVersion = $ver
# Optional absolute recall floor: a publisher can raise manifest.minAllowedVersion
# to retire a known-bad release fleet-wide. If this manifest's own version is
# below the declared floor, refuse it — so a replayed older (validly signed)
# manifest can never push a recalled build. Absent or unparsable = no floor
# (backward compatible: today's manifest has no such field).
$minVer = ('' + $obj.minAllowedVersion).Trim()
if (-not [string]::IsNullOrWhiteSpace($minVer)) {
$mv = $null
if ([Version]::TryParse($minVer, [ref]$mv) -and ($rv -lt $mv)) { return '' }
}
# Local anti-replay floor: refuse a manifest strictly OLDER than the newest we
# have already accepted on THIS box (the admin-only agent.version marker), so a
# compromised origin can't replay an old signed manifest. Equal or newer always
# allowed.
#
# Marker-corruption posture (refined 2026-06-09): a marker that EXISTS but is
# unreadable/unparsable is treated as corrupt rather than silently ignored. The
# signed manifest has already cleared the absolute minAllowedVersion floor above,
# so when a publisher declares that floor it remains our anti-rollback guarantee
# even with a corrupt local marker. When NO floor is declared we still fail OPEN
# (accept the verified signed manifest, so a corrupt marker never bricks updates)
# but LOG it; the advance block below then rewrites the marker from the verified
# version, so the next run is protected by the local floor again.
$marker = Join-Path (Split-Path (_gsh_paths).Config -Parent) 'agent.version'
if (Test-Path $marker) {
$seen = $null
try { $seen = ((Get-Content -Raw -Path $marker) | Out-String).Trim() } catch { $seen = $null }
$sv = $null
if (-not [string]::IsNullOrWhiteSpace($seen) -and [Version]::TryParse($seen, [ref]$sv)) {
if ($rv -lt $sv) { return '' }
} elseif ([string]::IsNullOrWhiteSpace($minVer)) {
_gsh_update_log 'anti-rollback: local version marker is unreadable and the manifest declares no minAllowedVersion floor; accepting the verified signed manifest and healing the marker'
} else {
_gsh_update_log 'anti-rollback: local version marker is unreadable; relying on the manifest minAllowedVersion floor'
}
}
# Record the newest accepted version (best-effort, admin-only) so the next run
# has a floor to compare against. Only advances; never lowers the marker.
try {
$advance = $true
if (Test-Path $marker) {
$seen2 = ((Get-Content -Raw -Path $marker) | Out-String).Trim()
$sv2 = $null
if ([Version]::TryParse($seen2, [ref]$sv2) -and ($rv -le $sv2)) { $advance = $false }
}
if ($advance) {
New-Item -ItemType Directory -Force -Path (Split-Path $marker -Parent) | Out-Null
Set-Content -Path $marker -Value $ver -NoNewline -Encoding ascii -ErrorAction SilentlyContinue
try { icacls $marker /inheritance:r /grant:r 'SYSTEM:F' 'Administrators:F' /Q | Out-Null } catch {}
}
} catch {}
return $sha
} catch {
return ''
} finally {
if ($rsa) { try { $rsa.Dispose() } catch {} }
}
}
# Keep the local hourly updater script itself current AND signature-verified.
# Before any binary work, re-download the SIGNED gsh-update.ps1, verify it (our
# publisher EKU + RFC3161 timestamp), and replace the local copy only if it both
# verifies and differs. This closes the "a script frozen at install time runs as
# SYSTEM forever" gap: the updater can only ever replace itself with a script our
# signing identity vouches for. Fail-safe: any download/verify hiccup leaves the
# current (already-verified) local copy untouched and we continue this run.
function _gsh_refresh_updater {
$dir = (_gsh_paths).Dir
$local = Join-Path $dir 'gsh-update.ps1'
if (-not (Test-Path $local)) { return }
# The temp MUST end in .ps1: Get-AuthenticodeSignature resolves a SCRIPT
# signature by file extension (the PowerShell SIP only handles .ps1/.psm1/...),
# so a copy ending in any other suffix verifies as UnknownError even when the
# bytes are validly signed. (PE/.exe signatures are read from the file format,
# so the binary swap's temp name is unaffected.)
$tmp = Join-Path $dir ('gsh-update.' + ([guid]::NewGuid().ToString('N')) + '.chk.ps1')
try {
Invoke-WebRequest ($script:GshDownloadBase + '/gsh-update.ps1') -OutFile $tmp -UseBasicParsing -TimeoutSec 120
if (-not (Test-Path $tmp)) { return }
# Accept only a properly signed replacement. When signing is enforced this
# throws on a bad signature (caught below -> keep the current copy); when not
# enforced (dev) it is a no-op and we fall through to the hash compare.
_gsh_verify_authenticode -Path $tmp
$remote = (Get-FileHash $tmp -Algorithm SHA256).Hash
$current = (Get-FileHash $local -Algorithm SHA256).Hash
if ($remote -ine $current) {
Move-Item -Path $tmp -Destination $local -Force
# Re-lock the updater script after the swap (the moved file inherits the
# Program Files ACL, which also grants Users read). File-level, no /T, no
# (OI)(CI): only SYSTEM (the task runs as SYSTEM) + Administrators can read
# or replace the script SYSTEM executes hourly. Safe form (the brick was
# /T + inherit flags on a DIR of executables); mirrors _gsh_secure_config.
try { icacls $local /inheritance:r /grant:r 'SYSTEM:F' 'Administrators:F' /Q | Out-Null } catch {}
_gsh_detail 'Updated the local maintenance script to the latest signed version.'
}
} catch {
} finally {
Remove-Item $tmp -Force -ErrorAction SilentlyContinue
}
}
# Install the hourly updater as a LOCAL script in the admin-only install dir, and
# return its path. The scheduled task runs this fixed local file via -File (no
# recurring remote execution). It is the SIGNED gsh-update.ps1 downloaded from
# the distribution origin and Authenticode-verified (our publisher EKU + RFC3161
# timestamp) BEFORE it becomes the script SYSTEM runs every hour; the signed file
# already ends with its own 'gsh-update' call, so it is self-executing. It lives
# under Program Files, which only Administrators/SYSTEM can write. Fail closed: a
# missing/tampered/unsigned updater throws and the install stops rather than
# registering an unverified SYSTEM task.
function _gsh_write_local_updater {
param([string] $Dir)
$updater = Join-Path $Dir 'gsh-update.ps1'
# Temp MUST end in .ps1 so Get-AuthenticodeSignature recognizes the script
# signature (a non-.ps1 suffix verifies as UnknownError even when validly
# signed; see _gsh_refresh_updater). PE/.exe checks are extension-independent.
$tmp = Join-Path $Dir ('gsh-update.' + ([guid]::NewGuid().ToString('N')) + '.new.ps1')
Invoke-WebRequest ($script:GshDownloadBase + '/gsh-update.ps1') -OutFile $tmp -UseBasicParsing -TimeoutSec 120
try {
if (-not (Test-Path $tmp)) { throw 'The maintenance script did not download. Antivirus may have removed it.' }
_gsh_verify_authenticode -Path $tmp
Move-Item -Path $tmp -Destination $updater -Force
# Lock the updater script the SYSTEM task runs hourly to SYSTEM + Administrators
# only. File-level, no /T, no inheritance flags (the moved file would otherwise
# inherit Program Files' Users-read ACE). This is the SAFE icacls form; the
# 2026-06-09 brick was /T + (OI)(CI) on a DIR of executables. Mirrors
# _gsh_secure_config. Best-effort: Program Files already blocks non-admin write.
try { icacls $updater /inheritance:r /grant:r 'SYSTEM:F' 'Administrators:F' /Q | Out-Null } catch {}
} finally {
Remove-Item $tmp -Force -ErrorAction SilentlyContinue
}
return $updater
}
function _gsh_register_tasks {
param([string] $Exe, [string] $UpdaterScript)
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
# Agent: boot start, restart on failure, no time limit.
$a1 = New-ScheduledTaskAction -Execute $Exe -Argument '-role core'
$t1 = New-ScheduledTaskTrigger -AtStartup
$s1 = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -RestartCount 9999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero)
Register-ScheduledTask -TaskName $script:GshTask -Action $a1 -Trigger $t1 -Principal $principal -Settings $s1 -Force | Out-Null
# Auto-updater: hourly, runs the LOCAL updater script via -File (no recurring
# remote 'irm | iex' execution as SYSTEM). The local file lives in the
# admin-only install dir, so a normal user can't tamper with it.
$a2 = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument ('-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "' + $UpdaterScript + '"')
# RandomDelay spreads the hourly check across a 30-min window so the whole fleet
# doesn't hit the download origin at the same instant (thundering herd / DoS-self).
$t2 = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(5) -RepetitionInterval (New-TimeSpan -Hours 1) -RepetitionDuration (New-TimeSpan -Days 3650) -RandomDelay (New-TimeSpan -Minutes 30)
$s2 = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
Register-ScheduledTask -TaskName $script:GshUpdaterTask -Action $a2 -Trigger $t2 -Principal $principal -Settings $s2 -Force | Out-Null
}
# ADR-0043: is this node opted into routing privileged operations through the
# helper? Reads the same "useHelper" flag in agent.json the agent itself reads,
# so the helper task's presence always tracks the routing flag. Fails CLOSED to
# disabled on ANY error (missing/unreadable/odd config), so a broken config can
# never grow a node's attack surface.
function _gsh_helper_enabled {
try {
$cfgPath = (_gsh_paths).Config
if (-not (Test-Path $cfgPath)) { return $false }
$cfg = Get-Content $cfgPath -Raw | ConvertFrom-Json
# Strict boolean compare with the property on the LEFT: a malformed string
# value like "false" must not coerce to enabled ([bool]'false' is true in
# PowerShell; 'false' -eq $true is false). Only a real JSON true enables.
if ($null -ne $cfg.PSObject.Properties['useHelper']) { return ($cfg.useHelper -eq $true) }
} catch {}
return $false
}
# Turn the privilege helper flag on in agent.json (ADR-0043 protected mode).
# Used by gsh-install when limited-core is on -- by default on a fresh machine,
# or via -EnableLimitedCore -- because the run-as flip requires the helper.
# Mirrors the node-validated edit shape (ConvertFrom/To-Json round trip, ascii).
# Best-effort: on any failure the flag stays off, the flip interlock fails
# closed, and the node simply keeps running as SYSTEM (stock behavior).
function _gsh_enable_use_helper {
param([string] $Config)
try {
if (-not (Test-Path $Config)) { return }
# -Encoding UTF8: the agent writes agent.json as BOM-less UTF-8, which PS
# 5.1's default read decodes as ANSI -- a non-ASCII dataDir would mojibake
# through the round trip and silently relocate the customer's game files.
# (PS 5.1 ConvertTo-Json escapes non-ASCII as \uXXXX, so the ascii write
# below is safe once the read is correct.)
$c = Get-Content $Config -Raw -Encoding UTF8 | ConvertFrom-Json
if (($null -ne $c.PSObject.Properties['useHelper']) -and ($c.useHelper -eq $true)) { return }
$c | Add-Member -NotePropertyName useHelper -NotePropertyValue $true -Force
# Atomic replace: agent.json carries the node's UNRECOVERABLE private key
# and the enrollment token is already spent, so a torn in-place write would
# strand a brand-new machine with no fix but re-enrollment. Write a sibling,
# then rename over (atomic on the same NTFS volume). -Depth 5 so a future
# nested config field is not silently stringified by the PS default of 2.
$tmp = $Config + '.tmp'
($c | ConvertTo-Json -Depth 5) | Set-Content $tmp -Encoding ascii
Move-Item -Force $tmp $Config
_gsh_update_log 'install: protected mode on (useHelper enabled in agent.json)'
} catch {
try { Remove-Item ($Config + '.tmp') -Force -ErrorAction SilentlyContinue } catch {}
_gsh_update_log ('install: could not enable useHelper (' + $_.Exception.Message + '); the agent stays in standard mode')
}
}
# Reconcile the GSHHelper task with the useHelper flag. Opted in: register the
# task (boot start, restart on failure, same SYSTEM principal as the agent) and
# start it if it is not running - this is also what brings the helper BACK after
# a binary swap kills its process (found during the 0.20.8 flag-on validation:
# the helper runs from the same gsh-agent.exe the updater replaces). Opted out:
# stop + remove the task entirely, so a stock node carries zero extra surface.
# Best-effort by design: helper trouble must never break the agent or the update
# path that calls this; every failure is logged and swallowed.
function _gsh_sync_helper_task {
param([string] $Exe)
try {
$want = _gsh_helper_enabled
$existing = Get-ScheduledTask -TaskName $script:GshHelperTask -ErrorAction SilentlyContinue
if (-not $want) {
if ($existing) {
Stop-ScheduledTask -TaskName $script:GshHelperTask -ErrorAction SilentlyContinue | Out-Null
Unregister-ScheduledTask -TaskName $script:GshHelperTask -Confirm:$false -ErrorAction SilentlyContinue
_gsh_update_log 'helper: useHelper is off; stopped + removed the GSHHelper task'
}
return
}
# -core-sid is the limited core account's VERIFIED SID on an opted-in node
# (ADR-0044 Phase 4a), else the Administrators placeholder: until the core
# leaves SYSTEM the SYSTEM core is admitted by the pipe's own SYSTEM ACE, and
# Administrators doubles as the SID elevated operators use for on-box
# diagnostics. The drift-re-register below swaps the placeholder for the real
# SID on the hourly sync once the account exists, with no reinstall.
$coreSid = _gsh_core_grant_sid
if ([string]::IsNullOrWhiteSpace($coreSid)) { $coreSid = 'S-1-5-32-544' }
$desiredArgs = '-role helper -core-sid ' + $coreSid
if ($existing) {
# Re-register on definition drift, so a future change to the helper's
# arguments (Phase 4 swaps the placeholder SID for the limited core
# account's) reaches already-opted-in nodes through this hourly sync
# instead of requiring a reinstall. Matching definitions still no-op.
$act = $existing.Actions | Select-Object -First 1
if (-not $act -or $act.Execute -ne $Exe -or $act.Arguments -ne $desiredArgs) {
Stop-ScheduledTask -TaskName $script:GshHelperTask -ErrorAction SilentlyContinue | Out-Null
Unregister-ScheduledTask -TaskName $script:GshHelperTask -Confirm:$false -ErrorAction SilentlyContinue
$existing = $null
_gsh_update_log 'helper: GSHHelper task definition changed; re-registering'
}
}
if (-not $existing) {
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
$a = New-ScheduledTaskAction -Execute $Exe -Argument $desiredArgs
$t = New-ScheduledTaskTrigger -AtStartup
$s = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -RestartCount 9999 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero)
Register-ScheduledTask -TaskName $script:GshHelperTask -Action $a -Trigger $t -Principal $principal -Settings $s -Force | Out-Null
_gsh_update_log 'helper: useHelper is on; registered the GSHHelper task'
}
$state = (Get-ScheduledTask -TaskName $script:GshHelperTask -ErrorAction SilentlyContinue).State
# A Disabled task means an operator switched the helper off on this box
# deliberately; respect that (the agent falls back in-process) rather than
# fighting them every hour.
if ($state -and $state -ne 'Running' -and $state -ne 'Disabled') {
Start-ScheduledTask -TaskName $script:GshHelperTask -ErrorAction SilentlyContinue
_gsh_update_log ('helper: GSHHelper task start requested (was ' + $state + ')')
}
} catch {
_gsh_update_log ('helper: task sync failed (' + $_.Exception.Message + '); the agent is unaffected')
}
}
function _gsh_step($m) { Write-Host ' -> ' -ForegroundColor Cyan -NoNewline; Write-Host $m }
function _gsh_ok($m) { Write-Host ' [OK] ' -ForegroundColor Green -NoNewline; Write-Host $m }
function _gsh_detail($m) { Write-Host (' ' + $m) -ForegroundColor DarkGray }
function _gsh_fail($m) { Write-Host ''; Write-Host ' [FAILED] ' -ForegroundColor Red -NoNewline; Write-Host $m -ForegroundColor Red }
function gsh-install {
[CmdletBinding()]
param([string] $EnrollmentToken, [string] $EnrollmentTokenFile, [string] $BrokerUrl, [string] $DataDir, [switch] $Sandbox, [switch] $NoDefenderExclusion, [string] $Channel, [switch] $EnableLimitedCore, [switch] $DisableLimitedCore)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
_gsh_tls
Write-Host ''
Write-Host ' ===================================' -ForegroundColor DarkYellow
Write-Host ' GameServersHub - Agent Installer' -ForegroundColor Yellow
Write-Host ' ===================================' -ForegroundColor DarkYellow
Write-Host ''
try {
if ([string]::IsNullOrWhiteSpace($script:GshDownloadBase)) {
throw 'This panel has no agent download URL configured (AGENT_DOWNLOAD_URL).'
}
_gsh_require_admin
# Pure parameter validation, hoisted ABOVE the download + enrollment: a
# conflicting pair must fail before the single-use enrollment token is spent
# (a post-enrollment throw burned the token AND the box's freshness, so the
# corrective re-run silently missed the default-on rollout).
if ($EnableLimitedCore -and $DisableLimitedCore) { throw 'Pass only one of -EnableLimitedCore / -DisableLimitedCore.' }
# Resolve the update channel BEFORE any download: -Channel pins this node to
# stable/beta/canary (validated; persisted to installer-policy.json below).
# Omitted = keep the node's existing channel, or stable on a first install.
if (-not [string]::IsNullOrWhiteSpace($Channel)) {
$c = $Channel.Trim().ToLowerInvariant()
if (-not ((_gsh_valid_channels) -contains $c)) {
throw ('Unknown update channel [' + $Channel + ']. Valid channels: stable, beta, canary.')
}
$script:GshChannelOverride = $c
}
$p = _gsh_paths
New-Item -ItemType Directory -Force -Path $p.Dir | Out-Null
# Ensure + lock the control/state dir (C:\ProgramData\GSH) before anything is
# written there, so the agent's readiness + shutdown handshake files can't be
# forged by a local non-admin (INST-H1/H2/H3). NOT named $dataDir: PowerShell
# variables are case-insensitive, so that name CLOBBERED the operator's
# -DataDir parameter and every fresh install since enrolled with the control
# dir as its game-files root (found live 2026-06-11 via the 'servers:'
# summary printing the wrong path).
$controlDir = Split-Path $p.Config -Parent
New-Item -ItemType Directory -Force -Path $controlDir | Out-Null
_gsh_secure_data_dir -Dir $controlDir
# Let the enrollment token come from a file so it need not sit in the operator's
# shell history. -EnrollmentToken still works and takes precedence if both given.
if ([string]::IsNullOrWhiteSpace($EnrollmentToken) -and -not [string]::IsNullOrWhiteSpace($EnrollmentTokenFile)) {
if (-not (Test-Path $EnrollmentTokenFile)) { throw ('Enrollment token file not found: ' + $EnrollmentTokenFile) }
$EnrollmentToken = ((Get-Content -Raw -Path $EnrollmentTokenFile) | Out-String).Trim()
}
_gsh_step 'Downloading agent'
_gsh_detail ((_gsh_channel_base) + '/gsh-agent.exe')
$expectedHash = _gsh_verified_hash
if ((_gsh_signing_enabled) -and [string]::IsNullOrWhiteSpace($expectedHash)) {
throw 'Could not verify the signed update manifest. Refusing to install an unverified binary.'
}
try {
_gsh_swap_binary -Exe $p.Exe -ExpectedHash $expectedHash -SkipDefenderExclusion:$NoDefenderExclusion
} catch {
# On a RE-install the swap first stops the already-running agent (graceful
# sentinel). If it then throws, restart the existing task so a failed
# re-install never strands an enrolled machine offline (the swap's rollback
# keeps a runnable gsh-agent.exe). A fresh install has no task yet, so this
# is a harmless no-op there. Mirrors the gsh-update fail-safe.
Start-ScheduledTask -TaskName $script:GshTask -ErrorAction SilentlyContinue
throw
}
_gsh_ok 'Agent installed'
# Fresh machine vs re-install: a config file means this node already has an
# identity. Captured BEFORE enrollment because the ADR-0044 rollout below
# defaults protected mode + the limited run-as ON for NEW machines only.
$freshInstall = -not (Test-Path $p.Config)
if (Test-Path $p.Config) {
_gsh_detail 'This machine is already enrolled; keeping its identity.'
} else {
if ([string]::IsNullOrWhiteSpace($EnrollmentToken)) {
throw 'First install needs -EnrollmentToken (generate it in the panel: Machines -> Add Host).'
}
# The broker this agent connects to is BAKED into this signed script
# ($script:GshBrokerOrigin), so -BrokerUrl is no longer required. It is kept
# as an optional parameter only so an older pasted command still runs; when
# supplied it must match the baked origin exactly (https), so a tampered
# command can never repoint the agent at a rogue broker.
if ([string]::IsNullOrWhiteSpace($BrokerUrl)) {
$BrokerUrl = $script:GshBrokerOrigin
} else {
$bu = $null
try { $bu = [uri]$BrokerUrl } catch {}
if (-not $bu -or $bu.Scheme -ne 'https' -or (($bu.Scheme + '://' + $bu.Authority) -ine $script:GshBrokerOrigin)) {
throw ('BrokerUrl must be ' + $script:GshBrokerOrigin + ' over https. Re-copy the command from the panel (Machines -> Add Host).')
}
}
_gsh_step 'Enrolling with the broker'
# Hand the token to the agent through a short-lived, admin-only file
# (-enroll-token-file) rather than -enroll-token, so the secret never lands on
# the agent's command line or in the machine's process list. Locked to
# SYSTEM + Administrators and deleted right after, even if enrollment throws.
$tokenFile = Join-Path $p.Dir ('enroll-' + ([guid]::NewGuid().ToString('N')) + '.tok')
$log = ''
$code = 1
try {
Set-Content -Path $tokenFile -Value $EnrollmentToken -NoNewline -Encoding ascii
try { icacls $tokenFile /inheritance:r /grant:r 'SYSTEM:F' 'Administrators:F' /Q | Out-Null } catch {}
$enrollArgs = @('-role','core','-enroll-token-file',$tokenFile,'-broker-url',$BrokerUrl,'-enroll-only')
if (-not [string]::IsNullOrWhiteSpace($DataDir)) { $enrollArgs += @('-data-dir',$DataDir) }
if ($Sandbox) { $enrollArgs += '-sandbox' }
# Run the agent through Start-Process with its stdout/stderr wired straight
# to files. This is the reliable way to stop Windows PowerShell 5.1 from
# painting the agent's ordinary stderr log lines as a red "NativeCommandError"
# (which looks like a crash even though enrollment succeeded): Start-Process
# hands the child's streams to the OS, so they never pass through
# PowerShell's own error stream the way "& exe 2>&1" / "& exe 2>file" do.
# Quote any argument containing a space (the token-file path lives under
# "Program Files"), since -ArgumentList is a single command-line string.
$outFile = Join-Path $p.Dir ('enroll-' + ([guid]::NewGuid().ToString('N')) + '.out')
$errFile = Join-Path $p.Dir ('enroll-' + ([guid]::NewGuid().ToString('N')) + '.err')
try {
$argLine = (@($enrollArgs) | ForEach-Object { $a = [string]$_; if ($a -match '\s') { '"' + $a + '"' } else { $a } }) -join ' '
$proc = Start-Process -FilePath $p.Exe -ArgumentList $argLine -NoNewWindow -Wait -PassThru -RedirectStandardOutput $outFile -RedirectStandardError $errFile
$code = $proc.ExitCode
$log = (((Get-Content -Raw -Path $outFile -ErrorAction SilentlyContinue) + (Get-Content -Raw -Path $errFile -ErrorAction SilentlyContinue)) | Out-String)
} finally {
Remove-Item $outFile, $errFile -Force -ErrorAction SilentlyContinue
}
} finally {
if (Test-Path $tokenFile) {
try { Clear-Content -Path $tokenFile -Force -ErrorAction SilentlyContinue } catch {}
Remove-Item $tokenFile -Force -ErrorAction SilentlyContinue
}
}
$log.Trim().Split([Environment]::NewLine) | ForEach-Object { if ($_) { _gsh_detail (_gsh_redact $_) } }
if ($code -ne 0) { throw ('Enrollment failed (exit ' + $code + '). The token may be expired or already used.') }
_gsh_ok 'Enrolled with broker'
}
# Persist the Defender-exclusion choice + update channel so future hourly
# updates honor both (fixes the prior gap where -NoDefenderExclusion was
# forgotten on update). _gsh_channel resolves the -Channel override, else the
# node's existing channel, else stable. ADR-0044: -EnableLimitedCore /
# -DisableLimitedCore opt this node into / out of the limited gsh-core model
# (persisted in policy). Reconcile the account BEFORE locking the config so the
# SID-pinned gsh-core:R grant lands on the same run the account is created.
# ADR-0044 rollout: NEW machines run protected by default -- the agent gets the
# SYSTEM helper (useHelper) and is flipped to the limited gsh-core account at
# the end of this install, unless the operator opts out (-DisableLimitedCore)
# or the node is sandboxed (the sandbox keeps SYSTEM until Phase 4c).
# EXISTING machines are NEVER auto-flipped: with no explicit switch their
# recorded policy is preserved exactly as before, so the fleet only moves via
# the explicit -EnableLimitedCore opt-in (canary nodes first, then wider).
$defaultCoreOn = ($freshInstall -and (-not $Sandbox) -and (-not $DisableLimitedCore) -and (-not $EnableLimitedCore))
if ($DisableLimitedCore) {
# Mark disabled now; the run-as flip-back to SYSTEM and the account removal
# happen AFTER the agent task exists + is back on SYSTEM (see the post-health
# _gsh_flip_core_runas), so we never delete an account a running task still
# uses as its principal.
_gsh_write_policy -AllowDefenderExclusion (-not $NoDefenderExclusion) -Channel (_gsh_channel) -LimitedCore $false -CoreSid ''
} elseif ($EnableLimitedCore -or $defaultCoreOn) {
_gsh_write_policy -AllowDefenderExclusion (-not $NoDefenderExclusion) -Channel (_gsh_channel) -LimitedCore $true
} else {
_gsh_write_policy -AllowDefenderExclusion (-not $NoDefenderExclusion) -Channel (_gsh_channel)
}
# The flip REQUIRES the helper (_gsh_should_run_core_limited fails closed
# without it), so turning limited-core on -- by default on a fresh machine or
# by explicit opt-in -- also turns the helper flag on. Without this, an
# operator's -EnableLimitedCore on an existing node would silently never flip.
if ($EnableLimitedCore -or $defaultCoreOn) { _gsh_enable_use_helper -Config $p.Config }
if ($EnableLimitedCore -and $Sandbox) { _gsh_detail 'Note: this machine uses per-server isolation, so the limited run-as arrives in a later update; everything else is active now.' }
_gsh_reconcile_core_account
_gsh_secure_config -Config $p.Config
# Reinstalls over a pre-signing install: drop the old Defender exclusions now
# that every binary swap is signature-verified.
_gsh_remove_defender_exclusions
_gsh_step 'Installing services (agent + hourly auto-updater)'
$updater = _gsh_write_local_updater -Dir $p.Dir
_gsh_register_tasks -Exe $p.Exe -UpdaterScript $updater
Start-ScheduledTask -TaskName $script:GshTask
if (_gsh_health_check_or_rollback -Exe $p.Exe) {
_gsh_ok 'Services installed and started'
} else {
# Not fatal. On a first install there is nothing to roll back to, and a
# brand-new unsigned binary often needs a moment to clear a one-time Windows
# security scan; the agent task keeps retrying in the background. Report it
# as a soft notice rather than failing (and locking) the whole install.
_gsh_ok 'Services installed'
_gsh_detail 'The agent is taking a little longer to come online (a one-time security check of a new file). It keeps trying in the background, so your machine should appear Online in the panel within a couple of minutes. If it stays offline, check C:\ProgramData\GSH\agent.log.'
}
# ADR-0043: reconcile the privilege helper AFTER the health verdict, so a
# reinstall over an opted-in node starts the helper on whichever binary
# actually survived (a rolled-back reinstall restored the old one). On a
# fresh machine the default-on path above already wrote useHelper, so this
# registers + starts the helper with the real gsh-core SID before the flip.
_gsh_sync_helper_task -Exe $p.Exe
# ADR-0044 step 3: now that the agent is healthy as SYSTEM and the helper is up
# with the real -core-sid, converge GSHAgent's run-as. On a qualifying node this
# FLIPS it to gsh-core (health-checked, reverts to SYSTEM on failure); on a node
# that no longer qualifies it flips back to SYSTEM. Runs LAST so the SYSTEM agent
# is proven healthy first, and the flip is its own separately-rolled-back step.
_gsh_flip_core_runas -Exe $p.Exe
# Opt-out: the account is removed only AFTER the flip-back to SYSTEM above, so we
# never delete an account a running task still uses as its principal.
if ($DisableLimitedCore) { _gsh_remove_core_account }
if (_gsh_agent_principal_is_core) { _gsh_detail 'Protected mode: the agent now runs as a limited account (gsh-core), not SYSTEM.' }
Write-Host ''
_gsh_ok 'Done - the GSH agent is installed and auto-updating.'
_gsh_detail ('binary: ' + $p.Exe)
_gsh_detail ('config: ' + $p.Config)
if (-not [string]::IsNullOrWhiteSpace($DataDir)) { _gsh_detail ('servers: ' + $DataDir) }
_gsh_detail ('log: ' + (Join-Path (Split-Path $p.Config) 'agent.log'))
_gsh_detail ('tasks: ' + $script:GshTask + ', ' + $script:GshUpdaterTask)
if ((_gsh_channel) -ne 'stable') { _gsh_detail ('channel: ' + (_gsh_channel) + ' (updates arrive from this channel before the stable fleet)') }
Write-Host ''
Write-Host ' Your machine should appear ' -NoNewline
Write-Host 'Online' -ForegroundColor Green -NoNewline
Write-Host ' in the panel within a few seconds.'
Write-Host ''
}
catch {
# Fail-safe: the binary swap STOPS the agent early, so a failure in a later
# step (e.g. writing the updater) would otherwise leave the machine offline
# until the next reboot. The swapped-in binary is already signature-verified,
# so best-effort restart the agent task before surfacing the error, keeping
# game servers managed even when the install only partially completed.
try {
if (Get-ScheduledTask -TaskName $script:GshTask -ErrorAction SilentlyContinue) {
Start-ScheduledTask -TaskName $script:GshTask -ErrorAction SilentlyContinue
}
} catch {}
_gsh_fail $_.Exception.Message
Write-Host ' Fix the issue above and re-run. Need help? support@gameservershub.com' -ForegroundColor DarkGray
Write-Host ''
# Re-throw rather than 'exit 1': this function is normally pasted into an
# interactive shell via 'irm | iex', where 'exit' would CLOSE the operator's
# PowerShell window (looks like a crash). 'throw' surfaces the failure in red,
# still yields a non-zero result for any -File/scripted run, and leaves the
# window open so they can read the message and retry.
throw
}
}
# Hash-gated self-update. Safe to run repeatedly AND concurrently: only swaps
# when R2 differs, and the whole swap is serialized behind the machine-global
# update mutex so the hourly task, a dashboard self-update, and a manual run can
# never collide on the binary.
function gsh-update {
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
_gsh_tls
_gsh_with_lock {
if ([string]::IsNullOrWhiteSpace($script:GshDownloadBase)) { return }
$p = _gsh_paths
if (-not (Test-Path $p.Exe)) { return }
# Heal the fleet: machines installed before code signing went live carry
# Defender exclusions they no longer need. One-time, marker-gated, and runs
# even when no binary update is due this hour.
_gsh_remove_defender_exclusions
# Heal the fleet: re-assert the control/state dir lockdown every run so
# machines installed before INST-H1/H2/H3 get the ACL without a reinstall.
# Idempotent + cheap; SYSTEM (this task) keeps full access.
_gsh_secure_data_dir -Dir (Split-Path $p.Config -Parent)
# ADR-0044: converge the limited core account on opted-in nodes (creates it if
# missing, records its SID) and re-apply the SID-pinned gsh-core:R on agent.json
# so the grant self-heals without a reinstall. No-op on stock nodes.
_gsh_reconcile_core_account
if (_gsh_limited_core_enabled) { _gsh_secure_config -Config $p.Config }
# Keep this updater script itself signed + current (ADR-0038 self-verify), so
# an improvement to the maintenance logic rolls out without a reinstall and a
# frozen copy can never drift from what our signing identity vouches for.
_gsh_refresh_updater
# Verified hash when signing is configured (fail closed: empty => skip update).
$chan = _gsh_channel
$remote = _gsh_verified_hash
if ([string]::IsNullOrWhiteSpace($remote)) {
_gsh_update_log ('check: channel=' + $chan + ', no verified manifest hash (signing unconfigured or verification failed) - skipping')
_gsh_write_update_status -Result 'check-failed' -Detail 'manifest could not be verified'
return
}
# Pre-flight (2026-06-10 incident): if the installed binary cannot be READ
# (that incident: a protected-and-EMPTY DACL denied everyone, even SYSTEM),
# do NOT treat the failed hash as "out of date" - the swap would stop the
# agent and then fail at the very same unreadable file, bouncing the node
# offline every hour. Log loudly and leave the running agent untouched;
# recovery is manual (takeown + icacls /reset + explicit SID grants), and
# updates resume on the next run once the file reads again.
$local = ''
try { $local = (Get-FileHash $p.Exe -Algorithm SHA256 -ErrorAction Stop).Hash } catch {}
if ([string]::IsNullOrWhiteSpace($local)) {
_gsh_update_log 'check: installed gsh-agent.exe is UNREADABLE (hash failed, likely a damaged ACL) - skipping the swap so the agent is not stopped; repair the file ACLs to resume updates'
_gsh_write_update_status -Result 'blocked' -Detail 'installed binary unreadable; repair file ACLs'
return
}
if ($remote -ieq $local) {
_gsh_update_log ('check: channel=' + $chan + ', up to date (hash ' + $remote.Substring(0, [Math]::Min(12, $remote.Length)) + ')')
_gsh_write_update_status -Result 'up-to-date' -Detail ('hash ' + $remote.Substring(0, [Math]::Min(12, $remote.Length)))
# ADR-0043: hourly reconcile of the privilege helper task (registers/starts
# it on opted-in nodes, self-heals a dead helper, removes it on opt-out).
_gsh_sync_helper_task -Exe $p.Exe
# ADR-0044: hourly run-as convergence (plan section 7: the updater re-runs
# the installer logic; the flip is idempotent). Without this, a default-on
# node whose first flip missed (helper write failed, account creation
# failed, or the flip health check hit a one-time Defender scan and
# reverted) stays SYSTEM forever despite limitedCore=true in policy. The
# flip no-ops when already converged, fails closed on the interlock, and
# carries its own health-check + revert-to-SYSTEM rollback.
_gsh_flip_core_runas -Exe $p.Exe
return
}
_gsh_update_log ('update: channel=' + $chan + ', swapping to verified hash ' + $remote.Substring(0, [Math]::Min(12, $remote.Length)))
# CRITICAL fail-safe: the swap stops the agent (graceful-stop sentinel) before
# touching the binary. If anything in the swap throws (bad/again-unverifiable
# download, AV race, transient), the agent must NOT be left stopped. The swap
# always leaves a runnable gsh-agent.exe in place (its own rollback restores
# the old one on a failed move), so we ALWAYS restart the task in a finally —
# a failed update then simply keeps the previous, working binary online instead
# of stranding the node offline. Without this, an exception here orphaned the
# stopped agent (2026-06-09 incident).
try {
_gsh_swap_binary -Exe $p.Exe -ExpectedHash $remote -SkipDefenderExclusion:(_gsh_skip_defender)
# ADR-0043 ordering fix (2026-06-13 incident): bring the helper back BEFORE
# the finally below starts the agent. The swap had to stop the helper (it
# runs the same exe image), but the agent reports helper reachability in
# its connect frame - so when the agent boots first, every opted-in node
# stamps "Protected mode: paused" on the dashboard after every fleet
# update, even though the helper comes back seconds later. Starting the
# helper here means the new agent's first probe finds the pipe up. The
# post-health sync further down stays: a health-check rollback restores
# the old binary and the helper must be bounced onto it.
_gsh_sync_helper_task -Exe $p.Exe
} catch {
_gsh_update_log ('update: swap FAILED (' + $_.Exception.Message + '); restarting previous binary')
_gsh_write_update_status -Result 'swap-failed' -Detail $_.Exception.Message
# ADR-0043: the failed swap's own rollback left the previous binary in
# place and the swap already stopped the helper; bring it back here, since
# the throw below skips the post-health sync.
_gsh_sync_helper_task -Exe $p.Exe
throw
} finally {
Start-ScheduledTask -TaskName $script:GshTask -ErrorAction SilentlyContinue
}
if (_gsh_health_check_or_rollback -Exe $p.Exe) {
_gsh_update_log 'update: health check passed; new binary live'
_gsh_write_update_status -Result 'updated' -Detail ('now on manifest version ' + $script:GshLastManifestVersion)
} else {
_gsh_update_log 'update: health check FAILED; rolled back to previous binary'
_gsh_write_update_status -Result 'rolled-back' -Detail 'post-swap health check failed; previous binary restored'
}
# ADR-0043: the swap stopped the helper (it runs from the same
# gsh-agent.exe); bring it back on the binary now in place. Runs after the
# updated AND rolled-back outcomes (the swap-failure path resyncs in its
# catch above), because either way a runnable binary is live and an
# opted-in node must not be left silently falling back in-process.
_gsh_sync_helper_task -Exe $p.Exe
# ADR-0044: converge the run-as after a swap too (same rationale as the
# up-to-date path above; the helper is back up first, so the interlock sees
# the real state).
_gsh_flip_core_runas -Exe $p.Exe
}
}
# Clean uninstall, reachable from the signed/served script (INST item 4) so an
# owner has a one-command removal path. Stops the agent gracefully (worlds save),
# removes the scheduled tasks (agent, updater, helper), drops our Defender exclusions, and deletes the
# binary + control/state dirs. Game-server files AND the per-server sandbox users
# are KEPT unless explicitly opted in, so an uninstall never destroys saves by
# accident. After this the machine is no longer enrolled; reinstalling needs a
# fresh single-use token from the panel. Inert until invoked, so shipping it in
# the signed script changes nothing for a running agent.
function gsh-uninstall {
[CmdletBinding()]
param([switch] $RemoveSandboxUsers, [switch] $RemoveGameServers, [string] $DataDir, [switch] $Force, [switch] $IUnderstandThisDeletesData)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
_gsh_require_admin
$p = _gsh_paths
$installDir = $p.Dir
$dataRoot = Split-Path $p.Config -Parent
Write-Host ''
Write-Host ' =====================================' -ForegroundColor DarkYellow
Write-Host ' GameServersHub - Agent Uninstaller' -ForegroundColor Yellow
Write-Host ' =====================================' -ForegroundColor DarkYellow
Write-Host ''
if ($RemoveGameServers -and [string]::IsNullOrWhiteSpace($DataDir)) {
throw '-RemoveGameServers requires -DataDir (the game-server folder to delete).'
}
# Destructive options permanently delete data and are NOT covered by -Force (which
# only skips the "type YES" prompt). Deleting game-server files or local user
# accounts is irreversible, so it requires its own explicit acknowledgement flag.
# This fires even under -Force, so an automated run can never silently wipe saves.
if (($RemoveGameServers -or $RemoveSandboxUsers) -and -not $IUnderstandThisDeletesData) {
throw 'Refusing a destructive uninstall: -RemoveGameServers and -RemoveSandboxUsers permanently delete game saves / local accounts. Re-run with -IUnderstandThisDeletesData to confirm.'
}
_gsh_detail ('binary dir : ' + $installDir)
_gsh_detail ('config dir : ' + $dataRoot + ' (agent identity + logs)')
_gsh_detail ('tasks : ' + $script:GshTask + ', ' + $script:GshUpdaterTask)
if ($RemoveSandboxUsers) { _gsh_detail 'will ALSO delete local users matching gsh-*' } else { _gsh_detail 'sandbox users (gsh-*): KEPT' }
if ($RemoveGameServers) { _gsh_detail ('will ALSO delete game-server files in ' + $DataDir) } else { _gsh_detail 'game-server files: KEPT' }
Write-Host ''
if (-not $Force) {
$ans = Read-Host ' Proceed with uninstall? Type YES to continue'
if ($ans -ne 'YES') { _gsh_detail 'Aborted; nothing changed.'; return }
Write-Host ''
}
# Graceful stop: drop the sentinel the agent watches (worlds save), wait, then
# hard-stop both tasks if it overruns.
_gsh_step 'Stopping the agent'
try { New-Item -ItemType Directory -Force -Path $dataRoot | Out-Null } catch {}
# ADR-0043: stop the helper before the graceful wait - it ignores the
# shutdown sentinel and would otherwise pin the wait to its full deadline.
try { Stop-ScheduledTask -TaskName $script:GshHelperTask -ErrorAction SilentlyContinue | Out-Null } catch {}
$sentinel = Join-Path $dataRoot 'shutdown.request'
try { New-Item -ItemType File -Path $sentinel -Force | Out-Null } catch {}
$deadline = (Get-Date).AddSeconds(40)
while ((Get-Date) -lt $deadline -and (Get-Process -Name 'gsh-agent' -ErrorAction SilentlyContinue)) { Start-Sleep -Milliseconds 500 }
foreach ($t in @($script:GshTask, $script:GshUpdaterTask, $script:GshHelperTask)) {
try { Stop-ScheduledTask -TaskName $t -ErrorAction SilentlyContinue | Out-Null } catch {}
}
Get-Process -Name 'gsh-agent' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
try { Remove-Item $sentinel -Force -ErrorAction SilentlyContinue } catch {}
_gsh_ok 'Agent stopped'
_gsh_step 'Removing scheduled tasks'
foreach ($t in @($script:GshTask, $script:GshUpdaterTask, $script:GshHelperTask)) {
try {
if (Get-ScheduledTask -TaskName $t -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $t -Confirm:$false -ErrorAction SilentlyContinue
_gsh_detail ('removed ' + $t)
}
} catch {}
}
_gsh_ok 'Tasks removed'
# ADR-0044: the limited core account (gsh-core) is infrastructure — it owns no
# game data — so remove it unconditionally, like the tasks. Best-effort + quiet
# (no-op on the SYSTEM-only fleet). It does NOT need the destructive opt-in that
# guards the per-server sandbox users.
_gsh_remove_core_account
_gsh_step 'Removing Windows Defender exclusions'
foreach ($d in @($installDir, $dataRoot)) {
try { Remove-MpPreference -ExclusionPath $d -ErrorAction SilentlyContinue } catch {}
}
_gsh_ok 'Defender exclusions cleared'
if ($RemoveSandboxUsers) {
_gsh_step 'Removing sandbox users (gsh-*)'
try {
$users = Get-LocalUser -Name 'gsh-*' -ErrorAction SilentlyContinue
foreach ($u in $users) { try { Remove-LocalUser -Name $u.Name -ErrorAction SilentlyContinue; _gsh_detail ('removed user ' + $u.Name) } catch {} }
} catch {
(net user) 2>$null | Out-String | Select-String -Pattern 'gsh-\S+' -AllMatches |
ForEach-Object { $_.Matches.Value } | Sort-Object -Unique |
ForEach-Object { try { net user $_ /delete | Out-Null; _gsh_detail ('removed user ' + $_) } catch {} }
}
_gsh_ok 'Sandbox users removed'
}
# Tasks are gone; kill once more in case one relaunched the agent before we
# unregistered it (a running gsh-agent.exe locks its own file and blocks delete).
_gsh_step 'Deleting agent files'
Get-Process -Name 'gsh-agent' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
Start-Sleep -Milliseconds 500
foreach ($d in @($installDir, $dataRoot)) {
if (Test-Path $d) {
try { Remove-Item -Recurse -Force $d -ErrorAction Stop; _gsh_detail ('deleted ' + $d) }
catch {
# Our own lockdown stripped inheritance; reclaim ownership + reset ACL, retry.
try {
takeown /F $d /R /D Y | Out-Null
icacls $d /reset /T /C /Q | Out-Null
Remove-Item -Recurse -Force $d -ErrorAction Stop
_gsh_detail ('deleted ' + $d + ' (after reclaiming ownership)')
} catch {
_gsh_detail ('could not fully delete ' + $d + '; reboot and delete it manually.')
}
}
}
}
_gsh_ok 'Agent files deleted'
if ($RemoveGameServers) {
_gsh_step 'Deleting game-server files'
if (Test-Path $DataDir) {
try { Remove-Item -Recurse -Force $DataDir -ErrorAction Stop; _gsh_detail ('deleted ' + $DataDir) }
catch { _gsh_detail ('could not fully delete ' + $DataDir) }
} else { _gsh_detail ('not found: ' + $DataDir) }
_gsh_ok 'Game-server files deleted'
}
Write-Host ''
_gsh_ok 'GSH agent uninstalled.'
_gsh_detail 'This machine is no longer enrolled. To reinstall, get a fresh token from the panel (Machines -> Add Host).'
Write-Host ''
}