Ut99Tools.psm1


function Find-UtLanServers {
    <#
        .SYNOPSIS
            Find UT Servers on a Lan and write their response to the pipeline.
        .DESCRIPTION
            Mimics the behavior of the UT client when a user selects the "Find LAN games option" by sending out broadcast packets to ports 8777-8786 and reading the responses it recieves.
        .EXAMPLE
            PS C:\> Find-UtLanServers
            
            GameType QueryPort Address
            -------- --------- -------
            ut 7778 192.168.0.6

            Running the Cmdlet will find all servers that are listening on the correct port range and output the above result.
        .INPUTS
            
        .OUTPUTS
            PsCustomObject
    #>

    [CmdletBinding()]
    param ()
    try {
        
        $udpClient = New-Object System.Net.Sockets.UdpClient
        $udpClient.EnableBroadcast = $true
        $udpClient.Client.ReceiveTimeout = 2000
        $udpClient.Client.SendTimeout = 2000
        $encoding = [System.Text.AsciiEncoding]::new()
    
        #fishing for servers on the network.
        [Byte[]] $sendQueryBytes = $encoding.GetBytes("REPORTQUERY");
        8777..8786 | ForEach-Object { 
            Write-Verbose "Sending broadcast message REPORTQUERY to port $_"
            $udpClient.Send($sendQueryBytes, $sendQueryBytes.Length, [IPEndPoint]::new([IPAddress]::Broadcast, $_)) | Out-Null }
        
        while ($true) {
            Write-Verbose "Waiting for responses"
            $ServerEndpoint = [IPEndpoint]::new([ipaddress]::Any, 0)
            [Byte[]] $receiveBytes = $udpClient.Receive([ref]$ServerEndpoint)
            [string] $returnData = $encoding.GetString($receiveBytes);
    
            if ($returnData) {
                Write-Verbose "Response received"
                $output = [PSCustomObject]@{
                    GameType  = $returnData.Split(' ')[0]
                    QueryPort = $returnData.Split(' ')[1]
                    Address   = $ServerEndpoint.Address
                }
                $output | Write-Output
            }
        }
    } catch {
        if ($_.Exception.InnerException.ErrorCode -eq 10060) {
            Write-Verbose "Haven't heard anything for a while. Shutting the connection down. "
        }
    } finally {
        $udpClient.Close()
        $udpClient.Dispose()
        
    }
}

