Noobinabox / RotS_Live

The current live code of the MUD Return of the Shadow.
https://rotsmud.org/
2 stars 3 forks source link

WebSocket Support #184

Closed kjvalencik closed 9 months ago

kjvalencik commented 1 year ago

Why

Currently, playing RotS requires a native app (e.g., telnet, JMC) and can't be played from a browser. This is because it expects raw TCP connections and browsers cannot make these connections.

Some players cannot install apps or may prefer to play from a browser.

What

If we naively put a WebSocket proxy in front of RotS, it would break multi-player connection because all source addresses would be the proxy. This PR includes proxying functionality that resolves this issue and is broken up into a few changes.

-p flag

I added a -p flag to the main rots binary. When this flag is set it expects to be proxied with a special protocol. In this protocol, the very first 4-bytes are the IPv4 address of the source IP. The RotS loop continues on as normal, using this as the source IP instead of getting it directly from the connection.

For simplicity (avoiding a state machine), this was added to the synchronous part of the loop, but since the proxy will flush these bytes immediately, we can rely on this not causing lag.

Proxy

I added a game proxy written in Rust using the high performance tokio I/O framework. At it's simplest, it:

  1. Listens on an address
  2. On receiving a TCP connection, opens a connection to RotS
  3. Writes the IPv4 address to the RotS connection
  4. Starts proxying with a bidirectional copy on the two connections

This allows TCP conditions to continue operating as they do today.

WebSocket

In the last commit, I extended the proxy to include a WebSocket listener. This listener will perform a WS handshake and will then start proxying bytes back and forth (just like the plain TCP proxy), but encapsulating in WS frames.

This provides two different ports that RotS can be connected:

  1. Plain TCP
  2. WebSockets

Alternatives

Omit the IP header on the connection

This is nice because it doesn't require proxying telnet connections, but means we need a completely different way to handle multi-playing and other types of abuse. IMO, proxying lets us make changes more incrementally.

WebSockets directly in main process

Handling websockets in C is quite a lot of work. The most realistic way would be to create a shared library (probably written in Rust) that exposes a small, specially crafted C API. However, this would still require a refactor in RotS to better abstract network code.

This would be nice because we drop the proxy and the proxy header, but it adds risk and build complexity to get it running. I prefer the proxy approach because it minimizes the changes to the codebase if the WebSocket experiment proves to not be super useful.

Running

This requires having Rust installed.

# Start RotS with the `-p` flag
./bin/ageland -p

# Start the WS proxy
cargo run -p proxy --release

# Get command help
# Shows how to listen and connect to different addresses/ports
cargo run -p proxy --release -- -h

Testing

I did some simple testing and I detected no lag and low resource consumption (thanks Rust!). I was able to connect both from tintin and a web browser with the correct IP address showing in the RotS logs.

image image

You can perform a quick test by opening a blank browser tab and opening the console:

socket = new WebSocket("ws://127.0.0.1:8080"); socket.addEventListener("message", async (ev) {
    console.log(await ev.data.text());
});

socket.send("playername");

Follow-ups

There are a few web based mud clients, but they tend to be game specific. We could adapt one, but it would also be cool to have a RotS specific one where it understands the game output and can have built-in parsers for things like mapping and a stats window.

To prove out the concept, I wrote a very simple client that can be opened locally.

<html>
    <head>
        <style>
            body {
                padding: 0;
                margin: 0;
                color: #eee;
                font-family: monospace;
                font-size: 14px;
            }

            #app {
                background: #ccc;
                position: absolute;
                width: 100%;
                height: 100%;
            }

            #game, #input {
                position: absolute;
                left: 20px;
                right: 20px;
            }

            #game {
                background: #111;
                top: 20px;
                bottom: 70px;
                padding: 20px;
                overflow-y: scroll;
            }

            #input {
                height: 30px;
                bottom: 20px;
            }

            pre {
                white-space: pre-wrap;
                margin: 0;
            }
        </style>
    </head>
    <body>
        <div id="app">
            <div id="game"></div>
            <input id="input">
        </div>

        <script>
            const ws = new WebSocket("ws://10.211.55.10:8080");
            const game = document.getElementById("game");
            const input = document.getElementById("input");

            ws.addEventListener("message", async ({ data }) => {
                const msg = await data.text();
                const nodes = msg
                    .split("\n")
                    .map(line => Object.assign(document.createElement("pre"), {
                        textContent: line === "" || line === " " ? "\u00A0" : line
                    }));

                game.append(...nodes);
                game.scrollTo(0, game.scrollHeight);
            });

            input.addEventListener("keypress", ev => {
                if (ev.key === "Enter") {
                    ws.send(input.value + "\n");
                    game.appendChild(Object.assign(document.createElement("pre"), {
                        textContent: "\u00A0"
                    }));
                    input.value = "";
                }
            });
        </script>
    </body>
</html>
kjvalencik commented 1 year ago

PoC running at http://tintin.valencik.com/

kjvalencik commented 1 year ago

Added support for reading Cloudflare's CF-Connecting-IP header. This makes it really easy to put TLS in front of it. Passwords are finally sent encrypted instead of plaintext! 😅

I've got this running on my local server will Cloudflare Tunnel.