toolkit: the egress proxy

Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
2026-09-23 01:01:13 -07:00
parent e87d876f26
commit 95872d94b8
5 changed files with 801 additions and 1 deletions
+1
View File
@@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone.
| Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model |
|---|---|---|---|---|---|---|---|
| M3b/08-toolkit-egress-proxy | 2026-09-23 | done | 2 | fail | none | Copied `docs/plans/M3b/files/crates/toolkit/tests/egress.rs` to `crates/toolkit/tests/`. Wrote `crates/toolkit/src/egress.rs`: the reply-code and timeout constants; `Allow { patterns: Vec<String> }` with `parse` splitting on ',' and rejecting any piece that fails `proto::hosts::valid_host_pattern` (an empty piece like `","`/`"x,"`/`""` errors) and `permits` = `valid_host(host) && any host_matches`; the `Dial` trait and `SystemDial` (`to_socket_addrs()?.collect()` and `connect_timeout`); `Proxy { allow, dial: Arc<dyn Dial>, handshake }` with `new`/`with_handshake_timeout`/`serve`/`handle`. `handle` shares one deadline across the whole handshake: `read_n` reads exactly the byte count the protocol gives, setting the read timeout to `deadline.checked_duration_since(now)` before each read and returning `None` (stop, no reply) on no time left, a timeout, a failed read, or 0 bytes — so a client trickling one byte per 100 ms is still cut at the deadline. The 11 exits are in order (egress.rs:133 version, :144 methods 0, :154 version/reserved, :160 command, :164 kind without reading the address, :172 zero length, :187 non-UTF-8, :190 port/host, :195 resolve / :203 no public addr, :208 connect, :211 success reply); step 9 takes the first address where `crate::addr::is_public` is true via `.find`, never trying the skipped non-public ones. `serve` is a single-threaded accept loop with an `AtomicUsize` count: over the limit is dropped at once, otherwise a `std::thread::Builder` thread handles it and uncounts itself on return, and `serve` never joins (so the second client is not starved). After the handshake the read timeout is cleared and two `try_clone`'d threads copy both ways with a half-close each way, then join. `main.rs` gained the `egress-proxy --socket <path> --allow <list>` form before the tool form via `std::env::args_os().skip(1)` (without `skip(1)` the first element is the program path and the form never matches); `Allow::parse`/bind (no removal first — a pre-existing file is a mistake)/`serve` errors print to stderr and exit 2. `lib.rs` gained `pub mod egress;`. First gate failed on clippy `question_mark` (accept loop → `stream?`) and `manual_contains` (`methods.contains(&0)`); fixed both and `make gate` prints `gate: ok`. 15 egress tests pass ten runs in a row. | ? |
| M3b/07-toolkit-addr | 2026-09-23 | done | 1 | pass | none | Wrote `crates/toolkit/src/addr.rs`: `is_public(ip)` matches on `IpAddr` and dispatches to `is_public_v4`/`is_public_v6`. The IPv4 function checks the 13 refused ranges in table order with early returns (lines 16-52) then returns `true`. The IPv6 function checks the two "judge as IPv4" rows first — `is_ipv4_mapped` for `::ffff:0:0/96` (lines 60-63) and `is_nat64` for `64:ff9b::/96` (lines 64-67) — reconstructing the last 32 bits as an `Ipv4Addr` via `(u32::from(s[6]) << 16) | u32::from(s[7])` with no `as` casts, then judging it through `is_public_v4`; the remaining rows `::/96` (line 68), `fc00::/7` (71), `fe80::/10` (74), `ff00::/8` (77) and `2001:db8::/32` (80) follow. `cargo fmt --all` first. Added `pub mod addr;` to `lib.rs` in alphabetical position (before `fetch`). All 4 addr tests pass; `make gate` prints `gate: ok` on the first run. | ? |
| M3b/06-toolkit-fetch | 2026-09-23 | done | 3 | fail | none | Wrote `crates/toolkit/src/fetch.rs`: `CURL=/bin/curl`, `PROXY=socks5h://localhost/run/egress/egress.sock`, `CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt`, `MAX_STDERR=65536`. `curl_args(url)` returns the 21 fixed strings in order ending in `--url <url>`. `fetch` calls `fetch_with(Path::new(CURL), args)`. `fetch_with`: spawn curl with `curl_args(&args.url)`, null stdin, stdout/stderr piped (spawn failure -> `"http_fetch: cannot start {}: {e}"`); read stderr on its own thread via the `read_capped` helper (8 KiB buffer, keeps the first 64 KiB and drains/discards the rest so curl never blocks on a full pipe), main thread reads stdout to the end; `wait()` then join the thread (a panicked reader falls back to empty stderr); exit 0 -> `done(from_utf8_lossy(body))` with the `--write-out` status line already in it, else the first non-blank trimmed stderr line as `why` or `"curl exited {code}"` or `"curl was killed"` when there is no code, wrapped as `"http_fetch: {url}: {why}"`, and a wait failure -> `"http_fetch: cannot wait for curl: {e}"`. `lib.rs` gained `pub mod fetch` (between `files` and `input`) and an `"http_fetch"` arm parsing `HttpFetchArgs` like the other three tools. First gate run failed on a rustfmt line-wrap of the spawn-error return; second on clippy `manual_unwrap_or_default` for the stderr-join match, switched to `unwrap_or_default()`. 7 fetch tests pass ten runs; `make gate` prints `gate: ok`. | ? |
| M3b/05-toolkit-shell | 2026-09-23 | done | 2 | fail | none | Wrote `crates/toolkit/src/shell.rs` (`SHELL=/bin/sh`, `DEFAULT_CWD=/tmp`, `MAX_OUTPUT=1048576`). `shell()`: `cwd` defaults to `/tmp`, `Path::is_dir` check returns `"shell: {cwd}: no such directory"`; one `io::pipe` with a `try_clone`'d second write end (stdout gets `writer`, stderr gets `writer2`); `Command::new(SHELL).arg("-c").arg(command).current_dir(cwd)` with null stdin, `spawn`, then `drop(command)` so the parent holds no write end and reading reaches EOF (the hang fix); an 8 KiB-buffer read loop keeping the first `MAX_OUTPUT` bytes and draining+discarding the rest with a `dropped` flag, retrying `Interrupted`, decoding with `from_utf8_lossy`; `wait()` then appending the dropped line and the exit/signal/none status in that order via a `match` on `code()`/`signal()``Outcome::done`. `lib.rs` gained `pub mod shell` (after `input`) and a `"shell"` arm parsing `ShellArgs` like the other two tools. One logic bug caught by the limit test: when the buffer fills `MAX_OUTPUT` exactly the drain branch never set `dropped`, fixed by marking `dropped` in the drain branch. First gate failed on an unused `Write` import (removed); 8 shell tests pass five runs, `make gate` prints `gate: ok`. | ? |