# 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 ```rust 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, 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; /// `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>; fn connect(&self, addr: SocketAddr, timeout: Duration) -> std::io::Result; } /// 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, handshake: Duration — private */ } impl Proxy { pub fn new(allow: Allow, dial: Arc) -> 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, 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 --allow `, 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.