▎ Lightweight async UDP proxy for DDNet (Teeworlds-based) traffic. Sits between client and server, transparently relaying packets while tracking per-client sessions with configurable idle timeouts. Optional debug-mode hex-dump logging of every packet in both directions. Built in Rust on Tokio.
Find a file
yorushi 3ba5addde6 Fix what an adversarial pass broke, then add moderation and a shared store
An adversarial review reproduced fifteen failures across two rounds; the
second round found four of them in the first round's own fixes. All are
reproduced in tests/adversarial.rs, which fails against the code that had
them and passes against this.

The redirect was the worst of it. Injecting the message as a packet of its
own claimed the wrong vital sequence, and a client accepts a vital chunk only
when its sequence is exactly ack+1 — so the hub's headline feature never
worked. It now replaces the triggering chunk in place: it inherits the slot
the client is already waiting for, every other chunk in that packet still
rides along, and the sequence stream is left undisturbed, which in turn means
the session no longer has to be torn down afterwards.

The rest of the round: chunk sizes are refused rather than masked when they
exceed the ten bits the header has for them, because a mis-sized header makes
every chunk behind it read from the wrong offset; a packet drained of chunks
is not sent at all, since DDNet rejects one outright and its ack goes with
it; netem charges its link clock only for packets it actually queues; the
send order is held by a ticket gate whenever nothing in the config asks for
reordering, waking one waiter rather than the whole queue; and the degenerate
configs that used to panic mid-relay are refused at parse time.

Sessions are no longer created for any datagram that arrives. That is what
let a single junk byte per source port exhaust the file descriptor table
long before the session cap was reached, and the cap is now counted across
listeners rather than per listener, which a hub multiplied by its number of
destinations.

Moderation runs over the server's own rcon. The proxy has no password and
does not want one: it watches the auth status the server sends its client and
takes that word for who may command it. A reply is not injected either, since
the client only accepts console output as a vital chunk and injecting one
would shift every sequence after it. The admin's own chunk is rewritten into
an echo instead, so the reply keeps that chunk's sequence and lands in the
console it was typed into. Anything a player supplied is stripped of the
characters DDNet's console treats as structure, or a ban reason would run as
a second command with the admin's rights.

Sanctions are keyed by address rather than in-game id, because DDNet recycles
an id as soon as a player leaves and a ban naming one would land on whoever
took the slot. The id-addressed forms resolve to an address first, learning
which id is which from status output the proxy watched go past.

Names are made unique across everything the proxy fronts. A server does that
among its own players, but a hub puts players on several at once and none of
them can see the others. The rule follows the server's exactly, prefix and
trimming and length limit alike, because a server that still considers the
name taken will simply rename the player again.

State that has to outlive a restart or be seen by another instance goes to
NATS: sanctions, the name registry, and recorded inputs. The proxy writes
nothing to disk itself. Nothing on the packet path waits for the bus either —
work is queued and dropped when the queue is full, because a slow bus must
not become lag for the players.
2026-08-23 04:55:57 -04:00
examples Add link emulation, a DDNet 0.6 codec, and a redirecting hub 2026-08-20 15:56:24 -04:00
src Fix what an adversarial pass broke, then add moderation and a shared store 2026-08-23 04:55:57 -04:00
tests Fix what an adversarial pass broke, then add moderation and a shared store 2026-08-23 04:55:57 -04:00
.gitignore ddnet-proxy: transparent UDP relay with resilient per-client sessions 2026-07-22 08:10:38 -04:00
Cargo.lock Fix what an adversarial pass broke, then add moderation and a shared store 2026-08-23 04:55:57 -04:00
Cargo.toml Fix what an adversarial pass broke, then add moderation and a shared store 2026-08-23 04:55:57 -04:00
LICENSE-APACHE ddnet-proxy: transparent UDP relay with resilient per-client sessions 2026-07-22 08:10:38 -04:00
LICENSE-MIT ddnet-proxy: transparent UDP relay with resilient per-client sessions 2026-07-22 08:10:38 -04:00
README.md Fix what an adversarial pass broke, then add moderation and a shared store 2026-08-23 04:55:57 -04:00

