A draft spec for the owner's review and 13 offline tasks with their given tests: shared tool arguments and host rules in proto, the sealed fetch target (M3a finding 14), the toolkit tools and SOCKS5 egress proxy, and brokerd's [runner], podman argument lists, runtime and proxy lifecycle. Each task's tests were run against a reference at that task's end state (560 to 638 tests, clippy clean); the reference is not in the repository. Adds the runner-unavailable runbook entry and tip T23 (ETXTBSY in script tests). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
161 lines
7.8 KiB
Markdown
161 lines
7.8 KiB
Markdown
# 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<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 when `handle` returns. An `accept` error ends `serve` with
|
|
that error. Use an `AtomicUsize` for the count.
|
|
|
|
## `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. 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.
|
|
|
|
## 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 && 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.
|