Skip to content
Lesson 9.7Network Messages, Authority, and IPC

Local Proxies and Bidirectional Streams

Understand how a local proxy relays two byte streams while preserving direction, framing, and the original messages.

Lesson 9.7
Read or listenPrint chapter or book
About 20 minutes

Keyboard shortcuts

?
Open this guide
Alt + B
Show or hide the lesson list
Alt + O
Show or fold the lesson outline
Alt + N
Open or close notes
Alt + H
Toggle semantic colours
Alt + D
Toggle page brightness
Alt + C
Toggle code brightness
← β†’
Move through a focused code trace
Home End
First or final step in a focused trace
Escape
Close this guide or an open panel

Reader shortcuts pause while you type in a text field.

Cheatsheet for Lesson 9.7Key ideas, reference and questions

A one-page summary of β€œLocal Proxies and Bidirectional Streams” you can keep beside your notes. Plain Markdown.

Lesson 9.7 β€” Local Proxies and Bidirectional Streams: cheatsheet

  • Remember: A relay preserves both directions of the conversation while providing a place to observe their bytes.
  • Also useful: One side can stop speaking while still listening. Closing both directions immediately discards that distinction.

Reading map

  • What a proxy does
  • Relay one direction
  • Log metadata, not unlimited payloads
  • Diagnose handshake failures
  • Inject a real local Wesnoth chat response
  • Run the complete proxy

Formulas and patterns

  • connection opened; client β†’ server: 142 bytes; server β†’ client: 68 bytes; connection closed

Review

  • Q: What makes a local TCP relay transparent? A: It relays both directions, copes with partial I/O and half-closes, and leaves data as is
  • Q: A client connects to a local proxy, which opens its own connection to the server. Why does reaching the server's lobby this way prove something? A: Both directions are being relayed correctly
Download cheatsheet (.md)

A local TCP proxy accepts a client connection, opens a connection to your local server, and relays bytes both ways.

Wesnoth client
Local proxy
127.0.0.1:27015
Local server
127.0.0.1:15000

Begin as a transparent relay. If the protocol fails before you modify anything, fix the relay first. This first version treats bytes as bytes. Only after it passes the normal Wesnoth login should the proxy switch to the framed, message-aware upstream path below. The two modes have different failure behavior: a transparent relay does not need to decode; a message-aware relay must decide what to do when decoding fails.

A proxy is easy to connect to the wrong service if an address is copied from a different test. Validate both endpoints as loopback addresses before opening either connection; this helper returns the accepted address so the caller cannot forget which one passed the check:

use std::net::{IpAddr, SocketAddr};
fn require_loopback(address: SocketAddr) -> anyhow::Result<SocketAddr> {
anyhow::ensure!(
address.ip() == IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
|| address.ip() == IpAddr::V6(std::net::Ipv6Addr::LOCALHOST),
"proxy endpoints must be loopback addresses"
);
Ok(address)
}

This course proxy is intentionally not a general remote interception tool.

This board needs JavaScript to move things around. Here is the lesson’s sorting:

Accepted
  • 127.0.0.1:15000 (the local Wesnoth server). 127.0.0.1 is the IPv4 loopback address, so this endpoint stays on the same computer.
  • 127.0.0.1:27015 (the proxy’s own address). Same loopback address. The check looks at the IP address, not the port.
  • [::1]:15000 (IPv6 localhost). The check accepts the IPv6 localhost address too, so this one passes as well.
Refused with an error
  • 192.168.1.20:15000 (another computer on the network). It is not loopback. This proxy is intentionally not a general remote interception tool, so the check refuses before opening any connection.
  • 8.8.8.8:15000 (a public internet address). Not loopback, so it is refused before anything connects.
  • 0.0.0.0:27015 (every network interface). The code compares with the localhost addresses exactly, and 0.0.0.0 is not one of them, so it is refused.

The standard library can copy a stream until EOF:

use std::{io, net::TcpStream};
fn relay(mut source: TcpStream, mut destination: TcpStream) -> io::Result<u64> {
io::copy(&mut source, &mut destination)
}

TCP is full duplex, so run two relays:

fn proxy_pair(client: TcpStream, server: TcpStream) -> anyhow::Result<()> {
let client_to_server = client.try_clone()?;
let server_to_client = server.try_clone()?;
let upstream = std::thread::spawn(move || relay(client_to_server, server));
let downstream = std::thread::spawn(move || relay(server_to_client, client));
upstream.join().map_err(|_| anyhow::anyhow!("upstream relay panicked"))??;
downstream.join().map_err(|_| anyhow::anyhow!("downstream relay panicked"))??;
Ok(())
}