function Get-UtMasterServerEndpointList {
    <#
        .SYNOPSIS
            Retrieves Endpoints from Master Servers.
        .DESCRIPTION
            Retrieves a list of IP Endpoints from master servers. It's useful for testing if your UT99 game server is successfully announcing its presense to a master server.
            
            When a UT Game server is started it might say:
            UdpServerUplink: Master Server is master.mplayer.com:27900
            UdpServerUplink: Port 7779 successfully bound.
            However, this just means that it was able to a) resolve the dns host name to an ip address and b) bind to port 7779 on the system.
            It does not mean the game server successfully announced its presense to that master server.

            Another problem is the UT99 Game client, will only show a list of servers in the browser that it managed to ping,
            so, you will not know if the problem is between the client and the game server, or the game server and the master server.

            This cmdlet allows you to test two things
            1. The master server address and port number are responding to connections.
            2. The list of IP Endpoints is retrievable and your game server's IP Endpoint is among them.

        .EXAMPLE
            PS C:\> Get-UtMasterServerEndpointList -Address utmaster.epicgames.com
            AddressFamily Address Port
            ------------- ------- ----
            InterNetwork 147.135.23.65 7978
            InterNetwork 216.155.140.138 7778
            InterNetwork 213.230.216.2 8889
            InterNetwork 85.14.229.240 7778
            InterNetwork 5.9.21.239 8076


            Providing only the required parameters will return the list of endpoints

        .EXAMPLE
            PS C:\> Get-UtMasterServerEndpointList -Address master.333networks.com

            AddressFamily Address Port
            ------------- ------- ----
            InterNetwork 147.135.23.65 7978
            InterNetwork 216.155.140.138 7778

            The cmdlet solves the server's secure challenge with the GameSpy algorithm (gsmsalg),
            so any master server that speaks the GameSpy 'secure' protocol for Unreal Tournament
            (gamename 'ut') will return its endpoint list.
        .EXAMPLE
            PS C:\> Get-UtMasterServerEndpointList -Address master.oldunreal.com -GameName ut -GameKey Z5Nfb0

            Supplying an explicit -GameName and -GameKey lets you query master servers for other
            GameSpy titles. The defaults ('ut' / 'Z5Nfb0') target Unreal Tournament, so they can
            be omitted for UT master servers.
        .EXAMPLE
            PS C:\> Get-UtMasterServerEndpointList -Address unreal.epicgames.com | where Address -eq '181.43.152.180'
            
            AddressFamily Address Port
            ------------- ------- ----
            InterNetwork 181.43.152.180 8201
            InterNetwork 181.43.152.180 8301
            InterNetwork 181.43.152.180 8401
            InterNetwork 181.43.152.180 7778
            InterNetwork 181.43.152.180 8305

            In this example, the endpoints are filtered to only show Endpoints where the ip address equals '181.43.152.180'.
        .INPUTS
            string
        .OUTPUTS
            object[] or System.Net.IPEndpoint
        .NOTES
            The master server's secure challenge is solved at runtime using the GameSpy master
            server algorithm (gsmsalg, enctype 0) keyed with the game key, so servers that issue
            different challenges are supported. Only the plain-text enctype 0 handshake used by
            Unreal Tournament master servers is implemented.
        .LINK
            https://github.com/RIKIKU/UT99-Tools
    #>

    [cmdletbinding()]
    param( 
        # IP address or hostname of the master server
        [Parameter(Mandatory = $true)]
        [string]
        $Address,
        # Port number to connect to the server on. Default 28900
        [Parameter(Mandatory = $false)]
        [int]
        $Port = 28900,
        # GameSpy game name announced in the list request. Default 'ut' (Unreal Tournament).
        [Parameter(Mandatory = $false)]
        [string]
        $GameName = 'ut',
        # GameSpy game key used to solve the master server's secure challenge. Default is the Unreal Tournament ('ut') key.
        [Parameter(Mandatory = $false)]
        [string]
        $GameKey = 'Z5Nfb0'
    )
    begin {
        #region helpy helpertons
        function streamDataWaiter {
            <#
                .SYNOPSIS
                    Waits for the stream.DataAvailable to be true or to timeout before moving on.
            #>

            [CmdletBinding()]
            param (
                #The stream to check for data.
                [Parameter(Mandatory = $true)]
                [System.Net.Sockets.NetworkStream]
                $stream,
                #Effectively a timeout. Each loop waits 200 ms between each check.
                [Parameter(Mandatory = $false)]
                [int]
                $LoopLimit = 2000 
            )

            $loop = 0
            while ($stream.DataAvailable -eq $false -and $loop -lt $LoopLimit) {
                $loop++
                Write-Verbose "Waiting for Server Response"
                Start-Sleep -m 200
            }
            if ($loop -ge $LoopLimit) {
                Write-Error "Connection Timed-out waiting for response from server." -ErrorAction Stop
            }
        }

        function computeGsSecureResponse {
            <#
                .SYNOPSIS
                    Solves a GameSpy 'secure' challenge (gsmsalg, enctype 0) using the game key.
            #>

            [CmdletBinding()]
            param (
                # The challenge string sent by the master server (the value following '\secure\').
                [Parameter(Mandatory = $true)]
                [string]
                $Challenge,
                # The GameSpy game key used to validate the challenge.
                [Parameter(Mandatory = $true)]
                [string]
                $GameKey
            )

            # Maps a 6-bit value to a GameSpy base64-style output character (returns its ASCII code).
            function gsvalfunc([int]$reg) {
                if ($reg -lt 26) { return ($reg + 65) }
                if ($reg -lt 52) { return ($reg + 71) }
                if ($reg -lt 62) { return ($reg - 4) }
                if ($reg -eq 62) { return 43 }
                if ($reg -eq 63) { return 47 }
                return 0
            }

            $encoder = [System.Text.Encoding]::ASCII
            $src = $encoder.GetBytes($Challenge)
            $key = $encoder.GetBytes($GameKey)

            # Key-scheduling: build the initial permutation from the game key.
            $enctmp = New-Object 'int[]' 256
            for ($i = 0; $i -lt 256; $i++) { $enctmp[$i] = $i }
            $a = 0
            for ($i = 0; $i -lt 256; $i++) {
                $a = ($a + $enctmp[$i] + $key[$i % $key.Length]) -band 0xff
                $swap = $enctmp[$a]
                $enctmp[$a] = $enctmp[$i]
                $enctmp[$i] = $swap
            }

            # Encrypt the challenge, then zero-pad the buffer to a multiple of three bytes.
            $tmp = New-Object 'int[]' 66
            $a = 0
            $b = 0
            for ($i = 0; $i -lt $src.Length; $i++) {
                $a = ($a + $src[$i] + 1) -band 0xff
                $x = $enctmp[$a]
                $b = ($b + $x) -band 0xff
                $y = $enctmp[$b]
                $enctmp[$b] = $x
                $enctmp[$a] = $y
                $tmp[$i] = $src[$i] -bxor $enctmp[($x + $y) -band 0xff]
            }
            for ($size = $i; ($size % 3) -ne 0; $size++) {
                $tmp[$size] = 0
            }

            # Emit the response as GameSpy base64 (4 output chars per 3 input bytes).
            $sb = [System.Text.StringBuilder]::new()
            for ($i = 0; $i -lt $size; $i += 3) {
                $x = $tmp[$i]
                $y = $tmp[$i + 1]
                $z = $tmp[$i + 2]
                [void]$sb.Append([char](gsvalfunc ($x -shr 2)))
                [void]$sb.Append([char](gsvalfunc ((($x -band 3) -shl 4) -bor ($y -shr 4))))
                [void]$sb.Append([char](gsvalfunc ((($y -band 15) -shl 2) -bor ($z -shr 6))))
                [void]$sb.Append([char](gsvalfunc ($z -band 63)))
            }
            $sb.ToString()
        }

        #endregion

    }
    process {
        try {
       
            try {
                Write-Verbose "Attempting connection to host"
                $tcpClient = New-Object System.Net.Sockets.TcpClient( $Address, $port )
                $tcpClient.Client.ReceiveTimeout = 5000
                $tcpClient.Client.SendTimeout = 5000
            } catch {
                Write-Error -Message $_.Exception.Message -Exception $_.Exception -ErrorAction Stop
            }


            $stream = $tcpClient.GetStream( )
            $writer = New-Object System.IO.StreamWriter( $stream )
            $buffer = New-Object System.Byte[] $tcpClient.ReceiveBufferSize
            $encoding = New-Object System.Text.AsciiEncoding
            $IpString = [System.Text.StringBuilder]::new()

            #streamDataWaiter -stream $stream
     
            $read = $stream.Read( $buffer, 0, $tcpClient.ReceiveBufferSize )
            $SecurityChallenge = $encoding.GetString( $buffer, 0, $read )
            Write-Verbose "Security Challenge $SecurityChallenge"

            <#
            The master server opens with '\basic\\secure\<challenge>'. The <challenge> is solved
            with the GameSpy master server algorithm (gsmsalg, enctype 0) keyed with the game key,
            producing the '\validate\' token the server expects. See computeGsSecureResponse.
            #>

            $challengeMatch = [regex]::Match($SecurityChallenge, '\\secure\\([^\\]+)')
            if (-not $challengeMatch.Success) {
                Write-Error "Unexpected handshake from server. Expected a '\secure\' challenge but received: $SecurityChallenge" -ErrorAction Stop
            }
            $challengeValue = $challengeMatch.Groups[1].Value
            Write-Verbose "Parsed secure challenge '$challengeValue'"

            $validate = computeGsSecureResponse -Challenge $challengeValue -GameKey $GameKey
            Write-Verbose "Computed validate response '$validate'"

            Write-Verbose "Sending request for ip address list"
            $writer.WriteLine("\gamename\$GameName\location\0\validate\$validate\final\\list\gamename\$GameName\")
            $writer.Flush( )
            #sometimes, this loop gets stuck here. I need to fix this. maybe with a timeout?
            #Adding the sleep above seemed to fix the issue, but it's not a good implementation.

            do {
                Write-Verbose "Receiving List of Endpoints"
                $read = $stream.Read( $buffer, 0, $tcpClient.ReceiveBufferSize )
                $IpString.Append($encoding.GetString( $buffer, 0, $read )) | Out-Null
        
                while ($stream.DataAvailable -eq $false) {
                    if ($IpString.tostring() -match 'final') { break }
                    Write-Verbose "waiting for data"
                    Start-Sleep -m 20
                }
            }while ( $stream.DataAvailable ) 
            Write-Verbose "Processing Ip Address List."
            $IpString = $IpString.ToString().Split('\final')[0]
            $IpString.Split('\ip\', [StringSplitOptions]::RemoveEmptyEntries).ForEach( { [IPEndpoint]::Parse($_) | Write-Output })
        } finally {
            if ( $writer ) {    
                $writer.Close( )    
                $writer.Dispose()
            }
            if ( $stream ) {    
                $stream.Close( )
                $stream.Dispose()
            }
        } 
    }
    end {}
}

function Invoke-UtServerQuery {
    <#
    .SYNOPSIS
        Send a query to a UT Game Server.
    .DESCRIPTION
        Send a query message to a Game Server's query port (game port + 1).
        This will retrieve the type of information that's usually visible in the game "Find Internet Games window" and more.
    .EXAMPLE
        PS C:\> Find-UtLanServers | Invoke-UtServerQuery

        hostaddress : 192.168.0.6
        hostport : 7777
        worldlog : false
        maptitle : Frigate
        maxplayers : 16
        gamemode : openplaying
        gametype : Assault
        mapname : AS-Frigate
        hostname : Testing UT Server In Docker
        wantworldlog : false
        minnetver : 432
        queryid : 5.1
        numplayers : 1
        gamever : 451

        In this example, Find-UtLanServers locates a server on the LAN and Invoke-UtServerQuery performs the info query on it..

    .EXAMPLE
        PS C:\> Get-UtMasterServerEndpointList -Address utmaster.epicgames.com | where Address -eq '181.43.152.180' | Invoke-UtServerQuery -QueryType rules | select hostname,mapname,numplayers
        
        hostname mapname numplayers
        -------- ------- ----------
        [Ragnarok] Heimdall - DM 1v1 - ip2 CTF-Lucius 0
        [Ragnarok] Odin - [Pug] ip3 CTF-Lucius 0
        [Ragnarok] Thor - [Pug] ip4 CTF-Lucius 0
        [Ragnarok] Tyr - CTF/DM Publico -… CTF-Novem… 0
        [Ragnarok] Loki - [Pug] ip5 OldCo… CTF-Incin… 0

        In this example, we query the master server for a list of game servers, then filter the list to only IP addresses that match '181.43.152.180' and query only those servers.
        Then we filter the output to only show hostname, mapname and numplayers.
    .INPUTS
        String, Int
    .OUTPUTS
        PSCustomObject
    .NOTES
        General notes
    #>

    [CmdletBinding()]
    param (
        #Address of the UT server to Query.
        [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [string]
        $Address,
        # The Game Server's query port (ServerPort + 1)
        [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [Alias('Port')]
        [int]
        $QueryPort,
        # Query type can be one of the following values: info, rules, players, 'status', 'echo', 'level_property', 'player_property'
        [Parameter(Mandatory = $false)]
        [ValidateSet(
            'info',
            'rules',
            'players',
            'status',
            'echo',
            'level_property',
            'player_property',
            IgnoreCase = $false
        )]
        [string]
        $QueryType = 'info'
    )
    begin {
    }
    process {
        try {
            
            $respondentEndpoint = [IPEndpoint]::new([ipaddress]::Parse($Address), $QueryPort)
            $UtServerUtpClient = [System.Net.Sockets.UdpClient]::new($respondentEndpoint.Port)

            $UtServerUtpClient.Client.SendTimeout = 2000
            $UtServerUtpClient.Client.ReceiveTimeout = 5000
            Write-Verbose "Connecting to server"
            $UtServerUtpClient.Connect($respondentEndpoint)

            $encoding = [System.Text.AsciiEncoding]::new()

            $query = '\{0}\' -f $QueryType
            [Byte[]] $sendInfoBytes = $encoding.GetBytes($query);
            Write-Verbose "Sending query"
            $UtServerUtpClient.Send($sendInfoBytes, $sendInfoBytes.Length) | Out-Null

            Write-Verbose "Attempting to receive response"
            [Byte[]] $receiveBytes = $UtServerUtpClient.Receive([ref]$respondentEndpoint)
            $returnData = $encoding.GetString($receiveBytes) 

        } catch {
            if ($_.Exception.InnerException.ErrorCode -eq 10060) {
                Write-Verbose "Host $($respondentEndpoint.ToString()) Didn't respond."
            }
        } finally {
            if ($UtServerUtpClient) {
                $UtServerUtpClient.Close()
                $UtServerUtpClient.Dispose()
            }
        }

        if ($returnData) {
            <#
            The response from the server is something like:
            \hostname\UT Server In Docker\hostport\7777\maptitle\HiSpeed...
            It's essentially a key value pair in string format, so we split it into separate objects and order them as a hash table.
            The hash is converted into a pscustomobject for that powershell feeling.
            #>
  
            Write-Verbose "Server Response: $returnData "

            #remove leading \ or \\ depending.
            Write-Verbose "Processing response"
            $returnData = $returnData.Substring(1, $returnData.Length - 1)
            if ($returnData.Substring(0, 2) -eq '\\') {
                $returnData = $returnData.Substring(2, $returnData.Length - 2)
            }
            $splitPath = $returnData.Split('\')
            $hash = @{}
            try {
                for ($i = 0; $i -lt $splitPath.Count; $i++) {
                    $hash[$splitPath[$i]] = $splitPath[$i + 1]
                    $i++
                }
                #final isn't used for anything.
                $hash.Remove('final')
                #Adding host address to output to make it more useful.
                $hash['hostaddress'] = $respondentEndpoint.Address
                [PSCustomObject]$hash | Write-Output
            } catch {
                Write-Error " There was a problem processing the response from the server."
            }
        }
    }
    end {

    }
}