From 2274151c17995287291c585bcd38120cd7001174 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Thu, 23 Apr 2026 00:35:41 +0300 Subject: [PATCH 1/2] fix(packet): parse SOA natively to stop malformed replies (#128) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SOA records were stored as opaque bytes (DnsRecord::UNKNOWN), so the RFC 1035 §3.3.13 MNAME/RNAME name-compression pointers — offsets into the upstream packet — were re-emitted verbatim. Once Numa applied its own compression to surrounding names, those pointers landed on garbage and clients rejected the reply ("malformed reply packet" in kdig). Parse SOA via read_qname and write via write_qname, matching the NS/CNAME/MX pattern. Adds the canonical-rdata arm in dnssec.rs for RRSIG verification. Regression test round-trips a CNAME-chain response with a compressed SOA in authority through hickory-proto strict parse. --- src/dnssec.rs | 22 +++++++ src/record.rs | 70 ++++++++++++++++++++- tests/soa_compression_bug.rs | 115 +++++++++++++++++++++++++++++++++++ 3 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 tests/soa_compression_bug.rs diff --git a/src/dnssec.rs b/src/dnssec.rs index 8614810..877b495 100644 --- a/src/dnssec.rs +++ b/src/dnssec.rs @@ -882,6 +882,28 @@ fn record_rdata_canonical(record: &DnsRecord) -> Vec { rdata.extend(type_bitmap); rdata } + DnsRecord::SOA { + mname, + rname, + serial, + refresh, + retry, + expire, + minimum, + .. + } => { + let mname_wire = name_to_wire(mname); + let rname_wire = name_to_wire(rname); + let mut rdata = Vec::with_capacity(mname_wire.len() + rname_wire.len() + 20); + rdata.extend(&mname_wire); + rdata.extend(&rname_wire); + rdata.extend(&serial.to_be_bytes()); + rdata.extend(&refresh.to_be_bytes()); + rdata.extend(&retry.to_be_bytes()); + rdata.extend(&expire.to_be_bytes()); + rdata.extend(&minimum.to_be_bytes()); + rdata + } DnsRecord::UNKNOWN { data, .. } => data.clone(), DnsRecord::RRSIG { .. } => Vec::new(), } diff --git a/src/record.rs b/src/record.rs index 7de9bb4..0fefd72 100644 --- a/src/record.rs +++ b/src/record.rs @@ -24,6 +24,17 @@ pub enum DnsRecord { host: String, ttl: u32, }, + SOA { + domain: String, + mname: String, + rname: String, + serial: u32, + refresh: u32, + retry: u32, + expire: u32, + minimum: u32, + ttl: u32, + }, CNAME { domain: String, host: String, @@ -100,6 +111,7 @@ impl DnsRecord { | DnsRecord::RRSIG { domain, .. } | DnsRecord::NSEC { domain, .. } | DnsRecord::NSEC3 { domain, .. } + | DnsRecord::SOA { domain, .. } | DnsRecord::UNKNOWN { domain, .. } => domain, } } @@ -111,6 +123,7 @@ impl DnsRecord { DnsRecord::NS { .. } => QueryType::NS, DnsRecord::CNAME { .. } => QueryType::CNAME, DnsRecord::MX { .. } => QueryType::MX, + DnsRecord::SOA { .. } => QueryType::SOA, DnsRecord::DNSKEY { .. } => QueryType::DNSKEY, DnsRecord::DS { .. } => QueryType::DS, DnsRecord::RRSIG { .. } => QueryType::RRSIG, @@ -132,6 +145,7 @@ impl DnsRecord { | DnsRecord::RRSIG { ttl, .. } | DnsRecord::NSEC { ttl, .. } | DnsRecord::NSEC3 { ttl, .. } + | DnsRecord::SOA { ttl, .. } | DnsRecord::UNKNOWN { ttl, .. } => *ttl, } } @@ -172,6 +186,12 @@ impl DnsRecord { + next_hashed_owner.capacity() + type_bitmap.capacity() } + DnsRecord::SOA { + domain, + mname, + rname, + .. + } => domain.capacity() + mname.capacity() + rname.capacity(), DnsRecord::UNKNOWN { domain, data, .. } => domain.capacity() + data.capacity(), } } @@ -188,6 +208,7 @@ impl DnsRecord { | DnsRecord::RRSIG { ttl, .. } | DnsRecord::NSEC { ttl, .. } | DnsRecord::NSEC3 { ttl, .. } + | DnsRecord::SOA { ttl, .. } | DnsRecord::UNKNOWN { ttl, .. } => *ttl = new_ttl, } } @@ -365,8 +386,31 @@ impl DnsRecord { ttl, }) } + QueryType::SOA => { + // MNAME/RNAME compressible per RFC 1035 §3.3.13 — decompress to avoid stale pointers on re-emit. + let mut mname = String::with_capacity(64); + buffer.read_qname(&mut mname)?; + let mut rname = String::with_capacity(64); + buffer.read_qname(&mut rname)?; + let serial = buffer.read_u32()?; + let refresh = buffer.read_u32()?; + let retry = buffer.read_u32()?; + let expire = buffer.read_u32()?; + let minimum = buffer.read_u32()?; + Ok(DnsRecord::SOA { + domain, + mname, + rname, + serial, + refresh, + retry, + expire, + minimum, + ttl, + }) + } _ => { - // SOA, TXT, SRV, etc. — stored as opaque bytes until parsed natively + // TXT, SRV, HTTPS, SVCB, etc. — stored as opaque bytes until parsed natively let data = buffer.get_range(buffer.pos(), data_len as usize)?.to_vec(); buffer.step(data_len as usize)?; Ok(DnsRecord::UNKNOWN { @@ -430,6 +474,30 @@ impl DnsRecord { let size = buffer.pos() - (pos + 2); buffer.set_u16(pos, size as u16)?; } + DnsRecord::SOA { + ref domain, + ref mname, + ref rname, + serial, + refresh, + retry, + expire, + minimum, + ttl, + } => { + write_header(buffer, domain, QueryType::SOA.to_num(), ttl)?; + let rdlen_pos = buffer.pos(); + buffer.write_u16(0)?; + buffer.write_qname(mname)?; + buffer.write_qname(rname)?; + buffer.write_u32(serial)?; + buffer.write_u32(refresh)?; + buffer.write_u32(retry)?; + buffer.write_u32(expire)?; + buffer.write_u32(minimum)?; + let rdlen = buffer.pos() - (rdlen_pos + 2); + buffer.set_u16(rdlen_pos, rdlen as u16)?; + } DnsRecord::AAAA { ref domain, ref addr, diff --git a/tests/soa_compression_bug.rs b/tests/soa_compression_bug.rs new file mode 100644 index 0000000..5f4f2f0 --- /dev/null +++ b/tests/soa_compression_bug.rs @@ -0,0 +1,115 @@ +//! Regression test for issue #128: SOA with compressed MNAME/RNAME must +//! survive Numa's round-trip — compression pointers reference the upstream +//! packet's byte layout, so we have to decompress on read and re-compress +//! on write. + +use numa::buffer::BytePacketBuffer; +use numa::packet::DnsPacket; + +const COMPRESSION_FLAG: u16 = 0xC000; + +fn upstream_packet() -> Vec { + let mut p = Vec::::new(); + + p.extend_from_slice(&[ + 0x12, 0x34, 0x81, 0x80, 0x00, 0x01, 0x00, 0x02, 0x00, 0x01, 0x00, 0x00, + ]); + + assert_eq!(p.len(), 12); + write_name(&mut p, &["odin", "adobe", "com"]); + p.extend_from_slice(&[0x00, 0x41, 0x00, 0x01]); + + p.extend_from_slice(&[0xC0, 0x0C]); + p.extend_from_slice(&[0x00, 0x05, 0x00, 0x01, 0x00, 0x00, 0x23, 0x7F]); + let rdlen_pos_1 = p.len(); + p.extend_from_slice(&[0x00, 0x00]); + let cname1_start = p.len(); + write_name(&mut p, &["cdn", "adobeaemcloud", "com"]); + let rdlen_1 = (p.len() - cname1_start) as u16; + p[rdlen_pos_1..rdlen_pos_1 + 2].copy_from_slice(&rdlen_1.to_be_bytes()); + + p.extend_from_slice(&(COMPRESSION_FLAG | cname1_start as u16).to_be_bytes()); + p.extend_from_slice(&[0x00, 0x05, 0x00, 0x01, 0x00, 0x00, 0x23, 0x7F]); + let rdlen_pos_2 = p.len(); + p.extend_from_slice(&[0x00, 0x00]); + let cname2_start = p.len(); + p.push(9); + p.extend_from_slice(b"adobe-aem"); + let map_label_off = p.len(); + p.push(3); + p.extend_from_slice(b"map"); + let fastly_label_off = p.len(); + p.push(6); + p.extend_from_slice(b"fastly"); + p.push(3); + p.extend_from_slice(b"net"); + p.push(0); + let rdlen_2 = (p.len() - cname2_start) as u16; + p[rdlen_pos_2..rdlen_pos_2 + 2].copy_from_slice(&rdlen_2.to_be_bytes()); + + p.extend_from_slice(&(COMPRESSION_FLAG | fastly_label_off as u16).to_be_bytes()); + p.extend_from_slice(&[0x00, 0x06, 0x00, 0x01, 0x00, 0x00, 0x07, 0x08]); + let rdlen_pos_soa = p.len(); + p.extend_from_slice(&[0x00, 0x00]); + let soa_rdata_start = p.len(); + p.extend_from_slice(&(COMPRESSION_FLAG | map_label_off as u16).to_be_bytes()); + p.extend_from_slice(&(COMPRESSION_FLAG | fastly_label_off as u16).to_be_bytes()); + p.extend_from_slice(&1u32.to_be_bytes()); + p.extend_from_slice(&7200u32.to_be_bytes()); + p.extend_from_slice(&3600u32.to_be_bytes()); + p.extend_from_slice(&1209600u32.to_be_bytes()); + p.extend_from_slice(&1800u32.to_be_bytes()); + let rdlen_soa = (p.len() - soa_rdata_start) as u16; + p[rdlen_pos_soa..rdlen_pos_soa + 2].copy_from_slice(&rdlen_soa.to_be_bytes()); + + p +} + +fn write_name(p: &mut Vec, labels: &[&str]) { + for l in labels { + p.push(l.len() as u8); + p.extend_from_slice(l.as_bytes()); + } + p.push(0); +} + +#[test] +fn compressed_soa_survives_numa_round_trip() { + let upstream = upstream_packet(); + + let hickory_in = hickory_proto::op::Message::from_vec(&upstream) + .expect("hand-crafted upstream must be valid"); + let soa_in_rd = hickory_in.name_servers()[0] + .data() + .clone() + .into_soa() + .expect("SOA rdata"); + assert_eq!(soa_in_rd.mname().to_string(), "map.fastly.net."); + assert_eq!(soa_in_rd.rname().to_string(), "fastly.net."); + + let mut in_buf = BytePacketBuffer::from_bytes(&upstream); + let pkt = DnsPacket::from_buffer(&mut in_buf).expect("numa parses upstream"); + assert_eq!(pkt.answers.len(), 2); + assert_eq!(pkt.authorities.len(), 1); + + let mut out_buf = BytePacketBuffer::new(); + pkt.write(&mut out_buf).expect("numa writes"); + let out = out_buf.filled().to_vec(); + + let hickory_out = + hickory_proto::op::Message::from_vec(&out).expect("numa re-emission must parse strictly"); + + let soa_out_rd = hickory_out.name_servers()[0] + .data() + .clone() + .into_soa() + .expect("SOA rdata on output"); + + assert_eq!(soa_out_rd.mname().to_string(), "map.fastly.net."); + assert_eq!(soa_out_rd.rname().to_string(), "fastly.net."); + assert_eq!(soa_out_rd.serial(), 1); + assert_eq!(soa_out_rd.refresh(), 7200); + assert_eq!(soa_out_rd.retry(), 3600); + assert_eq!(soa_out_rd.expire(), 1209600); + assert_eq!(soa_out_rd.minimum(), 1800); +} -- 2.34.1 From f7f35b34241769dd817eb18ef80da84924d53610 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 24 Apr 2026 15:09:16 +0300 Subject: [PATCH 2/2] docs: lift user-facing guides to recipes/, drop dangling docs/ refs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/ is gitignored; references to docs/implementation/*.md from public source, configs, and packaging were dead links outside the maintainer machine. Adds four recipes (README, dnsdist-front, doh-on-lan, odoh-upstream) under top-level recipes/ and repoints existing pointers. - numa.toml, packaging/client/{README.md,numa.toml}: point to recipes/odoh-upstream.md. - src/{bootstrap_resolver,forward,serve}.rs: reference issue #122 directly (module scope is broader than the ODoH-specific recipe). - src/health.rs: drop the §-ref; iOS HealthInfo remains named as the canonical consumer. --- numa.toml | 4 +-- packaging/client/README.md | 6 ++-- packaging/client/numa.toml | 2 +- recipes/README.md | 11 +++++++ recipes/dnsdist-front.md | 64 ++++++++++++++++++++++++++++++++++++++ recipes/doh-on-lan.md | 61 ++++++++++++++++++++++++++++++++++++ recipes/odoh-upstream.md | 59 +++++++++++++++++++++++++++++++++++ src/bootstrap_resolver.rs | 3 +- src/forward.rs | 3 +- src/health.rs | 9 +++--- src/serve.rs | 1 - 11 files changed, 207 insertions(+), 16 deletions(-) create mode 100644 recipes/README.md create mode 100644 recipes/dnsdist-front.md create mode 100644 recipes/doh-on-lan.md create mode 100644 recipes/odoh-upstream.md diff --git a/numa.toml b/numa.toml index 2138dd2..57d0249 100644 --- a/numa.toml +++ b/numa.toml @@ -51,8 +51,8 @@ api_port = 5380 # relay_ip = "178.104.229.30" # optional: pin IPs so numa doesn't leak the # target_ip = "104.16.249.249" # relay/target hostnames via the bootstrap # # resolver on cold boot when numa is its -# # own system DNS. See docs/implementation/ -# # bootstrap-resolver.md. +# # own system DNS. See +# # recipes/odoh-upstream.md. # root_hints = [ # only used in recursive mode # "198.41.0.4", # a.root-servers.net (Verisign) # "199.9.14.201", # b.root-servers.net (USC-ISI) diff --git a/packaging/client/README.md b/packaging/client/README.md index f6e76c0..f66359f 100644 --- a/packaging/client/README.md +++ b/packaging/client/README.md @@ -2,10 +2,10 @@ Single-container deploy that runs Numa as an ODoH (RFC 9230) client: every DNS query routes through an independent relay + target so neither operator -sees both your IP and your question. See the [ODoH integration doc][odoh] -for the full protocol and privacy trade-offs. +sees both your IP and your question. See the [ODoH upstream recipe][odoh] +for the protocol details and the bootstrap-pinning trade-offs. -[odoh]: ../../docs/implementation/odoh-integration.md +[odoh]: ../../recipes/odoh-upstream.md ## Prerequisites diff --git a/packaging/client/numa.toml b/packaging/client/numa.toml index 039d723..64b9268 100644 --- a/packaging/client/numa.toml +++ b/packaging/client/numa.toml @@ -1,7 +1,7 @@ # Numa — ODoH client mode (docker-compose starter). # Sends every DNS query through an independent relay + target pair so # neither operator sees both your IP and your question. See -# docs/implementation/odoh-integration.md for the protocol details and +# recipes/odoh-upstream.md for the protocol details and # packaging/client/README.md for deploy notes. [server] diff --git a/recipes/README.md b/recipes/README.md new file mode 100644 index 0000000..fa05c2d --- /dev/null +++ b/recipes/README.md @@ -0,0 +1,11 @@ +# Recipes + +Scenario-driven configs for common Numa deployments. Each recipe is self-contained: copy the snippet, adjust the marked fields, reload. + +## Transport / encryption + +- [DoH on the LAN](doh-on-lan.md) — expose Numa's built-in DNS-over-HTTPS to local clients. +- [dnsdist in front of Numa](dnsdist-front.md) — terminate public TLS externally, keep Numa on loopback. +- [ODoH upstream with bootstrap pinning](odoh-upstream.md) — oblivious DNS client mode without leaking the relay/target hostnames. + +Missing a scenario? Open an issue or PR — these are plain Markdown with no build step. diff --git a/recipes/dnsdist-front.md b/recipes/dnsdist-front.md new file mode 100644 index 0000000..310b53c --- /dev/null +++ b/recipes/dnsdist-front.md @@ -0,0 +1,64 @@ +# dnsdist in front of Numa + +For public DoH with a real (ACME-signed) cert, terminate TLS outside Numa and forward plain DNS (or loopback-only DoH) to the resolver. Cert renewal, rate-limiting, and load-balancing live in the front-end; Numa stays focused on resolution. + +## When to use this + +- Public hostname (`dns.example.com`) with a Let's Encrypt or internal PKI cert. +- You want a dedicated front-end for DoH/DoT/DoQ while Numa stays loopback-bound. +- You plan to run multiple Numa instances behind one endpoint. + +## Architecture + +``` + public 443/DoH ┐ + public 853/DoT ├─► dnsdist ─► 127.0.0.1:53 (Numa UDP/TCP) + public 443/DoQ ┘ +``` + +## dnsdist config + +```lua +-- /etc/dnsdist/dnsdist.conf + +newServer({address="127.0.0.1:53", name="numa", checkType="A", checkName="numa.rs."}) + +addDOHLocal( + "0.0.0.0:443", + "/etc/letsencrypt/live/dns.example.com/fullchain.pem", + "/etc/letsencrypt/live/dns.example.com/privkey.pem", + "/dns-query", + {doTCP=true, reusePort=true} +) + +addTLSLocal( + "0.0.0.0:853", + "/etc/letsencrypt/live/dns.example.com/fullchain.pem", + "/etc/letsencrypt/live/dns.example.com/privkey.pem" +) + +addAction(AllRule(), PoolAction("", false)) +``` + +## Numa config + +```toml +[proxy] +enabled = true # keep if you still use *.numa service routing +bind_addr = "127.0.0.1" # stays default +``` + +No changes to `[server]` — Numa keeps serving plain DNS on UDP/TCP 53, which dnsdist forwards. + +## Caveat: client IPs + +Without PROXY protocol support in Numa, the query log shows the front-end's IP on every query, not the real client. dnsdist can emit PROXY v2 (`useProxyProtocol=true` on `newServer`), but Numa doesn't yet parse it — tracked in the wish-list under #143. Until then, accept the blind spot or correlate against dnsdist's own logs. + +## Verify + +```bash +kdig +https @dns.example.com example.com +kdig +tls @dns.example.com example.com +``` + +Both should return clean answers. Numa's `/queries` API should show the request landing, sourced from the front-end IP. diff --git a/recipes/doh-on-lan.md b/recipes/doh-on-lan.md new file mode 100644 index 0000000..70b607e --- /dev/null +++ b/recipes/doh-on-lan.md @@ -0,0 +1,61 @@ +# DoH on the LAN + +Numa ships an RFC 8484 DoH endpoint (`POST /dns-query`) on the `[proxy]` HTTPS listener. By default it binds `127.0.0.1:443` with a self-signed cert — invisible to anything off the box. Three changes make it reachable from the LAN. + +## When to use this + +- Your phone/laptop is on the same network as Numa and you want encrypted DNS without a cloud resolver. +- You're OK installing Numa's self-signed CA on every client (one-time, via `/ca.pem` + the mobileconfig flow). + +For a publicly-trusted cert, see [dnsdist in front of Numa](dnsdist-front.md) instead. + +## Minimal config + +```toml +[proxy] +enabled = true # default +bind_addr = "0.0.0.0" # was 127.0.0.1 — expose to LAN +tls_port = 443 # default; DoH is served here +tld = "numa" # default — self-resolving, see below +``` + +`tld` is the DoH gate: Numa accepts the DoH request only when the `Host` header is loopback or equals (or is a subdomain of) `tld`. Clients therefore dial `https://numa/dns-query`. + +With the default `tld = "numa"`, there's no DNS bootstrap to configure: Numa already resolves `numa` and `*.numa` to its own LAN IP for remote clients (that's how the `*.numa` service-proxy feature works). Any client that uses Numa as its resolver will resolve `numa` correctly on first try. + +If you'd rather use a hostname that resolves via normal DNS (e.g. you want DoH-only clients that never talk plain DNS to Numa), set `tld = "dns.example.com"` and add a matching A record in whichever DNS your clients consult before reaching Numa. + +## Trust the CA on each client + +Numa generates a self-signed CA at startup. Fetch it once, import it wherever you'll run the DoH client: + +```bash +curl -o numa-ca.pem http://:5380/ca.pem +``` + +- **macOS** — `sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain numa-ca.pem` +- **iOS** — install the mobileconfig from the API (same CA, signed profile). Flip *Settings → General → About → Certificate Trust Settings* on after install. +- **Linux** — drop into `/usr/local/share/ca-certificates/` and run `sudo update-ca-certificates`. +- **Android** — requires the user-installed CA path; browsers may still refuse it for DoH. Consider the [dnsdist front](dnsdist-front.md) route instead. + +## Verify + +```bash +kdig +https @numa example.com +``` + +Without `+https` kdig uses plain DNS. With `+https` the same answers should flow over port 443. + +Raw check: + +```bash +curl -H 'accept: application/dns-message' \ + --data-binary @query.bin \ + https://numa/dns-query +``` + +## Gotchas + +- Port 443 is privileged on Linux/macOS. Run Numa via the provided service units, or grant `CAP_NET_BIND_SERVICE` (`sudo setcap 'cap_net_bind_service=+ep' /path/to/numa`). +- Non-matching `Host` header → HTTP 404 from the proxy's fallback handler. Double-check `tld`. +- ChromeOS enrollment rejects user-installed CAs for some flows — known pain point, see issue #136. diff --git a/recipes/odoh-upstream.md b/recipes/odoh-upstream.md new file mode 100644 index 0000000..0469bca --- /dev/null +++ b/recipes/odoh-upstream.md @@ -0,0 +1,59 @@ +# ODoH upstream with bootstrap pinning + +Numa can run as an Oblivious DoH (RFC 9230) client: the relay sees your IP but not the question, the target sees the question but not your IP. Neither party alone can re-identify a query. This recipe covers the minimal config and the bootstrap leak that `relay_ip` / `target_ip` close. + +## When to use this + +- You want split-trust encrypted DNS without a single provider seeing both who you are and what you asked. +- Numa is your system resolver (so there's no "other" DNS to ask). + +## Minimal config + +```toml +[upstream] +mode = "odoh" +relay = "https://odoh-relay.numa.rs/relay" +target = "https://odoh.cloudflare-dns.com/dns-query" +strict = true # refuse to fall back to a non-oblivious path on relay failure +``` + +`strict = true` means a relay-level HTTPS failure returns SERVFAIL instead of silently downgrading. Set it to `false` and configure `[upstream].fallback` if you'd rather keep resolving (at the cost of the oblivious property). + +## The bootstrap leak + +When Numa is the system resolver and needs to reach the relay/target, *something* has to translate `odoh-relay.numa.rs` → IP. If Numa asks itself, you deadlock. If Numa asks a bootstrap resolver (1.1.1.1, 9.9.9.9), that resolver learns which ODoH endpoint you use in cleartext — it can't see your questions, but it sees the destination. That's the leak ODoH was supposed to close. + +`relay_ip` and `target_ip` tell Numa the IPs directly, so it never asks anyone: + +```toml +[upstream] +mode = "odoh" +relay = "https://odoh-relay.numa.rs/relay" +target = "https://odoh.cloudflare-dns.com/dns-query" +relay_ip = "178.104.229.30" # pin the relay — no hostname lookup +target_ip = "104.16.249.249" # pin the target — no hostname lookup +``` + +Numa still validates TLS against the hostnames in `relay` / `target`, so a hijacked IP can't masquerade — pinning skips only the DNS step. + +## Finding current IPs + +```bash +dig +short odoh-relay.numa.rs +dig +short odoh.cloudflare-dns.com +``` + +Re-pin when an operator rotates. The community-maintained list at is a useful cross-reference. + +## Verify + +```bash +kdig @127.0.0.1 example.com +``` + +Numa's `/queries` API and startup banner should label the upstream as `odoh://`. Look for `ODoH relay returned ...` errors in the logs if routing fails. + +## Known gotchas + +- **Same-operator refused.** Numa's eTLD+1 check blocks configs where the relay and target belong to the same operator (pointless — same party sees both sides). Override only when testing. +- **Single relay.** Current config accepts one relay and one target. Multi-entry rotation/failover is tracked in #140. diff --git a/src/bootstrap_resolver.rs b/src/bootstrap_resolver.rs index c3be8bd..44214e4 100644 --- a/src/bootstrap_resolver.rs +++ b/src/bootstrap_resolver.rs @@ -2,8 +2,7 @@ //! relay/target, blocklist CDN). When numa is its own system resolver //! (`/etc/resolv.conf → 127.0.0.1`, HAOS add-on, Pi-hole-style container), //! the default `getaddrinfo` path loops back through numa before numa can -//! answer — a chicken-and-egg that deadlocks cold boot. See issue #122 and -//! `docs/implementation/bootstrap-resolver.md`. +//! answer — a chicken-and-egg that deadlocks cold boot. See issue #122. //! //! Resolution order per hostname: //! 1. Per-hostname overrides (e.g. ODoH `relay_ip` / `target_ip`) → return diff --git a/src/forward.rs b/src/forward.rs index e3f307b..1c39292 100644 --- a/src/forward.rs +++ b/src/forward.rs @@ -175,8 +175,7 @@ pub fn parse_upstream( /// /// Uses the system resolver. Callers running inside `serve::run` pass the /// shared [`crate::bootstrap_resolver::NumaResolver`] via -/// [`build_https_client_with_resolver`] to avoid the self-loop documented -/// in `docs/implementation/bootstrap-resolver.md`. +/// [`build_https_client_with_resolver`] to avoid the self-loop (issue #122). pub fn build_https_client() -> reqwest::Client { build_https_client_with_resolver(1, None) } diff --git a/src/health.rs b/src/health.rs index 5767f4b..30cad9a 100644 --- a/src/health.rs +++ b/src/health.rs @@ -7,11 +7,10 @@ //! Both handlers call [`HealthResponse::build`] to assemble the JSON //! response from `HealthMeta` + live inputs. //! -//! JSON schema is documented in `docs/implementation/ios-companion-app.md` -//! §4.2. The iOS companion app's `HealthInfo` struct is the canonical -//! consumer; any change to this response must keep that struct decoding -//! cleanly (all consumed fields are optional on the Swift side, but -//! `lan_ip` is load-bearing for the pipeline). +//! The iOS companion app's `HealthInfo` struct is the canonical consumer; +//! any change to this response must keep that struct decoding cleanly (all +//! consumed fields are optional on the Swift side, but `lan_ip` is +//! load-bearing for the pipeline). use std::net::Ipv4Addr; use std::path::Path; diff --git a/src/serve.rs b/src/serve.rs index c76d174..e20ebe8 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -52,7 +52,6 @@ pub async fn run(config_path: String) -> crate::Result<()> { // Routes numa-originated HTTPS (DoH upstream, ODoH relay/target, blocklist // CDN) away from the system resolver so lookups don't loop back through // numa when it's its own system DNS. - // See `docs/implementation/bootstrap-resolver.md`. let resolver_overrides = match config.upstream.mode { crate::config::UpstreamMode::Odoh => config .upstream -- 2.34.1