For many connections, an async runtime such as Tokio scales better. Threads keep the first lab easy to see.

One direction can reach EOF while the other still has data to send. A reliable local Wesnoth proxy should use shutdown(Write) on the destination after that relay finishes, then let the server-to-client relay deliver any remaining game messages before both sockets close.

Useful bounded log lines include:

connection opened
client β†’ server: 142 bytes
server β†’ client: 68 bytes
connection closed

If you parse frame previews, cap their length and avoid logging secrets or full chat histories by default.

A failed local handshake

A failed local handshake.

When a transparent proxy fails, compare direct and proxied captures:

  • Did both directions start?
  • Were bytes changed or dropped?
  • Did the client send the server’s port or address inside the protocol?
  • Is there TLS (Transport Layer Security, the encryption behind HTTPS) or a certificate bound to a hostname?
  • Did a relay block because it waited for EOF?
  • Did one side expect a UDP companion channel?

Comparing the handshake data

Do not bypass certificate validation. For encrypted protocols, create a development configuration with certificates you control.

The safest next step is a tee:

read bytes
β†’ feed a copy to a bounded parser/log channel
β†’ forward the original bytes unchanged

Keep parsing off the relay’s critical path. If the parser cannot keep up, drop log entries rather than blocking the connection. This tee is for observation. It cannot decide where to insert a new message, because a TCP read does not identify a frame boundary. The insertion path therefore waits for a complete bounded frame and handles parse errors explicitly.

Bound the parser’s queue as well as each frame. What a full queue may discard depends on the message meaning: a newer position snapshot may replace an older one under a documented policy, but discarding an unprocessed item grant loses the item. The tee can drop its log copies only because the relay still forwards every original frame; the forwarding path itself never drops game bytes to keep up.

The original lab used proxy port 27015 and server port 15000. Start wesnothd.exe, start the local proxy, then tell Wesnoth to connect to localhost:27015. Reaching the normal lobby through the proxy proves both directions work.

For injection, one task must own the upstream writer so two threads cannot interleave bytes. After the special four-zero negotiation has passed, have the client-to-server path read one complete Wesnoth frame at a time. Forward the original bytes unchanged, but inspect a decoded copy:

use std::io::Write;
fn forward_with_chat_hook(
client: &mut TcpStream,
server: &mut TcpStream,
) -> anyhow::Result<()> {
loop {
// πŸ“¦ `read_frame` returns one complete length-prefixed message. A plain
// `read` here could split one frame or combine several frames.
let original = read_frame(client)?;
// A decode error stops this modified upstream path before sending this frame.
// The transparent relay above has no such dependency on parsing.
let decoded = decode_wesnoth_frame(&original)?;
// πŸ“¦ Preserve the legitimate game packet exactly as captured and send
// it first; inspecting a copy must not silently replace normal traffic.
server.write_all(&original)?;
if decoded.contains("message=\"\\wave\"") {
// πŸ”’ This task is the sole owner of `server`, so the original and
// injected frames cannot be interleaved by the reverse relay.
let injected = encode_chat("ChatBot", "Hello!")?;
server.write_all(&injected)?;
}
server.flush()?;
}
}

Keep the server-to-client direction as an ordinary transparent relay. The handshake needs one small state flag because its first four zero bytes are not a gzip frame; forward those four bytes directly before switching the upstream parser into framed mode.

The contains check is enough only for this controlled one-command fixture. It could match that text inside an unrelated WML field. A general proxy should parse the WML tree from Lesson 9.4 and inspect the actual chat-message field and session state before inserting a response. The complete lab below uses the same narrow shortcut, so do not treat it as a general message router.

Now send \wave from the real Wesnoth client. The server receives both the original command and the injected Hello! message on the already authenticated connection. This is the practical advantage of the proxy: the official client performs its own login while the proxy observes and modifies the loopback traffic.

The local proxy is correct when:

  • direct and proxied sessions behave the same;
  • byte counts agree with a packet capture;
  • either side closing ends the session cleanly;
  • an undecodable frame in the message-aware mode returns a clear error before that frame is forwarded; proxy_pair then shuts down both sockets before waiting for the reverse relay. A separate transparent mode could relay bytes without decoding them;
  • non-loopback endpoints are rejected;
  • the only modification is the documented \wave response;
  • removing both decoding and injection returns the proxy to byte-for-byte transparent behavior.
