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
            
            The challenge received from the server was \basic\\secure\HZVXFR\final\.
            This cmdlet can't handle any key other than the one used by epic servers.

            In this example, the address for a master server was given where the challenge was outside of the capabilities of this cmdlet (for now).
            However, it did respond with a challenge which is shown in the host output, which means the server is there and responding to connections.
            This is a way you can test master servers to see if they are responding to requests.
        .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
            Known issues:
                - The cmdlet will only download IPEndpoints from servers that use the challenge '\basic\\secure\wookie'
        .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 
    )
    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
            }
        }

        #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"

            <#
            If the security challenge is 'wookie', we know the validation of that challenge we have to send back is '2/TYFMRc'
            Implementing gsmsalg is how you would get the correct validation for different keys.
            #>

            if ($SecurityChallenge -eq '\basic\\secure\wookie') {
            
                Write-Verbose "Sending request for ip address list"
                $writer.WriteLine("\gamename\ut\location\0\validate\2/TYFMRc\final\\list\gamename\ut\")
                $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 })
            } else {
                Write-Host "The challenge received from the server was $SecurityChallenge`nThis cmdlet can't handle any challenge other than the one used by epic servers."
            }
        } 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 {

    }
}