From 906e536daf03d944fb1ee91371326bcff1e2fcb8 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Wed, 30 Sep 2026 11:33:40 -0400 Subject: [PATCH] fix(http): resolve localhost names to loopback in every client RFC 6761 reserves localhost and every name under it for loopback. glibc in a container asks DNS for layout.localhost and fails, so the scenario suites could not reach their own servers on a spindle. The Node interop tool does the same. Co-Authored-By: Claude Opus 5.5 Change-Id: Idc35ea2d5ac3d2b7d735ea41f34e620508ec462c --- crates/didbot-http/src/address.rs | 9 ++-- crates/didbot-http/src/lib.rs | 11 ++++- crates/didbot-http/src/resolve.rs | 64 ++++++++++++++++++++++++++ crates/didbot-onboarding/src/native.rs | 11 +++-- tools/oauth-interop/sign-in.mjs | 22 +++++++++ 5 files changed, 106 insertions(+), 11 deletions(-) create mode 100644 crates/didbot-http/src/resolve.rs diff --git a/crates/didbot-http/src/address.rs b/crates/didbot-http/src/address.rs index 6b0e88fa..62beba28 100644 --- a/crates/didbot-http/src/address.rs +++ b/crates/didbot-http/src/address.rs @@ -164,13 +164,12 @@ pub async fn resolve_public( vec![SocketAddr::new(address.into(), port)], ), Some(Host::Domain(name)) => { - let resolved = tokio::net::lookup_host((name, port)) - .await - .map_err(|error| AddressError::Unresolvable { + let resolved = crate::lookup_host(name, port).await.map_err(|error| { + AddressError::Unresolvable { host: name.to_owned(), message: error.to_string(), - })? - .collect(); + } + })?; (name.to_owned(), resolved) } None => { diff --git a/crates/didbot-http/src/lib.rs b/crates/didbot-http/src/lib.rs index 580b746d..c199abe3 100644 --- a/crates/didbot-http/src/lib.rs +++ b/crates/didbot-http/src/lib.rs @@ -22,14 +22,19 @@ //! //! [`doh`] is DNS over HTTPS, one GET through a pinned client, for the //! records a stub resolver has no API for. +//! +//! [`lookup_host`] is how every client here resolves a name: RFC 6761's +//! `localhost` names go to loopback, and the rest go to the system. mod address; pub mod doh; +mod resolve; pub use address::{ is_loopback, is_public_address, pinned_client, refuse_private_host, resolve_public, AddressError, PrivateAddresses, REFUSED_BLOCKS, }; +pub use resolve::{is_localhost_name, lookup_host}; use std::time::Duration; @@ -77,7 +82,8 @@ pub const EXTRA_CA_CERTS: &str = "DIDBOT_EXTRA_CA_CERTS"; pub fn builder() -> reqwest::ClientBuilder { let mut builder = reqwest::Client::builder() .connect_timeout(DEFAULT_CONNECT_TIMEOUT) - .timeout(DEFAULT_TIMEOUT); + .timeout(DEFAULT_TIMEOUT) + .dns_resolver(std::sync::Arc::new(resolve::Resolver)); for certificate in extra_root_certificates() { builder = builder.add_root_certificate(certificate); } @@ -141,7 +147,8 @@ fn extra_root_certificates() -> Vec { pub fn blocking_builder() -> reqwest::blocking::ClientBuilder { let mut builder = reqwest::blocking::Client::builder() .connect_timeout(DEFAULT_CONNECT_TIMEOUT) - .timeout(DEFAULT_TIMEOUT); + .timeout(DEFAULT_TIMEOUT) + .dns_resolver(std::sync::Arc::new(resolve::Resolver)); for certificate in extra_root_certificates() { builder = builder.add_root_certificate(certificate); } diff --git a/crates/didbot-http/src/resolve.rs b/crates/didbot-http/src/resolve.rs new file mode 100644 index 00000000..2b7fa9d1 --- /dev/null +++ b/crates/didbot-http/src/resolve.rs @@ -0,0 +1,64 @@ +//! Name resolution for every client built here. +//! +//! RFC 6761 reserves `localhost` and every name under it for loopback, and +//! asks resolvers to answer them without asking DNS. systemd-resolved does; +//! glibc's own resolver, which a container usually has, does not, and +//! `layout.localhost` fails there. A development zone and every test stack +//! live under `.localhost`, so this answers those names itself and hands +//! every other name to the system. + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + +/// Whether `name` is `localhost` or a name under it. +pub fn is_localhost_name(name: &str) -> bool { + let name = name.strip_suffix('.').unwrap_or(name).to_ascii_lowercase(); + name == "localhost" || name.ends_with(".localhost") +} + +/// Every address `name` answers to at `port`: loopback for a +/// [localhost name](is_localhost_name), and the system's answer for any +/// other. +pub async fn lookup_host(name: &str, port: u16) -> std::io::Result> { + if is_localhost_name(name) { + return Ok([ + IpAddr::V4(Ipv4Addr::LOCALHOST), + IpAddr::V6(Ipv6Addr::LOCALHOST), + ] + .into_iter() + .map(|address| SocketAddr::new(address, port)) + .collect()); + } + Ok(tokio::net::lookup_host((name, port)).await?.collect()) +} + +/// [`lookup_host`] as the resolver every client built here uses. +pub(crate) struct Resolver; + +impl reqwest::dns::Resolve for Resolver { + fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving { + Box::pin(async move { + let addresses = lookup_host(name.as_str(), 0).await?; + Ok(Box::new(addresses.into_iter()) as reqwest::dns::Addrs) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn localhost_and_every_name_under_it() { + for name in [ + "localhost", + "LocalHost", + "layout.localhost", + "a.b.localhost.", + ] { + assert!(is_localhost_name(name), "{name}"); + } + for name in ["notlocalhost", "localhost.example", "example.com", "host"] { + assert!(!is_localhost_name(name), "{name}"); + } + } +} diff --git a/crates/didbot-onboarding/src/native.rs b/crates/didbot-onboarding/src/native.rs index 679e27de..5f1759df 100644 --- a/crates/didbot-onboarding/src/native.rs +++ b/crates/didbot-onboarding/src/native.rs @@ -59,7 +59,8 @@ impl NativeEnvironment { } /// The machine's own stub resolver, which is what every other consumer - /// of this server will be looking through. + /// of this server will be looking through. A `.localhost` name is + /// loopback here, as it is to a browser; see [`didbot_http::lookup_host`]. /// /// Deliberately not DoH: the answer that matters for "does this name /// resolve" is the one an ordinary consumer's stub resolver gets, and @@ -67,13 +68,15 @@ impl NativeEnvironment { /// The record types below it — a zone's nameservers, a handle's TXT — /// have no stub-resolver API, and those go over DoH. async fn addresses(&self, name: &str) -> Result, String> { - let target = format!("{name}:443"); - match tokio::time::timeout(CHECK_TIMEOUT, tokio::net::lookup_host(target)).await { + match tokio::time::timeout(CHECK_TIMEOUT, didbot_http::lookup_host(name, 443)).await { Err(_) => Err(format!( "the resolver did not answer within {CHECK_TIMEOUT:?}" )), Ok(Err(error)) => Err(error.to_string()), - Ok(Ok(addresses)) => Ok(addresses.map(|address| address.ip().to_string()).collect()), + Ok(Ok(addresses)) => Ok(addresses + .into_iter() + .map(|address| address.ip().to_string()) + .collect()), } } } diff --git a/tools/oauth-interop/sign-in.mjs b/tools/oauth-interop/sign-in.mjs index 85330fc8..5ad84db0 100644 --- a/tools/oauth-interop/sign-in.mjs +++ b/tools/oauth-interop/sign-in.mjs @@ -26,6 +26,7 @@ // crates/didbot-serve/tests/oauth_interop.rs runs this and checks each step. import { createHash } from "node:crypto"; +import dns from "node:dns"; import { once } from "node:events"; import { readFileSync } from "node:fs"; import { createServer as createHttpServer } from "node:http"; @@ -66,6 +67,27 @@ function isLoopbackHost(host) { return ["localhost", "127.0.0.1", "::1", "[::1]"].includes(host) || host.endsWith(".localhost"); } +/** + * RFC 6761's `localhost` names as loopback, as `didbot_http::lookup_host` + * answers them. The system resolver in a container fails on + * `zone.localhost`, and every connection Node makes looks names up here. + */ +const systemLookup = dns.lookup; +dns.lookup = function (hostname, options, callback) { + if (typeof options === "function") [options, callback] = [{}, options]; + if (typeof options === "number") options = { family: options }; + const host = hostname.toLowerCase().replace(/\.$/, ""); + if (host !== "localhost" && !host.endsWith(".localhost")) { + return systemLookup.call(this, hostname, options, callback); + } + const loopback = [ + { address: "127.0.0.1", family: 4 }, + { address: "::1", family: 6 }, + ].filter(({ family }) => !options.family || options.family === family); + if (options.all) return process.nextTick(callback, null, loopback); + return process.nextTick(callback, null, loopback[0].address, loopback[0].family); +}; + /** * DIDBOT_RESOLVE_PORTS as the Rust tools read it: `zone=port` pairs, loopback * names only, so it can never move where a real name resolves. -- 2.54.0