We are not implementing login yet, there is cleanup worth doing first. A few things were intentionally left behind in the previous article, and they are worth addressing before we go deeper.
Before implementing login, handling encrypted packets, and authenticating real players, we must ensure the foundation can withstand basic abuse. This means enforcing timeouts, limiting packet sizes, and laying the groundwork for future enhancements. Clean up first, then harden.
Currently, both servers duplicate the same TCP listener setup. Although the function is small and a bit of code duplication is tolerable, extracting this logic into a dedicated crate provides a single, clean place to apply these protections.
Introducing mu-runtime#
We are going to introduce a new crate called mu-runtime. The idea is simple: one generic server loop, any handler you plug in. Taking advantage of Rust's generics, we can implement a Server struct that accepts a generic handler for client connections:
pub type PacketStream = Framed<TcpStream, PacketCodec>;
pub struct Server {...}
impl Server {
pub async fn run_tcp_listener<H, F>(&self, handler: H) -> Result<()>
where
H: Fn(PacketStream, SocketAddr) -> F + Send + 'static,
F: Future<Output = Result<()>> + Send + 'static,
{
let listener = TcpListener::bind(self.bind_addr).await.with_context(|| {
format!("failed to bind to {bind_addr}", bind_addr = self.bind_addr)
})?;
let mut shutdown = Box::pin(tokio::signal::ctrl_c());
loop {
tokio::select! {
_ = &mut shutdown => {
break;
}
accepted = listener.accept() => {
let (socket, peer_addr) = match accepted {
Ok(conn) => conn,
Err(e) => {...}
};
let stream = Framed::new(socket, PacketCodec);
let h = handler(stream, peer_addr);
tokio::spawn(async move {
if let Err(e) = h.await {...}
});
}
}
}
Ok(())
}
}
If you compare this to the previous implementation on both servers, it's nearly identical. The only real difference is that instead of calling a hardcoded handle_client function, we now receive a generic handler. The magic is in the where clause.
Method signature: run_tcp_listener accepts a handler closure H that gets called for each inbound connection. The two generic parameters work together:
H: Fn(PacketStream, SocketAddr) -> F:The handler receives a framed stream and the client's address, and returns a future.Fn(notFnOnce) means the same handler is reused for every connection.F: Future<Output = Result<()>>:The returned future resolves toResult<()>, meaning each connection handler is async and can fail independently.
Trait bounds explained:
Send + 'staticonH: the handler can be shared across threads and doesn't hold any temporary references that might become invalid. Required because we spawn atokio::spawnper connection.Send + 'staticonF: the future returned by the handler can be sent to a Tokio task (again,tokio::spawnrequirement).
The reason we use Fn and not FnOnce is that FnOnce would consume the handler on the first connection, leaving nothing for the second accept(). The compiler would catch this, but the intent matters: we need a handler we can call repeatedly.
Now both GameServer and ConnectServer can use this Server struct to start listening:
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt::init();
let server = Server::new("GameServer".to_string(), "0.0.0.0:55901".parse().unwrap());
server
.run_tcp_listener(
move |stream, peer_addr| async move { handle_client(stream, peer_addr).await },
)
.await
}
This simplifies both servers considerably and gives us a single, central place to evolve the TCP layer. The workspace crate structure now looks like this:
ββββββββββββββββββββββββ ββββββββββββββββββββββββ
β connect-server β β game-server β
β (handlers, config) β β (handlers, config) β
ββββββββββββ¬ββββββββββββ ββββββββββββ¬ββββββββββββ
β β
βββββββββββββββ¬βββββββββββββ
β
βββββββββββββββΌβββββββββββββ
β mu-runtime β
β Server, PacketStream, β
β timeouts β
βββββββββββββββ¬βββββββββββββ
β
βββββββββββββββΌβββββββββββββ
β mu-protocol β
β Packet, PacketCodec, β
β size limits, errors β
ββββββββββββββββββββββββββββ
Both servers depend on mu-runtime for the TCP loop, and mu-runtime depends on mu-protocol for framing. The servers themselves only contain what is unique to them: their handlers and config. Now that we have a shared runtime, this is exactly the right place to enforce the protections we deliberately skipped in the first article.
Timeouts: The Idle Client Problem#
In the previous article, I mentioned some security measures that we intentionally skipped for simplicity. Let's start fixing that.
We use plain TCP as our transport. TCP provides reliability at the byte-stream level (retransmission, ordering, flow control), but it gives us no protection at the application level. Without additional safeguards, a single misbehaving or malicious client can tie up server resources indefinitely.
The first line of defense is timeouts. We need two:
- Read timeout: limits how long we wait for a client to send data. Without this, a client that connects but never sends anything (intentionally or due to a network issue) holds a connection and a spawned task open forever.
Read Timeout β Client sends partial data then goes silent
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Client Server
β β
βββββ [C1 04 00 01] ββββββββββββΊβ Valid packet header
β β Server expects remaining bytes...
β β
β (silence) β β± 1s...
β β β± 2s...
β β β± 3s...
β β β± READ_TIMEOUT reached
β β
β βββββ [connection closed]
β β
Without timeout: server task blocks here forever,
holding memory and a file descriptor hostage.
- Write timeout: limits how long we wait for a client to drain data we're sending. A client that stops reading (or reads extremely slowly) causes backpressure that can stall our write side indefinitely.
Write Timeout β Client connects but never reads responses
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Client Server
β β
βββββ [packet request] βββββββββΊβ
β βββββ [packet response] βββββΊ TCP buffer
β β
β (client stops reading) β Server sends next packet...
β βββββ [data packet] βββββββΊ TCP buffer
β β TCP buffer full!
β β
β β .send() blocks waiting
β β for client to drain buffer
β β β± 1s...
β β β± 2s...
β β β± WRITE_TIMEOUT reached
β β
β βββββ [connection closed]
β β
Without timeout: server task stalls on .send(),
backpressure from one client blocks its entire task.
Without both of these, our server is vulnerable to resource exhaustion. Even a handful of bad clients could starve legitimate connections.
To put it simply:
- Read timeout: for a client that won't talk.
- Write timeout: for a client that won't listen.
These two timeouts protect against well-known attack patterns. The read timeout defends against Slowloris, an attack where a client opens many connections and keeps them alive by sending data just slowly enough to prevent the server from closing them, eventually exhausting the connection pool. The write timeout defends against backpressure exhaustion, where a client deliberately stops reading, causing TCP send buffers to fill up and server tasks to stall indefinitely waiting on a .send() that can never complete.
Implementing Timeouts in PacketStream#
To add timeouts, we are going to evolve our PacketStream. Instead of a plain type alias for Framed, we are going to make it a proper wrapper with recv and send methods that layer tokio::time::timeout on top:
pub struct PacketStream {
inner: Framed<TcpStream, PacketCodec>,
read_timeout: Duration,
write_timeout: Duration,
}
impl PacketStream {
pub async fn recv(&mut self) -> Option<Result<RawPacket, ConnectionError>> {
match tokio::time::timeout(self.read_timeout, self.inner.next()).await {
Ok(result) => result.map(|r| r.map_err(ConnectionError::from)),
Err(_) => Some(Err(ConnectionError::ReadTimeout)),
}
}
pub async fn send(&mut self, packet: RawPacket) -> Result<(), ConnectionError> {
tokio::time::timeout(self.write_timeout, self.inner.send(packet))
.await
.map_err(|_| ConnectionError::WriteTimeout)?
.map_err(ConnectionError::from)
}
}
And in run_tcp_listener, we replace the old stream with the new wrapper:
// Old
let stream = Framed::new(socket, PacketCodec);
// New
let stream = PacketStream::new(
socket,
self.config.read_timeout,
self.config.write_timeout,
);
A small change with a real impact.
Seeing It in Action#
The best part about these protections is how trivially easy it is to trigger the attack and then watch the server shut it down. No special tools needed, just nc.
Simulating a read-timeout attack (Slowloris):
Open a terminal and connect to the ConnectServer without sending anything:
$ nc 127.0.0.1 44405
# just sit here and do nothing
That's it. Before our fix, that connection would hold a Tokio task and a file descriptor open indefinitely. With the read timeout in place, the server cuts it off after 30 seconds:
RUST_LOG=info cargo run -p connect-server
2026-02-20T19:57:18.632733Z INFO mu_runtime: ConnectServer listening bind_addr=0.0.0.0:44405
2026-02-20T19:57:19.536570Z INFO mu_runtime: client connected peer=127.0.0.1:55927
2026-02-20T19:57:19.536892Z DEBUG connect_server: sent hello packet peer_addr=127.0.0.1:55927
2026-02-20T19:57:49.538663Z WARN connect_server: Packet read error error=read timed out
2026-02-20T19:57:49.538787Z INFO connect_server: connect-server client disconnected peer=127.0.0.1:55927
The nc session gets terminated on the client side too. The connection just drops. One idle connection, handled cleanly, no leaked resources.
Simulating a write-timeout attack (backpressure exhaustion):
Honestly, the write timeout is impossible to demonstrate with the ConnectServer as it currently stands, and it's worth understanding why. For a write to block, the client's TCP receive buffer must be completely full while the server is actively trying to send. The ConnectServer's packets are tiny, the Hello is 4 bytes, a ServerListResponse for two servers is ~15 bytes. The OS enforces a minimum receive buffer of around 4KB regardless of what you set on the socket. Those tiny packets will never come close to filling it, so .send() always completes instantly and the write timeout never gets a chance to fire.
The protection becomes real once the GameServer starts streaming larger payloads such as: position updates, inventory data, map state. A client that connects, triggers a large response, and then stops reading will fill that buffer quickly. At that point, .send() blocks, and without a write timeout the server task stalls indefinitely.
So for now, trust the code more than the demo: the mechanism is correct, the ConnectServer just doesn't produce enough data to make the failure mode visible yet. We'll see it in action when the GameServer gets more interesting.
Max Packet Size: Starving the Allocator#
Another vector worth closing is unbounded memory allocation. A malicious client can send a crafted header claiming an excessively large packet size, forcing the server to allocate memory it will never use before any parsing occurs. Because this happens at the framing layer, thatβs also the ideal point to block it.
The ConnectServer, for example, only ever receives C1 packets meaning a maximum of 255 bytes. It has no business accepting a C2/C4 packet, which can be up to 65,535 bytes. If we blindly allocate memory for whatever length the client claims, a malicious client can send a crafted header claiming a massive packet size and force us to allocate memory we'll never actually use. That's a memory allocation DoS vector, and it happens before any parsing even occurs.
The GameServer legitimately receives C2/C4 packets, so it needs a higher ceiling. But we don't know yet if a real game packet comes close to the 65KB protocol maximum. We can set a tighter limit something like 12β24KB and adjust as real packets are implemented.
The right place to enforce this is the codec, which is already responsible for framing the byte stream. The change is minimal:
pub struct PacketCodec {
max_packet_size: usize,
}
fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
...
if declared_len > self.max_packet_size {
return Err(ProtocolError::PacketTooLarge {
max: self.max_packet_size,
actual: declared_len,
});
}
...
}
// Each server enforces its own limit:
let server = Server::new(ServerConfig {
name: "ConnectServer".to_string(),
bind_addr: "0.0.0.0:44405".parse().unwrap(),
read_timeout: Duration::from_secs(15),
write_timeout: Duration::from_secs(15),
max_packet_size: SMALL_PACKET_MAX_SIZE, // C1 only no need for larger packets
});
We reject oversized packets at the codec level, before we touch the payload. No parsing, no allocation beyond the declared limit.
Seeing It in Action#
This one is easy to trigger with just nc and printf. A C2 packet header is only 3 bytes: the type byte plus the 2-byte length field. That is all the codec needs to make its decision. We never have to send the actual payload.
# C2 packet claiming to be 1000 bytes:
# \xC2 β C2 type (large packet, 2-byte length)
# \x03\xE8 β length 1000 in big-endian
# \xF4\x06 β code/sub-code (doesn't matter, codec rejects before parsing)
$ printf '\xC2\x03\xE8\xF4\x06' | nc 127.0.0.1 44405
The ConnectServer is configured with SMALL_PACKET_MAX_SIZE = 255. As soon as the codec reads the 3-byte prefix and sees declared_len = 1000 > 255, it returns PacketTooLarge. No allocation, no parsing, connection closed:
2026-02-20T20:18:42.764238Z INFO mu_runtime: ConnectServer listening bind_addr=0.0.0.0:44405
2026-02-20T20:18:45.055888Z INFO mu_runtime: client connected peer=127.0.0.1:56865
2026-02-20T20:18:45.056244Z DEBUG connect_server: sent hello packet peer_addr=127.0.0.1:56865
2026-02-20T20:18:45.056295Z WARN connect_server: Packet read error error=protocol error: packet too large: max 255 bytes, got 1000
2026-02-20T20:18:45.056312Z INFO connect_server: connect-server client disconnected peer=127.0.0.1:56865
The attack is dead before it even starts. The same packet sent to the GameServer which has a higher limit would pass through, because it legitimately handles large packets. Each server enforces the right limit for its own protocol role.
Where We Are Now#
With these changes, our server is meaningfully more robust. To summarize what we added:
mu-runtime:A centralized, generic TCP listener that both servers share.- Read timeout: Disconnects clients that connect but never send data (Slowloris protection).
- Write timeout: Disconnects clients that connect but never read responses (backpressure exhaustion protection).
- Max packet size: Rejects oversized packets at the codec level before any allocation happens (memory DoS protection).
There are more mechanisms we could add such as: rate limiting, max concurrent connections, per-IP throttling, but we will revisit those when the need arises. For now, the foundation is solid enough to move forward.
In the next article, I promise you that we will finally get to the login flow. That means dealing with XOR32-encrypted client packets and implementing our first real game logic.
All the code can be found in the repository. Feel free to explore, experiment, and leave comments! Happy coding!



