diff --git a/crates/didbot-serve/src/bin/didbot-pds.rs b/crates/didbot-serve/src/bin/didbot-pds.rs index 16a0e5e9..98ff5c26 100644 --- a/crates/didbot-serve/src/bin/didbot-pds.rs +++ b/crates/didbot-serve/src/bin/didbot-pds.rs @@ -327,7 +327,9 @@ usage: didbot-pds [options] [capacity] also takes evaluation_log_retention_days: how long a sealed segment of the policy evaluation log survives before it is trimmed (default 30). - --port listen port (default 3000) + --port listen port (default 3000). A .localhost zone listens on + the loopback address; any other zone listens on every + address this host has. --zone zone agents are minted under (default agents.localhost). May be given more than once: the first is the primary -- the server's own hostname, and the only zone accounts are @@ -335,9 +337,9 @@ usage: didbot-pds [options] backend and, under --tls acme, its own certificate pair. --owner the operator DID every account this run mints names as answerable for it (default did:web:owner.invalid, which - resolves to nothing). Outside .localhost, the server polls - this DID's repository for its bot.did.operator claim and - policy records. + resolves to nothing; required under --tls acme). Outside + .localhost, the server polls this DID's repository for its + bot.did.operator claim and policy records. --demo provision n agents on startup, then delete and pin one. Refused unless the primary zone is under .localhost. --names how to name agents. `hostname` (the default) uses the @@ -1294,6 +1296,19 @@ async fn run(mut args: Args) -> Result<(), String> { )); } } + // A run behind a real certificate is a public one, and every account + // it mints names the owner it was started with. `DEFAULT_OWNER` + // resolves to nothing, so every one of them would be an account + // nobody can be asked about — permanently, since the owner is written + // into the account at mint time. Refused here rather than warned + // about below, where a public deployment is exactly the case the + // warning would be scrolling past. + if args.owner == DEFAULT_OWNER { + return Err(format!( + "--tls acme needs --owner: every account this run mints would name \ + {DEFAULT_OWNER}, which resolves to nobody" + )); + } } // A zone this run may actually write needs to know what to write. The @@ -1475,11 +1490,22 @@ async fn run(mut args: Args) -> Result<(), String> { )?, }; - // Bind to both IPv4 and IPv6 loopback. On a systemd-resolved host, - // `*.localhost` resolves to `::1`, so the server must listen on IPv6. - // Binding to `[::]` with dual-stack enabled (IPV6_V6ONLY=0) listens on - // both the IPv6 unspecified address and the IPv4 loopback, accepting - // connections on either `127.0.0.1` or `::1`. + // Which addresses this run answers on. A `.localhost` zone is a + // development run: it mints with no attestation claim (see this file's + // own doc) and, without `--data`, holds every blob in memory, so a + // listener on every interface hands both to anybody sharing the network. + // It binds the IPv6 loopback instead — `*.localhost` resolves to `::1`, + // which is what a browser and this server's own DNS stub both reach. + // + // A real zone is a deployment meant to be reached, and binds the IPv6 + // unspecified address with dual-stack enabled (IPV6_V6ONLY=0), which + // answers on every IPv4 and IPv6 address this host has. + let loopback_only = zones.iter().all(|zone| LoopbackDns::accepts(zone.host())); + let bind_host = if loopback_only { + Ipv6Addr::LOCALHOST + } else { + Ipv6Addr::UNSPECIFIED + }; let socket = socket2::Socket::new(Domain::IPV6, Type::STREAM, Some(Protocol::TCP)) .map_err(|err| format!("could not create socket: {err}"))?; socket @@ -1488,7 +1514,7 @@ async fn run(mut args: Args) -> Result<(), String> { socket .set_reuse_address(true) .map_err(|err| format!("could not set reuse address: {err}"))?; - let addr = SocketAddr::from((Ipv6Addr::UNSPECIFIED, args.port)); + let addr = SocketAddr::from((bind_host, args.port)); socket .bind(&addr.into()) .map_err(|err| format!("could not bind {addr}: {err}"))?; @@ -1501,6 +1527,11 @@ async fn run(mut args: Args) -> Result<(), String> { .map_err(|err| format!("could not set non-blocking: {err}"))?; let listener = TcpListener::from_std(std_listener) .map_err(|err| format!("could not convert to tokio listener: {err}"))?; + info!( + %addr, + loopback_only, + "listening" + ); // A `.localhost` run admits no host: there is no operator poll to find a // vouch (see below), so no node key ever enters the attestation verifier @@ -3231,6 +3262,67 @@ mod config_tests { assert!(err.contains("--demo"), "error: {err}"); } + /// **What a default run exposes.** With no flags the zone is + /// `agents.localhost`, which mints with no attestation claim and, with + /// no `--data`, holds every blob in memory. A listener on every + /// interface hands both to anybody sharing the network, so a + /// `.localhost` zone binds the loopback address and a real zone binds + /// the unspecified one. + #[test] + fn only_a_real_zone_listens_on_every_interface() { + let bind_host_for = |zone: &str| { + let zone = Zone::new(zone).expect("a valid zone"); + if LoopbackDns::accepts(zone.host()) { + Ipv6Addr::LOCALHOST + } else { + Ipv6Addr::UNSPECIFIED + } + }; + assert_eq!(bind_host_for(DEFAULT_ZONE), Ipv6Addr::LOCALHOST); + assert_eq!(bind_host_for("agents.localhost"), Ipv6Addr::LOCALHOST); + assert_eq!(bind_host_for("agents.pds.example"), Ipv6Addr::UNSPECIFIED); + + // And the binary reads it the one way, from the zones it serves — + // read out of the source, because standing the listener up is what + // `run` does after it has taken the data directory's lock. + let source = include_str!("didbot-pds.rs"); + let (binary, _) = source + .split_once("#[cfg(test)]") + .expect("the binary ends where its tests begin"); + assert!( + binary.contains( + "let loopback_only = zones.iter().all(|zone| LoopbackDns::accepts(zone.host()));" + ), + "the listener no longer decides its address from the zones it serves" + ); + } + + /// A run behind a real certificate is a public one, and an account it + /// mints names its owner forever. The default owner resolves to nobody. + #[tokio::test] + async fn acme_without_an_owner_is_refused() { + let args = parse_args( + [ + "--tls", + "acme", + "--data", + "/nonexistent-this-run-never-gets-there", + "--zone", + "agents.pds.example", + "--route53-zone-id", + "Z123", + ] + .into_iter() + .map(str::to_owned), + ) + .unwrap(); + let err = match run(args).await { + Ok(()) => panic!("--tls acme with no --owner should have been refused"), + Err(err) => err, + }; + assert!(err.contains("--owner"), "error: {err}"); + } + /// A `Route53Dns` built and never resynced starts believing the zone is /// empty, and its local bookkeeping is what `withdraw`/`withdraw_txt` /// consult before deciding a record exists — so a fresh process after a