Quake 3 network protocol 43 proxy server

I decided to experiment with java NIO and thought it’d be useful to resurrect my quake 3 proxy server. The completed code may be a useful followup to an original article I wrote many years ago about the Quake 3 network protocol

If I get time I’ll create a version for protocol 68 that can decode the Huffman compressed packet data on the fly, but don’t hold your breath!

Instructions can be found in the code comments, otherwise leave me a message below.

package uk.org.tilion.quake3.proxy;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.channels.DatagramChannel;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.charset.Charset;
import java.util.Iterator;

/**
 * A Quake3 proxy server that communicates using protocol 43, 1.11 - 1.16 point
 * release of quake3 engine. It's designed to handle a single client and provide
 * a way of experimenting with the quake3 protocol on the fly.
 * 
 * Packet fragmentation is ignored for simplicity, with sequenceId being read
 * without performing fragmentation checks.
 * 
 * Example handshake (OOB prefixed packets)
 *   CLIENT : getChallenge
 *   SERVER : challengeResponse <ID>
 *   CLIENT : connect "cg_predictItems1sexmalehandicap100color3snaps40rate10000modeldoom/rednameUnnamaedPlayerprotocol68qport<PORT>challenge<ID>"
 *   SERVER : connectResponse
 * 
 * Client game data packet
 * +-----------------+-----+----------+-----------+
 * | NAME            | LEN | ENCODING | ENDIANESS |
 * +-----------------+-----+----------+-----------+
 * |  SequenceNumber |  32 |     None |    Little |
 * |           QPort |  16 |     None |    Little |
 * |                 |     |      XOR |           |
 * 
 * Server game data packet
 * +-----------------+-----+----------+-----------+
 * | NAME            | LEN | ENCODING | ENDIANESS |
 * +-----------------+-----+----------+-----------+
 * |  SequenceNumber |  32 |     None |    Little |
 * |                 |     |      XOR |           |
 * 
 * If anyone gets time to implement the XOR decoding, do let me know! I suspect
 * it's the Netchan_UnScamblePacket() function from qcommon/net_chan.c in the 
 * 1.32 full source release.
 * 
 * @author Darren Edmonds
 */
public class Protocol43ProxyServer {

    private static final int BUFFER_SIZE = 1024 * 2;  // size in bytes
    
    private int localPort;
    private SocketAddress serverAddr;
    private SocketAddress clientAddr;
    private DatagramChannel serverChannel; // proxy <==> quake3 server
    private DatagramChannel clientChannel; // proxy <==> quake3 client
    private boolean running;
    
    Protocol43ProxyServer(int localPort, String remoteServer, int remotePort) {
        this.localPort = localPort;
        this.serverAddr = new InetSocketAddress(remoteServer, remotePort);
    }

    /**
     * Start the proxy server
     * @throws IOException 
     */
    public void start() throws IOException {
        Selector selector = Selector.open();
        
        this.clientChannel = DatagramChannel.open();
        this.clientChannel.configureBlocking(false);
        this.clientChannel.socket().bind(new InetSocketAddress(this.localPort));
        this.clientChannel.register(selector, SelectionKey.OP_READ);
        
        this.serverChannel = DatagramChannel.open();
        this.serverChannel.configureBlocking(false);
        this.serverChannel.socket().bind(null);
        this.serverChannel.register(selector, SelectionKey.OP_READ);
        
        ByteBuffer buffer = ByteBuffer.allocateDirect(BUFFER_SIZE);
        buffer.order(ByteOrder.BIG_ENDIAN);

        this.running = true;
        Iterator<SelectionKey> it = null;
        SelectionKey key = null;
        while (this.running) {
            int n = selector.select();
            if (n == 0) continue; // nothing to do
            
            it = selector.selectedKeys().iterator();
            while (it.hasNext()) {
                key = it.next();
                
                if (key.isReadable()) {
                    this.handlePacket((DatagramChannel)key.channel(), buffer);
                }
                
                it.remove();
            }
        }
    }

