Files
boxmaker/docs/plans/M3b/08-toolkit-egress-proxy.md
T
kyleandClaude Opus 5.5 e87d876f26 M3b plan: task 08 says not to join handler threads and to skip argv[0]
The first attempt at task 08 did both and stopped without a commit. Also asks
the implementer to debug inside the repository, since OpenCode refuses /tmp.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 00:46:23 -07:00

8.8 KiB

M3b task 08: the egress proxy

Branch: m3b (run git switch m3b; git status --short must be empty, otherwise stop) Commit subject: toolkit: the egress proxy

Goal

http_fetch's container has no network. Its only way out is a Unix socket to this proxy, which runs in a second container that does have a network. The proxy speaks the part of SOCKS5 (RFC 1928) that curl --proxy socks5h:// uses, allows only the call's hosts, only port 443, only public addresses (task 07), and after the handshake copies bytes both ways without looking at them (TLS runs end to end between curl and the server). Everything it reads during the handshake comes from the untrusted side: read exactly what the protocol says and nothing more, and never index or allocate by a number you have not checked. Spec section 5.

Files

  • Copy: crates/toolkit/tests/egress.rs
  • Create: crates/toolkit/src/egress.rs
  • Modify: crates/toolkit/src/lib.rs (pub mod egress;), crates/toolkit/src/main.rs, docs/implementer-log.md

Interfaces

pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
pub const MAX_CONNECTIONS: usize = 8;
// SOCKS5 reply codes the proxy sends:
pub const NOT_ALLOWED: u8 = 2;
pub const HOST_UNREACHABLE: u8 = 4;
pub const CONNECTION_REFUSED: u8 = 5;
pub const COMMAND_NOT_SUPPORTED: u8 = 7;
pub const ADDRESS_TYPE_NOT_SUPPORTED: u8 = 8;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Allow { /* patterns: Vec<String>, private */ }
impl Allow {
    /// One or more host patterns separated by ','. Every piece must pass
    /// `proto::hosts::valid_host_pattern`; an empty piece (",", "a.com,", "") is an error.
    pub fn parse(list: &str) -> Result<Allow, String>;
    /// `valid_host(host)` and some pattern `host_matches` it.
    pub fn permits(&self, host: &str) -> bool;
}

/// Name resolution and connecting, so tests need no network.
pub trait Dial: Send + Sync {
    fn resolve(&self, host: &str, port: u16) -> std::io::Result<Vec<SocketAddr>>;
    fn connect(&self, addr: SocketAddr, timeout: Duration) -> std::io::Result<TcpStream>;
}
/// The system's: `(host, port).to_socket_addrs()` and `TcpStream::connect_timeout`.
pub struct SystemDial;
impl Dial for SystemDial {  }

pub struct Proxy { /* allow: Allow, dial: Arc<dyn Dial>, handshake: Duration — private */ }
impl Proxy {
    pub fn new(allow: Allow, dial: Arc<dyn Dial>) -> Proxy;            // handshake = HANDSHAKE_TIMEOUT
    pub fn with_handshake_timeout(self, handshake: Duration) -> Proxy; // for tests
    /// Accept for ever; at most MAX_CONNECTIONS handled at once, more are closed at once.
    pub fn serve(self: Arc<Self>, listener: UnixListener) -> std::io::Result<()>;
    /// One connection, from greeting to the end of the copy.
    pub fn handle(&self, client: UnixStream);
}

handle: the handshake, every exit

The whole handshake shares one deadline: now + handshake. Before every read, set the read timeout to the time left (deadline.checked_duration_since(Instant::now())); if none is left, or a read times out, fails, or reads 0 bytes (the peer closed), stop: return without a reply. A client that sends one byte every 100 ms must still be cut off at the deadline (a test does exactly that). Read with a loop that fills a buffer of the exact length the protocol gives, never more.

