Skip to main content

agent_bridle_tool_shell/
net_proxy.rs

1//! Loopback egress proxy for the macOS `net` host allow-list (#124, ADR 0016).
2//!
3//! SBPL cannot name a remote host (ADR 0015), so a general `net: Only([host, …])`
4//! grant cannot be kernel-confined by hostname. The honest mechanism: kernel-fence
5//! a spawned child's egress to the **loopback interface** (the ADR 0015 rule, via
6//! [`agent_bridle_core::loopback_fenced_caveats`]), then run **this** loopback
7//! forward proxy — which the child is pointed at through `*_PROXY` env — to enforce
8//! the hostname allow-list. The child can reach *nothing* off-box except through
9//! the proxy, and the proxy admits only allow-listed hosts.
10//!
11//! **Grade (honesty):** the loopback fence is kernel-grade and unbypassable; the
12//! hostname match is **userspace** (this parent-side process). So the `net` axis
13//! stays reported `Advisory` — the proxy *over-delivers* above that floor (the
14//! `report.rs` doctrine), it does not raise the honest kernel claim.
15//!
16//! **Scope:** HTTP `CONNECT` (HTTPS tunnelling — the proxy never terminates TLS)
17//! and `http://` absolute-form forwarding. Non-proxy-aware traffic (raw sockets,
18//! tools ignoring `*_PROXY`) is kernel-fenced to loopback and therefore blocked
19//! off-box — fail-closed, the safe direction.
20//!
21//! Std-only (`std::net` + `std::thread`); no async runtime, no new dependency —
22//! so [`ProxyHandle`] is a plain RAII value whose `Drop` tears the listener down.
23
24use std::collections::HashSet;
25use std::io::{self, BufRead, BufReader, Read, Write};
26use std::net::{Shutdown, SocketAddr, TcpListener, TcpStream, ToSocketAddrs};
27use std::sync::atomic::{AtomicBool, Ordering};
28use std::sync::{Arc, Mutex};
29use std::thread::{self, JoinHandle};
30use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
31
32use serde::{Deserialize, Serialize};
33
34/// Longest request line / header block the proxy will buffer before giving up
35/// (a request line is short; this only bounds a hostile client). 8 KiB.
36const MAX_HEAD: usize = 8 * 1024;
37/// Per-connection socket timeout, so a stuck peer cannot pin a proxy thread.
38const CONN_TIMEOUT: Duration = Duration::from_secs(30);
39
40// ── Audit (#124, ADR 0016): the proxy is the child's sole egress chokepoint, so
41// every proxy-visible connection is recorded through an operator-supplied sink.
42// This is observability only — it never changes an enforcement decision. ────────
43
44/// The kind of egress a child requested through the proxy.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum NetKind {
48    /// `CONNECT host:port` — an opaque (HTTPS) tunnel.
49    Connect,
50    /// `http://…` plaintext forward.
51    Http,
52}
53
54/// The proxy's allow-list decision for one connection.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum NetDecision {
58    /// Host on the allow-list — connection made.
59    Allowed,
60    /// Host **not** on the allow-list — refused with 403 (the exfil-attempt signal).
61    Denied,
62    /// Allow-listed but the origin could not be reached (DNS/connect failure).
63    Error,
64}
65
66/// One audited egress connection through the proxy — a complete record of the
67/// child's proxy-visible network activity (#124, ADR 0016). Serialised as one
68/// JSON line by [`JsonlSink`]; the `bridle-netmon` binary renders a live view.
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct NetAuditEvent {
71    /// Unix-epoch milliseconds when the connection was decided.
72    pub ts_ms: u64,
73    /// The requested host (CONNECT authority or `http://` URI host).
74    pub host: String,
75    /// The requested port.
76    pub port: u16,
77    /// Tunnel (`connect`) or plaintext forward (`http`).
78    pub kind: NetKind,
79    /// Allow-list outcome.
80    pub decision: NetDecision,
81    /// Bytes the child sent (client → origin); `0` for a denied/errored conn.
82    pub bytes_up: u64,
83    /// Bytes the origin returned (origin → child).
84    pub bytes_down: u64,
85    /// Connection lifetime in milliseconds.
86    pub dur_ms: u64,
87}
88
89/// A destination for [`NetAuditEvent`]s — the operator's audit trail. `record` is
90/// called from a per-connection thread, so implementations must be thread-safe
91/// and must never block the connection for long.
92pub trait AuditSink: Send + Sync {
93    /// Record one completed connection.
94    fn record(&self, event: &NetAuditEvent);
95}
96
97/// The default sink — discard everything (audit off, zero overhead).
98pub struct NullSink;
99
100impl AuditSink for NullSink {
101    fn record(&self, _event: &NetAuditEvent) {}
102}
103
104/// Append each event as one JSON line to a `Write` (a file, stderr, a pipe).
105pub struct JsonlSink<W: Write + Send>(Mutex<W>);
106
107impl<W: Write + Send> JsonlSink<W> {
108    /// Wrap a writer as a JSON-lines audit sink.
109    pub fn new(w: W) -> Self {
110        Self(Mutex::new(w))
111    }
112}
113
114impl<W: Write + Send> AuditSink for JsonlSink<W> {
115    fn record(&self, event: &NetAuditEvent) {
116        if let (Ok(mut w), Ok(mut line)) = (self.0.lock(), serde_json::to_string(event)) {
117            line.push('\n');
118            let _ = w.write_all(line.as_bytes());
119            let _ = w.flush();
120        }
121    }
122}
123
124/// Unix-epoch milliseconds now (saturating to 0 before the epoch — never panics).
125fn now_ms() -> u64 {
126    SystemTime::now()
127        .duration_since(UNIX_EPOCH)
128        .map(|d| d.as_millis() as u64)
129        .unwrap_or(0)
130}
131
132/// Resolves a proxied hostname to the address the proxy dials. A seam so a test
133/// can map an allow-listed name to a loopback origin (hermetic, no real DNS).
134pub trait Resolver: Send + Sync {
135    /// Resolve `host:port` to a single dial target, or an error if it cannot.
136    fn resolve(&self, host: &str, port: u16) -> io::Result<SocketAddr>;
137}
138
139/// The production resolver — the platform's own `getaddrinfo`, run in the parent
140/// (never the fenced child), taking the first address.
141pub struct StdResolver;
142
143impl Resolver for StdResolver {
144    fn resolve(&self, host: &str, port: u16) -> io::Result<SocketAddr> {
145        (host, port)
146            .to_socket_addrs()?
147            .next()
148            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no address for host"))
149    }
150}
151
152// ── I/O seams (#166): the per-connection logic is written against `Conn` +
153// `Connector` traits, not concrete `TcpStream`, so the parse / allow-list /
154// forward / tunnel / audit LOGIC can be unit-tested against an in-memory duplex
155// with NO real socket (deterministic, portable). Production wires the real
156// `TcpStream` / `TcpConnector`; the tests wire scripted in-memory endpoints. ───
157
158/// A bidirectional connection the proxy speaks over. Abstracts the three
159/// `TcpStream`-specific operations the forward/tunnel path needs beyond
160/// `Read`/`Write` — a second owned handle ([`Self::dup`], for the read/write
161/// split and the origin clone), directional [`Self::shutdown`] (how the copy
162/// threads signal EOF), and the per-connection timeouts ([`Self::set_timeouts`]).
163/// `TcpStream` implements it for production; an in-memory scripted stream
164/// implements it for tests.
165pub trait Conn: Read + Write + Send {
166    /// A second owned handle to the SAME underlying stream — for `TcpStream`
167    /// this is `try_clone` (both handles share one socket); the proxy holds one
168    /// half for reading and one for writing, and `splice` needs a third for the
169    /// origin write direction.
170    fn dup(&self) -> io::Result<Box<dyn Conn>>;
171    /// Shut down the read half, write half, or both — how a copy thread signals
172    /// EOF to its peer and how a response closes the client.
173    fn shutdown(&self, how: Shutdown) -> io::Result<()>;
174    /// Apply the per-connection read+write timeouts. A no-op for in-memory
175    /// fakes, which never block on a real socket.
176    fn set_timeouts(&self, dur: Duration) -> io::Result<()>;
177}
178
179impl Conn for TcpStream {
180    fn dup(&self) -> io::Result<Box<dyn Conn>> {
181        Ok(Box::new(self.try_clone()?))
182    }
183    fn shutdown(&self, how: Shutdown) -> io::Result<()> {
184        TcpStream::shutdown(self, how)
185    }
186    fn set_timeouts(&self, dur: Duration) -> io::Result<()> {
187        self.set_read_timeout(Some(dur))?;
188        self.set_write_timeout(Some(dur))?;
189        Ok(())
190    }
191}
192
193/// Dials a resolved origin, returning the connection to forward to. A seam so a
194/// test can hand the proxy a scripted in-memory origin instead of a real TCP
195/// dial — the second half (with [`Conn`]) of what makes the forward path
196/// testable without a socket.
197pub trait Connector: Send + Sync {
198    /// Connect to `addr`, or fail (surfaced to the client as `502 Bad Gateway`).
199    fn connect(&self, addr: SocketAddr) -> io::Result<Box<dyn Conn>>;
200}
201
202/// The production connector — a real bounded TCP dial to the resolved origin.
203pub struct TcpConnector;
204
205impl Connector for TcpConnector {
206    fn connect(&self, addr: SocketAddr) -> io::Result<Box<dyn Conn>> {
207        let s = TcpStream::connect_timeout(&addr, CONN_TIMEOUT)?;
208        s.set_read_timeout(Some(CONN_TIMEOUT))?;
209        s.set_write_timeout(Some(CONN_TIMEOUT))?;
210        Ok(Box::new(s))
211    }
212}
213
214/// What a client's first request line asked for.
215#[derive(Debug, PartialEq, Eq)]
216enum Target {
217    /// `CONNECT host:port HTTP/x` — a TLS tunnel to open.
218    Connect { host: String, port: u16 },
219    /// `METHOD http://host[:port]/path HTTP/x` — a plaintext request to forward;
220    /// `origin_line` is the same line rewritten to origin-form (`METHOD /path …`).
221    Http {
222        host: String,
223        port: u16,
224        origin_line: String,
225    },
226}
227
228/// Parse a proxy request line. Returns `None` on anything malformed or a scheme
229/// the proxy does not speak (only `CONNECT` and `http://` absolute-form).
230fn parse_request_line(line: &str) -> Option<Target> {
231    let line = line.trim_end_matches(['\r', '\n']);
232    let mut parts = line.split(' ');
233    let method = parts.next()?;
234    let uri = parts.next()?;
235    let version = parts.next()?;
236    if !version.starts_with("HTTP/") || parts.next().is_some() {
237        return None;
238    }
239    if method.eq_ignore_ascii_case("CONNECT") {
240        let (host, port) = split_host_port(uri, 443)?;
241        return Some(Target::Connect { host, port });
242    }
243    // Absolute-form: METHOD http://host[:port]/path HTTP/x  (proxy requests only).
244    let rest = uri.strip_prefix("http://")?;
245    let (authority, path) = match rest.find('/') {
246        Some(i) => (&rest[..i], &rest[i..]),
247        None => (rest, "/"),
248    };
249    let (host, port) = split_host_port(authority, 80)?;
250    Some(Target::Http {
251        host,
252        port,
253        origin_line: format!("{method} {path} {version}\r\n"),
254    })
255}
256
257/// Split `host:port` (or a bare host, or a bracketed IPv6 literal) into
258/// `(host, port)`, defaulting the port. Returns `None` on an unparsable port.
259fn split_host_port(authority: &str, default_port: u16) -> Option<(String, u16)> {
260    // Bracketed IPv6 literal: [::1] or [::1]:8080
261    if let Some(rest) = authority.strip_prefix('[') {
262        let (host, after) = rest.split_once(']')?;
263        let port = match after.strip_prefix(':') {
264            Some(p) => p.parse().ok()?,
265            None if after.is_empty() => default_port,
266            None => return None,
267        };
268        return Some((host.to_string(), port));
269    }
270    match authority.rsplit_once(':') {
271        // Host part still holds a ':' → an unbracketed IPv6 literal ("::1"); take
272        // the whole authority as the host at the default port (fail-safe: a weird
273        // string just misses the exact-name allow-list).
274        Some((h, _)) if h.contains(':') => Some((authority.to_string(), default_port)),
275        // ":443" — no host.
276        Some(("", _)) => None,
277        // A single ':' → host:port; a non-numeric port is malformed (reject).
278        Some((h, p)) => Some((h.to_string(), p.parse::<u16>().ok()?)),
279        // No ':' → a bare host at the default port.
280        None => Some((authority.to_string(), default_port)),
281    }
282}
283
284/// The exact-hostname allow-list, mirroring `ToolContext::check_net`'s membership.
285#[derive(Clone)]
286struct HostPolicy(Arc<HashSet<String>>);
287
288impl HostPolicy {
289    fn new(hosts: impl IntoIterator<Item = String>) -> Self {
290        Self(Arc::new(hosts.into_iter().collect()))
291    }
292    fn allows(&self, host: &str) -> bool {
293        self.0.contains(host)
294    }
295}
296
297/// A running loopback egress proxy. Dropping the handle shuts it down.
298pub struct ProxyHandle {
299    addr: SocketAddr,
300    shutdown: Arc<AtomicBool>,
301    accept: Option<JoinHandle<()>>,
302    /// #196: out-of-allow-list hosts the child tried to reach — refused with 403.
303    /// Accumulated across all connections (independent of the opt-in audit sink)
304    /// so the shell tool can surface them as structured `net` denials.
305    refused: Arc<Mutex<HashSet<String>>>,
306}
307
308impl ProxyHandle {
309    /// The loopback address the fenced child is pointed at. Only tests read it
310    /// directly; production wires the child via [`Self::proxy_env`].
311    #[cfg(test)]
312    pub fn addr(&self) -> SocketAddr {
313        self.addr
314    }
315
316    /// #196: the CONNECT hosts this proxy REFUSED (not on the allow-list),
317    /// deduplicated and sorted. The shell tool reads this after the child is
318    /// reaped and turns each into a `Denial { kind: Net, target: host }` so a
319    /// consumer can prompt per-host. Empty when the child only reached
320    /// allow-listed hosts (or none).
321    #[must_use]
322    pub fn refused_hosts(&self) -> Vec<String> {
323        self.refused
324            .lock()
325            .map(|s| {
326                let mut v: Vec<String> = s.iter().cloned().collect();
327                v.sort();
328                v
329            })
330            .unwrap_or_default()
331    }
332
333    /// The `*_PROXY` environment the child needs to route through this proxy.
334    /// Both cases are set: curl honours lowercase `http_proxy` (it ignores the
335    /// uppercase form for CGI-safety) but uppercase `HTTPS_PROXY`/`ALL_PROXY`;
336    /// other tools (wget, requests, node) read the rest.
337    #[must_use]
338    pub fn proxy_env(&self) -> Vec<(String, String)> {
339        let url = format!("http://{}", self.addr);
340        [
341            "http_proxy",
342            "https_proxy",
343            "all_proxy",
344            "HTTP_PROXY",
345            "HTTPS_PROXY",
346            "ALL_PROXY",
347        ]
348        .iter()
349        .map(|k| ((*k).to_string(), url.clone()))
350        .collect()
351    }
352}
353
354impl Drop for ProxyHandle {
355    fn drop(&mut self) {
356        self.shutdown.store(true, Ordering::SeqCst);
357        // Wake the blocking `accept()` so the loop observes the flag and exits.
358        let _ = TcpStream::connect_timeout(&self.addr, Duration::from_millis(200));
359        if let Some(h) = self.accept.take() {
360            let _ = h.join();
361        }
362    }
363}
364
365/// Start a loopback forward proxy that admits only `allow_hosts`, resolving via
366/// `resolver` and auditing every connection through `sink` ([`NullSink`] for no
367/// audit). Binds `127.0.0.1:0` (an ephemeral port — concurrent runs never
368/// collide) and serves until the returned [`ProxyHandle`] is dropped.
369///
370/// Fail-closed: an error binding the listener is returned so the caller refuses
371/// the run rather than spawning an unfenced child.
372pub fn start(
373    allow_hosts: impl IntoIterator<Item = String>,
374    resolver: Arc<dyn Resolver>,
375    sink: Arc<dyn AuditSink>,
376) -> io::Result<ProxyHandle> {
377    let listener = TcpListener::bind(("127.0.0.1", 0))?;
378    let addr = listener.local_addr()?;
379    let shutdown = Arc::new(AtomicBool::new(false));
380    let policy = HostPolicy::new(allow_hosts);
381    // The production origin dialer — a real TCP connect. Tests bypass `start`
382    // entirely and drive `handle_conn` with a scripted in-memory connector.
383    let connector: Arc<dyn Connector> = Arc::new(TcpConnector);
384    // #196: shared refused-host accumulator, populated by each connection thread.
385    let refused: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
386
387    let accept = {
388        let shutdown = Arc::clone(&shutdown);
389        let refused = Arc::clone(&refused);
390        thread::Builder::new()
391            .name("agent-bridle-egress-proxy".to_string())
392            .spawn(move || {
393                for stream in listener.incoming() {
394                    if shutdown.load(Ordering::SeqCst) {
395                        break;
396                    }
397                    let Ok(client) = stream else { continue };
398                    let policy = policy.clone();
399                    let resolver = Arc::clone(&resolver);
400                    let connector = Arc::clone(&connector);
401                    let sink = Arc::clone(&sink);
402                    let refused = Arc::clone(&refused);
403                    // One detached thread per connection; it ends at EOF.
404                    let _ = thread::Builder::new()
405                        .name("agent-bridle-egress-conn".to_string())
406                        .spawn(move || {
407                            let _ = handle_conn(
408                                Box::new(client),
409                                &policy,
410                                connector.as_ref(),
411                                resolver.as_ref(),
412                                sink.as_ref(),
413                                &refused,
414                            );
415                        });
416                }
417            })?
418    };
419
420    Ok(ProxyHandle {
421        addr,
422        shutdown,
423        accept: Some(accept),
424        refused,
425    })
426}
427
428/// Serve one client connection: parse its request line, enforce the allow-list,
429/// and either tunnel (`CONNECT`) or forward (`http://`) to the resolved origin.
430/// Every connection with a parsed host is recorded through `sink`.
431fn handle_conn(
432    client: Box<dyn Conn>,
433    policy: &HostPolicy,
434    connector: &dyn Connector,
435    resolver: &dyn Resolver,
436    sink: &dyn AuditSink,
437    refused: &Mutex<HashSet<String>>,
438) -> io::Result<()> {
439    client.set_timeouts(CONN_TIMEOUT)?;
440    let mut reader = BufReader::new(client.dup()?);
441    let t0 = Instant::now();
442
443    let line = read_line_bounded(&mut reader)?;
444    let Some(target) = parse_request_line(&line) else {
445        // No host to attribute — a malformed request is not an egress event.
446        return respond(client.as_ref(), 400, "Bad Request");
447    };
448
449    let (host, port, kind) = match &target {
450        Target::Connect { host, port } => (host.clone(), *port, NetKind::Connect),
451        Target::Http { host, port, .. } => (host.clone(), *port, NetKind::Http),
452    };
453    // Emit the audit record once, whatever the outcome.
454    let audit = |decision: NetDecision, up: u64, down: u64| {
455        sink.record(&NetAuditEvent {
456            ts_ms: now_ms(),
457            host: host.clone(),
458            port,
459            kind,
460            decision,
461            bytes_up: up,
462            bytes_down: down,
463            dur_ms: t0.elapsed().as_millis() as u64,
464        });
465    };
466
467    if !policy.allows(&host) {
468        audit(NetDecision::Denied, 0, 0); // the exfil-attempt signal
469                                          // #196: record the refused host so the shell tool can surface it as a
470                                          // structured `net` denial (the audit sink is opt-in; this is always on).
471        if let Ok(mut set) = refused.lock() {
472            set.insert(host.clone());
473        }
474        return respond(client.as_ref(), 403, "Forbidden");
475    }
476
477    match target {
478        Target::Connect { host, port } => {
479            // CONNECT: drain the remaining request headers (up to the blank line)
480            // before the tunnel begins — the client waits for our 200 first.
481            drain_headers(&mut reader)?;
482            let origin = match resolver
483                .resolve(&host, port)
484                .and_then(guard_target)
485                .and_then(|addr| connector.connect(addr))
486            {
487                Ok(o) => o,
488                Err(_) => {
489                    audit(NetDecision::Error, 0, 0);
490                    return respond(client.as_ref(), 502, "Bad Gateway");
491                }
492            };
493            // A CONNECT success is a *bare* status line — no body, no
494            // `Content-Length` — after which the socket is an opaque tunnel. (Do
495            // NOT use `respond`, which appends a body that would corrupt it.)
496            let mut client = client;
497            client.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")?;
498            // Forward via `splice_buffered` (not a raw `tunnel`) so any bytes the
499            // client already pipelined past the CONNECT header block — buffered in
500            // `reader` — reach the origin (a TLS ClientHello sent with the CONNECT;
501            // #138). The up-copy reads the BufReader, which drains its buffer first.
502            let (up, down) = splice_buffered(reader, client, origin)?;
503            audit(NetDecision::Allowed, up, down);
504            Ok(())
505        }
506        Target::Http {
507            host,
508            port,
509            origin_line,
510        } => {
511            // Read the client's request headers and **drop any client-supplied
512            // Host** — an attacker could send `Host: disallowed.com` to domain-front
513            // off a shared/CDN origin at the allow-listed IP. Substitute the proxy's
514            // own Host, derived from the *validated* authority, so the origin only
515            // ever sees the host the allow-list approved.
516            let headers = read_headers(&mut reader)?;
517            let mut origin = match resolver
518                .resolve(&host, port)
519                .and_then(guard_target)
520                .and_then(|addr| connector.connect(addr))
521            {
522                Ok(o) => o,
523                Err(_) => {
524                    audit(NetDecision::Error, 0, 0);
525                    return respond(client.as_ref(), 502, "Bad Gateway");
526                }
527            };
528            let host_hdr = if port == 80 {
529                format!("Host: {host}\r\n")
530            } else {
531                format!("Host: {host}:{port}\r\n")
532            };
533            origin.write_all(origin_line.as_bytes())?;
534            origin.write_all(host_hdr.as_bytes())?;
535            for h in &headers {
536                if !h.get(..5).is_some_and(|p| p.eq_ignore_ascii_case("host:")) {
537                    origin.write_all(h.as_bytes())?;
538                }
539            }
540            origin.write_all(b"\r\n")?; // end of the (rewritten) header block
541            let (up, down) = splice_buffered(reader, client, origin)?; // forward the body
542            audit(NetDecision::Allowed, up, down);
543            Ok(())
544        }
545    }
546}
547
548/// Read one CRLF-terminated line, bounded to [`MAX_HEAD`]. Generic over the
549/// buffered reader's source so the same logic drives a real socket or an
550/// in-memory test stream.
551fn read_line_bounded<R: Read>(reader: &mut BufReader<R>) -> io::Result<String> {
552    let mut buf = Vec::new();
553    reader.take(MAX_HEAD as u64).read_until(b'\n', &mut buf)?;
554    Ok(String::from_utf8_lossy(&buf).into_owned())
555}
556
557/// Read request header lines up to (not including) the terminating blank line,
558/// bounded by [`MAX_HEAD`]. Each returned line keeps its trailing CRLF.
559fn read_headers<R: Read>(reader: &mut BufReader<R>) -> io::Result<Vec<String>> {
560    let mut lines = Vec::new();
561    let mut total = 0usize;
562    loop {
563        let line = read_line_bounded(reader)?;
564        total += line.len();
565        if line == "\r\n" || line == "\n" || line.is_empty() || total > MAX_HEAD {
566            return Ok(lines);
567        }
568        lines.push(line);
569    }
570}
571
572/// Consume request headers up to and including the terminating blank line.
573fn drain_headers<R: Read>(reader: &mut BufReader<R>) -> io::Result<()> {
574    let mut total = 0usize;
575    loop {
576        let line = read_line_bounded(reader)?;
577        total += line.len();
578        if line == "\r\n" || line == "\n" || line.is_empty() || total > MAX_HEAD {
579            return Ok(());
580        }
581    }
582}
583
584/// Write a minimal HTTP/1.1 status response and close.
585fn respond(client: &dyn Conn, code: u16, reason: &str) -> io::Result<()> {
586    let mut c = client.dup()?;
587    let body = format!("{code} {reason}\n");
588    write!(
589        c,
590        "HTTP/1.1 {code} {reason}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
591        body.len()
592    )?;
593    let _ = c.flush();
594    let _ = client.shutdown(Shutdown::Both);
595    Ok(())
596}
597
598/// Copy `from` → `to`, returning the bytes **forwarded** — preserved even if a
599/// stream errors mid-copy (a reset after data still counts what flowed), so the
600/// audit totals are not silently zeroed by an abrupt close (`std::io::copy`
601/// discards its count on error). Stops at EOF, a write failure, or a read error.
602fn copy_counted(from: &mut impl Read, to: &mut impl Write) -> u64 {
603    let mut buf = [0u8; 16 * 1024];
604    let mut total = 0u64;
605    loop {
606        match from.read(&mut buf) {
607            Ok(0) => break,
608            Ok(n) => {
609                if to.write_all(&buf[..n]).is_err() {
610                    break;
611                }
612                total += n as u64;
613            }
614            Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
615            Err(_) => break,
616        }
617    }
618    total
619}
620
621/// Bidirectional raw byte tunnel between `client` and `origin` (the `CONNECT`
622/// case): two copy threads, each shutting its write half at EOF. Returns
623/// `(bytes_up, bytes_down)` — child→origin and origin→child — for the audit.
624/// SSRF-pivot guard (#138): refuse to dial a resolved origin whose IP is on an
625/// **internal** range — RFC1918 private, `169.254/16` link-local (incl. the cloud
626/// metadata endpoint `169.254.169.254`), `100.64/10` CGNAT, and IPv6 ULA/link-local.
627/// The egress proxy fronts a *loopback-fenced* child; without this an allow-listed
628/// name that resolves (or is rebound) to an internal address would give that child
629/// a parent-mediated path to endpoints its own kernel fence forbids. Loopback is
630/// **allowed** (the fenced child can already reach loopback directly, and the test
631/// origins live there) and global addresses are allowed.
632///
633/// Default-on and unconditional today; a `NetPolicy` opt-out is future work (I13,
634/// #152). Returns the address unchanged when permitted, else `PermissionDenied`.
635fn guard_target(addr: SocketAddr) -> io::Result<SocketAddr> {
636    if is_internal_ip(&addr.ip()) {
637        return Err(io::Error::new(
638            io::ErrorKind::PermissionDenied,
639            "SSRF-guard: refusing to proxy to an internal (non-loopback) address",
640        ));
641    }
642    Ok(addr)
643}
644
645/// `true` if `ip` is on a private/internal range the egress proxy must not pivot
646/// to (see [`guard_target`]). Loopback and global addresses return `false`.
647fn is_internal_ip(ip: &std::net::IpAddr) -> bool {
648    match ip {
649        std::net::IpAddr::V4(v4) => {
650            let o = v4.octets();
651            v4.is_private()
652                || v4.is_link_local()
653                || v4.is_unspecified()
654                || v4.is_broadcast()
655                // 100.64.0.0/10 (CGNAT) — not covered by the std predicates.
656                || (o[0] == 100 && (64..=127).contains(&o[1]))
657        }
658        std::net::IpAddr::V6(v6) => {
659            let seg0 = v6.segments()[0];
660            v6.is_unspecified()
661                || (seg0 & 0xfe00) == 0xfc00 // fc00::/7 unique-local
662                || (seg0 & 0xffc0) == 0xfe80 // fe80::/10 link-local
663        }
664    }
665}
666
667/// Like [`tunnel`] but the client side is a `BufReader` that may already hold
668/// buffered bytes (the `http://` forward case, after the request line was read).
669/// Returns `(bytes_up, bytes_down)` for the audit.
670fn splice_buffered(
671    mut client_reader: BufReader<Box<dyn Conn>>,
672    client: Box<dyn Conn>,
673    origin: Box<dyn Conn>,
674) -> io::Result<(u64, u64)> {
675    let mut o_write = origin.dup()?;
676    let up = thread::spawn(move || {
677        let n = copy_counted(&mut client_reader, &mut o_write);
678        let _ = o_write.shutdown(Shutdown::Write);
679        n
680    });
681    let mut o_read = origin;
682    let mut c_write = client;
683    let down = copy_counted(&mut o_read, &mut c_write);
684    // The origin side has closed, so tear the whole connection down: shut down
685    // BOTH client halves, not just write. `Write` alone leaves the `up` thread
686    // blocked reading the client's (half-open) upload direction until CONN_TIMEOUT
687    // — a plain client that sends its request then only reads the response never
688    // closes its write half until it sees our EOF, and we never see its EOF: a
689    // ~30s deadlock on every forward/tunnel (surfaced by the flaky `audit_records_*`
690    // test, since the audit fires only once `up` joins). `Both` gives `up`'s read
691    // on the shared socket an immediate EOF.
692    let _ = c_write.shutdown(Shutdown::Both);
693    let up = up.join().unwrap_or(0);
694    Ok((up, down))
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700    use std::collections::VecDeque;
701
702    /// #138 (SSRF pivot): the proxy must refuse to dial an allow-listed name that
703    /// resolves to an internal address (RFC1918 / link-local incl. cloud metadata /
704    /// CGNAT / IPv6 ULA+link-local), while permitting loopback (the fenced child can
705    /// reach it directly + the test origins live there) and global addresses.
706    #[test]
707    fn guard_target_refuses_internal_permits_loopback_and_global() {
708        use std::net::{IpAddr, Ipv4Addr, SocketAddr};
709        // Built from octets (not dotted-string literals) so the internal-specifics
710        // linter doesn't flag the RFC1918/CGNAT probe addresses (docs/PRIVACY.md).
711        let refused: [IpAddr; 7] = [
712            Ipv4Addr::new(10, 0, 0, 5).into(),        // RFC1918
713            Ipv4Addr::new(172, 16, 9, 9).into(),      // RFC1918
714            Ipv4Addr::new(192, 168, 1, 1).into(),     // RFC1918
715            Ipv4Addr::new(169, 254, 169, 254).into(), // link-local: cloud metadata
716            Ipv4Addr::new(100, 64, 0, 1).into(),      // CGNAT
717            "fe80::1".parse().unwrap(),               // v6 link-local
718            "fc00::1".parse().unwrap(),               // v6 unique-local
719        ];
720        for ip in refused {
721            assert!(is_internal_ip(&ip), "{ip} must classify as internal");
722            assert!(
723                guard_target(SocketAddr::new(ip, 80)).is_err(),
724                "{ip} must be refused"
725            );
726        }
727        let allowed = [
728            "127.0.0.1",
729            "::1",
730            "8.8.8.8",
731            "1.1.1.1",
732            "2606:4700:4700::1111",
733        ];
734        for s in allowed {
735            let ip: IpAddr = s.parse().unwrap();
736            assert!(!is_internal_ip(&ip), "{s} must be permitted");
737            assert!(
738                guard_target(SocketAddr::new(ip, 443)).is_ok(),
739                "{s} must be permitted"
740            );
741        }
742    }
743
744    #[test]
745    fn parses_connect() {
746        assert_eq!(
747            parse_request_line("CONNECT example.com:443 HTTP/1.1\r\n"),
748            Some(Target::Connect {
749                host: "example.com".to_string(),
750                port: 443
751            })
752        );
753        // Default port when omitted.
754        assert_eq!(
755            parse_request_line("CONNECT example.com HTTP/1.1"),
756            Some(Target::Connect {
757                host: "example.com".to_string(),
758                port: 443
759            })
760        );
761    }
762
763    #[test]
764    fn parses_http_absolute_form_and_rewrites_to_origin_form() {
765        let t = parse_request_line("GET http://example.com/a/b?q=1 HTTP/1.1\r\n").unwrap();
766        assert_eq!(
767            t,
768            Target::Http {
769                host: "example.com".to_string(),
770                port: 80,
771                origin_line: "GET /a/b?q=1 HTTP/1.1\r\n".to_string(),
772            }
773        );
774        // No path → "/". Explicit port honoured.
775        let t = parse_request_line("HEAD http://h:8080 HTTP/1.0").unwrap();
776        assert_eq!(
777            t,
778            Target::Http {
779                host: "h".to_string(),
780                port: 8080,
781                origin_line: "HEAD / HTTP/1.0\r\n".to_string(),
782            }
783        );
784    }
785
786    #[test]
787    fn parses_ipv6_authority() {
788        assert_eq!(
789            parse_request_line("CONNECT [::1]:8443 HTTP/1.1"),
790            Some(Target::Connect {
791                host: "::1".to_string(),
792                port: 8443
793            })
794        );
795    }
796
797    #[test]
798    fn rejects_malformed_and_unspoken_schemes() {
799        assert!(parse_request_line("GET / HTTP/1.1").is_none()); // origin-form, not a proxy req
800        assert!(parse_request_line("GET https://x/ HTTP/1.1").is_none()); // https absolute-form
801        assert!(parse_request_line("GET ftp://x/ HTTP/1.1").is_none());
802        assert!(parse_request_line("garbage").is_none());
803        assert!(parse_request_line("CONNECT x:notaport HTTP/1.1").is_none());
804    }
805
806    /// A resolver that maps every name to a fixed loopback origin — hermetic.
807    struct FixedResolver(SocketAddr);
808    impl Resolver for FixedResolver {
809        fn resolve(&self, _host: &str, _port: u16) -> io::Result<SocketAddr> {
810            Ok(self.0)
811        }
812    }
813
814    /// Start the proxy with no audit sink (most tests don't inspect the audit).
815    fn start_null(
816        hosts: impl IntoIterator<Item = String>,
817        resolver: Arc<dyn Resolver>,
818    ) -> io::Result<ProxyHandle> {
819        start(hosts, resolver, Arc::new(NullSink))
820    }
821
822    /// An audit sink that collects every event into a shared vec, for assertions.
823    #[derive(Clone, Default)]
824    struct CapturingSink(Arc<Mutex<Vec<NetAuditEvent>>>);
825    impl AuditSink for CapturingSink {
826        fn record(&self, event: &NetAuditEvent) {
827            self.0.lock().unwrap().push(event.clone());
828        }
829    }
830    impl CapturingSink {
831        fn events(&self) -> Vec<NetAuditEvent> {
832            self.0.lock().unwrap().clone()
833        }
834    }
835
836    // ── In-memory connection fakes (#166) ───────────────────────────────────
837    //
838    // The proxy's forward/tunnel/allow-list/audit LOGIC is driven through the
839    // real `handle_conn` against these scripted endpoints — no sockets, no
840    // accept loops, no timing races. `handle_conn` joins its splice thread
841    // before returning, so every assertion below is fully synchronous and
842    // deterministic (the old socket tests polled/slept up to 30s and still
843    // flaked on constrained runners; #135/#155/#165/#166).
844
845    /// A scripted in-memory [`Conn`]: `read` drains a preset script then returns
846    /// EOF (never blocks); `write` captures bytes for assertions; `dup` shares
847    /// both (as `TcpStream::try_clone` shares one socket). `shutdown`/timeouts
848    /// are no-ops — the fake EOFs on script exhaustion, so nothing can block.
849    #[derive(Clone, Default)]
850    struct ScriptedConn {
851        /// Bytes the proxy reads FROM this endpoint (the client's request, or the
852        /// origin's canned response). Drained by `read`; empty ⇒ EOF.
853        to_read: Arc<Mutex<VecDeque<u8>>>,
854        /// Bytes the proxy WROTE to this endpoint (its client response, or the
855        /// request it forwarded to the origin). Captured for assertions.
856        written: Arc<Mutex<Vec<u8>>>,
857    }
858
859    impl ScriptedConn {
860        fn with_script(bytes: &[u8]) -> Self {
861            Self {
862                to_read: Arc::new(Mutex::new(bytes.iter().copied().collect())),
863                written: Arc::new(Mutex::new(Vec::new())),
864            }
865        }
866        /// The bytes the proxy wrote to this endpoint.
867        fn written(&self) -> Vec<u8> {
868            self.written.lock().unwrap().clone()
869        }
870        /// The bytes the proxy wrote, as a lossy string (for readable asserts).
871        fn written_str(&self) -> String {
872            String::from_utf8_lossy(&self.written()).into_owned()
873        }
874    }
875
876    impl Read for ScriptedConn {
877        fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
878            let mut q = self.to_read.lock().unwrap();
879            let n = buf.len().min(q.len());
880            for slot in buf.iter_mut().take(n) {
881                *slot = q.pop_front().unwrap();
882            }
883            Ok(n) // n == 0 ⇒ script exhausted ⇒ EOF (never blocks)
884        }
885    }
886
887    impl Write for ScriptedConn {
888        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
889            self.written.lock().unwrap().extend_from_slice(buf);
890            Ok(buf.len())
891        }
892        fn flush(&mut self) -> io::Result<()> {
893            Ok(())
894        }
895    }
896
897    impl Conn for ScriptedConn {
898        fn dup(&self) -> io::Result<Box<dyn Conn>> {
899            Ok(Box::new(self.clone()))
900        }
901        fn shutdown(&self, _how: Shutdown) -> io::Result<()> {
902            Ok(())
903        }
904        fn set_timeouts(&self, _dur: Duration) -> io::Result<()> {
905            Ok(())
906        }
907    }
908
909    /// A connector that hands back a scripted in-memory origin (no real dial).
910    struct FakeConnector(ScriptedConn);
911    impl Connector for FakeConnector {
912        fn connect(&self, _addr: SocketAddr) -> io::Result<Box<dyn Conn>> {
913            Ok(Box::new(self.0.clone()))
914        }
915    }
916
917    /// A connector whose dial always fails — for the `502 Bad Gateway` path.
918    struct FailingConnector;
919    impl Connector for FailingConnector {
920        fn connect(&self, _addr: SocketAddr) -> io::Result<Box<dyn Conn>> {
921            Err(io::Error::new(
922                io::ErrorKind::ConnectionRefused,
923                "origin unreachable",
924            ))
925        }
926    }
927
928    /// The outcome of driving one connection through the real `handle_conn`.
929    struct Driven {
930        /// What the proxy sent back to the client (response / forwarded origin bytes).
931        client: ScriptedConn,
932        /// What the proxy forwarded to the origin (empty if it was never dialled).
933        origin: ScriptedConn,
934        /// Hosts refused with 403 (deduped, sorted).
935        refused: Vec<String>,
936        /// Audit events emitted (synchronously, before `handle_conn` returned).
937        audit: Vec<NetAuditEvent>,
938    }
939
940    /// Drive one client `request` through `handle_conn` with `connector` (used
941    /// for the deny / malformed / 502 paths, where the origin is never
942    /// successfully forwarded to — `out.origin` stays empty). Deterministic:
943    /// `handle_conn` joins its splice thread before returning, so all buffers +
944    /// the audit are fully populated on return. For the ALLOWED forward path
945    /// (where you want to inspect the forwarded bytes) use [`drive_forward`].
946    fn drive_with(request: &[u8], allow: &[&str], connector: &dyn Connector) -> Driven {
947        let client = ScriptedConn::with_script(request);
948        let policy = HostPolicy::new(allow.iter().map(|s| s.to_string()));
949        let resolver = FixedResolver("127.0.0.1:9".parse().unwrap());
950        let sink = CapturingSink::default();
951        let refused = Mutex::new(HashSet::new());
952        let _ = handle_conn(
953            Box::new(client.clone()),
954            &policy,
955            connector,
956            &resolver,
957            &sink,
958            &refused,
959        );
960        let mut refused: Vec<String> = refused.into_inner().unwrap().into_iter().collect();
961        refused.sort();
962        Driven {
963            client,
964            origin: ScriptedConn::default(),
965            refused,
966            audit: sink.events(),
967        }
968    }
969
970    /// Convenience: drive an ALLOWED forward/tunnel with an explicit origin
971    /// script, returning the `Driven` outcome (with the origin's captured bytes).
972    fn drive_forward(request: &[u8], allow: &[&str], origin_script: &[u8]) -> Driven {
973        let client = ScriptedConn::with_script(request);
974        let origin = ScriptedConn::with_script(origin_script);
975        let policy = HostPolicy::new(allow.iter().map(|s| s.to_string()));
976        let resolver = FixedResolver("127.0.0.1:9".parse().unwrap());
977        let sink = CapturingSink::default();
978        let refused = Mutex::new(HashSet::new());
979        let connector = FakeConnector(origin.clone());
980        let _ = handle_conn(
981            Box::new(client.clone()),
982            &policy,
983            &connector,
984            &resolver,
985            &sink,
986            &refused,
987        );
988        let mut refused: Vec<String> = refused.into_inner().unwrap().into_iter().collect();
989        refused.sort();
990        Driven {
991            client,
992            origin,
993            refused,
994            audit: sink.events(),
995        }
996    }
997
998    #[test]
999    fn allowed_http_host_is_forwarded_to_origin() {
1000        // The allow-listed host is forwarded; the origin's body reaches the client.
1001        let out = drive_forward(
1002            b"GET http://allowed.test/x HTTP/1.1\r\nHost: ignored\r\nConnection: close\r\n\r\n",
1003            &["allowed.test"],
1004            b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\nConnection: close\r\n\r\norigin",
1005        );
1006        let client_saw = out.client.written_str();
1007        assert!(client_saw.contains("200"), "client saw: {client_saw}");
1008        assert!(
1009            client_saw.contains("origin"),
1010            "the origin's body must reach the client: {client_saw}"
1011        );
1012        // The request reached the origin (proof the forward actually happened).
1013        assert!(
1014            out.origin.written_str().starts_with("GET /x HTTP/1.1"),
1015            "origin must receive the origin-form request: {}",
1016            out.origin.written_str()
1017        );
1018        assert!(out.refused.is_empty(), "an allowed host is not refused");
1019    }
1020
1021    #[test]
1022    fn disallowed_http_host_is_refused_403_without_reaching_origin() {
1023        // A denied host must never be dialled: FailingConnector would surface as a
1024        // 502 if it were ever called, so a 403 here also proves it was NOT.
1025        let out = drive_with(
1026            b"GET http://evil.test/x HTTP/1.1\r\nHost: ignored\r\n\r\n",
1027            &["allowed.test"],
1028            &FailingConnector,
1029        );
1030        let client_saw = out.client.written_str();
1031        assert!(
1032            client_saw.contains("403"),
1033            "denied host must get 403: {client_saw}"
1034        );
1035        assert!(
1036            !client_saw.contains("502"),
1037            "the origin must not be dialled for a denied host: {client_saw}"
1038        );
1039        assert!(out.origin.written().is_empty(), "origin must see nothing");
1040    }
1041
1042    #[test]
1043    fn unreachable_allowed_origin_yields_502() {
1044        // Allow-listed, but the dial fails → 502 Bad Gateway + an `Error` audit.
1045        let out = drive_with(
1046            b"GET http://allowed.test/x HTTP/1.1\r\nHost: ignored\r\n\r\n",
1047            &["allowed.test"],
1048            &FailingConnector,
1049        );
1050        assert!(
1051            out.client.written_str().contains("502"),
1052            "an unreachable allowed origin must get 502: {}",
1053            out.client.written_str()
1054        );
1055        let ev = out.audit.iter().find(|e| e.host == "allowed.test").unwrap();
1056        assert_eq!(ev.decision, NetDecision::Error);
1057    }
1058
1059    #[test]
1060    fn malformed_request_yields_400_and_no_audit() {
1061        // A request line the proxy does not speak → 400, and (deliberately) NO
1062        // audit event, since there is no host to attribute an egress attempt to.
1063        let out = drive_with(
1064            b"GET / HTTP/1.1\r\n\r\n",
1065            &["allowed.test"],
1066            &FailingConnector,
1067        );
1068        assert!(out.client.written_str().contains("400"));
1069        assert!(
1070            out.audit.is_empty(),
1071            "a malformed request is not an egress event: {:?}",
1072            out.audit
1073        );
1074        assert!(out.refused.is_empty());
1075    }
1076
1077    /// #196: the proxy accumulates every host it REFUSES and surfaces them via
1078    /// `refused_hosts()` (deduped, sorted); allowed hosts are never listed.
1079    #[test]
1080    fn refused_hosts_surfaces_denied_hosts_deduped_and_omits_allowed() {
1081        // Drive three requests through ONE shared refused-set: allowed (forwarded),
1082        // then the same denied host twice (must dedupe to a single entry).
1083        let policy = HostPolicy::new(["allowed.test".to_string()]);
1084        let resolver = FixedResolver("127.0.0.1:9".parse().unwrap());
1085        let sink = NullSink;
1086        let refused = Mutex::new(HashSet::new());
1087        let origin = ScriptedConn::with_script(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n");
1088        let connector = FakeConnector(origin);
1089
1090        for (req, conn) in [
1091            (
1092                &b"GET http://allowed.test/x HTTP/1.1\r\nHost: a\r\n\r\n"[..],
1093                &connector as &dyn Connector,
1094            ),
1095            (
1096                &b"GET http://evil.test/y HTTP/1.1\r\nHost: a\r\n\r\n"[..],
1097                &FailingConnector,
1098            ),
1099            (
1100                &b"GET http://evil.test/z HTTP/1.1\r\nHost: a\r\n\r\n"[..],
1101                &FailingConnector,
1102            ),
1103        ] {
1104            let client = ScriptedConn::with_script(req);
1105            let _ = handle_conn(Box::new(client), &policy, conn, &resolver, &sink, &refused);
1106        }
1107
1108        let mut got: Vec<String> = refused.into_inner().unwrap().into_iter().collect();
1109        got.sort();
1110        assert_eq!(
1111            got,
1112            vec!["evil.test".to_string()],
1113            "only the denied host, deduped; the allowed host must NOT appear: {got:?}"
1114        );
1115    }
1116
1117    #[test]
1118    fn audit_records_allowed_with_bytes_and_denied_attempts() {
1119        // Allowed forward: an `Allowed` Http event on port 80 with response bytes.
1120        let allowed = drive_forward(
1121            b"GET http://allowed.test/x HTTP/1.1\r\nHost: ignored\r\n\r\n",
1122            &["allowed.test"],
1123            b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\nConnection: close\r\n\r\norigin",
1124        );
1125        let ev = allowed
1126            .audit
1127            .iter()
1128            .find(|e| e.host == "allowed.test")
1129            .expect("an allowed event");
1130        assert_eq!(ev.decision, NetDecision::Allowed);
1131        assert_eq!(ev.kind, NetKind::Http);
1132        assert_eq!(ev.port, 80);
1133        assert!(
1134            ev.bytes_down > 0,
1135            "an allowed connection records response bytes: {ev:?}"
1136        );
1137
1138        // Denied: a `Denied` event (the exfil-attempt signal) with zero bytes.
1139        let denied = drive_with(
1140            b"GET http://evil.test/y HTTP/1.1\r\nHost: ignored\r\n\r\n",
1141            &["allowed.test"],
1142            &FailingConnector,
1143        );
1144        let ev = denied
1145            .audit
1146            .iter()
1147            .find(|e| e.host == "evil.test")
1148            .expect("a denied event");
1149        assert_eq!(ev.decision, NetDecision::Denied);
1150        assert_eq!(ev.bytes_up, 0);
1151        assert_eq!(ev.bytes_down, 0);
1152    }
1153
1154    #[test]
1155    fn jsonl_sink_appends_one_newline_terminated_json_line_per_event() {
1156        #[derive(Clone, Default)]
1157        struct SharedBuf(Arc<Mutex<Vec<u8>>>);
1158        impl Write for SharedBuf {
1159            fn write(&mut self, b: &[u8]) -> io::Result<usize> {
1160                self.0.lock().unwrap().extend_from_slice(b);
1161                Ok(b.len())
1162            }
1163            fn flush(&mut self) -> io::Result<()> {
1164                Ok(())
1165            }
1166        }
1167        let buf = SharedBuf::default();
1168        let sink = JsonlSink::new(buf.clone());
1169        let mk = |host: &str| NetAuditEvent {
1170            ts_ms: 1,
1171            host: host.into(),
1172            port: 80,
1173            kind: NetKind::Http,
1174            decision: NetDecision::Allowed,
1175            bytes_up: 1,
1176            bytes_down: 2,
1177            dur_ms: 3,
1178        };
1179        sink.record(&mk("a"));
1180        sink.record(&mk("b"));
1181        let text = String::from_utf8(buf.0.lock().unwrap().clone()).unwrap();
1182        let lines: Vec<&str> = text.lines().collect();
1183        assert_eq!(lines.len(), 2, "one JSON line per event: {text:?}");
1184        assert_eq!(
1185            serde_json::from_str::<NetAuditEvent>(lines[0])
1186                .unwrap()
1187                .host,
1188            "a"
1189        );
1190        assert_eq!(
1191            serde_json::from_str::<NetAuditEvent>(lines[1])
1192                .unwrap()
1193                .host,
1194            "b"
1195        );
1196    }
1197
1198    #[test]
1199    fn audit_event_json_round_trips() {
1200        let e = NetAuditEvent {
1201            ts_ms: 1,
1202            host: "h".into(),
1203            port: 443,
1204            kind: NetKind::Connect,
1205            decision: NetDecision::Allowed,
1206            bytes_up: 10,
1207            bytes_down: 20,
1208            dur_ms: 5,
1209        };
1210        let line = serde_json::to_string(&e).unwrap();
1211        assert!(line.contains("\"decision\":\"allowed\"") && line.contains("\"kind\":\"connect\""));
1212        assert_eq!(serde_json::from_str::<NetAuditEvent>(&line).unwrap(), e);
1213    }
1214
1215    #[test]
1216    fn connect_allowed_host_tunnels_opaque_bytes() {
1217        // The client pipelines "PING" right after the CONNECT header block; the
1218        // (scripted) origin returns "PING" as its side of the opaque exchange.
1219        // Proof of a real bidirectional tunnel: the origin receives the client's
1220        // PING (up-copy) and the client receives the origin's PING (down-copy),
1221        // after a bare 200 status line — driven through the real splice logic.
1222        let out = drive_forward(
1223            b"CONNECT allowed.test:443 HTTP/1.1\r\nHost: allowed.test:443\r\n\r\nPING",
1224            &["allowed.test"],
1225            b"PING",
1226        );
1227        let client_saw = out.client.written_str();
1228        assert!(
1229            client_saw.starts_with("HTTP/1.1 200"),
1230            "CONNECT must be accepted with a bare 200: {client_saw:?}"
1231        );
1232        assert!(
1233            client_saw.contains("PING"),
1234            "the origin's bytes must tunnel back to the client: {client_saw:?}"
1235        );
1236        assert_eq!(
1237            out.origin.written(),
1238            b"PING",
1239            "the client's pipelined bytes must reach the origin through the tunnel"
1240        );
1241    }
1242
1243    #[test]
1244    fn connect_disallowed_host_is_refused_403() {
1245        let out = drive_with(
1246            b"CONNECT evil.test:443 HTTP/1.1\r\n\r\n",
1247            &["allowed.test"],
1248            &FailingConnector,
1249        );
1250        assert!(
1251            out.client.written_str().contains("403"),
1252            "a denied CONNECT must get 403, not a tunnel: {}",
1253            out.client.written_str()
1254        );
1255    }
1256
1257    #[test]
1258    fn http_host_header_is_normalized_to_the_validated_authority() {
1259        // The authority (allowed.test) is validated, but the client LIES with a
1260        // spoofed `Host: evil.test` to domain-front. The origin must see the
1261        // validated authority substituted in, never the spoof.
1262        let out = drive_forward(
1263            b"GET http://allowed.test/ HTTP/1.1\r\nHost: evil.test\r\nConnection: close\r\n\r\n",
1264            &["allowed.test"],
1265            b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
1266        );
1267        let origin_saw = out.origin.written_str();
1268        assert!(
1269            origin_saw.contains("Host: allowed.test\r\n"),
1270            "origin must receive the validated Host: {origin_saw:?}"
1271        );
1272        assert!(
1273            !origin_saw.contains("evil.test"),
1274            "the spoofed Host must not reach the origin: {origin_saw:?}"
1275        );
1276    }
1277
1278    #[test]
1279    fn proxy_env_points_at_the_bound_loopback_addr() {
1280        // One of two remaining REAL-socket tests: `proxy_env` is derived from the
1281        // actually-bound loopback address, so this exercises the real `bind`.
1282        let proxy = start_null(["x".to_string()], Arc::new(StdResolver)).unwrap();
1283        let env = proxy.proxy_env();
1284        let url = format!("http://127.0.0.1:{}", proxy.addr().port());
1285        assert!(env.iter().any(|(k, v)| k == "https_proxy" && *v == url));
1286        assert!(env.iter().any(|(k, v)| k == "HTTPS_PROXY" && *v == url));
1287        assert!(env.iter().all(|(_, v)| v.starts_with("http://127.0.0.1:")));
1288    }
1289
1290    /// A one-shot HTTP origin on loopback that replies 200 with a marker body —
1291    /// used ONLY by the macOS kernel e2e below, which must drive real `curl`
1292    /// through the real proxy over a real socket (the in-memory fakes cannot
1293    /// exercise a kernel fence). Gated to that test's platform so it is not dead
1294    /// code elsewhere.
1295    #[cfg(all(target_os = "macos", feature = "macos-seatbelt"))]
1296    fn spawn_origin() -> SocketAddr {
1297        let l = TcpListener::bind(("127.0.0.1", 0)).unwrap();
1298        let addr = l.local_addr().unwrap();
1299        thread::spawn(move || {
1300            for s in l.incoming().flatten() {
1301                let mut s = s;
1302                let mut b = [0u8; 512];
1303                let _ = s.read(&mut b);
1304                let _ = s.write_all(
1305                    b"HTTP/1.1 200 OK\r\nContent-Length: 6\r\nConnection: close\r\n\r\norigin",
1306                );
1307            }
1308        });
1309        addr
1310    }
1311
1312    /// End-to-end kernel proof (#124, ADR 0016): a real `curl` child, confined by
1313    /// the ADR 0015 loopback fence, reaches an ALLOWED host only through the proxy
1314    /// (200), is refused a DENIED host by the proxy (403), and — crucially —
1315    /// **cannot bypass** the proxy: a direct off-box connect is kernel-denied. All
1316    /// hermetic — the resolver maps the allow-listed name to a loopback origin, so
1317    /// no real network is touched. macOS + `macos-seatbelt` only; self-skips if
1318    /// `sandbox-exec`/`curl` are unavailable. This is the one test that genuinely
1319    /// needs the real socket path (the fence is a kernel property); the forward /
1320    /// tunnel / allow-list / audit LOGIC is covered deterministically in-memory
1321    /// above.
1322    #[cfg(all(target_os = "macos", feature = "macos-seatbelt"))]
1323    #[test]
1324    fn fenced_child_reaches_allowed_via_proxy_denied_refused_direct_kernel_blocked() {
1325        use agent_bridle_core::{
1326            best_available_sandbox, loopback_fenced_caveats, seatbelt_is_supported, Caveats,
1327            SandboxPolicy, Scope,
1328        };
1329        if !seatbelt_is_supported() {
1330            eprintln!("skipping: /usr/bin/sandbox-exec unavailable");
1331            return;
1332        }
1333        let curl = "/usr/bin/curl";
1334        if !std::path::Path::new(curl).exists() {
1335            eprintln!("skipping: no curl(1)");
1336            return;
1337        }
1338
1339        let origin = spawn_origin();
1340        let proxy = start_null(
1341            ["allowed.test".to_string()],
1342            Arc::new(FixedResolver(origin)),
1343        )
1344        .unwrap();
1345
1346        // The grant is a general remote-host allow-list; its loopback-fenced form
1347        // is what actually confines the child (the ADR 0016 mechanism).
1348        let granted = Caveats {
1349            net: Scope::only(["allowed.test".to_string()]),
1350            ..Caveats::top()
1351        };
1352        let prefix = best_available_sandbox(&Arc::new(SandboxPolicy::default()))
1353            .command_prefix(&loopback_fenced_caveats(&granted))
1354            .expect("seatbelt wrapper");
1355
1356        // Run `curl` wrapped by the fence, with the given env. `-v` surfaces the
1357        // connect-time error on stderr so the FENCE leg can assert a *permission*
1358        // denial (not a mere routing failure).
1359        let run = |proxy_env: bool, url: &str| -> std::process::Output {
1360            let mut cmd = std::process::Command::new(&prefix[0]);
1361            cmd.args(&prefix[1..])
1362                .arg(curl)
1363                .args(["-sv", "--max-time", "5", url])
1364                .env_clear();
1365            if proxy_env {
1366                cmd.envs(proxy.proxy_env());
1367            }
1368            cmd.output().expect("spawn sandbox-exec")
1369        };
1370
1371        // ALLOW: via the proxy, the allow-listed host reaches the loopback origin.
1372        let allow = run(true, "http://allowed.test/");
1373        assert!(
1374            String::from_utf8_lossy(&allow.stdout).contains("origin"),
1375            "allow-listed host must reach the origin through the proxy: {allow:?}"
1376        );
1377        // DENY: via the proxy, a non-allow-listed host gets the proxy's 403 — and
1378        // never reaches any origin.
1379        let deny = run(true, "http://denied.test/");
1380        assert!(
1381            String::from_utf8_lossy(&deny.stdout).contains("403"),
1382            "denied host must get the proxy's 403: {deny:?}"
1383        );
1384        // FENCE: WITHOUT the proxy env the child tries to egress directly; a literal
1385        // off-box IP is kernel-denied at the socket. Assert curl exit 7 AND an EPERM
1386        // signal ("Operation not permitted") — so a no-internet runner (ENETUNREACH,
1387        // also exit 7) cannot make this pass vacuously; it must be a *permission*
1388        // denial, proving the fence (not the network) blocked it.
1389        let direct = run(false, "http://1.1.1.1/");
1390        let stderr = String::from_utf8_lossy(&direct.stderr);
1391        assert_eq!(
1392            direct.status.code(),
1393            Some(7),
1394            "direct off-box egress must be kernel-denied (curl exit 7): {stderr}"
1395        );
1396        assert!(
1397            stderr.contains("Operation not permitted"),
1398            "the block must be a kernel EPERM, not a routing failure: {stderr}"
1399        );
1400
1401        drop(proxy);
1402    }
1403
1404    /// The second (and only other) REAL-socket test: it must exercise the actual
1405    /// `TcpListener` bind + accept loop + `Drop` teardown, which no in-memory
1406    /// fake can. Self-contained (its own ephemeral port, no shared origin), so it
1407    /// does not need the old cross-test serialization lock.
1408    #[test]
1409    fn dropping_the_handle_stops_the_listener() {
1410        let proxy = start_null(["x".to_string()], Arc::new(StdResolver)).unwrap();
1411        let addr = proxy.addr();
1412        drop(proxy);
1413        // After shutdown the port is no longer served: a connect either refuses
1414        // or the accept loop has exited. Give the OS a moment, then assert we
1415        // cannot complete an HTTP exchange through it.
1416        thread::sleep(Duration::from_millis(100));
1417        if let Ok(mut c) = TcpStream::connect_timeout(&addr, Duration::from_millis(200)) {
1418            c.set_read_timeout(Some(Duration::from_millis(500)))
1419                .unwrap();
1420            let _ = write!(c, "GET http://x/ HTTP/1.1\r\n\r\n");
1421            let mut resp = Vec::new();
1422            let _ = c.read_to_end(&mut resp);
1423            assert!(resp.is_empty(), "a stopped proxy must not serve requests");
1424        }
1425    }
1426}