    /**
     * Read a packet of data from the client (or server) datagram channel then
     * proxy it over to the other channel.
     * @param channel
     * @param buffer
     * @throws IOException 
     */
    private void handlePacket(DatagramChannel channel, ByteBuffer buffer)
            throws IOException {
        buffer.clear();
        
        if (channel == this.clientChannel) {
            SocketAddress sender;
            
            while ((sender = channel.receive(buffer)) != null) {
                if (this.clientAddr == null) this.clientAddr = sender;
                //System.out.println("CLIENT sent " + buffer.position() + " bytes");
                inspectClientPacket(buffer);
                writePacket(this.serverChannel, buffer, this.serverAddr);
            }
            
        } else {
            while (channel.receive(buffer) != null) {
                //System.out.println("SERVER sent " + buffer.position() + " bytes");
                inspectServerPacket(buffer);
                writePacket(this.clientChannel, buffer, this.clientAddr);
            }
        }
    }

    /**
     * Write buffer to channel, transmitting content to recipient
     * @param channel
     * @param buffer
     * @param recipient
     * @throws IOException 
     */
    private void writePacket(DatagramChannel channel, ByteBuffer buffer,
            SocketAddress recipient) throws IOException {
        buffer.flip();
        while (buffer.hasRemaining()) {
            channel.send(buffer, recipient);
        }
    }

    /**
     * Chance to inspect/modify packet sent from client before it is relayed
     * to the server
     * @param buffer
     */
    private void inspectClientPacket(ByteBuffer buffer) {
        buffer.order(ByteOrder.LITTLE_ENDIAN);
        int sequenceId = buffer.getInt(0);

        if (sequenceId == -1) { // 4 OOB bytes in the header
            byte[] textArr = new byte[buffer.position() - 4];
            int oldPos = buffer.position();
            buffer.position(4);
            buffer.get(textArr);
            buffer.position(oldPos);

            String command = new String(textArr, Charset.forName("UTF-8"));
            System.out.println("CLIENT " + command);
            
            if (command.startsWith("connect ")) {
                /* rewrite the qport value to reflect the proxy local port
                 * rather than the client local port - not critical for testing
                 * but required for multiple clients on same IP via NAT */
            }
        } else {
            int qport = buffer.getShort(4);
            System.out.println("CLIENT seq=  " + String.format("%15d", sequenceId));
            System.out.println("CLIENT qport=" + String.format("%15d", qport));
        }
        
        buffer.order(ByteOrder.BIG_ENDIAN);
    }

    /*
     * Chance to inspect/modify packet sent from server before it is relayed
     * to the client
     * @param buffer
     */
    private void inspectServerPacket(ByteBuffer buffer) {
        buffer.order(ByteOrder.LITTLE_ENDIAN);
        int sequenceId = buffer.getInt(0);
        
        if (sequenceId == -1) { // 4 OOB bytes in the header
            byte[] textArr = new byte[buffer.position() - 4];
            int oldPos = buffer.position();
            buffer.position(4);
            buffer.get(textArr);
            buffer.position(oldPos);

            String command = new String(textArr, Charset.forName("UTF-8"));
            System.out.println("SERVER " + command);
            
            if (command.startsWith("connectResponse")) {
                /* server puts client into connecting state and starts sending
                 * game updates */
            }
        } else {
            System.out.println("SERVER seq=  " + String.format("%15d", sequenceId));
        }
        
        buffer.order(ByteOrder.BIG_ENDIAN);
    }

    /**
     * Main
     * @param args 
     */
    public static void main(String[] args) {
        Protocol43ProxyServer server = new Protocol43ProxyServer(
                27960, 
                "myserver.com", // CHANGE TO REAL SERVER IP
                27961); // CHNAGE TO REAL SERVER PORT
        try {
            server.start();
        } catch (Exception e) {
            e.printStackTrace(System.err);
        }
        
        /* now start quake3.exe and connect localhost:27960
         * your connection will end up at myserver.com:27961, proxied via
         * localhost to allow you to inspect packets on the fly */
    }
}