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

+88
View File
@@ -181,6 +181,22 @@ impl Drop for FlaggedServer {
}
}
/// Run the server with `args` that should be refused before it serves
/// anything: its status, stdout and stderr.
fn refused_server(home: &Home, args: &[String]) -> std::process::Output {
Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
.arg("server")
.arg("--root")
.arg(home.root())
.args(args)
.env_clear()
.env("PATH", "/usr/bin:/bin")
.env("HOME", home.dir.path())
.env("XDG_CONFIG_HOME", home.dir.path().join(".config"))
.output()
.unwrap()
}
/// A port nothing is listening on now, for the tunnel's door.
fn free_port() -> u16 {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
@@ -195,6 +211,78 @@ fn start_of(wire: &mut Wire) -> Value {
// ---------------------------------------------------------------- the tests
/// The tunnel's door is loopback only (plan 3.9, P9a): the connection is
/// plaintext with a static bearer, so any other address is refused before
/// anything binds or a host starts, never warned about or fallen back
/// from. A loopback address still starts and answers with the bearer.
#[test]
fn the_tunnel_door_binds_loopback_only() {
let home = Home::new("tunnel-loopback");
for addr in ["0.0.0.0:0", "192.0.2.1:0", "[::]:0"] {
let out = refused_server(&home, &["--http".to_string(), addr.to_string()]);
assert!(!out.status.success(), "--http {addr} was accepted");
let said = String::from_utf8_lossy(&out.stderr);
assert!(
said.contains("loopback only"),
"--http {addr} was refused for the wrong reason: {said}"
);
assert!(
!String::from_utf8_lossy(&out.stdout).contains("ready"),
"--http {addr} said ready"
);
// Nothing was left half-open: no sockets, no lock holder.
for sock in ["mcp.sock", "admin.sock", "lease.sock"] {
assert!(
!home.run().join(sock).exists(),
"--http {addr} left {sock} behind"
);
}
}
// The same refusal through `serve`, before it starts a host at all.
let out = Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
.args(["serve", "--root"])
.arg(home.root())
.args(["--http", "0.0.0.0:0"])
.env_clear()
.env("PATH", "/usr/bin:/bin")
.env("HOME", home.dir.path())
.env("XDG_CONFIG_HOME", home.dir.path().join(".config"))
.output()
.unwrap();
assert!(!out.status.success(), "serve accepted a network address");
assert!(
String::from_utf8_lossy(&out.stderr).contains("loopback only"),
"serve: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(!home.run().join("lease.sock").exists(), "a host started");
// A loopback address starts, and the bearer reaches the books.
let port = free_port();
let server = FlaggedServer::start(&home, &["--http".to_string(), format!("127.0.0.1:{port}")]);
home.create(BOOK, "USD");
let d = Deployment {
root: home.root(),
server: format!("http://127.0.0.1:{port}"),
book: BOOK.into(),
token: home.token(BOOK),
write_book: BOOK.into(),
write_token: home.token(BOOK),
source: None,
};
let rt = tokio::runtime::Runtime::new().unwrap();
let start = rt.block_on(async {
let client = connect_over_http(&d, &d.book, &d.token).await;
let answer = common::call(&client, "books.start", json!({})).await;
let _ = client.cancel().await;
answer
});
let start = start.expect("books.start over the loopback door");
assert_eq!(start["isolation"], "same_user (advisory)");
assert!(server.close().success());
}
/// The server told `--isolation enforced` says so (`books.start`), over
/// its Unix socket and over the HTTP door, and a server not told says the
/// local truth: the server never guesses (plan 3.8, P9a).
+16 -7
View File
@@ -254,12 +254,20 @@ pub fn serve(
server_flags.push(level);
}
"--http" => {
let addr = match args.peek() {
Some(v) if !v.starts_with('-') => args.next().unwrap_or_default(),
_ => "127.0.0.1:7411".to_string(),
};
// Before a host starts: the door is loopback only (3.9, P9a).
let addr = match ledger_mcp::local::tunnel_address(&addr) {
Ok(addr) => addr,
Err(why) => {
eprintln!("error: {why}");
return Err(exit(2));
}
};
server_flags.push(arg);
if let Some(v) = args.peek()
&& !v.starts_with('-')
{
server_flags.push(args.next().unwrap_or_default());
}
server_flags.push(addr.to_string());
}
"--help" | "-h" => {
eprintln!("usage: {prog} [--root DIR] [--isolation LEVEL] [--http [ADDR]]");
@@ -267,8 +275,9 @@ pub fn serve(
eprintln!(" after nothing else (the app, an agent session) holds them.");
eprintln!(" --isolation same_user | enforced: how books.start says these books");
eprintln!(" are kept from agents (enforced: the owner's own machine, P9a).");
eprintln!(" --http [ADDR]: also serve the MCP door on 127.0.0.1:7411 (or ADDR),");
eprintln!(" for a TLS proxy or a tunnel in front of it.");
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; any");
eprintln!(" other address is refused.");
return Ok(());
}
other => {
+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" => {
+5 -2
View File
@@ -148,8 +148,11 @@ is not a command; if your machine has a persistent Secret Service, use it.)
## 6. TLS and the door
The server listens on 127.0.0.1:7411 for the tunnel, and on its own Unix
sockets for anything running as the same account. Put TLS in front of the
loopback port. Two ways that need no open inbound port:
sockets for anything running as the same account. **The TCP door is
loopback only, on purpose**: it is plaintext with a static bearer token,
so `--http` refuses `0.0.0.0`, a LAN address or `[::]` before it binds
anything; the TLS proxy or tunnel is what faces the network. Put TLS in
front of the loopback port. Two ways that need no open inbound port:
**Tailscale Funnel** (a tailnet name, TLS included):
+4 -2
View File
@@ -235,8 +235,10 @@ reports `service_user (enforced)` (`server::Isolation`).
The door is the same HTTP service on a second listener:
`--http 127.0.0.1:7411` binds the MCP routes beside the Unix sockets
(3.9), and Caddy, Tailscale Funnel or cloudflared puts TLS and the public
name in front of it. The bearer token guards every call exactly as on the
(3.9), and the address is loopback only (any other is refused before
anything binds: the door is plaintext with a static bearer, so the TLS
proxy or tunnel belongs in front of loopback); Caddy, Tailscale Funnel or
cloudflared puts TLS and the public name in front of it. The bearer token guards every call exactly as on the
socket; the tunnel only forwards. The socket's peer-credential check
(`OwnerOnly`) is for local callers and does not apply to the loopback
door, which is why the bearer and the loopback-only bind matter there.
+16 -8
View File
@@ -2993,6 +2993,11 @@ loopback door (`--http`) for Caddy or a tunnel.
already running keeps the flags it was started with (documented in
`deploy/README.md` and the opener's comment).
- **`crates/ledger-cli/tests/two_user.rs`:**
`the_tunnel_door_binds_loopback_only` (a network address — `0.0.0.0`,
a public sample address, `[::]` — is refused by `server` with a message
naming loopback, before any socket or lock exists, and by `serve` before
a host starts; a loopback address still starts and answers with the
bearer);
`isolation_label_is_enforced_under_the_unit` (a server with no flag says
`same_user (advisory)`; with `--isolation enforced --http` it says
`service_user (enforced)` over the Unix socket and over the loopback
@@ -3034,11 +3039,14 @@ loopback door (`--http`) for Caddy or a tunnel.
would report the advisory truth on the enforced machine);
`Isolation::label`'s two strings; `start_out`'s `self.isolation.label()`
(it replaced a literal, and a mutant that restores a literal must fail the
two-door test); the `--http` arm's `args.peek()` guard and the default
`127.0.0.1:7411` (binding the wrong interface, or consuming the next flag,
must fail); `tunnel.is_some()` and the bind failure path (ready must still
come after the try, and a failed bind must not be fatal); `open_with`'s
`isolation` into `Served` and `BooksServer::new`'s copy (a dropped field
must fail `isolation_label_is_enforced_under_the_unit`); the host's
`server_flags` extension and `parse_args`' passthrough of `--isolation`
and its optional `--http` value.
two-door test); `local::tunnel_address`'s `is_loopback` test (a mutant
that accepts any address must fail `the_tunnel_door_binds_loopback_only`,
and one that rejects loopback must fail its second half); the `--http`
arm's `args.peek()` guard and the default `127.0.0.1:7411` (binding the
wrong interface, or consuming the next flag, must fail); `tunnel.is_some()`
and the bind failure path (ready must still come after the try, and a
failed bind must not be fatal); `open_with`'s `isolation` into `Served`
and `BooksServer::new`'s copy (a dropped field must fail
`isolation_label_is_enforced_under_the_unit`); the host's `server_flags`
extension and `parse_args`' passthrough of `--isolation` and its optional
`--http` value.
+3 -3
View File
@@ -1480,14 +1480,14 @@ Each unit follows the 8.0.4 template. "A-n" is an owner answer numbered in 8.3.1
5. **Files:** owns `deploy/`, `.github/workflows/two-user.yml`, `crates/ledger-cli/tests/two_user.rs`, `handbook/self-host.md`; touches `books.start`'s `isolation` (`tools/kept.rs`), `crates/ledger-host/src/commands.rs` (the flag).
6. **Tests and proofs:** `agent_uid_cannot_read_tenant`, `self_host_adopt_same_head`; `isolation_label_is_enforced_under_the_unit`.
7. **Done when:** (1) the CI job passes; (2) the recipe, followed on a clean VM by someone other than its author, reaches `books.start` from a desktop Claude Code in under 30 minutes (recorded in the As-built block); (3) `just check` green.
8. **As built** (2026-10-26, `a5ec3056`):
- **`--isolation` and `--http` had to be built, not just wired.** Build item 1's unit file names `--http` for the tunnel (3.9), but the server had only Unix sockets, and `isolation` was a literal in `tools/kept.rs::start_out`. Both are server flags now (`local::run`): `--isolation same_user|enforced` sets `server::Isolation` (reported by `books.start`), and `--http [ADDR]` binds the same router on loopback (default `127.0.0.1:7411`) before the ready line; a failed bind is said and is not fatal (3.9). `serve` carries both to the server through the host (`Opener::starting_with`, `start::start_host`, `host::Args`), and validates `--isolation` itself so a typo fails before a host starts. A host already running keeps the flags it was started with; on a self-host machine `serve` is the only holder.
8. **As built** (2026-10-26, `6ff53e64`):
- **`--isolation` and `--http` had to be built, not just wired.** Build item 1's unit file names `--http` for the tunnel (3.9), but the server had only Unix sockets, and `isolation` was a literal in `tools/kept.rs::start_out`. Both are server flags now (`local::run`): `--isolation same_user|enforced` sets `server::Isolation` (reported by `books.start`), and `--http [ADDR]` binds the same router on **loopback only** (default `127.0.0.1:7411`) before the ready line; a failed bind is said and is not fatal (3.9). Any other address — `0.0.0.0`, a LAN or public IP, `[::]` — is refused by `local::tunnel_address` before anything binds, and by `serve` before it starts a host (review finding: the door is plaintext with a static bearer, so the TLS proxy or tunnel belongs in front of loopback; no decision authorises exposing it). `serve` carries both flags to the server through the host (`Opener::starting_with`, `start::start_host`, `host::Args`), and validates `--isolation` itself so a typo fails before a host starts. A host already running keeps the flags it was started with; on a self-host machine `serve` is the only holder.
- **The move's head check needed a source at this version.** `books adopt` writes the copy in this version's journal format (3.9), so adopting a copy written by an older version appends `journal.format_set` and the source's head is one event behind. The CI job therefore makes the desktop source with this version first (adopt the repository fixture there), closes it, and copies that book; the source's head and the moved books' are then equal, and the test compares them. The preview still says when a copy was upgraded ("The copy is in this version's journal format").
- **`token create` on a headless server.** It keeps the token in the OS keyring and revokes it if it cannot, so a server with no Secret Service cannot mint one (tokens.rs INVARIANT 4, as built P2 slice 4). The recipe mints inside `dbus-run-session` with `gnome-keyring-daemon` and copies the text once; the CI job uses the `file_keyring` test knob. A "print it without keeping it" flag was considered and not added: it is new surface, and the text still lands in a terminal.
- **The remote bearer on the client.** 3.3's "header helper `wanderbooks token print`" prints the token *this* machine's keyring holds for its own data root; there is no command that keeps a remote token on a client. The recipe puts the bearer in Claude Code's `--header` (its config is the owner's, mode 0600) or in Codex's `bearer_token_env_var`. No new tool or flag was invented; this is the one place the recipe does not follow 3.3 literally, and it is in `deploy/README.md` and `docs/testing.md`.
- **`IPAddressDeny=any` blocks the server's own backup push.** The unit has the plan's lines; a book whose backup target is a remote repository needs that host allowed in an `IPAddressAllow` drop-in (README step 4). A target on the same machine needs nothing.
- **The write checks use a second book.** `agent_uid_cannot_read_tenant` must write to prove the door, and a write advances the moved books' head; it writes to a `tunnel` book the job makes, so `acme`'s head is left for `self_host_adopt_same_head`.
- **Unverified here (owner/EM):** Done when 1, the GitHub Actions job (no run from this worktree) and Done when 2, the recipe walked on a clean VM by someone other than its author in under 30 minutes. Verified locally: `isolation_label_is_enforced_under_the_unit`, and both two-account tests against a stand-in deployment whose client ran in a user namespace as host uid 100000 (the server as 1000), over `--http` on 127.0.0.1, with the tenant root 0700 and refused to the client; the moved books reported the source's seq, hash and event count. `just check`'s steps pass (format, clippy, rustdoc, the whole workspace's tests, and `server-only`), run one by one rather than through the recipe: its temp sweep would delete other engineers' `/tmp/wb-*` dirs on a shared machine. No kernel, `Ledger` command, Kani harness or Lean theorem changed, so `just prove` is not touched.
- **Unverified here (owner/EM):** Done when 1, the GitHub Actions job (no run from this worktree) and Done when 2, the recipe walked on a clean VM by someone other than its author in under 30 minutes. Verified locally: `the_tunnel_door_binds_loopback_only`, `isolation_label_is_enforced_under_the_unit`, and both two-account tests against a stand-in deployment whose client ran in a user namespace as host uid 100000 (the server as 1000), over `--http` on 127.0.0.1, with the tenant root 0700 and refused to the client; the moved books reported the source's seq, hash and event count. `just check` passes on the rebased branch (format, clippy and rustdoc for both workspaces, the whole workspace's tests plus the bookspec tests, and `server-only`), after `main`'s `fix(just)` made the temp sweep day-old only. No kernel, `Ledger` command, Kani harness or Lean theorem changed, so `just prove` is not touched.
**P9b (planned, not scheduled; after Q-6):** claude.ai and ChatGPT need OAuth 2.1. The recommendation is D-7 (b): an external identity provider (Authentik, Ory or Keycloak) issues tokens and the server only validates them (audience = the book URI, `iss`, expiry), so no authorization server is written in-house and no external security review is needed; the Origin allowlist gains `https://claude.ai`; `books.job` and the 240 s rule come here if a call is measured over the limit. **P9c (not planned, D-51):** the remote viewer replica (`/replica/*`, `Snapshot::from_lines`, the blob LRU), `books.invite`, the `/h/` pages for remote people, the MCP App upload widget and `openai/fileParams`. The viewer on another machine reads a backup clone (D-30, D-31) read-only instead.