490 lines
22 KiB
PowerShell
490 lines
22 KiB
PowerShell
# Shared helpers for the Windows scripts (setup.ps1, install.ps1, join.ps1, cs.ps1).
|
|
# Windows PowerShell 5.1: keep this file ASCII and avoid PowerShell 7-only syntax.
|
|
$ErrorActionPreference = 'Stop'
|
|
$ProgressPreference = 'SilentlyContinue' # the progress bar makes downloads crawl in 5.1
|
|
# older Windows 10 installs still default to TLS 1.0: GitHub needs 1.2
|
|
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
|
|
|
$Script:Utf8 = New-Object Text.UTF8Encoding $false
|
|
|
|
# Defaults for settings.txt (same keys as the Linux .env)
|
|
$Script:DefaultSettings = [ordered]@{
|
|
CS_HOSTNAME = 'CS 1.6 Deathmatch'
|
|
CS_PASSWORD = ''
|
|
CS_MAXPLAYERS = '16'
|
|
CS_PORT = '27015'
|
|
CS_MAP = 'de_dust2'
|
|
CS_MODE = 'dm'
|
|
CS_BOTS = '6'
|
|
CS_BOT_DIFFICULTY = '2'
|
|
CS_PUBLIC_ADDR = ''
|
|
}
|
|
|
|
function Say($text) { Write-Host ''; Write-Host "== $text" -ForegroundColor Cyan }
|
|
function Step($text) { Write-Host " - $text" }
|
|
function Warn($text) { Write-Host $text -ForegroundColor Yellow }
|
|
|
|
# Writes text as UTF-8 without BOM (HLDS ignores UTF-16 cfg files, which is what 5.1's > writes)
|
|
function Write-TextFile($path, $text) { [IO.File]::WriteAllText($path, $text, $Script:Utf8) }
|
|
|
|
# ---------- settings.txt (KEY=value lines, # comments) ----------
|
|
|
|
function Read-Settings($path) {
|
|
$s = [ordered]@{}
|
|
foreach ($k in $Script:DefaultSettings.Keys) { $s[$k] = $Script:DefaultSettings[$k] }
|
|
if (Test-Path -LiteralPath $path) {
|
|
foreach ($line in [IO.File]::ReadAllLines($path)) {
|
|
if ($line -match '^\s*#' -or $line -notmatch '=') { continue }
|
|
$i = $line.IndexOf('=')
|
|
$s[$line.Substring(0, $i).Trim()] = $line.Substring($i + 1).Trim()
|
|
}
|
|
}
|
|
return $s
|
|
}
|
|
|
|
function Write-Setting($path, $key, $value) {
|
|
$value = ([string]$value) -replace '["\r\n]', ''
|
|
$lines = New-Object Collections.Generic.List[string]
|
|
if (Test-Path -LiteralPath $path) { $lines.AddRange([string[]][IO.File]::ReadAllLines($path)) }
|
|
$done = $false
|
|
for ($i = 0; $i -lt $lines.Count; $i++) {
|
|
if ($lines[$i] -match ('^' + [regex]::Escape($key) + '=')) { $lines[$i] = "$key=$value"; $done = $true }
|
|
}
|
|
if (-not $done) { $lines.Add("$key=$value") }
|
|
Write-TextFile $path (($lines -join "`r`n") + "`r`n")
|
|
}
|
|
|
|
# ---------- install folder ----------
|
|
|
|
# Where to install by default: Documents\cs16, unless Documents is synced by OneDrive or the
|
|
# path has characters SteamCMD/HLDS can't handle. Returns @{ Path = ...; Reason = ... }.
|
|
function Resolve-InstallDir($documents, $userProfile, [string[]]$oneDriveRoots) {
|
|
$path = Join-Path $documents 'cs16'
|
|
$reason = ''
|
|
foreach ($od in $oneDriveRoots) {
|
|
if ($od -and $documents.StartsWith($od, [StringComparison]::OrdinalIgnoreCase)) {
|
|
$path = Join-Path $userProfile 'cs16'
|
|
$reason = 'Your Documents folder is synced by OneDrive, which would upload ~1 GB of server files and can break them.'
|
|
}
|
|
}
|
|
if ($path -match '[^\x20-\x7e]') {
|
|
$path = 'C:\cs16'
|
|
$reason = 'Your user folder name has special characters that the game server cannot handle.'
|
|
}
|
|
return @{ Path = $path; Reason = $reason }
|
|
}
|
|
|
|
# Returns a problem description for a folder the user typed, or $null when it is usable
|
|
function Test-InstallDir($path) {
|
|
if (-not [IO.Path]::IsPathRooted($path)) { return 'give a full path, like C:\cs16' }
|
|
if ($path -match '[^\x20-\x7e]') { return 'use only plain letters and digits in the path (no accents or other alphabets)' }
|
|
if ($path -match '[\[\]]') { return 'leave out [ and ] in the folder name' }
|
|
foreach ($pf in @($env:ProgramFiles, ${env:ProgramFiles(x86)})) {
|
|
if ($pf -and $path.StartsWith($pf, [StringComparison]::OrdinalIgnoreCase)) { return 'Program Files needs admin rights: pick another folder' }
|
|
}
|
|
return $null
|
|
}
|
|
|
|
# ---------- pinned downloads ----------
|
|
|
|
# tools/versions.txt minus Linux-only files, plus tools/versions-windows.txt
|
|
# Each item also gets a Mirror URL (backup copy on the project's Gitea, '#mirror <base>' line)
|
|
function Get-PinnedDownloads($repo) {
|
|
$out = @(); $mirror = ''
|
|
foreach ($f in @('versions.txt', 'versions-windows.txt')) {
|
|
$p = Join-Path $repo "tools\$f"
|
|
if (-not (Test-Path -LiteralPath $p)) { continue }
|
|
foreach ($line in [IO.File]::ReadAllLines($p)) {
|
|
$line = $line.Trim()
|
|
if ($line.StartsWith('#mirror ')) { $mirror = $line.Substring(8).Trim(); continue }
|
|
if ($line -eq '' -or $line.StartsWith('#')) { continue }
|
|
$parts = $line -split '\s+'
|
|
if ($parts.Count -lt 3 -or $parts[0] -match '-linux') { continue }
|
|
$out += [pscustomobject]@{ File = $parts[0]; Sha = $parts[1].ToLower(); Url = $parts[2]; Mirror = '' }
|
|
}
|
|
}
|
|
if ($mirror) { foreach ($i in $out) { $i.Mirror = $mirror + ($i.File -split '/')[-1] } }
|
|
return $out
|
|
}
|
|
|
|
function Get-Sha256($path) { # .NET directly: Get-FileHash is missing when 5.1 inherits PowerShell 7's module path
|
|
$sha = [Security.Cryptography.SHA256]::Create(); $f = [IO.File]::OpenRead($path)
|
|
try { return (-join ($sha.ComputeHash($f) | ForEach-Object { $_.ToString('x2') })) } finally { $f.Dispose(); $sha.Dispose() }
|
|
}
|
|
|
|
function Get-Url($url, $outFile) {
|
|
for ($try = 1; $try -le 3; $try++) {
|
|
try { Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $outFile -TimeoutSec 120; return }
|
|
catch { if ($try -eq 3) { throw "download failed: $url ($($_.Exception.Message))" }; Start-Sleep -Seconds (2 * $try) }
|
|
}
|
|
}
|
|
|
|
# Downloads one pinned file into $dlDir unless it is already there with the right hash
|
|
function Get-Pinned($item, $dlDir) {
|
|
$dest = Join-Path $dlDir ($item.File -replace '/', '\')
|
|
if ((Test-Path -LiteralPath $dest) -and (Get-Sha256 $dest) -eq $item.Sha) { return $dest }
|
|
New-Item -ItemType Directory -Force -Path (Split-Path $dest) | Out-Null
|
|
$part = "$dest.part"
|
|
# main site first; if it fails or sends something else (an error page, a blocked-site page),
|
|
# the backup copy. Whatever arrives must match the pinned hash.
|
|
$got = $null
|
|
try { Get-Url $item.Url $part; $got = Get-Sha256 $part } catch { if (-not $item.Mirror) { throw } }
|
|
if ($got -ne $item.Sha -and $item.Mirror) {
|
|
Write-Host " $($item.File): main site failed, trying the backup copy"
|
|
try { Get-Url $item.Mirror $part } catch { throw "download failed: $($item.Url) (and the backup copy)" }
|
|
$got = Get-Sha256 $part
|
|
}
|
|
if ($got -ne $item.Sha) {
|
|
Remove-Item -LiteralPath $part -Force
|
|
throw "checksum mismatch for $($item.File): expected $($item.Sha), got $got"
|
|
}
|
|
Move-Item -LiteralPath $part -Destination $dest -Force
|
|
return $dest
|
|
}
|
|
|
|
# Extracts the entries of $zip under $prefix (e.g. 'bin/win32/') into $dest, without the prefix
|
|
function Expand-ZipPart($zip, $prefix, $dest) {
|
|
$z = [IO.Compression.ZipFile]::OpenRead($zip)
|
|
try {
|
|
$n = 0
|
|
foreach ($e in $z.Entries) {
|
|
$name = $e.FullName -replace '\\', '/'
|
|
if (-not $name.StartsWith($prefix) -or $name -eq $prefix) { continue }
|
|
$target = Join-Path $dest (($name.Substring($prefix.Length)) -replace '/', '\')
|
|
if ($name.EndsWith('/')) { New-Item -ItemType Directory -Force -Path $target | Out-Null; continue } # e.g. amxmodx\logs
|
|
New-Item -ItemType Directory -Force -Path (Split-Path $target) | Out-Null
|
|
[IO.Compression.ZipFileExtensions]::ExtractToFile($e, $target, $true)
|
|
$n++
|
|
}
|
|
if ($n -eq 0) { throw "nothing under '$prefix' in $zip" }
|
|
} finally { $z.Dispose() }
|
|
}
|
|
|
|
# Old 6-field CSDM spawn lines (de_prodigy, de_train) -> the 10 fields CSDM-ReAPI reads
|
|
function Convert-SpawnLine($line) {
|
|
$f = @($line.Trim() -split '\s+' | Where-Object { $_ -ne '' })
|
|
if ($f.Count -eq 6) { return "$($line.TrimEnd()) 0 0 0 0" }
|
|
return $line
|
|
}
|
|
|
|
# ---------- talking to the server (A2S query, RCON) ----------
|
|
|
|
$Script:Hdr = [byte[]](255, 255, 255, 255)
|
|
|
|
function Send-Udp($client, [byte[]]$data) { [void]$client.Send($data, $data.Length) }
|
|
|
|
function Receive-Udp($client) {
|
|
$ep = New-Object Net.IPEndPoint ([Net.IPAddress]::Any), 0
|
|
try { return $client.Receive([ref]$ep) } catch { return $null }
|
|
}
|
|
|
|
function Read-CString([byte[]]$d, [ref]$pos) {
|
|
$start = $pos.Value
|
|
while ($pos.Value -lt $d.Length -and $d[$pos.Value] -ne 0) { $pos.Value++ }
|
|
$s = [Text.Encoding]::UTF8.GetString($d, $start, $pos.Value - $start)
|
|
$pos.Value++
|
|
return $s
|
|
}
|
|
|
|
# Parses an A2S_INFO reply ('I' packet with the 4-byte header)
|
|
function ConvertFrom-A2SInfo([byte[]]$d) {
|
|
if ($d.Length -lt 6 -or $d[4] -ne 0x49) { return $null }
|
|
$pos = 6
|
|
$name = Read-CString $d ([ref]$pos); $map = Read-CString $d ([ref]$pos)
|
|
[void](Read-CString $d ([ref]$pos)); [void](Read-CString $d ([ref]$pos))
|
|
$pos += 2 # app id
|
|
return [pscustomobject]@{ Name = $name; Map = $map; Players = $d[$pos]; Max = $d[$pos + 1]; Bots = $d[$pos + 2]; Password = ($d[$pos + 5] -ne 0) }
|
|
}
|
|
|
|
function Get-A2SInfo($hostName, [int]$port, [int]$timeoutMs = 3000) {
|
|
$c = New-Object Net.Sockets.UdpClient
|
|
try {
|
|
$c.Client.ReceiveTimeout = $timeoutMs
|
|
$c.Connect($hostName, $port)
|
|
$q = $Script:Hdr + [Text.Encoding]::ASCII.GetBytes("TSource Engine Query") + [byte[]](0)
|
|
Send-Udp $c $q
|
|
$d = Receive-Udp $c
|
|
if ($d -and $d.Length -ge 9 -and $d[4] -eq 0x41) { # challenge first: ask again with it
|
|
Send-Udp $c ($q + $d[5..8])
|
|
$d = Receive-Udp $c
|
|
}
|
|
if (-not $d) { return $null }
|
|
return ConvertFrom-A2SInfo $d
|
|
} catch { return $null } finally { $c.Close() }
|
|
}
|
|
|
|
function Format-A2SInfo($i) { "$($i.Name) | map $($i.Map) | players $($i.Players)/$($i.Max) (bots $($i.Bots))" }
|
|
|
|
function Invoke-Rcon([int]$port, $password, $cmd) {
|
|
$c = New-Object Net.Sockets.UdpClient
|
|
try {
|
|
$c.Client.ReceiveTimeout = 3000
|
|
$c.Connect('127.0.0.1', $port)
|
|
Send-Udp $c ($Script:Hdr + [Text.Encoding]::ASCII.GetBytes("challenge rcon`n"))
|
|
$d = Receive-Udp $c
|
|
if (-not $d) { return '(no answer: server down or changing map?)' }
|
|
$parts = [Text.Encoding]::ASCII.GetString($d, 4, $d.Length - 4).Trim() -split '\s+'
|
|
if ($parts.Count -lt 3) { return '(no answer: server down or changing map?)' }
|
|
Send-Udp $c ($Script:Hdr + [Text.Encoding]::UTF8.GetBytes("rcon $($parts[2]) `"$password`" $cmd"))
|
|
$sb = New-Object Text.StringBuilder
|
|
while ($true) { # long replies come in several packets
|
|
$d = Receive-Udp $c
|
|
if (-not $d) { break }
|
|
if ($d.Length -ge 5 -and $d[0] -eq 255 -and $d[1] -eq 255 -and $d[2] -eq 255 -and $d[3] -eq 255) {
|
|
[void]$sb.Append([Text.Encoding]::UTF8.GetString($d, 5, $d.Length - 5))
|
|
} else { [void]$sb.Append([Text.Encoding]::UTF8.GetString($d)) }
|
|
$c.Client.ReceiveTimeout = 400
|
|
}
|
|
$text = ($sb.ToString() -split "`r?`n" | Where-Object { $_ -match '^Bad rcon_password' -or $_ -notmatch 'rcon_password|sv_password' }) -join "`n"
|
|
if ($text.Trim() -eq '') { return '(ok, no output)' }
|
|
return $text.TrimEnd()
|
|
} catch { return '(no answer: server down or changing map?)' } finally { $c.Close() }
|
|
}
|
|
|
|
# ---------- kill counter ----------
|
|
|
|
$Script:KillRx = New-Object regex '^L (\d\d/\d\d/\d{4}) - [\d:]+: "(.+?)<\d+><[^>]*><[^>]*>" killed ".+?<\d+><[^>]*><[^>]*>" with "([^"]+)"'
|
|
|
|
function Get-KillLines([string[]]$lines) {
|
|
foreach ($l in $lines) {
|
|
$m = $Script:KillRx.Match($l)
|
|
if ($m.Success) { [pscustomobject]@{ Day = $m.Groups[1].Value; Killer = $m.Groups[2].Value; Weapon = $m.Groups[3].Value } }
|
|
}
|
|
}
|
|
|
|
# Reads a text file the server may still be writing (File.ReadAllLines fails on it)
|
|
function Read-SharedLines($path) {
|
|
$fs = New-Object IO.FileStream $path, ([IO.FileMode]::Open), ([IO.FileAccess]::Read), ([IO.FileShare]::ReadWrite)
|
|
$r = New-Object IO.StreamReader $fs, $Script:Utf8
|
|
try { return @($r.ReadToEnd() -split "`r?`n") } finally { $r.Dispose() }
|
|
}
|
|
|
|
function ConvertTo-Hash($o) {
|
|
$h = @{}
|
|
if ($o) { foreach ($p in $o.PSObject.Properties) { $h[$p.Name] = [int]$p.Value } }
|
|
return $h
|
|
}
|
|
|
|
# Counts the kill lines in the server logs. Per-log counts are kept in $stateFile so totals
|
|
# survive deleted logs. Returns @{ Total; Days; Maps; Who (hashtable); Weapons (hashtable) }.
|
|
function Update-KillTally($logsDir, $stateFile) {
|
|
$files = @{}
|
|
if (Test-Path -LiteralPath $stateFile) {
|
|
try {
|
|
$st = [IO.File]::ReadAllText($stateFile) | ConvertFrom-Json
|
|
foreach ($p in $st.files.PSObject.Properties) {
|
|
$v = $p.Value
|
|
$files[$p.Name] = @{ size = [long]$v.size; kills = [int]$v.kills; who = (ConvertTo-Hash $v.who); wpn = (ConvertTo-Hash $v.wpn); days = @($v.days) }
|
|
}
|
|
} catch { $files = @{} }
|
|
}
|
|
if (Test-Path -LiteralPath $logsDir) {
|
|
foreach ($f in Get-ChildItem -LiteralPath $logsDir -Filter *.log) {
|
|
if ($files.ContainsKey($f.Name) -and $files[$f.Name].size -eq $f.Length) { continue }
|
|
$who = @{}; $wpn = @{}; $days = @{}; $n = 0
|
|
foreach ($k in Get-KillLines (Read-SharedLines $f.FullName)) {
|
|
$n++; $days[$k.Day] = 1; $who[$k.Killer] = 1 + [int]$who[$k.Killer]; $wpn[$k.Weapon] = 1 + [int]$wpn[$k.Weapon]
|
|
}
|
|
$files[$f.Name] = @{ size = $f.Length; kills = $n; who = $who; wpn = $wpn; days = @($days.Keys) }
|
|
}
|
|
}
|
|
$json = @{ files = $files } | ConvertTo-Json -Depth 6 -Compress
|
|
Write-TextFile "$stateFile.tmp" $json
|
|
Move-Item -LiteralPath "$stateFile.tmp" -Destination $stateFile -Force
|
|
|
|
$t = @{ Total = 0; Days = @{}; Maps = $files.Count; Who = @{}; Weapons = @{} }
|
|
foreach ($v in $files.Values) {
|
|
$t.Total += $v.kills
|
|
foreach ($d in $v.days) { if ($d) { $t.Days[$d] = 1 } }
|
|
foreach ($k in $v.who.Keys) { $t.Who[$k] = $v.who[$k] + [int]$t.Who[$k] }
|
|
foreach ($k in $v.wpn.Keys) { $t.Weapons[$k] = $v.wpn[$k] + [int]$t.Weapons[$k] }
|
|
}
|
|
return $t
|
|
}
|
|
|
|
# ---------- the server process ----------
|
|
|
|
function Get-ServerProcess($hldsDir) {
|
|
$exe = Join-Path $hldsDir 'hlds.exe'
|
|
Get-Process -Name hlds -ErrorAction SilentlyContinue | Where-Object {
|
|
try { $_.Path -and ($_.Path -ieq $exe) } catch { $false }
|
|
} | Select-Object -First 1
|
|
}
|
|
|
|
function Test-UdpPortFree([int]$port) {
|
|
try { $u = New-Object Net.Sockets.UdpClient $port; $u.Close(); return $true } catch { return $false }
|
|
}
|
|
|
|
function Get-RconPassword($root) { ([IO.File]::ReadAllText((Join-Path $root 'secrets\rcon-password.txt'))).Trim() }
|
|
|
|
# Writes the per-start configs from settings.txt (twin of tools/start.sh), then starts hlds.exe
|
|
# in its own window. Does nothing when this install's server is already running.
|
|
function Start-CsServer($root) {
|
|
$h = Join-Path $root 'hlds'; $c = Join-Path $h 'cstrike'
|
|
if (Get-ServerProcess $h) { Write-Host ' the server is already running'; return $false }
|
|
$s = Read-Settings (Join-Path $root 'settings.txt')
|
|
$clean = { param($v) ([string]$v) -replace '["\r\n]', '' }
|
|
Write-TextFile (Join-Path $c 'env.cfg') ("// written from settings.txt at every start: edit settings.txt instead`r`nhostname `"" + (& $clean $s.CS_HOSTNAME) + "`"`r`n")
|
|
Write-TextFile (Join-Path $c 'secrets.cfg') ("// written at every start (RCON: secrets\rcon-password.txt, join: CS_PASSWORD in settings.txt)`r`n" +
|
|
"rcon_password `"" + (Get-RconPassword $root) + "`"`r`nsv_password `"" + (& $clean $s.CS_PASSWORD) + "`"`r`n")
|
|
$ycfg = Join-Path $c 'addons\yapb\conf\yapb.cfg'
|
|
$y = [IO.File]::ReadAllText($ycfg)
|
|
$y = $y -replace '(?m)^yb_quota ".*"', ('yb_quota "' + [int]$s.CS_BOTS + '"')
|
|
$y = $y -replace '(?m)^yb_difficulty ".*"', ('yb_difficulty "' + [int]$s.CS_BOT_DIFFICULTY + '"')
|
|
Write-TextFile $ycfg $y
|
|
$hargs = "-console -condebug -game cstrike -port $([int]$s.CS_PORT) +maxplayers $([int]$s.CS_MAXPLAYERS) +map $($s.CS_MAP) +log on"
|
|
# A fresh install has no steam_appid.txt. hlds writes it while starting, but too late: Steam
|
|
# has already failed and the server sits on a "Fatal error: Unable to initialize Steam" box.
|
|
# 10 = Counter-Strike.
|
|
$appid = Join-Path $h 'steam_appid.txt'
|
|
if (-not (Test-Path -LiteralPath $appid) -or [IO.File]::ReadAllText($appid).Trim() -ne '10') { Write-TextFile $appid "10`r`n" }
|
|
# If that box still shows up, close the server and start it again. The window starts minimized
|
|
# so it doesn't take the keyboard away from whatever you're doing.
|
|
for ($try = 1; $try -le 3; $try++) {
|
|
$p = Start-Process -FilePath (Join-Path $h 'hlds.exe') -WorkingDirectory $h -ArgumentList $hargs -PassThru -WindowStyle Minimized
|
|
if (-not (Test-SteamFailure $p ([int]$s.CS_PORT))) { break }
|
|
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
|
|
Start-Sleep -Seconds 2
|
|
Write-Host " (Steam wasn't ready yet, starting again)"
|
|
}
|
|
return $true
|
|
}
|
|
|
|
# Waits up to 40 s for the server to answer or to show its "Fatal error" box.
|
|
# Returns $true for that box.
|
|
function Test-SteamFailure($p, [int]$port) {
|
|
$end = (Get-Date).AddSeconds(40)
|
|
while ((Get-Date) -lt $end -and -not $p.HasExited) {
|
|
if (Get-A2SInfo '127.0.0.1' $port 1000) { return $false }
|
|
if (@(Get-ProcessWindowTitles $p.Id | Where-Object { $_ -like 'Fatal error*' }).Count) { return $true }
|
|
Start-Sleep -Milliseconds 500
|
|
}
|
|
return $false
|
|
}
|
|
|
|
# Titles of all top-level windows of a process (the error box is not always its main window)
|
|
function Get-ProcessWindowTitles([int]$processId) {
|
|
if (-not ('Cs16Win' -as [type])) {
|
|
Add-Type @'
|
|
using System; using System.Text; using System.Collections.Generic; using System.Runtime.InteropServices;
|
|
public static class Cs16Win {
|
|
delegate bool EnumProc(IntPtr h, IntPtr l);
|
|
[DllImport("user32.dll")] static extern bool EnumWindows(EnumProc f, IntPtr l);
|
|
[DllImport("user32.dll")] static extern int GetWindowThreadProcessId(IntPtr h, out int pid);
|
|
[DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern int GetWindowText(IntPtr h, StringBuilder s, int n);
|
|
public static string[] Titles(int want) {
|
|
var r = new List<string>();
|
|
EnumWindows((h, l) => { int pid; GetWindowThreadProcessId(h, out pid);
|
|
if (pid == want) { var t = new StringBuilder(256); GetWindowText(h, t, 256); r.Add(t.ToString()); } return true; }, IntPtr.Zero);
|
|
return r.ToArray();
|
|
}
|
|
}
|
|
'@
|
|
}
|
|
[Cs16Win]::Titles($processId)
|
|
}
|
|
|
|
# An earlier version (2026-10-15) hid this Steam setting while starting the server; this puts it
|
|
# back if a start was interrupted then. Harmless otherwise.
|
|
$Script:SteamKey = 'HKCU:\Software\Valve\Steam\ActiveProcess'
|
|
$Script:SteamHidden = 'SteamClientDll_hidden_by_cs16'
|
|
|
|
function Restore-SteamClientSetting {
|
|
$v = Get-ItemProperty -Path $Script:SteamKey -ErrorAction SilentlyContinue
|
|
if (-not $v -or -not $v.($Script:SteamHidden)) { return }
|
|
if ($v.SteamClientDll) { Remove-ItemProperty -Path $Script:SteamKey -Name $Script:SteamHidden } # Steam wrote a fresh one
|
|
else { Rename-ItemProperty -Path $Script:SteamKey -Name $Script:SteamHidden -NewName SteamClientDll }
|
|
}
|
|
|
|
function Stop-CsServer($root) {
|
|
$h = Join-Path $root 'hlds'
|
|
$p = Get-ServerProcess $h
|
|
if (-not $p) { return $false }
|
|
$s = Read-Settings (Join-Path $root 'settings.txt')
|
|
[void](Invoke-Rcon ([int]$s.CS_PORT) (Get-RconPassword $root) 'quit')
|
|
if (-not $p.WaitForExit(8000)) { Stop-Process -Id $p.Id -Force }
|
|
return $true
|
|
}
|
|
|
|
# Waits for the server to answer a query on 127.0.0.1; returns the info or $null
|
|
function Wait-CsServer([int]$port, [int]$seconds = 60) {
|
|
$end = (Get-Date).AddSeconds($seconds)
|
|
while ((Get-Date) -lt $end) {
|
|
$i = Get-A2SInfo '127.0.0.1' $port 2000
|
|
if ($i) { return $i }
|
|
Write-Host -NoNewline '.'
|
|
Start-Sleep -Seconds 2
|
|
}
|
|
return $null
|
|
}
|
|
|
|
function Get-ConsoleTail($root, [int]$n = 20) {
|
|
$log = Join-Path $root 'hlds\qconsole.log' # -condebug writes it next to hlds.exe
|
|
if (-not (Test-Path -LiteralPath $log)) { return @() }
|
|
Get-Content -LiteralPath $log -Tail $n | Where-Object { $_ -notmatch 'rcon_password|sv_password|Rcon:' }
|
|
}
|
|
|
|
function Get-ServerMode($root) {
|
|
if (Test-Path -LiteralPath (Join-Path $root 'hlds\cstrike\addons\amxmodx\configs\plugins-gungame.ini')) { 'GunGame' } else { 'deathmatch' }
|
|
}
|
|
|
|
# ---------- network addresses ----------
|
|
|
|
$Script:VirtualIf = 'vEthernet|Loopback|VirtualBox|VMware|Hyper-V|WSL|Tailscale|ZeroTier|Bluetooth'
|
|
|
|
function Get-LanIPv4 {
|
|
@(Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object {
|
|
$_.IPAddress -notmatch '^(127\.|169\.254\.)' -and $_.InterfaceAlias -notmatch $Script:VirtualIf -and
|
|
-not (Test-CgnatAddress $_.IPAddress)
|
|
} | ForEach-Object { $_.IPAddress })
|
|
}
|
|
|
|
function Test-CgnatAddress($ip) { # 100.64.0.0/10: Tailscale uses it
|
|
$o = $ip -split '\.'
|
|
return ($o[0] -eq '100' -and [int]$o[1] -ge 64 -and [int]$o[1] -le 127)
|
|
}
|
|
|
|
function Get-VpnIPv4 {
|
|
@(Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object {
|
|
$_.InterfaceAlias -match 'Tailscale|ZeroTier' -or (Test-CgnatAddress $_.IPAddress)
|
|
} | ForEach-Object { [pscustomobject]@{ IP = $_.IPAddress; Name = $_.InterfaceAlias } })
|
|
}
|
|
|
|
# ---------- firewall ----------
|
|
|
|
$Script:FwRule = 'CS 1.6 server (cs16-dm-server)'
|
|
|
|
# Normal (non-admin) sessions often can't read firewall rules, so the admin step below leaves a
|
|
# marker with the exact server path once its rule is in place.
|
|
function Get-FirewallMarker($root) { Join-Path $root 'secrets\firewall-rule.txt' }
|
|
|
|
function Test-FirewallRule($root) {
|
|
$m = Get-FirewallMarker $root
|
|
if (-not (Test-Path -LiteralPath $m)) { return $false }
|
|
return ([IO.File]::ReadAllText($m).Trim() -ieq (Join-Path $root 'hlds\hlds.exe'))
|
|
}
|
|
|
|
# Asks Windows (admin prompt) to let hlds.exe receive connections on every network type, and
|
|
# removes the block rule Windows creates when its own "allow access" popup gets cancelled.
|
|
# The elevated part checks its own result and reports it through its exit code.
|
|
function Add-FirewallRule($root) {
|
|
$exePath = Join-Path $root 'hlds\hlds.exe'
|
|
$exe = $exePath -replace "'", "''" # inside single quotes below
|
|
$ps = "`$ErrorActionPreference = 'Stop'; try { " +
|
|
"Get-NetFirewallApplicationFilter -Program '$exe' -ErrorAction SilentlyContinue | Get-NetFirewallRule | Where-Object { `$_.Direction -eq 'Inbound' -and `$_.Action -eq 'Block' } | Remove-NetFirewallRule; " +
|
|
"Get-NetFirewallRule -DisplayName '$($Script:FwRule)' -ErrorAction SilentlyContinue | Remove-NetFirewallRule; " +
|
|
"New-NetFirewallRule -DisplayName '$($Script:FwRule)' -Direction Inbound -Action Allow -Program '$exe' -Profile Any | Out-Null; " +
|
|
"if (Get-NetFirewallRule -DisplayName '$($Script:FwRule)' -ErrorAction SilentlyContinue) { exit 0 } else { exit 1 } } catch { exit 1 }"
|
|
$enc = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($ps))
|
|
try {
|
|
$p = Start-Process -FilePath powershell.exe -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList "-NoProfile -EncodedCommand $enc"
|
|
if ($p.ExitCode -ne 0) { return $false }
|
|
Write-TextFile (Get-FirewallMarker $root) $exePath
|
|
return $true
|
|
} catch { return $false } # the admin prompt was answered No
|
|
}
|
|
|
|
# repair a Steam setting left hidden by an interrupted Start-CsServer
|
|
Restore-SteamClientSetting
|