- Rust 100%
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. |
||
|---|---|---|
| examples | ||
| src | ||
| tests | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| LICENSE-APACHE | ||
| LICENSE-MIT | ||
| README.md | ||
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--timeoutseconds 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 unboundedbind()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-payloadexists 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.rsparses 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
ratelimit 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 slowratewould 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
replacefor 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, includingi32::MINandi32::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:
reorderrequiresdelay. 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.jitterreorders too. Independent draws per packet mean a later packet can be assigned a shorter delay and arrive first. That's realistic, but it meansjitterandreorderaren't independent knobs.rateproduces 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 untillimitis hit — which is what a congested link actually feels like. A limiter that just dropped packets would beloss.corruptmostly 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-seedmakes 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
- MIT license (LICENSE-MIT)
- Apache License, Version 2.0 (LICENSE-APACHE)
at your option.