commit 70f76797ea76c41e6b948880cd8886bb1f841fb6 Author: onyx-and-iris Date: Wed Nov 29 16:18:03 2023 +0000 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6b02c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,109 @@ +bin/ +obj/ +.ionide/ +project.lock.json +*-tests.xml +/debug/ +/staging/ +/Packages/ +*.nuget.props + +# dotnet cli install/uninstall scripts +dotnet-install.ps1 +dotnet-install.sh +dotnet-uninstall-pkgs.sh +dotnet-uninstall-debian-packages.sh + +# VS auto-generated solution files for project.json solutions +*.xproj +*.xproj.user +*.suo + +# VS auto-generated files for csproj files +*.csproj.user + +# Visual Studio IDE directory +.vs/ + +# VSCode directories that are not at the repository root +/**/.vscode/ + +# Project Rider IDE files +.idea.powershell/ + +# Ignore executables +*.exe +*.msi +*.appx +*.msix + +# Ignore binaries and symbols +*.pdb +*.dll +*.wixpdb + +# Ignore packages +*.deb +*.tar.gz +*.zip +*.rpm +*.pkg +*.nupkg +*.AppImage + +# default location for produced nuget packages +/nuget-artifacts + +# resgen output +gen + +# Per repo profile +.profile.ps1 + +# macOS +.DS_Store +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk +.AppleDouble +.LSOverride + +# TestsResults +TestsResults*.xml +ParallelXUnitResults.xml +xUnitResults.xml + +# Resharper settings +PowerShell.sln.DotSettings.user +*.msp +StyleCop.Cache + +# Ignore SelfSignedCertificate autogenerated files +test/tools/Modules/SelfSignedCertificate/ + +# BenchmarkDotNet artifacts +test/perf/BenchmarkDotNet.Artifacts/ + +# Test generated module +test/tools/Modules/Microsoft.PowerShell.NamedPipeConnection/ + +# Test generated startup profile +StartupProfileData-NonInteractive + +# Ignore logfiles +logfile/* + +# Manifest +*.psd1 + +# config +config.psd1 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2a8663d --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# Q3 Rcon client for Powershell + +Send Rcon commands to your Quake-3 Engine server from Powershell! + +## Tested against + +Currently only tested on COD servers (2, 4 and 5). + +## Requirements + +- Powershell 7.2+ + +## Installation + +`Install-Module -Name Q3Rcon -Scope CurrentUser` + +## Use + +```powershell +Import-Module Q3Rcon + +try { + $rcon = Connect-Rcon -hostname "hostname.server" -port 28960 -passwd "strongrconpassword" + + $rcon.Map() + + "Rotating the map..." + $rcon.MapRotate() + + Start-Sleep -Milliseconds 3000 # wait for map to rotate + + $rcon.Map() +} +finally { + Disconnect-Rcon -rcon $rcon +} +``` diff --git a/examples/cli.ps1 b/examples/cli.ps1 new file mode 100644 index 0000000..1b59a86 --- /dev/null +++ b/examples/cli.ps1 @@ -0,0 +1,39 @@ +[cmdletbinding()] +param() + +Import-Module ../lib/Q3Rcon.psm1 + +Function Read-HostUntilEmpty { + param([object]$rcon) + + "Input 'Q' or to exit." + while (($line = Read-Host -Prompt "Send command") -cne [string]::Empty) { + if ($line -eq "Q") { + break + } + + if ($line -in @("fast_restart", "map_rotate", "map_restart")) { + $cmd = $line -replace '(?:^|_)(\p{L})', { $_.Groups[1].Value.ToUpper() } + $resp = $rcon.$cmd() + } + else { + $resp = $rcon.Send($line) + } + $resp | Write-Host + } +} + +Function Get-ConnFromPSD1 { + $configpath = Join-Path $PSScriptRoot "config.psd1" + return Import-PowerShellDataFile -Path $configpath +} + + +try { + $conn = Get-ConnFromPSD1 + $rcon = Connect-Rcon -hostname $conn.host -port $conn.port -passwd $conn.passwd + Read-HostUntilEmpty -rcon $rcon +} +finally { + Disconnect-Rcon -rcon $rcon +} diff --git a/examples/commands.ps1 b/examples/commands.ps1 new file mode 100644 index 0000000..a6c1e2f --- /dev/null +++ b/examples/commands.ps1 @@ -0,0 +1,26 @@ +[cmdletbinding()] +param() + +Import-Module ../lib/Q3Rcon.psm1 + +Function Get-ConnFromPSD1 { + $configpath = Join-Path $PSScriptRoot "config.psd1" + return Import-PowerShellDataFile -Path $configpath +} + +try { + $conn = Get-ConnFromPSD1 + $rcon = Connect-Rcon -hostname $conn.host -port $conn.port -passwd $conn.passwd + + $rcon.Map() + + "Rotating the map..." + $rcon.MapRotate() + + Start-Sleep -Milliseconds 3000 # wait for map to rotate + + $rcon.Map() +} +finally { + Disconnect-Rcon -rcon $rcon +} diff --git a/lib/Q3Rcon.psm1 b/lib/Q3Rcon.psm1 new file mode 100644 index 0000000..657642a --- /dev/null +++ b/lib/Q3Rcon.psm1 @@ -0,0 +1,79 @@ +. $PSScriptRoot\packet.ps1 +. $PSScriptRoot\base.ps1 + + +class Rcon { + [Object]$base + + Rcon ([string]$hostname, [int]$port, [string]$passwd) { + $this.base = New-Base -hostname $hostname -port $port -passwd $passwd + } + + [Rcon] Login() { + $resp = $this.base._send("login") + if ($resp -in @("Bad rcon", "Bad rconpassword.", "Invalid password.")) { + throw "invalid rcon password" + } + $this.base.ToString() | Write-Debug + return $this + } + + [string] Send($msg) { + return $this.base._send($msg) + } + + [void] Say($msg) { + $this.base._send($msg) + } + + [void] FastRestart() { + $this.base._send("fast_restart", 2000) + } + + [void] MapRotate() { + $this.base._send("map_rotate", 2000) + } + + [void] MapRestart() { + $this.base._send("map_restart", 2000) + } + + [string] Map() { + return $this.base._send("mapname") + } + + [void] SetMap($mapname) { + $this.base._send("map mp_$mapname") + } + + [string] Gametype() { + return $this.base._send("g_gametype") + } + + [void] SetGametype($gametype) { + $this.base._send("g_gametype $gametype") + } + + [string] HostName() { + return $this.base._send("sv_hostname") + } + + [void] SetHostName($hostname) { + $this.base._send("sv_hostname $hostname") + } +} + +Function Connect-Rcon { + param([string]$hostname, [int]$port, [string]$passwd) + + [Rcon]::new($hostname, $port, $passwd).Login() +} + +Function Disconnect-Rcon { + param([Rcon]$rcon) + + $rcon.base._close() + "Disconnected from {0}:{1}" -f $rcon.base.hostname, $rcon.base.port | Write-Debug +} + +Export-ModuleMember -Function Connect-Rcon, Disconnect-Rcon diff --git a/lib/base.ps1 b/lib/base.ps1 new file mode 100644 index 0000000..b046850 --- /dev/null +++ b/lib/base.ps1 @@ -0,0 +1,88 @@ +class Base { + [string]$hostname + [int]$port + [string]$passwd + [Object]$request + [Object]$response + hidden [System.Net.Sockets.Socket] $_socket + + Base ([string]$hostname, [int]$port, [string]$passwd) { + $this.hostname = $hostname + $this.port = $port + $this.passwd = $passwd + + $this.request = New-RequestPacket($this.passwd) + $this.response = New-ResponsePacket + + $ip = [system.net.IPAddress]::Parse([System.Net.Dns]::GetHostAddresses($this.hostname)[0].IPAddressToString) + + $endpoint = New-Object System.Net.IPEndPoint $ip, $this.port + try { + $this._socket = [System.Net.Sockets.Socket]::New( + [System.Net.Sockets.AddressFamily]::InterNetwork, + [System.Net.Sockets.SocketType]::Dgram, + [System.Net.Sockets.ProtocolType]::UDP + ) + $this._socket.Connect($endpoint) + $this._socket.ReceiveTimeout = 100 + } + catch [System.Net.Sockets.SocketException] { + throw "Failed to create UDP connection to server." + } + } + + [string] ToString () { + return "Rcon connection {0}:{1} with pass {2}" -f $this.hostname, $this.port, $this.passwd + } + + [string] _send([string]$msg) { + $this._socket.Send($this.request.Payload($msg)) + + [string[]]$data = @() + $sw = [Diagnostics.Stopwatch]::StartNew() + While ($sw.ElapsedMilliseconds -lt 50) { + try { + $buf = New-Object System.Byte[] 4096 + $this._socket.Receive($buf) + $data += [System.Text.Encoding]::ASCII.GetString($($buf | Select-Object -Skip $($this.response.Header().Length - 1))) + } + catch [System.Net.Sockets.SocketException] { + if ( $_.Exception.SocketErrorCode -eq 'TimedOut' ) { + "finished waiting for fragment" | Write-Debug + } + } + } + $sw.Stop() + return [string]::Join("", $data) + } + + [string] _send([string]$msg, [int]$timeout) { + $this._socket.Send($this.request.Payload($msg)) + + [string[]]$data = @() + $sw = [Diagnostics.Stopwatch]::StartNew() + While ($sw.ElapsedMilliseconds -lt $timeout) { + try { + $buf = New-Object System.Byte[] 4096 + $this._socket.Receive($buf) + $data += [System.Text.Encoding]::ASCII.GetString($($buf | Select-Object -Skip $($this.response.Header().Length - 1))) + } + catch [System.Net.Sockets.SocketException] { + if ( $_.Exception.SocketErrorCode -eq 'TimedOut' ) { + "finished waiting for fragment" | Write-Debug + } + } + } + return [string]::Join("", $data) + } + + [void] _close() { + $this._socket.Close() + } +} + +Function New-Base { + param([string]$hostname, [int]$port, [string]$passwd) + + [Base]::new($hostname, $port, $passwd) +} diff --git a/lib/packet.ps1 b/lib/packet.ps1 new file mode 100644 index 0000000..acf1b1c --- /dev/null +++ b/lib/packet.ps1 @@ -0,0 +1,37 @@ +class Packet { + [System.Byte[]]$MAGIC = @(, 0xFF * 4) + + [string] Header() { + throw "method not implemented" + } +} + +class RequestPacket : Packet { + [string]$passwd + + RequestPacket([string]$passwd) { + $this.passwd = $passwd + } + + [System.Byte[]] Header() { + return $this.MAGIC + [System.Text.Encoding]::ASCII.GetBytes("rcon") + } + + [System.Byte[]] Payload([string]$msg) { + return $this.Header() + [System.Text.Encoding]::ASCII.GetBytes($(" {0} {1}" -f $this.passwd, $msg)) + } +} + +class ResponsePacket : Packet { + [System.Byte[]] Header() { + return $this.MAGIC + [System.Text.Encoding]::ASCII.GetBytes("print\n") + } +} + +Function New-RequestPacket([string]$passwd) { + [RequestPacket]::new($passwd) +} + +Function New-ResponsePacket { + [ResponsePacket]::new() +}