mcrcon.psm1
# https://developer.valvesoftware.com/wiki/Source_RCON_Protocol # Constants for RCon packet types enum RConPacketType { SERVERDATA_AUTH = 3 SERVERDATA_AUTH_RESPONSE = 2 SERVERDATA_EXECCOMMAND = 2 # Not a typo, the same as the response SERVERDATA_RESPONSE_VALUE = 0 } $script:SessionConfigs = "$($env:HOME)/rconsessionconfig.xml" # Very Minecraft specific class RconSessionConfig { hidden [string] $_id = (New-Guid).Guid [string]$Address [int]$Port [string]$PathToServerProperties } Function New-RconSessionConfig { [RconSessionConfig]::new() } Function Import-RconSessionConfigs { if (Test-Path -Path $script:SessionConfigs) { return Import-Clixml -Path $script:SessionConfigs } else { # create the clixml file $null | Export-Clixml -Path $script:SessionConfigs -Force return @() } } Function Add-RconSessionConfig { [CmdletBinding()] param ( [Parameter(Mandatory, ValueFromPipeline)] [RconSessionConfig[]]$Configs ) begin { $currentConfigs = Import-RconSessionConfigs $currentConfigsList = [System.Collections.ArrayList]@($currentConfigs) } process { foreach ($config in $Configs) { if ($config._id -notin $currentConfigsList._id) { [void]$currentConfigsList.Add($config) } } } end { $currentConfigsList | Export-Clixml -Path $script:SessionConfigs -Force } } Function Remove-RconSessionConfig { [CmdletBinding()] param ( [Parameter(Mandatory, ValueFromPipeline)] [RconSessionConfig[]]$Configs ) begin { $currentConfigs = Import-RconSessionConfigs $currentConfigsList = [System.Collections.ArrayList]@($currentConfigs) } process { foreach ($config in $Configs) { $currentConfigsList = $currentConfigsList | Where-Object { $_._id -ne $config._id } } } end { $currentConfigsList | Export-Clixml -Path $script:SessionConfigs -Force } } class RconCommand { [string]$Command [ValidateSet(2, 3)] [int]$Type = ([int][RConPacketType]::SERVERDATA_EXECCOMMAND) RconCommand([string]$Command, [int]$Type) { $this.Command = $Command $this.Type = $Type } RconCommand([string]$Command) { $this.Command = $Command } } Function New-RconCommand { [CmdletBinding()] param ( [Parameter(Mandatory)] [string]$Command, [Parameter()] [int]$Type = ([int][RConPacketType]::SERVERDATA_EXECCOMMAND) ) [RconCommand]::new($Command, $Type) } class RconSession { hidden [string] $_id = (New-Guid).Guid [System.Net.Sockets.Socket]$Socket [string]$Address [int]$Port [securestring]$Password RconSession([string]$Address, [int]$Port, [securestring]$Password) { $this.Address = $Address $this.Port = $Port $this.Password = $Password $this.Connect() $this.Authenticate() } [void] Connect() { $_Socket = [System.Net.Sockets.Socket]::New( [System.Net.Sockets.AddressFamily]::InterNetwork, [System.Net.Sockets.SocketType]::Stream, [System.Net.Sockets.ProtocolType]::Tcp ) $_Socket.Connect($this.Address, $this.Port) $this.Socket = $_Socket } # Would need testing against other Rcon implementations (currently only Minecraft Java Edition) [void] Authenticate() { $ResponseBuffer = $this.Send((New-RconCommand -Command ($this.Password | ConvertFrom-SecureString -AsPlainText) -Type ([int][RConPacketType]::SERVERDATA_AUTH))) if ([BitConverter]::ToInt32($ResponseBuffer[4..7], 0) -eq -1) { Write-Error "Authentication failed, bad password?" } } [byte[]] Packet([RconCommand]$RconCommand) { $PktId = [byte[]]::new(4) [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($PktId) $PktCmdType = [byte[]]::new(4) $PktCmdType[0] = $RconCommand.Type # The command string, null terminated $PktCmdPayload = [System.Text.Encoding]::ASCII.GetBytes($RconCommand.Command) + 0x00 $PktSize = [BitConverter]::GetBytes($PktCmdPayload.Length + 9) # The full packet, in the required structure return $PktSize + $PktId + $PktCmdType + $PktCmdPayload + 0x00 } [byte[]] Send([RconCommand]$RconCommand) { if (-not $this.Socket.Connected) { $this.Connect() if (-not $RconCommand.Type -eq [int][RConPacketType]::SERVERDATA_AUTH) { Write-Warning "Socket was dead, attempting to Re-Authenticate" $this.Authenticate() } } $this.Socket.Send($this.Packet($RconCommand)) | Out-Null $ResponseBuffer = [byte[]]::new(4096) $this.Socket.Receive($ResponseBuffer) | Out-Null return $ResponseBuffer } [void] Close() { if ($null -ne $this.Socket -and $this.Socket.Connected) { $this.Socket.Close() } $this.Socket = $null } } Function New-RconSession { [CmdletBinding()] param ( [Parameter(Mandatory)] [string]$Address, [Parameter(Mandatory)] [int]$Port, [Parameter(Mandatory)] [securestring]$Password ) [RconSession]::new($Address, $Port, $Password) } Function Close-RconSession { [CmdletBinding()] param ( [Parameter(Mandatory, ValueFromPipeline)] [RconSession[]]$Session ) process { $Session | ForEach-Object { $_.Close() } } } Function Set-InteractiveSessionPassword { $Password = Read-Host -Prompt "Enter RCon password" -AsSecureString $Password } # Minecraft server specific (only tested with Java Edition) Function Get-RconPasswordFromServerProperties { [CmdletBinding()] param ( [Parameter(Mandatory)] [string]$ServerPropertiesPath ) $rgx = '^rcon\.password=' (Get-Content $ServerPropertiesPath) | Where-Object { $_ -match $rgx } | ForEach-Object { $_ -replace $rgx , '' } | ConvertTo-SecureString -AsPlainText -Force } Function New-RconSessionsFromConfigFile { Import-RconSessionConfigs | ForEach-Object { New-RconSession -Address $_.Address -Port $_.Port -Password (Get-RconPasswordFromServerProperties -ServerPropertiesPath $_.PathToServerProperties) } } Function Get-RconResponse { [CmdletBinding()] param( [Parameter(Mandatory)] [byte[]]$Buffer, [int]$StartIndex = 12 ) [System.Text.Encoding]::ASCII.GetString($Buffer[$StartIndex..($Buffer.Length)]) } Function Send-RconCommand { [CmdletBinding()] param ( [Parameter(Mandatory, ValueFromPipeline)] [RconSession]$Session, [Parameter(Mandatory)] [RconCommand]$Command ) $resp = $Session.Send($Command) Get-RconResponse $resp } # Wraps around Send-RconCommand to provide a consistent output object Function Send-RconCommandWrapper { [CmdletBinding()] param ( [Parameter(Mandatory, ValueFromPipeline)] [RconSession[]]$Session, [Parameter(Mandatory)] [string]$Command ) begin { # This is to combat the odd behaviour I am seeing when accessing the Address property of the session object # Without explicitly setting the index of the object being processed, we get OverloadDefinitions for Address # rather than its string value. $Index = 0 } process { [PSCustomObject]@{ Session = $Session ServerAddress = "$($Session[$Index].Address):$($Session.Port)" Response = ($Session | Send-RconCommand -Command (New-RconCommand -Command $Command)) } $Index++ } } #region Minecraft Server commands # https://minecraft.wiki/w/Commands Function Get-PlayersRaw { [CmdletBinding()] param ( [Parameter(Mandatory, ValueFromPipeline)] [RconSession[]]$Session, [switch]$OmmitUUIDs ) begin { $Command = if ($OmmitUUID) { "list" } else { "list uuids" } } process { $Session | Send-RconCommandWrapper -Command $Command } } Function Get-Players { [CmdletBinding()] param ( [Parameter(Mandatory, ValueFromPipeline)] [RconSession[]]$Session ) process { $Session | Get-PlayersRaw | ForEach-Object { $response = $_.Response $reg = [regex]::new("(?<username>\w+) \((?<uuid>\w{8}-\w{4}-\w{4}-\w{4}-\w{12})\)") $AllMatches = $reg.Matches($response) $players = @() foreach ($match in $AllMatches) { $username = $match.Groups["username"].Value $uuid = $match.Groups["uuid"].Value $players += [PSCustomObject]@{ Username = $username UUID = $uuid } } [PSCustomObject]@{ Session = $_.Session ServerAddress = $_.ServerAddress PlayerCount = $AllMatches.Count Players = $players } } } } Function Send-ActivePlayersAnnouncement { [CmdletBinding()] param ( [Parameter(Mandatory, ValueFromPipeline)] [RconSession[]]$Session ) process { $Session | Get-Players | ForEach-Object { if ($_.PlayerCount -gt 0) { $players = $_.Players | ForEach-Object { $_.Username } $players = $players -join ", " $Session | Send-RconCommandWrapper -Command "say $(Get-Date -Format 'HH:mm:ss') Active players: $players" } } } } Function Send-ServerMsg { [CmdletBinding()] param ( [Parameter(Mandatory, ValueFromPipeline)] [RconSession[]]$Session, [Parameter(Mandatory)] [string]$Message ) process { $Session | Send-RconCommandWrapper -Command "say $Message" } } #endregion |