ddnet-proxy

Lightweight async UDP proxy for DDNet (Teeworlds-based) traffic. Sits between client and server, transparently relaying packets while tracking per-client sessions with configurable idle timeouts. Can also emulate a degraded link — loss, delay, jitter, duplication, reordering, corruption and bandwidth caps — to see how the game behaves on a bad connection, decode DDNet 0.6 traffic into readable lines instead of hex, rewrite messages by rule, moderate players over rcon, keep names unique, record inputs, and act as a hub that sends players on to other servers. Several instances share state over NATS. Built in Rust on Tokio.

client <-> proxy <-> server

Why it's built this way

  • One UDP socket per client, not a single shared pipe to the server. UDP has no built-in multiplexing — without a dedicated socket per client, the proxy would have no way to tell which client a server reply belongs to, short of parsing the DDNet protocol itself. This keeps the proxy protocol-agnostic: it never has to understand what it's relaying.
  • Idle sessions are actively reaped, not just left in the table. UDP has no FIN/RST — a client that quits never tells the proxy it's gone. Without a reaper, every player who ever connected would leak a socket and a background task for the lifetime of the process.
  • A session is dropped the instant its read fails, not on the next reaper sweep. If the server briefly can't be reached, recv() fails right away (e.g. ICMP port-unreachable). Waiting for the idle timeout would silently blackhole that client's traffic for up to --timeout seconds even though the proxy already knows the pipe is broken.
  • Session count is capped (--max-sessions). UDP source addresses are trivially spoofable, so nothing stops an attacker from forcing unbounded bind() calls with fake client addresses. Without a cap, exhausting file descriptors/ephemeral ports this way used to crash the entire proxy — see the next point — not just fail for the attacker.
  • A single client's socket failure can never kill the process. bind()/connect() errors during session setup are caught and logged, not propagated with ?. One client hitting a resource limit must not disconnect every other player.
  • Structured logging, payload dump opt-in. Full hex-dumping every packet by default would be expensive and noisy at game tick-rate; --log-payload exists so it only costs anything when you're actively debugging the protocol.
  • Link emulation stays byte-level. Every netem knob treats the payload as opaque, so the proxy still never has to understand what it relays — the same property that makes one-socket-per-client work. Nothing in src/netem.rs parses DDNet.
  • Emulation state is per session, not per process. Each client stands in for its own link, so one client's queue backing up behind a rate limit doesn't charge every other player for it.
  • Queued packets are capped (limit=). Delay and rate limiting both work by holding packets, and a held packet costs a task plus a copy of its payload. Without a ceiling, aiming a flood at a proxy set to a slow rate would grow memory without bound instead of behaving like the congested link it's emulating.
  • An unconfigured proxy pays nothing for netem. The send path checks for a no-op config first: no RNG draw, no task spawn, no payload copy, and packets go out inline in receive order exactly as before.

Build

cargo build --release

The binary is produced at target/release/ddnet-proxy.

Usage

./target/release/ddnet-proxy --listen 0.0.0.0:8303 --server <ddnet-server-ip>:8303

Point your DDNet client at the proxy's --listen address instead of the real server.

CLI options

Flag Default Description
-l, --listen 0.0.0.0:8303 Address the proxy listens on for client packets
-s, --server required Address of the real DDNet server to forward traffic to
-t, --timeout 60 Seconds of inactivity before a client session is dropped
-m, --max-sessions 4096 Maximum number of concurrent client sessions
--log-payload off Include a hex dump of each packet's payload in debug logs
--netem off Link conditions to emulate in both directions
--netem-up off Link conditions for client -> server only; overrides --netem
--netem-down off Link conditions for server -> client only; overrides --netem
--netem-seed random Seed the emulator's RNG so a run replays identically
--inspect off Decode relayed traffic into readable lines
--inspect-raw off With --inspect, also dump each message body as hex
-c, --config none Hub and rule file; serves every destination it names
--nats none NATS server for the shared store; in-memory without it
--instance proxy Name this instance goes by on the bus
--no-color off Never colour the output, even on a terminal