"Refuse with code" means: write [5, code, 0, 1, 0, 0, 0, 0, 0, 0] (ignore a write error) and return, which closes the connection.

  1. Read 2 bytes: version, count. version != 5 → return, no reply.
  2. Read count bytes of methods. If none of them is 0: write [5, 0xff], return. Else write [5, 0].
  3. Read 4 bytes: version, command, reserved, kind. version != 5 or reserved != 0 → return, no reply.
  4. command != 1 → refuse with COMMAND_NOT_SUPPORTED.
  5. kind != 3 (not a domain name; 1 is IPv4, 4 is IPv6) → refuse with ADDRESS_TYPE_NOT_SUPPORTED. Do not read the address.
  6. Read 1 byte: length. length == 0 → refuse with NOT_ALLOWED.
  7. Read length bytes of name, then 2 bytes of port (u16::from_be_bytes).
  8. The name is not UTF-8, or port != 443, or !allow.permits(&name) → refuse with NOT_ALLOWED.
  9. dial.resolve(&name, port). An error → refuse with HOST_UNREACHABLE. Take the first address for which crate::addr::is_public(addr.ip()) is true; none → refuse with HOST_UNREACHABLE. Non-public addresses are skipped, never tried.
  10. dial.connect(addr, CONNECT_TIMEOUT). An error → refuse with CONNECTION_REFUSED.
  11. Write the success reply [5, 0, 0, 1, 0, 0, 0, 0, 0, 0]; if that write fails, return.

After the handshake: the copy

Clear the client's read timeout (set_read_timeout(None)). Copy both ways until both directions are done, passing a half-close on:

  • A second thread copies client → server (std::io::copy), then shutdown(Shutdown::Write) on the server side.
  • The handler's own thread copies server → client, then shutdown(Shutdown::Write) on the client side, then joins the second thread.

Use try_clone for the second handle of each stream. If a try_clone fails, return.

serve

For each accepted stream: if the number of connections being handled is already MAX_CONNECTIONS, drop the stream at once (the client reads the end). Otherwise count it, handle it on its own thread (std::thread::Builder, not spawn, which panics; if the thread cannot be started, uncount it), and uncount it inside that thread when handle returns. An accept error ends serve with that error. Use an AtomicUsize for the count.

Never wait for (join) a handler thread in serve. Start it, drop its JoinHandle, and go straight back to accept. Joining makes the proxy serve one connection at a time: the second client never gets its greeting, and more_than_the_limit_of_connections_are_closed_at_once fails with a read timeout (WouldBlock). (A first attempt at this task did exactly that.)

main.rs

Add one form before the tool form: the arguments after the program name are exactly egress-proxy --socket <path> --allow <list>, in that order, all UTF-8. Take them with std::env::args_os().skip(1): without skip(1), the first element is the program's own path, the form never matches, and the proxy never listens (the given test then fails with "the proxy never listened", and standard error shows toolkit: unknown tool ""; a first attempt did exactly that). Then:

  1. Allow::parse(list); an error e → print toolkit: egress-proxy: --allow: {e} to standard error, exit 2.
  2. UnixListener::bind(path). Do not remove anything first: the directory is fresh for each call, so a file already there is a mistake. An error → print toolkit: egress-proxy: cannot listen on {path}: {e}, exit 2.
  3. Arc::new(Proxy::new(allow, Arc::new(SystemDial))).serve(listener). If it returns an error → print toolkit: egress-proxy: {e}, exit 2.

Any other argument list that starts with egress-proxy is not this form, and goes to the tool form as before, which answers it as an unknown tool (exit 2).

About the given tests

egress.rs handles one connection per test over UnixStream::pair(), with a fake Dial whose names resolve from a table and whose connections all go to a local echo server. A proxy that closes with some of the client's bytes unread makes the client's next read fail with "connection reset" rather than read the end; the tests treat both as closed. That is normal and needs nothing from you.

Debugging

Work only inside the repository. Do not use /tmp or other directories outside it: OpenCode refuses them, and a refused command ends nothing but wastes the turn. To try the program by hand, put the socket under target/, for example target/debug/toolkit egress-proxy --socket target/egress-try.sock --allow example.com.

Steps

  • 1. Copy. git switch m3b, then cp docs/plans/M3b/files/crates/toolkit/tests/egress.rs crates/toolkit/tests/
  • 2. See it fail. cargo test -p toolkit --test egress. Expected: it does not compile.
  • 3. Write egress.rs and the lib.rs and main.rs changes. Run cargo fmt --all.
  • 4. See it pass. cargo test -p toolkit --test egress. Expected: 15 passed. Run it ten times; it must pass every time.
  • 5. Walk the exits. Point at the line of your code for each of the 11 handshake steps.
  • 6. Run the gate. make gate. Expected last line: gate: ok.
  • 7. Log and commit. git add crates/toolkit docs/implementer-log.md Cargo.lock && git commit

Done when

  • cargo test -p toolkit --test egress reports 15 passed ten times running; make gate prints gate: ok.

Stop and report if

  • A test wants the proxy to try a non-public address, or to answer an IP-literal request with anything but ADDRESS_TYPE_NOT_SUPPORTED.
  • A test hangs.