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:
1 parent
47951e5734
commit
c8541ea9fc
7 files changed
+156
-29
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
Reference in new issue
Block a user