Moderation over rcon

The proxy has no rcon password of its own. It watches NETMSG_RCON_AUTH_STATUS go past on the way to the client — the server itself says who is an admin — and from then on answers proxy_* commands from that connection. A command from anyone else is relayed untouched for the server to reject.

proxy_help                       proxy_kick_ip  <ip> [reason]
proxy_status                     proxy_ban_ip   <ip> <minutes> [reason]
proxy_unban_ip  <ip>             proxy_mute_ip  <ip> <seconds> [reason]
proxy_unmute_ip <ip>             proxy_move_ip  <ip> <destination>
                                 proxy_kick_id / proxy_ban_id /
                                 proxy_mute_id / proxy_move_id

Bans are in minutes and mutes in seconds, because a ban measured in seconds and a mute measured in minutes are both the wrong unit to have to think in. A ban disconnects immediately; a mute leaves the player connected.

Sanctions are keyed by address, not by in-game id: DDNet recycles an id the moment a player leaves, so a ban naming an id would land on whoever took the slot next. The _id forms exist for convenience and resolve to an address first. They learn which id is which by watching status output go past — the server prints id=N addr=<{...}>, and that address is one of the proxy's own sockets. Matching is on the port, which survives ordinary NAT, rather than the whole address, which does not. If no status has gone past yet the command says so instead of guessing.

How a reply gets back

Not by injecting one. NETMSG_RCON_LINE is only accepted as a vital chunk, and a vital chunk injected into the server's stream shifts every sequence after it, which would have to be rewritten for the rest of the connection.

Instead the admin's own RCON_CMD chunk is rewritten into echo .... It keeps its sequence, and the server prints the text back to the console the command was typed in. ; separates commands in DDNet's console, which is what lets one rewritten chunk carry several lines — and is also why everything that came from a player is stripped of ; and quotes before it goes anywhere near that string.

The same limit is why the commands do not tab-complete: advertising them needs NETMSG_RCON_CMD_ADD, which the client also only accepts as a vital chunk.

The shared store

--nats points the proxy at a NATS server with JetStream. The proxy never writes to disk itself; JetStream does that on its own side.

What Where
Sanctions KV bucket ddnet_proxy_sanctions, loaded at startup, watched for peer changes
Name registry KV bucket ddnet_proxy_names, entries expire after 5 minutes
Recorded inputs Stream ddnet_proxy_inputs, subject ddnet.proxy.inputs.<instance>.<client>

Several instances sharing one NATS see each other's bans the moment they are made, and share one name registry. Expiry is stored as an absolute instant, so a restart cannot silently extend a sanction the way a remaining-duration would.

Nothing on the packet path waits for the bus. Calls from there hand work to a bounded queue and return; a full queue drops the work rather than turning a slow bus into lag for the players.

Inputs are recorded as {instance, server, client, acked, tick, input[]} — one message per NETMSG_INPUT, so roughly fifty a second per player. The stream is capped at an hour and 512 MB: the point is the recent past, not an archive.

Names

A DDNet server renames duplicate players among its own, but a hub puts players on several servers at once and none of them can see the others. The proxy is the only place that sees all of them, so it rewrites the name in CL_STARTINFO and CL_CHANGEINFO before the server ever gets it.

The rule is DDNet's own, from CServer::SetClientNameImpl: whitespace trimmed off both ends and a taken name gets a (N) prefix — (1)Yorushi, not Yorushi(1). Matching the server matters, because a server that considers the name taken will just rename the player again.

MAX_NAME_LENGTH is 16 bytes including the terminator, so the tag does not get appended so much as take room away from the name: ABCDEFGHIJKLMNOPQRSTUVWXYZ becomes ABCDEFGHIJKLMNO, and the second player asking for it gets (1)ABCDEFGHIJKL. Cuts land on character boundaries, never mid-UTF-8.

