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>
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.
- Read 2 bytes:
version,count.version != 5→ return, no reply. - Read
countbytes of methods. If none of them is 0: write[5, 0xff], return. Else write[5, 0]. - Read 4 bytes:
version,command,reserved,kind.version != 5orreserved != 0→ return, no reply. command != 1→ refuse withCOMMAND_NOT_SUPPORTED.kind != 3(not a domain name; 1 is IPv4, 4 is IPv6) → refuse withADDRESS_TYPE_NOT_SUPPORTED. Do not read the address.- Read 1 byte:
length.length == 0→ refuse withNOT_ALLOWED. - Read
lengthbytes of name, then 2 bytes of port (u16::from_be_bytes). - The name is not UTF-8, or
port != 443, or!allow.permits(&name)→ refuse withNOT_ALLOWED. dial.resolve(&name, port). An error → refuse withHOST_UNREACHABLE. Take the first address for whichcrate::addr::is_public(addr.ip())is true; none → refuse withHOST_UNREACHABLE. Non-public addresses are skipped, never tried.dial.connect(addr, CONNECT_TIMEOUT). An error → refuse withCONNECTION_REFUSED.- 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), thenshutdown(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:
Allow::parse(list); an errore→ printtoolkit: egress-proxy: --allow: {e}to standard error, exit 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 → printtoolkit: egress-proxy: cannot listen on {path}: {e}, exit 2.Arc::new(Proxy::new(allow, Arc::new(SystemDial))).serve(listener). If it returns an error → printtoolkit: 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, thencp 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.rsand thelib.rsandmain.rschanges. Runcargo 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 egressreports 15 passed ten times running;make gateprintsgate: 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.