fix(deploy): the tunnel's door binds loopback only (P9a)

Review finding: `--http` parsed whatever address it was given and bound
it. The door is the plaintext MCP service with a static bearer token;
plan 3.9 and the P9a model put a TLS proxy or tunnel in front of a
loopback listener, and no decision authorises exposing the books
unencrypted. `--http 0.0.0.0:7411` would have done exactly that.

`local::tunnel_address` now accepts only a loopback address
(127.0.0.0/8 or ::1; `127.0.0.1:7411` stays the default) and refuses
anything else with a message naming loopback and why. `server` refuses
while parsing, before it takes the lock or binds a socket; `serve`
refuses before it starts a host, so nothing is left half-open. The
bind-failure behaviour for a loopback address is unchanged (said, not
fatal, 3.9), as is the unit's hardening.

The test `the_tunnel_door_binds_loopback_only` refuses 0.0.0.0, a public
sample address and [::] through `server`, checks no socket or host was
left behind, refuses through `serve`, then starts a loopback listener and
reaches `books.start` with the bearer.

No accounting code, report or figure changes.
This commit is contained in:
swaits committed 2026-10-27 09:35:36 -06:00
1 parent 47951e5734
commit c8541ea9fc
7 files changed
+156 -29

No files matched your search

+24 -7
View File
@@ -1855,14 +1855,34 @@ fn bind(run: &Path, name: &str) -> std::io::Result<tokio::net::UnixListener> {
Ok(listener)
}
/// The address `--http` takes: loopback only (plan 3.9, P9a).
///
/// The tunnel's door is the same plaintext MCP service with a static
/// bearer token; a TLS proxy or tunnel runs in front of it on the same
/// machine. Any other address would put the books' plaintext door on the
/// network, which 3.9 and P9a do not allow, so it is refused rather than
/// bound (or warned about).
pub fn tunnel_address(text: &str) -> Result<std::net::SocketAddr, String> {
let addr: std::net::SocketAddr = text
.parse()
.map_err(|e| format!("--http takes an address like 127.0.0.1:7411: {e}"))?;
if !addr.ip().is_loopback() {
return Err(format!(
"--http is loopback only (127.0.0.1:7411 or [::1]:7411), not {addr}: this door is plaintext, so the TLS proxy or tunnel belongs in front of loopback"
));
}
Ok(addr)
}
fn print_usage(prog: &str) {
eprintln!("usage: {prog} [--root DIR] [--isolation LEVEL] [--http [ADDR]]");
eprintln!(" The books server for the data root: started by the host, not by hand.");
eprintln!(" It runs until its standard input ends, or SIGTERM, SIGINT or SIGHUP.");
eprintln!(" --isolation same_user | enforced: what books.start says of how these books");
eprintln!(" are kept from agents (enforced on the owner's own machine: P9a).");
eprintln!(" --http [ADDR]: also serve the MCP door on 127.0.0.1:7411 (or ADDR), for a");
eprintln!(" TLS proxy or a tunnel in front of it. Off unless given.");
eprintln!(" --http [ADDR]: also serve the MCP door on loopback only (default");
eprintln!(" 127.0.0.1:7411), for a TLS proxy or a tunnel in front of it. Off unless");
eprintln!(" given; any other address is refused.");
}
/// `wanderbooks server`: serve every book in the data root until told to
@@ -1890,17 +1910,14 @@ pub fn run(
isolation = Isolation::parse(&value)?;
}
// The tunnel's door (plan 3.9, P9a): the same MCP service on
// 127.0.0.1, for a TLS proxy or tunnel in front of it. Off
// loopback, for a TLS proxy or tunnel in front of it. Off
// unless asked for; the bearer token still guards every call.
"--http" => {
let addr = match args.peek() {
Some(v) if !v.starts_with('-') => args.next().unwrap_or_default(),
_ => "127.0.0.1:7411".to_string(),
};
tunnel =
Some(addr.parse().map_err(|e| {
format!("--http takes an address like 127.0.0.1:7411: {e}")
})?);
tunnel = Some(tunnel_address(&addr)?);
}
// How long the books stay unwritten before a backup (tests).
"--backup-quiet" => {