Hub: sending players to other servers

DDNet's redirect names a port and nothing else — CServer::RedirectClient packs a single integer — so a server can only hand a player to another port on its own address. The hub turns that into "anywhere" by owning a port per destination: the client is redirected within the proxy, and the proxy is what reaches the far machine.

                     ┌── :8304 ──> novice server (another machine)
player ──> proxy :8303 ── hub map with tune zones
                     └── :8305 ──> moderate server (another machine)

A player walks into a tune zone on the hub map; the hub server sends them that zone's tuning; the proxy recognises the marker value and issues the redirect. SV_TUNEPARAMS carries values and no zone number, so a zone is identified by a tuning value the map author picked to be unmistakable.

ddnet-proxy --config examples/hub.toml --inspect
ddnet-proxy listening listen=0.0.0.0:8303 server=127.0.0.1:8310
destination ready name=novice listen=0.0.0.0:8304 server=45.83.104.1:8303
ctrl CONNECTACCEPT token=abcdef01
[v1 SV_TUNEPARAMS 47 values]                          <- baseline on join
[v2 SV_TUNEPARAMS ground_control_speed 10.00->12.34]  <- walked into the portal
redirecting player destination="novice" port=8304
ctrl CONNECTACCEPT token=11223344                     <- now on the other server

Tune messages are reported as what changed, because crossing a zone moves a couple of forty-seven values and that couple is the whole story.

Clients older than DDNet 17020 do not understand the redirect message. DDNet kicks them with instructions instead; the hub cannot do that for them.

Rules

The same file describes what to do with messages. See examples/hub.toml for a commented one.

[[rule]]
name      = "hide advertising"
direction = "server->client"   # or "client->server", or "both"
message   = "sv_chat"          # the name --inspect prints, case-insensitive
contains  = "discord.gg"       # optional substring of the message text
action    = { replace = { text = "[link removed]" } }
Action Effect
"log" Note the match and forward it untouched
"drop" Remove the message — non-vital messages only, see below
{ replace = { text = "..." } } Swap the message's text, keep everything else
{ redirect = { to = "novice" } } Send the player to that destination

Things the rule engine will not let you do, and why:

  • A vital message cannot be dropped. It would never be acknowledged, so the sender resends it forever and the connection stalls on that sequence. The proxy logs the refusal and forwards the message instead of hanging the game. Use replace for chat and anything else that arrives vitally.
  • A packet the codec could only partly read is never rebuilt, because the part it could not read would be silently dropped. It is relayed untouched.
  • Nothing is redirected before the handshake finishes. An injected packet needs the negotiated security token, so the player has to finish arriving before they can be sent anywhere.

Rewrites keep the chunk's vital sequence number, so the client accepts the edited message in the slot it was already waiting for.

The config is validated at startup — duplicate ports, a destination clashing with the hub's own port, a tune parameter DDNet has no name for, or a rule pointing at a destination that does not exist are all refused with a message saying which, rather than becoming a hub that quietly never redirects anyone.

Reading the traffic

--inspect decodes DDNet 0.6 packets instead of dumping hex:

./target/release/ddnet-proxy --server 1.2.3.4:8303 --inspect
ctrl CONNECT
ctrl CONNECTACCEPT token=130ec948
ctrl ACCEPT
ack=0 chunks=1 [v1 INFO <22 bytes>]
ack=1 chunks=5 [v1 ping@ddnet.tw <16 bytes>] [v2 rcon-type@ddnet.tw <1 bytes>]
               [v3 capabilities@ddnet.tw <2 bytes>] [v4 map-details@ddnet.tw <50 bytes>]
               [v5 MAP_CHANGE map="Castaway" crc=d30620fe size=362902]
ctrl CLOSE reason="You have been banned (VPN detected...)"

vN marks a vital chunk and its sequence number. Message bodies are decoded where it helps — chat, MOTD, broadcast, tune params, map change — and shown as a name and a size otherwise. Extended messages are resolved from their UUID to the name DDNet registers them under.