Windows VM

Start wesnothd.exe on its normal local port, then run:

Terminal window
.\target\i686-pc-windows-msvc\release\wesnoth_proxy.exe

Connect the official Wesnoth 1.14.9 client to localhost:27015. Type \wave after login and confirm the injected Hello! appears. The complete two-direction relay is wesnoth_proxy.rs. It binds only to loopback, forwards the unframed negotiation, parses bounded upstream frames, preserves each original frame, injects through the single upstream writer, half-closes both directions, and keeps the server-to-client stream transparent.

Complete lab source: wesnoth_proxy.rs

wesnoth_proxy.rs

windows-labs/src/bin/ Β· Rust, 94 lines

Show the codeHide the code
use std::{
    io::{self, Read, Write},
    net::{IpAddr, Ipv4Addr, Shutdown, SocketAddr, TcpListener, TcpStream},
    thread,
    time::Duration,
};

use anyhow::{Context, Result};
use gha_windows_labs::wesnoth_protocol::{decode_frame, encode_chat, read_frame};

fn loopback(port: u16) -> SocketAddr {
    SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port)
}

fn upstream(mut client: TcpStream, mut server: TcpStream) -> Result<()> {
    // The negotiation request is the protocol's one unframed client message.
    let mut negotiation = [0_u8; 4];
    client.read_exact(&mut negotiation)?;
    anyhow::ensure!(negotiation == [0, 0, 0, 0], "unexpected negotiation bytes");
    server.write_all(&negotiation)?;
    server.flush()?;

    loop {
        let frame = match read_frame(&mut client) {
            Ok(frame) => frame,
            Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => break,
            Err(error) => return Err(error.into()),
        };
        let decoded = decode_frame(&frame)?;
        server.write_all(&frame)?;
        if decoded.contains("message=\"\\wave\"") {
            server.write_all(&encode_chat("ChatBot", "Hello!")?)?;
        }
        server.flush()?;
    }
    server.shutdown(Shutdown::Write)?;
    Ok(())
}

fn downstream(mut server: TcpStream, mut client: TcpStream) -> Result<()> {
    io::copy(&mut server, &mut client)?;
    client.shutdown(Shutdown::Write)?;
    Ok(())
}

fn proxy_pair(client: TcpStream) -> Result<()> {
    let server = TcpStream::connect_timeout(&loopback(15_000), Duration::from_secs(3))
        .context("start local wesnothd.exe on port 15000 first")?;
    client.set_read_timeout(Some(Duration::from_secs(30)))?;
    client.set_write_timeout(Some(Duration::from_secs(5)))?;
    server.set_read_timeout(Some(Duration::from_secs(30)))?;
    server.set_write_timeout(Some(Duration::from_secs(5)))?;

    // Keep control handles so a decoding failure in the upstream worker can
    // close both directions before we wait for the downstream worker.
    let client_control = client.try_clone()?;
    let server_control = server.try_clone()?;
    let upstream_client = client.try_clone()?;
    let downstream_server = server.try_clone()?;
    let up = thread::spawn(move || upstream(upstream_client, server));
    let down = thread::spawn(move || downstream(downstream_server, client));
    let upstream_result = up
        .join()
        .map_err(|_| anyhow::anyhow!("upstream thread panicked"))
        .and_then(|result| result);
    if upstream_result.is_err() {
        let _ = client_control.shutdown(Shutdown::Both);
        let _ = server_control.shutdown(Shutdown::Both);
    }
    let downstream_result = down
        .join()
        .map_err(|_| anyhow::anyhow!("downstream thread panicked"))
        .and_then(|result| result);
    upstream_result?;
    downstream_result?;
    Ok(())
}

