#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 '' }