# Restricted MapleStory WebSocket bridge

Browsers cannot open MapleStory's TCP protocol directly. `web/ws_proxy.py`
bridges WebSocket binary messages to an operator-configured Cosmic host without
letting the browser choose a host or arbitrary port.

This is a transport adapter only. It doesn't parse MapleStory packets, inspect
credentials, or change Cosmic behavior.

## Local setup

Install the pinned runtime dependency:

```bash
python -m pip install -r web/requirements.txt
```

Start the development HTTP server and bridge from the repository root in
separate terminals:

```bash
python web/server.py
python web/ws_proxy.py
```

The secure defaults are:

- WebSocket listener: `127.0.0.1:8080` (loopback only)
- exact WebSocket path: `/game`
- fixed TCP host: `127.0.0.1`
- route allowlist: `login=8484` plus Cosmic world 0 channels 1–20 (`7575`–`7594`)
- exact Origins: `http://localhost:8000` and `http://127.0.0.1:8000`

Add every configured Cosmic channel port explicitly. Supplying any `--route`
replaces the default route set, so include login as well:

```bash
python web/ws_proxy.py \
  --route login=8484 \
  --route channel-1=7575 \
  --route channel-2=7576 \
  --allowed-origin http://localhost:8000
```

On PowerShell, put those options on one line or use PowerShell's backtick as
the line continuation character.

## Selector and forwarding protocol

After the WebSocket handshake:

1. The first message selects one allowlisted destination. The WASM client sends
   an ASCII decimal port in a binary message, such as `8484`. Text messages may
   contain either that numeric port or a configured route name such as `login`.
2. The selector is consumed by the bridge and is never sent to Cosmic. There is
   no acknowledgement message.
3. Every later WebSocket message must be binary game traffic. A text message
   closes the connection.
4. TCP is a byte stream. The bridge preserves byte order, but TCP reads may
   combine or split WebSocket message payloads.

Selectors such as `host:port`, URLs, paths, non-ASCII binary data, unknown route
names, and ports outside the route map are rejected. The TCP host always comes
from trusted process configuration.

The root Docker Compose stack binds the published bridge port to host loopback,
uses `host.docker.internal` as its fixed TCP host, and allowlists login plus
all 20 conventional Cosmic world 0 channel ports. Override `MAPLE_TCP_HOST`,
`MAPLE_WS_ROUTES`, or `MAPLE_WS_ALLOWED_ORIGINS` before starting Compose when
your Cosmic configuration differs.

## Production configuration

Production should terminate HTTPS/WSS at the reverse proxy and keep this
process on loopback. For example, a same-origin deployment can use:

```bash
export MAPLE_WS_BIND_HOST=127.0.0.1
export MAPLE_WS_PORT=18080
export MAPLE_WS_PATH=/mapleweb/game
export MAPLE_TCP_HOST=127.0.0.1
export MAPLE_WS_ROUTES='login=8484,channel-1=7575,channel-2=7576,channel-3=7577,channel-4=7578,channel-5=7579,channel-6=7580,channel-7=7581,channel-8=7582,channel-9=7583,channel-10=7584,channel-11=7585,channel-12=7586,channel-13=7587,channel-14=7588,channel-15=7589,channel-16=7590,channel-17=7591,channel-18=7592,channel-19=7593,channel-20=7594'
export MAPLE_WS_ALLOWED_ORIGINS=https://darkkyshadow.com
python web/ws_proxy.py
```

Origins are compared exactly during the HTTP handshake. Do not add a trailing
slash, path, wildcard, or a broader site than the page that runs the client.
Requests with a missing or different Origin are rejected before a bridge slot
is allocated.

Environment variables and their matching command-line options are:

| Environment variable | CLI option | Default |
|---|---|---:|
| `MAPLE_WS_BIND_HOST` | `--bind-host` | `127.0.0.1` |
| `MAPLE_WS_PORT` | `--ws-port` | `8080` |
| `MAPLE_WS_PATH` | `--ws-path` | `/game` |
| `MAPLE_TCP_HOST` | `--tcp-host` | `127.0.0.1` |
| `MAPLE_WS_ROUTES` | repeat `--route NAME=PORT` | login plus channels 1–20 (`7575`–`7594`) |
| `MAPLE_WS_ALLOWED_ORIGINS` | repeat `--allowed-origin` | local port 8000 Origins |
| `MAPLE_WS_MAX_MESSAGE_BYTES` | `--max-message-bytes` | `262144` |
| `MAPLE_WS_MAX_CONNECTIONS` | `--max-connections` | `128` |
| `MAPLE_WS_MAX_QUEUE` | `--max-queue` | `16` |
| `MAPLE_WS_SELECTOR_TIMEOUT` | `--selector-timeout` | `5` seconds |
| `MAPLE_TCP_CONNECT_TIMEOUT` | `--connect-timeout` | `5` seconds |
| `MAPLE_WS_IDLE_TIMEOUT` | `--idle-timeout` | `900` seconds |
| `MAPLE_WS_LIFETIME_TIMEOUT` | `--lifetime-timeout` | `43200` seconds |
| `MAPLE_WS_WRITE_TIMEOUT` | `--write-timeout` | `10` seconds |
| `MAPLE_WS_PING_INTERVAL` | `--ping-interval` | `20` seconds |
| `MAPLE_WS_PING_TIMEOUT` | `--ping-timeout` | `20` seconds |
| `MAPLE_WS_CLOSE_TIMEOUT` | `--close-timeout` | `5` seconds |
| `MAPLE_TCP_READ_BYTES` | `--tcp-read-bytes` | `65536` |

`MAPLE_WS_ROUTES` and `MAPLE_WS_ALLOWED_ORIGINS` are comma-separated. Repeated
CLI options replace the corresponding environment value. Limits and timeouts
must be positive; the TCP read size cannot exceed the WebSocket message limit.

The bridge uses WebSocket ping/pong for dead-peer detection. Application idle
time is reset only by forwarded game bytes, not by ping traffic. A lifetime cap
also bounds connections that remain continuously active.

## Logging and client errors

Logs are one JSON object per line with lifecycle metadata: an opaque connection
ID, configured route, byte/message counters, duration, and normalized outcome.
They never contain peer addresses, Origin headers, selectors, packet bytes,
credentials, or exception text.

Clients receive only standard WebSocket close codes and generic reasons:

- `1003`: a game frame wasn't binary
- `1008`: the selector or request policy failed
- `1009`: the message limit was exceeded
- `1011`: the configured upstream was unavailable
- `1013`: the connection cap was reached

## Tests

The tests use an in-process TCP echo server; Cosmic doesn't need to be running:

```bash
python -m unittest discover -s web/tests -v
```

They cover allowed forwarding, arbitrary target and Origin rejection, selector
and frame types, message and connection boundaries, ping/idle behavior,
fragmented WebSocket messages, TCP coalescing, registration E2E proxying, and
log redaction.

On Windows, the interactive browser-registration gate uses the packaged site,
the pinned Cosmic account schema, and a disposable MySQL container:

```powershell
.\scripts\run_registration_browser_e2e.ps1
```

Open the printed loopback URL and submit the printed disposable credentials.
The harness verifies the resulting bcrypt hash, then removes its processes,
container, and temporary files. It uses loopback HTTP because the controlled
browser has an isolated certificate store; the harness maps only its exact
Origin to the HTTPS Origin required by the unchanged registration service.
This proves browser UI, same-origin fetch, CSRF cookie/header, API, least-
privilege insert, and secret clearing. It does not replace the separate
trusted-certificate HTTPS/WSS reverse-proxy gate.