fn main() -> Result<()> {
    let listener = TcpListener::bind(loopback(27_015))?;
    println!("Wesnoth proxy listening on 127.0.0.1:27015 -> 127.0.0.1:15000");
    println!("Connect the official 1.14.9 client to localhost:27015.");
    for connection in listener.incoming() {
        let client = connection?;
        anyhow::ensure!(
            client.peer_addr()?.ip().is_loopback(),
            "client is not loopback"
        );
        if let Err(error) = proxy_pair(client) {
            eprintln!("session ended: {error:#}");
        }
    }
    Ok(())
}
use std::{
io::{self, Read, Write},
net::{IpAddr, Ipv4Addr, Shutdown, SocketAddr, TcpListener, TcpStream},
thread,
time::Duration,
};
use anyhow::{Context, Result};
use gha_windows_labs::wesnoth_protocol::{decode_frame, encode_chat, read_frame};
fn loopback(port: u16) -> SocketAddr {
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port)
}
fn upstream(mut client: TcpStream, mut server: TcpStream) -> Result<()> {
// ⚠️ The negotiation request is the protocol's one unframed client message.
let mut negotiation = [0_u8; 4];
client.read_exact(&mut negotiation)?;
anyhow::ensure!(negotiation == [0, 0, 0, 0], "unexpected negotiation bytes");
server.write_all(&negotiation)?;
server.flush()?;
loop {
let frame = match read_frame(&mut client) {
Ok(frame) => frame,
// βœ… EOF stops this direction before any incomplete frame is forwarded.
Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => break,
Err(error) => return Err(error.into()),
};
let decoded = decode_frame(&frame)?;
// πŸ” Forward the authentic frame even when it does not trigger the lab.
server.write_all(&frame)?;
if decoded.contains("message=\"\\wave\"") {
server.write_all(&encode_chat("ChatBot", "Hello!")?)?;
}
server.flush()?;
}
// 🧹 Signal β€œno more client bytes” while leaving the read half available
// for the downstream task to drain the server's final response.
server.shutdown(Shutdown::Write)?;
Ok(())
}
fn downstream(mut server: TcpStream, mut client: TcpStream) -> Result<()> {
io::copy(&mut server, &mut client)?;
client.shutdown(Shutdown::Write)?;
Ok(())
}
fn proxy_pair(client: TcpStream) -> Result<()> {
let server = TcpStream::connect_timeout(&loopback(15_000), Duration::from_secs(3))
.context("start local wesnothd.exe on port 15000 first")?;
client.set_read_timeout(Some(Duration::from_secs(30)))?;
client.set_write_timeout(Some(Duration::from_secs(5)))?;
server.set_read_timeout(Some(Duration::from_secs(30)))?;
server.set_write_timeout(Some(Duration::from_secs(5)))?;
// πŸ”€ Clones share each socket but each thread owns one traffic direction.
// Only `upstream` ever writes to the game server, preserving frame order.
let client_control = client.try_clone()?;
let server_control = server.try_clone()?;
let upstream_client = client.try_clone()?;
let downstream_server = server.try_clone()?;
let up = thread::spawn(move || upstream(upstream_client, server));
let down = thread::spawn(move || downstream(downstream_server, client));
let upstream_result = up
.join()
.map_err(|_| anyhow::anyhow!("upstream thread panicked"))
.and_then(|result| result);
if upstream_result.is_err() {
let _ = client_control.shutdown(Shutdown::Both);
let _ = server_control.shutdown(Shutdown::Both);
}
let downstream_result = down
.join()
.map_err(|_| anyhow::anyhow!("downstream thread panicked"))
.and_then(|result| result);
upstream_result?;
downstream_result?;
Ok(())
}
fn main() -> Result<()> {
let listener = TcpListener::bind(loopback(27_015))?;
println!("Wesnoth proxy listening on 127.0.0.1:27015 -> 127.0.0.1:15000");
println!("Connect the official 1.14.9 client to localhost:27015.");
for connection in listener.incoming() {
let client = connection?;
anyhow::ensure!(
client.peer_addr()?.ip().is_loopback(),
"client is not loopback"
);
if let Err(error) = proxy_pair(client) {
eprintln!("session ended: {error:#}");
}
}
Ok(())
}

Only the upstream task writes to the server. That single-writer rule prevents the original frame and injected frame from being interleaved. The downstream task stays byte-for-byte transparent, so one experimental hook cannot accidentally rewrite both directions.

The proxy needs TCP because its two endpoints are network clients. When two cooperating programs run on the same computer, they can exchange the same kind of bounded message through shared memory or a named pipe instead. Lessons 9.8 and 9.9 compare those local transports.

Knowledge check

Preserve both byte streams

What makes a local TCP relay transparent?

Finished Lesson 9.7?

Mark it done and the lesson list shows a beside it. A chapter whose lessons are all done is marked done too. Progress is saved in this browser only.

Chapter 9: 10 lessons

This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.