Adds tests/manual/install-trust-macos.sh — a sudo bash script that
mirrors trust_ca_macos / untrust_ca_macos against a fixture cert with
a unique CN. Designed to coexist with a running production numa:
- Refuses to run if a real "Numa Local CA" is already in System.keychain
(fail-closed protection for dogfood installs)
- Uses a unique CN ("Numa Local CA Test <pid-timestamp>") so the test
cert can never collide with production
- Mirrors the by-hash deletion loop from untrust_ca_macos
- Trap-cleanup on success or interrupt
Lives under tests/manual/ to signal "host-mutating, dev-only" — distinct
from tests/docker/install-trust.sh which is hermetic.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Closes#35.
trust_ca_linux now detects which trust store the distro ships and
runs the matching refresh command, instead of hardcoding Debian's
update-ca-certificates. Detection walks a const table in priority
order, picking the first whose anchor dir exists:
- debian: /usr/local/share/ca-certificates (update-ca-certificates)
- pki: /etc/pki/ca-trust/source/anchors (update-ca-trust extract)
- p11kit: /etc/ca-certificates/trust-source/anchors (trust extract-compat)
Falls back with a clear error listing every backend tried.
Adds Windows support via certutil -addstore Root / -delstore Root,
removing the silent CA-trust gap on numa install (previously the
service installed but the trust step quietly errored, leaving every
HTTPS .numa request throwing browser warnings).
Refactor: trust_ca and untrust_ca are now thin dispatchers calling
per-platform helpers. CA_COMMON_NAME and CA_FILE_NAME are centralized
in tls.rs and reused from system_dns.rs and api.rs. untrust_ca_linux
no longer pre-checks file existence (TOCTOU) and skips the refresh
when no file was actually removed.
Test: tests/docker/install-trust.sh runs the install/uninstall
contract against debian:stable, fedora:latest, and archlinux:latest
in containers, asserting the cert lands in (and is removed from)
the system bundle. All three pass locally.
README notes the Firefox/NSS limitation (separate trust store).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a workflow that runs on release:published (and via manual
workflow_dispatch), fetches sha256 checksums from the published release
assets, and rewrites razvandimescu/homebrew-tap/numa.rb in place:
version, URL paths, and sha256 lines after each url. The formula's
existing on_macos/on_linux structure is preserved.
Uses HOMEBREW_TAP_GITHUB_TOKEN (already set as a repo secret) to push
directly to the tap's main branch.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Expands the DoT paragraph to make the trust model explicit. The
previous version said "self-signed or bring your own cert" without
explaining when to pick which or what the user experience looks like.
The two modes close numa's gap vs AdGuard Home: BYO cert mode is
functionally identical (Let's Encrypt via DNS-01 + cert_path/key_path),
and the self-signed mode is numa's advantage on LAN-only deploys.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds DoT to the four existing touchpoints in the README where the
feature naturally belongs:
- Hero paragraph: mentions DoT alongside DNSSEC as a headline feature
- Ad Blocking & Privacy section: dedicated paragraph with RFC 7858
reference, config hint, and the ALPN strictness guarantee
- Comparison table: new "Encrypted clients (DoT listener)" row.
Pi-hole "Needs stunnel sidecar" (verified — Pi-hole explicitly
closed the native-DoT feature request as out of scope; community
uses stunnel or AdGuard DNS Proxy as a TLS terminator)
- Roadmap: checks off "DNS-over-TLS listener" alongside the existing
DoH entry
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
v0.10.0 ships DNS-over-TLS. Tagged release v0.10.0 on main after
merge will pick up this Cargo.toml version, keeping tag and manifest
aligned for release.yml.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reverts the NUMA_DATA_DIR env var added in the previous commit and
replaces it with a [server] data_dir TOML field. Numa already has a
well-developed config system; adding a parallel env-var mechanism
for a single knob was wrong.
The principle: TOML is for application behavior configuration. Env
vars are for bootstrap values (HOME, SUDO_USER to discover paths
before config loads) and standard ecosystem conventions (RUST_LOG).
data_dir is neither — it's an app knob, so it belongs in the TOML.
Changes:
- lib.rs::data_dir() reverts to the platform-specific fallback only
- config.rs adds `data_dir: Option<PathBuf>` to ServerConfig
- main.rs resolves config.server.data_dir with fallback to
numa::data_dir() and passes it to build_tls_config, then stores the
resolved path on ctx.data_dir for downstream consumers
- tls.rs::build_tls_config takes `data_dir: &Path` as an explicit
parameter instead of calling crate::data_dir() behind the caller's
back. regenerate_tls and dot.rs self_signed_tls now pass
&ctx.data_dir, honoring whatever path the config resolved to
- tests/integration.sh Suite 6 uses `data_dir = "$NUMA_DATA"` in its
test TOML instead of the NUMA_DATA_DIR env var prefix
- numa.toml gains a commented-out data_dir example
No behavior change for existing production deployments (the default
path is unchanged). Test harness is now fully config-driven, and
containerized deploys can override data_dir via mount+config without
needing env var injection.
127/127 unit tests pass, Suite 6 passes end-to-end.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds integration test coverage for the realistic production shape
where both the HTTPS proxy and DoT are enabled simultaneously. This
was previously untested — every existing suite had either one or the
other, so the interaction path was implicit.
What Suite 6 verifies:
- Both listeners bind without panic
- DoT still resolves queries with the proxy enabled
- Proxy HTTPS handshake still works with DoT enabled
- Both certs validate against the same shared CA
To run non-root, adds a NUMA_DATA_DIR env var override to data_dir()
that lets callers point the CA/cert storage at any writable path.
Useful beyond tests: containerized deployments, CI runners, dev
testing without sudo. The fallback is the existing platform-specific
path (unix: /usr/local/var/numa, windows: %PROGRAMDATA%\numa).
Suite 6 sets NUMA_DATA_DIR=/tmp/numa-integration-data before
starting numa, then trusts the generated CA at $NUMA_DATA_DIR/ca.pem
for both kdig (DoT query) and openssl s_client (HTTPS proxy
handshake) verification.
All 6 suites, 32 checks, run non-root and pass locally.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds tests/integration.sh Suite 5 (DoT via kdig + openssl) and
fixes a startup panic caught by it.
Bug: when [dot] cert_path/key_path was set AND [proxy] was disabled,
numa panicked on the first DoT handshake with "Could not
automatically determine the process-level CryptoProvider from Rustls
crate features". In normal deployments the proxy's build_tls_config
installs the default provider as a side effect, masking the missing
call in dot.rs::load_tls_config. Disable the proxy and the panic
surfaces. Fix: call
rustls::crypto::ring::default_provider().install_default() at the
top of load_tls_config (no-op if already installed).
Suite 5 exercises:
- DoT listener binds on configured port
- Resolves a local zone A record over TLS (kdig +tls)
- Persistent connection reuse (kdig +keepopen, 3 queries, 1 handshake)
- ALPN "dot" negotiation (openssl s_client -alpn dot)
- ALPN mismatch rejected with no_application_protocol (openssl -alpn h2)
Uses a pre-generated cert at /tmp so the test runs non-root.
Skips gracefully if kdig or openssl aren't installed.
Also: Dockerfile now EXPOSE 853/tcp so docker run -p 853:853 works
out of the box when users enable DoT.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds dot_rejects_non_dot_alpn to assert the rustls server enforces
ALPN strictness rather than silently accepting a mismatched
negotiation. This is the load-bearing behavior behind the cross-
protocol confusion defense — without enforcement, the ALPN "dot"
advertisement is just a sign hung on an unlocked door.
Refactors test_tls_configs to return the leaf cert DER instead of a
prebuilt client config, and adds a dot_client(cert_der, alpn) helper
so each test can build a client config with the ALPN list it needs.
The five existing DoT tests gain one line each to call dot_client
with dot_alpn(); behavior unchanged.
127/127 tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
self_signed_tls was passing an empty service_names list, so the
generated cert only had the *.numa wildcard SAN. Strict TLS clients
(browsers, possibly some iOS versions) reject wildcards under
single-label TLDs — see the existing comment in tls.rs explaining
why the proxy lists each service explicitly.
setup-phone's mobileconfig sends ServerName "numa.numa" as SNI, so
the DoT cert must have an explicit numa.numa SAN. Pass proxy_tld
itself as a service name, mirroring how main.rs already registers
"numa" as a service for the proxy's TLS cert.
Test fixture updated to mirror the production SAN shape (*.numa +
numa.numa) and switched the client to SNI "numa.numa", so the
existing DoT test suite implicitly exercises the SNI path used by
setup-phone clients.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Both were restating what the code already said — dot_alpn's doc
narrated the function name and the test comment restated the
assertion. RFC 7858 §3.2 is already cited on self_signed_tls and
build_tls_config where the "why" actually matters.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two DoS/interop hardening items:
1. Bound write_framed by WRITE_TIMEOUT (10s) so a slow-reader
attacker can't indefinitely hold a worker task and its connection
permit. Symmetric to the existing handshake timeout.
2. Advertise ALPN "dot" per RFC 7858 §3.2. Required by some strict
DoT clients (newer Apple stacks, some Android versions). rustls
ServerConfig exposes alpn_protocols as a pub field so we set it
after with_single_cert:
- load_tls_config (user-provided cert/key): set directly
- self_signed_tls (new, replaces fallback_tls): builds a fresh
DoT-specific TLS config via build_tls_config with the ALPN list
build_tls_config now takes an `alpn: Vec<Vec<u8>>` parameter so
DoT and the proxy can pass different ALPN lists while sharing the
same CA. Proxy callers pass Vec::new() (unchanged behavior).
Dropped the ctx.tls_config reuse branch: we can't mutate a shared
Arc<ServerConfig> to add DoT-specific ALPN, and reusing the proxy
config was already quietly broken re: SAN (proxy cert covers
*.{tld}, not the DoT server's bind hostname/IP).
Added dot_negotiates_alpn test that asserts conn.alpn_protocol()
returns Some(b"dot") after handshake. 126/126 tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Matches the style of the other opt-in sections (blocking, dnssec, lan).
Documents all five DotConfig fields with their defaults.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Collapse two 4-arm read/timeout matches to let-else (lose one
defensive debug log on payload-read timeout; idle timeouts are
routine on persistent DoT connections anyway)
- Drop MIN_MSG_LEN: DnsPacket::from_buffer rejects truncated input
on its own, and BytePacketBuffer is zero-init so buf[0..2] for
sub-2-byte messages just yields a harmless FORMERR with id=0
- Inline ACCEPT_ERROR_BACKOFF (single use site)
- Drop the partial cert/key warning: missing one of cert_path/
key_path silently falls back to self-signed; users see the
self-signed cert at startup and figure it out
- Drop dot_localhost_resolution test: RFC 6761 localhost is tested
in ctx.rs; this test only verified DoT transport, which
dot_resolves_local_zone already covers
- Drop self-documenting comment in dot_multiple_queries_on_persistent_connection
Net -32 lines, 125/125 tests pass, no behavior change users would notice.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Flatten 4-arm cert/key match in start_dot to 2 arms with the
partial-config warning hoisted into a one-liner above the match.
- Extract send_response() that serializes a DnsPacket and writes it
framed, used by both the FORMERR-on-parse-error and SERVFAIL-on-
resolve-error paths. Removes duplicated buffer/write/log boilerplate
and unifies the rescode logging via {:?}.
No behavior change; 126/126 tests still pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Address review findings on PR #25:
- Refactor resolve_query to take a pre-parsed DnsPacket. Parse-error
handling moves to the UDP caller, eliminating the double warn! line
on malformed UDP queries.
- Enforce MIN_MSG_LEN=12 (DNS header) in handle_dot_connection so
query_id extraction is always reading client-sent bytes, not the
zeroed buffer tail.
- Parse the DoT query before calling resolve_query and retain it, so
SERVFAIL responses can echo the original question section via
response_from(). Parse failures send FORMERR with the client id.
- Extract write_framed() helper for length-prefix + flush, reused by
success, SERVFAIL, and FORMERR paths.
- Back off 100ms on listener.accept() errors to avoid tight-looping
on fd exhaustion.
- Replace the hardcoded 127.0.0.1:53 upstream in dot_nxdomain_for_unknown
with a bound-but-unresponsive UDP socket owned by the test, making it
independent of the host's local resolver. Test now runs in ~220ms
(timeout lowered to 200ms) instead of 3s and asserts the question is
echoed in the SERVFAIL response.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add 10s timeout on TLS handshake — prevents clients from holding a
semaphore permit without completing the handshake
- Add IDLE_TIMEOUT on payload read_exact — prevents slowloris after
sending a valid length prefix then trickling bytes
- Extract accept_loop() shared between start_dot and tests — eliminates
duplicated accept logic that could drift
- Add 5s timeout on TCP reads in recursive test mock server
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Send SERVFAIL response (with correct query ID) when resolve_query
fails, preventing DoT clients from hanging until idle timeout
- Extract handle_dot_connection() so tests use the same logic as
production, eliminating duplicated accept/read/resolve loop
- Replace magic 4096 with named MAX_MSG_LEN constant tied to BUF_SIZE
- Add flush() after each TLS write to prevent buffered responses
- Extract fallback_tls() helper, handle partial cert/key config,
support IPv6 bind address, remove redundant crypto provider init
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Refactor handle_query into transport-agnostic resolve_query that returns
a BytePacketBuffer, keeping the UDP path zero-alloc. Add a TLS listener
on port 853 with persistent connections, idle timeout, connection limits,
and coalesced writes. Supports user-provided certs or self-signed CA
fallback. Includes 5 integration tests.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Gate exe_path in restart_service() and replace_exe_path() behind
#[cfg(any(target_os = "macos", target_os = "linux"))] to fix
unused variable and dead code warnings on Windows
- Add macOS CI job (clippy + tests)
- Add test for template substitution in plist and systemd unit files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Conditional forwarding (Tailscale .ts.net, VPC private zones) was
only checked in the forward mode branch. In recursive mode, queries
for forwarding-rule domains went to root servers instead of the
configured upstream, returning NXDOMAIN for private domains.
Move the forwarding rule check before the recursive/forward branch
so it takes priority regardless of mode.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Chrome treats single-label TLDs (e.g. frontend.numa) as search
queries unless a trailing slash is added. Adding "numa" as a search
domain tells the OS resolver that .numa is valid, so browsers
resolve it directly.
macOS: networksetup -setsearchdomains, cleared on uninstall
Linux (resolved): Domains=~. numa in drop-in
Linux (resolv.conf): search numa
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New: Windows DNS configuration (install/uninstall/auto-start).
Fix: DoH fallback uses IP to avoid DNS bootstrap loop.
Fix: UDP ConnectionReset crash on Windows.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: Windows DNS configuration via netsh
numa install/uninstall now set/restore system DNS on Windows via
netsh. Parses ipconfig /all per-interface (adapter name, DHCP status,
DNS servers), saves backup to %APPDATA%\numa\original-dns.json, and
restores on uninstall (DHCP or static with secondary servers).
Handles localization (German adapter/DHCP/DNS labels), disconnected
adapters, multiple interfaces, and missing admin privileges. Adds IP
validation to discover_windows() for consistency.
No Windows Service or CA trust yet — user runs numa in a terminal.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* ci: add cargo test to Windows CI job
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* ci: upload Windows binary as artifact for testing
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: SRTT decay tests panic on Windows due to Instant underflow
On Windows, Instant starts near boot time — subtracting large
durations panics. Use checked_sub with a process-start fallback.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: SRTT decay tests use binary search for max Instant age
Replace age() helper with set_age_secs() on SrttCache that
binary-searches for the maximum subtractable duration. Prevents
panic on Windows (Instant starts at boot) while still producing
the oldest representable instant for correct decay calculations.
Also removes ephemeral test-ubuntu.sh from git.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use ProgramData for Windows DNS backup path
APPDATA differs between user and admin contexts — install runs as
admin but uninstall might resolve a different APPDATA. Use
ProgramData which is consistent across elevation contexts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: disable Dnscache on Windows install, re-enable on uninstall
Windows DNS Client (Dnscache) holds port 53 at kernel level and
can't be stopped via sc/net stop. Disable via registry during
install (requires reboot), re-enable on uninstall.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: rewrite SRTT decay tests as pure functions
Decay tests manipulated Instant timestamps which panics on Windows
(Instant can't go before boot time). Rewrite to test decay_for_age()
directly — a pure function taking srtt_ms and age_secs, no platform
dependency.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use Quad9 IP (9.9.9.9) for DoH fallback, not hostname
DoH to dns.quad9.net requires DNS to resolve the hostname, which
creates a chicken-and-egg loop when numa IS the system resolver
(e.g. after numa install on Windows). Using the IP directly avoids
the bootstrap dependency.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: extract DOH_FALLBACK constant
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: extract QUAD9_IP constant
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: remove dead test helpers, fix constant placement
Remove unused get_srtt_ms() and saturated_penalty_cache() left over
from SRTT test rewrite. Move QUAD9_IP/DOH_FALLBACK after use block.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: ignore ConnectionReset on UDP socket (Windows ICMP error)
Windows delivers ICMP port-unreachable as ConnectionReset on the
next UDP recv_from, crashing numa. Linux/macOS silently ignore these.
Catch and continue the recv loop.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: auto-start numa on Windows boot via registry Run key
Without a Windows Service, rebooting after numa install leaves DNS
broken (pointing at 127.0.0.1 with nothing listening). Register
numa in HKLM\...\Run so it starts automatically. Removed on
uninstall.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs: update README, Windows plan, and launch drafts for Windows support
- README: platform-specific Quick Start, install/uninstall table
- Windows plan: Phase 2 complete, Phase 3 scoped
- Launch drafts: updated "Does it support Windows?" response
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: remove docs from git tracking (already gitignored)
docs/ is in .gitignore but files were force-added. Remove from
tracking — files remain on disk.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Breaking: default mode changed from auto to forward.
New: memory footprint stats + dashboard panel.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add memory footprint to /stats and dashboard
Per-structure heap estimation (cache, blocklist, query log, SRTT,
overrides) with process RSS via mach_task_basic_info / sysconf.
Dashboard gets a 6th stat card and a sidebar breakdown panel with
stacked bar visualization.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use phys_footprint on macOS to match Activity Monitor
Switch from MACH_TASK_BASIC_INFO (resident_size) to TASK_VM_INFO
(phys_footprint) which matches Activity Monitor's Memory column.
Also: capacity-aware heap estimation, entry counts in memory payload,
heap_bytes tests for all stores.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: remove redundant fields and fix naming in memory stats
Remove duplicate entry counts from MemoryStats (already in parent
StatsResponse), rename process_rss_bytes to process_memory_bytes
to match macOS phys_footprint semantics, drop restating comments.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: auto recursive mode, fix Linux install
Auto mode (new default): probes a root server on startup; uses
recursive resolution if outbound DNS works, falls back to Quad9 DoH
if blocked. Dashboard shows mode indicator (green/yellow).
Linux install fixes:
- Add DNSStubListener=no to resolved drop-in (frees port 53)
- Configure DNS before starting service (correct ordering)
- Skip 127.0.0.53 in upstream detection
- `numa install` now does everything (service + DNS + CA)
- `numa uninstall` mirrors install (stop service + restore DNS)
- Extract is_loopback_or_stub() for consistent filtering
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: enable DNSSEC validation by default
With recursive as the default mode, DNSSEC validation completes the
trustless resolution chain. Strict mode remains off by default.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: forward search domains to VPC resolver on Linux
Parse search/domain lines from resolv.conf and create conditional
forwarding rules to the original nameserver or AWS VPC resolver
(169.254.169.253). Fixes internal hostname resolution on cloud VMs
where recursive mode can't resolve private DNS zones.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: single-pass resolv.conf parsing, eliminate redundancies
Parse resolv.conf once for both upstream and search domains instead
of 2-3 reads. Extract CLOUD_VPC_RESOLVER constant. Use &'static str
for mode in StatsResponse. Remove dead read_upstream_from_file.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: macOS install health check, harden recursive probe
Verify numa is listening (API port) before redirecting system DNS on
macOS — if the service fails to start (e.g. port 53 in use), unload
the service and abort instead of breaking DNS. Probe up to 3 root
hints before declaring recursive mode unavailable. Validate IPs from
resolvectl to avoid IPv6 fragment extraction. Extract DEFAULT_API_PORT
constant.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: widen make_rule cfg gate to include Linux
make_rule was gated to macOS-only but discover_linux() calls it for
search domain forwarding rules. CI failed on Linux with E0425.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: forward mode as default, recursive opt-in
Forward mode (transparent proxy to system DNS) is now the default.
Recursive and auto modes are explicit opt-in via config. This avoids
bypassing corporate DNS policies, captive portals, VPC private zones,
and parental controls on first install.
- Move #[default] from Auto to Forward on UpstreamMode
- DNSSEC defaults to off (no-op in forward mode)
- 3-way match in main: Forward/Recursive/Auto with clean separation
- Post-install message suggests mode = "recursive" for sovereignty
- Update README, site, and launch drafts messaging
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The cache hit rate was computed as cached/(cached+forwarded+local+overridden),
excluding recursive and coalesced queries from the denominator. This inflated
the displayed rate (e.g. 57.9%) far above the actual cache proportion (20.9%).
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat: resolve .numa services to LAN IP for remote clients
Remote DNS clients (e.g. phones on same WiFi) received 127.0.0.1 for
local .numa services, which is unreachable from their perspective.
Now returns the host's LAN IP when the query originates from a
non-loopback address. Also auto-widens proxy bind to 0.0.0.0 when
DNS is already public, and adds a startup warning when the proxy
remains localhost-only.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: respect proxy bind_addr config, don't auto-widen
The auto-widen silently overrode an explicit config value — the user's
config should be the source of truth. Now the proxy always uses the
configured bind_addr, and the warning fires whenever it's 127.0.0.1.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* docs: update proxy bind_addr comment in example config
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add DnsPacket::query(id, domain, qtype) constructor; replace mock_query,
make_query, and 4 inline constructions across ctx/forward/recursive/api
- Add record_to_addr() in recursive.rs; replace 4 identical A/AAAA match
blocks with filter_map one-liners
- Add sinkhole_record() in ctx.rs; consolidate localhost and blocklist
A/AAAA branching into single calls
- Remove now-unused DnsQuestion imports
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* refactor: extract resolve_coalesced, rewrite tests against real code
Extract Disposition enum, acquire_inflight(), and resolve_coalesced()
from handle_query so coalescing logic is independently testable. Rewrite
integration tests to call resolve_coalesced directly with mock futures
instead of fighting the iterative resolver's NS chain. All 12 coalescing
tests now exercise production code paths, not tokio primitives.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: SERVFAIL echoes question section, preserve error messages
resolve_coalesced now takes &DnsPacket instead of query_id so SERVFAIL
responses use response_from (echoing question section per RFC). Error
messages preserved via Option<String> return for upstream error logging.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Replace text-based chain trace with a visual diagram showing the
verification flow from cloudflare.com through .com TLD to root
trust anchor. Matches site color palette and typography.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: in-flight query coalescing for recursive resolver
When multiple queries for the same (domain, qtype) arrive concurrently
and all miss the cache, only the first triggers recursive resolution.
Subsequent queries wait on a broadcast channel for the result.
Prevents thundering herd where N concurrent cache misses each
independently walk the full NS chain, compounding timeouts.
Uses InflightGuard (Drop impl) to guarantee map cleanup on
panic/cancellation — prevents permanent SERVFAIL poisoning.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* style: add InflightMap type alias for clippy
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add COALESCED query path and coalescing tests
Followers in the inflight coalescing path now log as COALESCED instead
of RECURSIVE, making it visible in the dashboard when queries were
deduplicated vs independently resolved. Adds 10 tests covering
InflightGuard cleanup, broadcast mechanics, and concurrent handle_query
coalescing through a mock TCP DNS server.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* style: cargo fmt
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: extract acquire_inflight, rewrite tests against real code
Move Disposition enum and inflight acquisition logic into a standalone
acquire_inflight() function. Rewrite 4 tests that were exercising tokio
primitives to call the real coalescing code path instead.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>