How the codec is kept honest

The protocol layer lives in src/proto/ and is checked against DDNet itself rather than against its own assumptions:

  • Huffman is decoded from 40 vectors produced by DDNet's own CHuffman::Compress (tests/data/huffman_vectors.txt). A round trip against our own encoder would pass just as happily with a subtly wrong tree.
  • Varints are checked against 821 vectors from CVariableInt::Pack, including i32::MIN and i32::MAX, in both directions.
  • Extended-message UUIDs are the UUIDv3 derivation DDNet uses, verified against an independent RFC 4122 implementation.
  • The framing is exercised end to end against live DDNet servers.

Decoding is one-way on purpose: the compression flag is per packet and every peer accepts uncompressed input, so anything this proxy rebuilds goes out uncompressed. That removes a whole class of "our encoder disagrees with theirs by one bit" bugs, and an uncompressed rebuild always still fits the MTU because the sender's own pre-compression buffer was bounded by it.

Only 0.6 is decoded. A 0.7 ("sixup") packet is recognised and passed through untouched rather than misread — the two disagree about where the payload starts.

Emulating a bad connection

# 2% loss and 50±10ms of latency, both directions
./target/release/ddnet-proxy --server 1.2.3.4:8303 --netem 'loss=2%,delay=50ms,jitter=10ms'

# asymmetric: a clean uplink, a congested downlink
./target/release/ddnet-proxy --server 1.2.3.4:8303 \
    --netem-up 'delay=15ms' \
    --netem-down 'delay=15ms,jitter=25ms,loss=5%,rate=256kbit'

The proxy logs a netem active line at startup whenever it isn't relaying transparently — a proxy that silently drops packets is the kind of thing you otherwise waste an afternoon debugging inside the game.

Spec grammar

A comma-separated list of key=value pairs. Everything is optional and anything left out is off.

Key Accepts Meaning
loss 2% or 0.02 Chance a packet is dropped outright
delay 50ms, 1s, 2500us, bare = ms Base one-way delay added in this direction
jitter same as delay Uniform ± spread around delay, clamped at zero
dup 1% or 0.01 Chance a packet is sent twice, each copy delayed separately
reorder 5% or 0.05 Share of packets that skip the delay and overtake the queue
corrupt 0.1% or 0.001 Chance one random bit in the payload is flipped
rate 1mbit, 256kbit, bare = bit/s Bandwidth cap, modelled as a serial link
limit packet count (default 1000) Queue depth before packets are tail-dropped

Things worth knowing before you reach for these:

  • reorder requires delay. It works by letting a share of packets skip the queue and overtake what's already waiting, so with no delay there'd be nothing to overtake. A spec that asks for it without a delay is rejected rather than silently emulating a perfect link.
  • jitter reorders too. Independent draws per packet mean a later packet can be assigned a shorter delay and arrive first. That's realistic, but it means jitter and reorder aren't independent knobs.
  • rate produces queueing delay, not thinning. A packet can't start transmitting until the one ahead of it has drained, so a too-low rate shows up as latency climbing until limit is hit — which is what a congested link actually feels like. A limiter that just dropped packets would be loss.
  • corrupt mostly gets discarded. DDNet will reject a mangled packet, so its practical use is checking that client and server handle garbage gracefully, not producing interesting in-game behaviour.
  • --netem-seed makes a run repeatable. Seeds are handed to sessions in creation order, so a single-client run replays identically; with several clients connecting concurrently the order isn't guaranteed.

Logging

Controlled via RUST_LOG:

# connection/session events only (default)
RUST_LOG=ddnet_proxy=info ./target/release/ddnet-proxy --server 1.2.3.4:8303

# full packet trace, including hex dumps if --log-payload is set
RUST_LOG=ddnet_proxy=debug ./target/release/ddnet-proxy --server 1.2.3.4:8303 --log-payload

License

Licensed under either of

at your option.