diff --git a/docs/design/fips-gateway.md b/docs/design/fips-gateway.md index 51ce4714..47165abb 100644 --- a/docs/design/fips-gateway.md +++ b/docs/design/fips-gateway.md @@ -130,7 +130,7 @@ There is no `fipsctl gateway` subcommand; clients (including │ │ │ ┌──────────────┐ ┌───────────┐ │ │ │ DNS proxy │ │ Virtual │ │ - │ │ ([::1]:5353) │─▶│ IP pool │ │ + │ │ ([::1]:5365) │─▶│ IP pool │ │ │ │ .fips only │ │ (state │ │ │ └──────┬───────┘ │ machine) │ │ │ │ └─────┬─────┘ │ @@ -182,8 +182,10 @@ involving the DNS proxy or the pool. ### DNS Resolution Flow 1. A LAN client sends a DNS query to the gateway's listener (default - `[::1]:5353`, configurable via `gateway.dns.listen`). The default - is loopback-only on an unprivileged port: the canonical deployment + `[::1]:5365`, configurable via `gateway.dns.listen`). The default + is not 5353, the mDNS port, which the daemon's LAN rendezvous and + other mDNS responders hold. It is loopback-only on an unprivileged + port: the canonical deployment has another resolver on the host (dnsmasq, systemd-resolved, BIND) holding port 53 and forwarding `.fips` queries to the gateway over loopback. Operators on a host without a pre-existing resolver on diff --git a/docs/how-to/deploy-gateway.md b/docs/how-to/deploy-gateway.md index cbd80d7d..e1bb3595 100644 --- a/docs/how-to/deploy-gateway.md +++ b/docs/how-to/deploy-gateway.md @@ -143,7 +143,7 @@ virtual IPs, which is the gateway's hard cap regardless of CIDR width. This minimum config is enough to start the gateway. The `dns.*` block -is optional and defaults to `listen: "[::1]:5353"` and +is optional and defaults to `listen: "[::1]:5365"` and `upstream: "[::1]:5354"`. The full block — including `dns.*`, `pool_grace_period`, `conntrack.*`, and `port_forwards[]` — is documented in @@ -196,7 +196,7 @@ Constraints: ```yaml gateway: dns: - listen: "[::1]:5353" + listen: "[::1]:5365" upstream: "[::1]:5354" ttl: 60 ``` @@ -204,15 +204,16 @@ gateway: Common cases: - **Another resolver on the host (the canonical case):** the default - `listen: "[::1]:5353"` is loopback-only on an unprivileged port, + `listen: "[::1]:5365"` is loopback-only on an unprivileged port, so it never conflicts with dnsmasq, systemd-resolved, or BIND holding 53. Configure the existing resolver to forward `.fips` - queries to `[::1]:5353` and you are done — this is what the - OpenWrt ipk does automatically. + queries to `[::1]:5365` and you are done — this is what the + OpenWrt ipk does automatically. On OpenWrt the init script reads + `gateway.dns.listen` and points dnsmasq at whatever port it sets. - **No other resolver on the host:** set `listen: "[::]:53"` explicitly and LAN clients can query the gateway directly. - **systemd-resolved is on port 53:** the default already side-steps - this — leave the listen address at `[::1]:5353` and configure the + this — leave the listen address at `[::1]:5365` and configure the stub or a small forwarder to delegate `.fips` to the gateway. If you would rather have the gateway on 53 directly, disable the systemd stub listener (`DNSStubListener=no` in diff --git a/docs/how-to/troubleshoot-gateway.md b/docs/how-to/troubleshoot-gateway.md index a101a7d4..719c8868 100644 --- a/docs/how-to/troubleshoot-gateway.md +++ b/docs/how-to/troubleshoot-gateway.md @@ -111,6 +111,7 @@ The error names the service most likely to hold the port: - **5354**: the fips daemon's own DNS responder. `gateway.dns.listen` must not be the daemon's DNS port. - **5355**: LLMNR, held by systemd-resolved unless `LLMNR=no`. +- **5365**, the default: another fips-gateway already running. - **Any other port**: another process. Find the actual holder, replacing the port with your own: @@ -121,13 +122,17 @@ sudo ss -ulpn 'sport = :53' netstat -ulnp ``` -The default listen address, `[::1]:5353`, is loopback-only on an -unprivileged port. Two options: +The default listen address, `[::1]:5365`, is loopback-only on an +unprivileged port. Releases before 0.5.2 defaulted to `[::1]:5353`, +the mDNS port; a config that still sets it explicitly keeps it, and +the gateway warns at startup. On OpenWrt, an upgrade rewrites the +previously shipped `listen: "[::1]:5353"` line to the new default. +Two options: - **Move the gateway.** Set `gateway.dns.listen` to a free port and point the resolver that forwards `.fips` at the same port. With the loopback default, configure the existing resolver to forward `.fips` - queries to `[::1]:5353` (the canonical OpenWrt deployment works this + queries to `[::1]:5365` (the canonical OpenWrt deployment works this way out of the box). - **Relocate the conflicting resolver.** Move it to a different port @@ -240,7 +245,7 @@ not running or not enabled. Check that the daemon config has **Step 2.** Verify the gateway is listening on its DNS port: ```sh -sudo ss -tulnp | grep -E ':(53|5353)\b' +sudo ss -tulnp | grep -E ':(53|5365)\b' ``` If nothing is listening on the configured `dns.listen` address, the diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 117f82fd..5d0ac209 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -889,7 +889,7 @@ Non-`.fips` queries are answered with `REFUSED`. | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `gateway.dns.listen` | string | `"[::1]:5353"` | DNS listen address. The default binds IPv6 loopback on an unprivileged port, matching the canonical deployment where another resolver on the host (dnsmasq, systemd-resolved, BIND) holds port 53 and forwards `.fips` queries to the gateway over loopback. Bind on the LAN-side IP (e.g., `"192.168.1.1:53"`) or wildcard (`"[::]:53"`) only on hosts with no other resolver on 53 and where LAN clients query the gateway directly. See [../how-to/troubleshoot-gateway.md](../how-to/troubleshoot-gateway.md). | +| `gateway.dns.listen` | string | `"[::1]:5365"` | DNS listen address. The default binds IPv6 loopback on an unprivileged port, not 5353, the mDNS port, matching the canonical deployment where another resolver on the host (dnsmasq, systemd-resolved, BIND) holds port 53 and forwards `.fips` queries to the gateway over loopback. Bind on the LAN-side IP (e.g., `"192.168.1.1:53"`) or wildcard (`"[::]:53"`) only on hosts with no other resolver on 53 and where LAN clients query the gateway directly. See [../how-to/troubleshoot-gateway.md](../how-to/troubleshoot-gateway.md). | | `gateway.dns.upstream` | string | `"[::1]:5354"` | Upstream FIPS daemon resolver. **Must match the daemon's `dns.bind_addr` and `dns.port`.** Defaults match the daemon defaults (`::1:5354`). A v4 upstream (`"127.0.0.1:5354"`) cannot reach a daemon bound on `[::1]:5354` — Linux IPv6 sockets bound to explicit `::1` do not accept v4-mapped traffic. If you change the daemon's `dns.bind_addr`, update this field accordingly. | | `gateway.dns.ttl` | u32 | `60` | TTL in seconds on AAAA responses returned to LAN clients. Smaller values let the gateway recycle pool addresses faster; larger values reduce LAN-side query traffic. | @@ -936,7 +936,7 @@ gateway: pool: "fd01::/112" lan_interface: "enp3s0" dns: - listen: "[::1]:5353" + listen: "[::1]:5365" upstream: "[::1]:5354" ttl: 60 pool_grace_period: 60 diff --git a/docs/tutorials/deploy-fips-gateway.md b/docs/tutorials/deploy-fips-gateway.md index d505eab2..2328d6df 100644 --- a/docs/tutorials/deploy-fips-gateway.md +++ b/docs/tutorials/deploy-fips-gateway.md @@ -132,7 +132,7 @@ gateway: pool: "fd01::/112" # virtual IP range (up to 65535 addresses) lan_interface: "br-lan" # LAN-facing interface for proxy NDP dns: - listen: "[::1]:5353" # gateway DNS bind (IPv6 loopback only) + # listen: "[::1]:5365" # the default; the init script points dnsmasq at this port upstream: "[::1]:5354" # FIPS daemon DNS resolver (matches daemon default) ttl: 60 # DNS TTL and mapping lifetime (seconds) pool_grace_period: 60 # seconds after last session before reclaiming @@ -147,9 +147,10 @@ Three things to notice: - `lan_interface: "br-lan"` — the OpenWrt LAN bridge. The gateway installs proxy-NDP entries on this interface so LAN clients can ARP-equivalent for pool addresses. -- `dns.listen: "[::1]:5353"` — the gateway's DNS bind, pinned to - IPv6 loopback only. dnsmasq, which owns LAN port 53, forwards - `.fips` queries to it. The init script wires up that forwarding; +- `dns.listen`, commented out — the gateway's DNS bind, left at its + default `[::1]:5365`, IPv6 loopback only. dnsmasq, which owns LAN + port 53, forwards `.fips` queries to it. The init script reads + `gateway.dns.listen` and points dnsmasq at whatever port it sets; you don't bind to a LAN address yourself. For the full reference, see @@ -172,7 +173,7 @@ Behind that single command, the init script `/etc/sysctl.d/fips-gateway.conf`. 2. **Reconfigures dnsmasq via UCI** so `.fips` queries arriving at the LAN's port 53 are forwarded to the gateway's loopback - listener on port 5353 instead of going straight to the daemon's + listener on port 5365 instead of going straight to the daemon's resolver on port 5354. (Dnsmasq still owns 53; the gateway sits in front of the daemon for `.fips` only.) 3. **Adds a global-scope IPv6 prefix** to `br-lan`. Without a @@ -242,7 +243,7 @@ Expectations: > **What just happened end to end.** Your client asked dnsmasq for > `test-us01.fips`. Dnsmasq forwarded the query to the gateway's -> loopback listener on port 5353. The gateway forwarded the query on +> loopback listener on port 5365. The gateway forwarded the query on > to the daemon's resolver on port 5354. The daemon answered with > `test-us01`'s mesh address (`fd97:...`). The gateway allocated a > virtual IP from `fd01::/112`, installed nftables DNAT/SNAT/ diff --git a/packaging/common/fips.yaml b/packaging/common/fips.yaml index e23b4bf1..885bec7e 100644 --- a/packaging/common/fips.yaml +++ b/packaging/common/fips.yaml @@ -133,7 +133,7 @@ transports: # pool: "fd01::/112" # lan_interface: "eth0" # dns: -# listen: "[::1]:5353" +# listen: "[::1]:5365" # # upstream must match the daemon's dns.bind_addr above. The # # default "[::1]:5354" matches the daemon's default. If you set # # the daemon to bind on a wildcard ("::") or specific address, diff --git a/packaging/openwrt-ipk/files/etc/fips/fips.yaml b/packaging/openwrt-ipk/files/etc/fips/fips.yaml index 2f15160d..ea0bb1de 100644 --- a/packaging/openwrt-ipk/files/etc/fips/fips.yaml +++ b/packaging/openwrt-ipk/files/etc/fips/fips.yaml @@ -164,15 +164,15 @@ transports: # No BLE transport: OpenWrt builds target musl, which has no BlueZ backend. -# Outbound LAN gateway. dnsmasq forwards .fips queries to listen=[::1]:5353 -# while it runs (configured by the fips-gateway init script). Requires IPv6 -# forwarding enabled. +# Outbound LAN gateway. While it runs, the fips-gateway init script points +# dnsmasq's .fips forwarding at the port dns.listen sets (default [::1]:5365). +# Requires IPv6 forwarding enabled. gateway: enabled: true pool: "fd01::/112" lan_interface: "br-lan" dns: - listen: "[::1]:5353" + # listen: "[::1]:5365" # the default; the init script points dnsmasq at this port upstream: "[::1]:5354" ttl: 60 pool_grace_period: 60 diff --git a/packaging/openwrt-ipk/files/etc/init.d/fips-gateway b/packaging/openwrt-ipk/files/etc/init.d/fips-gateway index e9e16879..2c12326a 100755 --- a/packaging/openwrt-ipk/files/etc/init.d/fips-gateway +++ b/packaging/openwrt-ipk/files/etc/init.d/fips-gateway @@ -15,8 +15,10 @@ STOP=09 PROG=/usr/bin/fips-gateway CONFIG=/etc/fips/fips.yaml -# Port the gateway DNS listens on (must match dns.listen in fips.yaml). -GW_DNS_PORT=5353 +# Port the gateway DNS listens on when gateway.dns.listen is not set. Must +# match DEFAULT_DNS_LISTEN in the gateway's source; the scenario harness checks +# the two agree. The port actually used comes from gateway_dns_port. +GW_DNS_DEFAULT=5365 # Port the FIPS daemon DNS listens on. DAEMON_DNS_PORT=5354 @@ -40,11 +42,11 @@ start_service() { # Load conntrack module for /proc/net/nf_conntrack. modprobe nf_conntrack 2>/dev/null || true - # Redirect dnsmasq .fips forwarding from daemon (5354) to gateway (5353) - # so LAN clients get virtual IPs instead of raw mesh addresses. - # Done early and synchronously so dnsmasq is ready before the gateway - # starts accepting DNS queries. - dnsmasq_swap_fips_upstream "$GW_DNS_PORT" + # Redirect dnsmasq .fips forwarding from the daemon (5354) to the port the + # gateway listens on, so LAN clients get virtual IPs instead of raw mesh + # addresses. Done early and synchronously so dnsmasq is ready before the + # gateway starts accepting DNS queries. + dnsmasq_swap_fips_upstream "$(gateway_dns_port)" sleep 1 # Add a global-scope IPv6 prefix to br-lan so Android/Chrome clients @@ -87,6 +89,35 @@ gateway_config_enabled() { awk '/^gateway:/{found=1; next} found && /^[^ ]/{found=0} found && /enabled:/{gsub(/.*enabled:[[:space:]]*/, ""); gsub(/["'"'"']/, ""); print; exit}' "$CONFIG" } +# Print the port the gateway's DNS listener will bind: the digits after the +# last ":" of the "listen:" value inside the top-level "gateway:" block of +# fips.yaml, or $GW_DNS_DEFAULT when there is no such line or its value does +# not end in a port. Commented lines are skipped, and "listen_port:" (a port +# forward key) does not match. +# +# Block-style YAML only: a flow-style "dns: {listen: ...}" reads as the +# default. The dnsmasq entry this port feeds is always ::1#, so a +# gateway listening only on 127.0.0.1 is still not reachable through it. +gateway_dns_port() { + local port + port="$(awk ' + /^[A-Za-z_]/ { top = $1 } + top != "gateway:" { next } + /^[[:space:]]*#/ { next } + /^[[:space:]]+listen:/ { + v = $0 + sub(/^[[:space:]]+listen:[[:space:]]*/, "", v) + sub(/[[:space:]]+#.*$/, "", v) + gsub(/["'"'"']/, "", v) + sub(/[[:space:]]+$/, "", v) + n = split(v, part, ":") + if (n > 1 && part[n] ~ /^[0-9]+$/) print part[n] + exit + } + ' "$CONFIG" 2>/dev/null)" + echo "${port:-$GW_DNS_DEFAULT}" +} + # Extract the gateway pool CIDR from fips.yaml. # Looks for "pool:" indented under the top-level "gateway:" block. gateway_pool_cidr() { @@ -178,13 +209,20 @@ gateway_remove_global_prefix() { # $1 = target port number dnsmasq_swap_fips_upstream() { local port="$1" + local server - # Remove both possible entries, then add the correct one. - uci -q del_list dhcp.@dnsmasq[0].server="/fips/127.0.0.1#${DAEMON_DNS_PORT}" 2>/dev/null - uci -q del_list dhcp.@dnsmasq[0].server="/fips/127.0.0.1#${GW_DNS_PORT}" 2>/dev/null - # Also handle IPv6 loopback variants. - uci -q del_list dhcp.@dnsmasq[0].server="/fips/::1#${DAEMON_DNS_PORT}" 2>/dev/null - uci -q del_list dhcp.@dnsmasq[0].server="/fips/::1#${GW_DNS_PORT}" 2>/dev/null + # Remove every loopback .fips forward, then add the one for $port. That + # covers the daemon's port, this gateway's, and a stale entry for any other + # local port, such as the old default 5353 or a changed gateway.dns.listen. + # A .fips forward to another host and servers for other domains are kept. + # uci prints a list on one line separated by spaces. + for server in $(uci -q get 'dhcp.@dnsmasq[0].server' 2>/dev/null); do + case "$server" in + "/fips/::1#"* | "/fips/127.0.0.1#"*) + uci -q del_list dhcp.@dnsmasq[0].server="$server" 2>/dev/null + ;; + esac + done uci add_list dhcp.@dnsmasq[0].server="/fips/::1#${port}" uci commit dhcp diff --git a/packaging/openwrt-ipk/files/etc/uci-defaults/90-fips-setup b/packaging/openwrt-ipk/files/etc/uci-defaults/90-fips-setup index a89c27b5..44965971 100644 --- a/packaging/openwrt-ipk/files/etc/uci-defaults/90-fips-setup +++ b/packaging/openwrt-ipk/files/etc/uci-defaults/90-fips-setup @@ -1,8 +1,14 @@ #!/bin/sh # FIPS first-boot setup — runs once after package installation. # Configures the firewall and kernel modules for FIPS operation. -# This script is executed by /etc/rc.d/S19sysctl on first boot and -# then deleted by the UCI defaults mechanism. +# The UCI defaults mechanism runs it once and deletes it when it ends with +# status 0. +# +# It is executed by the package's postinst, but sourced, not executed, by +# OpenWrt's default_postinst for a package built from the SDK feed and by the +# first-boot uci-defaults run after a sysupgrade. Nothing here may exit early, +# change directory or set shell options, and it must end with status 0: a +# non-zero status leaves the script in place to run again on every boot. # --------------------------------------------------------------------------- # 1. Kernel modules @@ -63,9 +69,13 @@ uci commit firewall # dnsmasq init script builds its config from UCI and loads no directory under # /etc. The daemon's DNS responder binds ::1. The 127.0.0.1 del_list removes # the entry older packages added. While fips-gateway runs, its init script -# points this entry at the gateway's DNS port instead. +# points this entry at the gateway's DNS port instead. The two del_lists after +# the first remove the gateway's entry for its old default port, left behind +# by a gateway that stopped without its init script's stop running. uci -q del_list dhcp.@dnsmasq[0].server="/fips/127.0.0.1#5354" 2>/dev/null || true +uci -q del_list dhcp.@dnsmasq[0].server="/fips/::1#5353" 2>/dev/null || true +uci -q del_list dhcp.@dnsmasq[0].server="/fips/127.0.0.1#5353" 2>/dev/null || true uci -q del_list dhcp.@dnsmasq[0].server="/fips/::1#5354" 2>/dev/null || true uci add_list dhcp.@dnsmasq[0].server="/fips/::1#5354" uci -q del_list dhcp.@dnsmasq[0].rebind_domain="fips" 2>/dev/null || true @@ -86,4 +96,40 @@ grep -qxF 'nf_conntrack' /etc/modules.d/nf-conntrack 2>/dev/null || \ # proxy NDP entries are actually added. sysctl -p /etc/sysctl.d/fips-gateway.conf 2>/dev/null || true +# --------------------------------------------------------------------------- +# 5. Gateway DNS listen port +# --------------------------------------------------------------------------- +# Every release up to 0.5.1 shipped the gateway's DNS listener on 5353, the +# mDNS port, which the daemon's LAN rendezvous can hold. fips.yaml is a +# conffile and fips-ap-setup edits it, so an upgrade keeps the old line. +# Rewrite exactly that shipped line, four-space indent and nothing after the +# closing quote, inside the top-level gateway block, to the line a fresh +# install ships. Any other value is left as configured; the gateway warns at +# startup when it is still on the mDNS port. +# +# Runs last, and cannot fail the script: see the note at the top. +fips_migrate_gateway_dns_listen() { + local cfg=/etc/fips/fips.yaml + local old=' listen: "[::1]:5353"' + local new=' # listen: "[::1]:5365" # the default; the init script points dnsmasq at this port' + local msg='fips: moved gateway.dns.listen off the mDNS port 5353 to the default [::1]:5365' + + if [ -f "$cfg" ] && grep -qxF "$old" "$cfg" 2>/dev/null; then + if awk -v old="$old" -v new="$new" ' + /^[A-Za-z_]/ { top = $1 } + top == "gateway:" && $0 == old { print new; changed = 1; next } + { print } + END { exit changed ? 0 : 1 } + ' "$cfg" > "$cfg.tmp" 2>/dev/null && + chmod 600 "$cfg.tmp" 2>/dev/null && + mv -f "$cfg.tmp" "$cfg" 2>/dev/null; then + logger -t fips "$msg" 2>/dev/null || true + echo "$msg" + else + rm -f "$cfg.tmp" 2>/dev/null || true + fi + fi +} +fips_migrate_gateway_dns_listen + exit 0 diff --git a/src/bin/fips-gateway.rs b/src/bin/fips-gateway.rs index 589deb6e..3d2b784d 100644 --- a/src/bin/fips-gateway.rs +++ b/src/bin/fips-gateway.rs @@ -372,6 +372,11 @@ async fn main() { // Before the pool, NAT table and routes exist, so a port that is already // taken ends the gateway with nothing to tear down, and a service manager // restarting it does not churn nftables. + if gw_config.dns.is_mdns() { + warn!( + "gateway.dns.listen uses port 5353, the mDNS port; an mDNS responder (the fips daemon's LAN rendezvous, avahi) will conflict with it; the default is now [::1]:5365" + ); + } let dns_socket = match dns::bind_listener(gw_config.dns.listen()).await { Ok(socket) => socket, Err(e) => { diff --git a/src/config/gateway.rs b/src/config/gateway.rs index f7d4cf00..22c383d6 100644 --- a/src/config/gateway.rs +++ b/src/config/gateway.rs @@ -9,7 +9,10 @@ use serde::{Deserialize, Serialize}; /// Default gateway DNS listen address. /// -/// Loopback-only on the unprivileged port 5353. The canonical +/// Loopback-only on the unprivileged port 5365, which IANA leaves +/// unassigned and no common resolver uses. It is not 5353, the mDNS +/// port, which the daemon's LAN rendezvous, avahi-daemon and +/// systemd-resolved can hold. The canonical /// gateway deployment is a host already serving DHCP/DNS to a LAN /// segment (e.g., an OpenWrt AP), where port 53 is taken by the /// existing resolver and `.fips` queries are forwarded to the @@ -21,7 +24,7 @@ use serde::{Deserialize, Serialize}; /// explicit `::1` do not accept v4-mapped traffic. Forwarders that /// reach the gateway over IPv4 loopback (`127.0.0.1`) need to be /// pointed at an explicit IPv4 listen address instead. -const DEFAULT_DNS_LISTEN: &str = "[::1]:5353"; +const DEFAULT_DNS_LISTEN: &str = "[::1]:5365"; /// Default upstream DNS resolver (FIPS daemon). /// @@ -131,7 +134,7 @@ pub struct PortForward { /// Gateway DNS resolver configuration (`gateway.dns.*`). #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct GatewayDnsConfig { - /// Listen address and port (default: `[::1]:5353`). + /// Listen address and port (default: `[::1]:5365`). #[serde(default, skip_serializing_if = "Option::is_none")] pub listen: Option, @@ -146,7 +149,7 @@ pub struct GatewayDnsConfig { } impl GatewayDnsConfig { - /// Get the listen address (default: `[::1]:5353`). + /// Get the listen address (default: `[::1]:5365`). pub fn listen(&self) -> &str { self.listen.as_deref().unwrap_or(DEFAULT_DNS_LISTEN) } @@ -166,6 +169,12 @@ impl GatewayDnsConfig { pub(crate) fn port_of(listen: &str) -> Option { listen.rsplit_once(':')?.1.parse().ok() } + + /// Whether the listen address is on the mDNS port, which an mDNS + /// responder can take from the gateway at any time. + pub fn is_mdns(&self) -> bool { + Self::port_of(self.listen()) == Some(5353) + } } /// Conntrack timeout overrides (`gateway.conntrack.*`). @@ -224,7 +233,7 @@ lan_interface: "eth0" assert!(!config.enabled); assert_eq!(config.pool, "fd01::/112"); assert_eq!(config.lan_interface, "eth0"); - assert_eq!(config.dns.listen(), "[::1]:5353"); + assert_eq!(config.dns.listen(), "[::1]:5365"); assert_eq!(config.dns.upstream(), "[::1]:5354"); assert_eq!(config.dns.ttl(), 60); assert_eq!(config.grace_period(), 60); @@ -232,6 +241,43 @@ lan_interface: "eth0" assert_eq!(config.conntrack.udp_timeout(), 30); } + #[test] + fn a_listen_on_5353_is_flagged_as_mdns() { + for listen in [ + "[::1]:5353", + "[::]:5353", + "127.0.0.1:5353", + "localhost:5353", + ] { + let dns = GatewayDnsConfig { + listen: Some(listen.to_string()), + ..Default::default() + }; + assert!(dns.is_mdns(), "{listen} must be flagged as the mDNS port"); + } + } + + #[test] + fn the_default_and_other_ports_are_not_flagged_as_mdns() { + assert!(!GatewayDnsConfig::default().is_mdns()); + for listen in [ + "[::1]:5365", + "[::]:53", + "192.168.1.1:53", + "[::]:5355", + "localhost", + ] { + let dns = GatewayDnsConfig { + listen: Some(listen.to_string()), + ..Default::default() + }; + assert!( + !dns.is_mdns(), + "{listen} must not be flagged as the mDNS port" + ); + } + } + #[test] fn test_gateway_config_custom() { let yaml = r#" diff --git a/testing/deb-install/test.sh b/testing/deb-install/test.sh index 9b46c0b4..a1419491 100755 --- a/testing/deb-install/test.sh +++ b/testing/deb-install/test.sh @@ -304,6 +304,42 @@ EOF return } +# The installed gateway, on the default config, serves .fips on its default +# listen address. Each check needs the gateway running: a gateway that exited +# fails the first one rather than letting the others pass on nothing. +# Args: , the daemon's npub to resolve through the gateway. +check_gateway_default_listener() { + local name="$1" npub="$2" + local journal="" _i + for _i in $(seq 1 5); do + journal=$(docker exec "$name" journalctl -u fips-gateway.service --no-pager 2>/dev/null) || journal="" + printf '%s\n' "$journal" | grep -q "fips-gateway running" && break + sleep 1 + done + if ! printf '%s\n' "$journal" | grep -q "fips-gateway running"; then + fail "the installed fips-gateway did not reach 'fips-gateway running', so its default listener was not observed" + echo " --- fips-gateway journal ---" + printf '%s\n' "$journal" | tail -15 + return + fi + + local sockets + sockets=$(docker exec "$name" ss -Hulnp 'sport = :5365' 2>/dev/null) || sockets="" + if printf '%s\n' "$sockets" | grep -F '[::1]:5365' | grep -q 'fips-gateway'; then + pass "fips-gateway listens on its default [::1]:5365" + else + fail "no fips-gateway socket on [::1]:5365: '$sockets'" + fi + + local answer + answer=$(docker exec "$name" dig +short +tries=1 +time=3 @::1 -p 5365 AAAA "${npub}.fips" 2>&1) + if printf '%s\n' "$answer" | grep -qE '^fd01::[0-9a-f]{1,4}$'; then + pass "the gateway answers ${npub}.fips on [::1]:5365 from its fd01::/112 pool" + else + fail "the gateway did not answer ${npub}.fips on [::1]:5365 from its pool: '$answer'" + fi +} + # Purge the package with the DNS routing file planted and fips-dns stopped, and # check that postrm removes the file and restarts systemd-resolved. # @@ -709,6 +745,8 @@ DOCKERFILE docker exec "$name" journalctl -u fips-gateway.service --no-pager 2>&1 | tail -15 fi + check_gateway_default_listener "$name" "$npub" + check_purge_clears_dns "$name" "$expected_backend" cleanup_container "$name" diff --git a/testing/dns-resolver/test.sh b/testing/dns-resolver/test.sh index 8aea1bb9..07f72df9 100755 --- a/testing/dns-resolver/test.sh +++ b/testing/dns-resolver/test.sh @@ -712,6 +712,47 @@ read_gateway_log() { return 0 } +# The gateway on its default config binds [::1]:5365 and gets past the +# bind to the NAT step, logging one of the two NAT lines whichever way NAT +# goes in this container. Those are the lines the held-port check requires +# to be absent, so this shows the gateway still emits them in that text. +check_gateway_default_bind() { + local name="$1" + local log=/var/log/fips-gateway.log + local text="" read_ok=0 listening=0 nat_step=0 _i + for _i in $(seq 1 5); do + if text=$(read_gateway_log "$name" "$log"); then + read_ok=1 + if printf '%s\n' "$text" | grep -q 'Gateway DNS resolver listening.*addr=\[::1\]:5365'; then + listening=1 + fi + if printf '%s\n' "$text" | grep -qE 'Created nftables table|Failed to create nftables table'; then + nat_step=1 + break + fi + fi + sleep 1 + done + if [ "$read_ok" = "0" ]; then + fail "could not read $log, so the gateway's default bind was not observed" + return + fi + if [ "$listening" = "1" ]; then + pass "fips-gateway listens on its default [::1]:5365" + else + fail "fips-gateway did not log listening on [::1]:5365" + fi + if [ "$nat_step" = "1" ]; then + pass "fips-gateway gets past the DNS bind to the NAT step" + else + fail "fips-gateway logged neither NAT-step line after the DNS bind" + fi + if [ "$listening" = "0" ] || [ "$nat_step" = "0" ]; then + echo " --- $log ---" + printf '%s\n' "$text" | tail -20 + fi +} + # The gateway exits at the DNS bind when its listen port is held. The # daemon in this container holds [::1]:5354, so a gateway configured to # listen there must exit non-zero with the hint naming the daemon, before @@ -980,9 +1021,10 @@ EOF' echo " --- fips-gateway log ---" docker exec "$name" tail -20 /var/log/fips-gateway.log 2>&1 || true fi - # Stop the gateway (it will likely have failed past the upstream - # check on something unrelated in this minimal container — we only - # care that the upstream reachability step succeeded). + check_gateway_default_bind "$name" + + # Stop the gateway (it may have failed after the DNS bind on something + # unrelated in this minimal container). docker exec "$name" pkill -f fips-gateway 2>/dev/null || true check_gateway_exits_on_held_port "$name" diff --git a/testing/openwrt/fixtures/released-fips.yaml b/testing/openwrt/fixtures/released-fips.yaml new file mode 100644 index 00000000..2f15160d --- /dev/null +++ b/testing/openwrt/fixtures/released-fips.yaml @@ -0,0 +1,190 @@ +# FIPS Node Configuration + +node: + identity: + # By default, a new ephemeral keypair is generated on each start. + # Uncomment persistent to keep the same identity across restarts; + # on first start a keypair is saved to fips.key/fips.pub next to + # this config file (mode 0600/0644). + # persistent: true + # + # Or set an explicit key (overrides persistent): + # nsec: "nsec1..." + # Mesh-lookup protocol (node.lookup.*): the overlay coordinate-lookup engine + # (mesh address -> coordinates). Defaults shown; uncomment to override. + # lookup: + # ttl: 64 + # attempt_timeouts_secs: [1, 2, 4, 8] + # recent_expiry_secs: 10 + # backoff_base_secs: 0 + # backoff_max_secs: 0 + # forward_min_interval_secs: 2 + rendezvous: + # Optional Nostr-mediated overlay endpoint rendezvous. + # nostr: + # enabled: true + # policy: configured_only # disabled | configured_only | open + # open_discovery_max_pending: 64 # caps queued open-rendezvous retries + # app: "fips-overlay-v1" + # advertise: true + # advert_relays: + # - "wss://relay.damus.io" + # - "wss://nos.lol" + # - "wss://offchain.pub" + # dm_relays: + # - "wss://relay.damus.io" + # - "wss://nos.lol" + # - "wss://offchain.pub" + # # Optional override. If omitted, FIPS uses the built-in STUN list. + # # Built-in relay/STUN defaults are best-effort and should be + # # overridden by operators for production use. + # stun_servers: + # - "stun:stun.l.google.com:19302" + # - "stun:stun.cloudflare.com:3478" + # - "stun:global.stun.twilio.com:3478" + + # mDNS/DNS-SD peer rendezvous on the local link. Ships commented (the + # daemon default is off); 'fips-ap-setup' uncomments it when creating + # the access SSID — phone FIPS apps cannot see raw-Ethernet beacons, + # so mDNS is how they find this router's daemon. Daemon-wide switch, + # left enabled on 'fips-ap-setup remove'. + # lan: + # enabled: true + +tun: + enabled: true + name: fips0 + mtu: 1280 + +dns: + enabled: true + # bind_addr defaults to "::1" (IPv6 loopback). The shipped + # fips-dns-setup script configures systemd-resolved with a global + # /etc/systemd/resolved.conf.d/fips.conf drop-in pointing at + # [::1]:5354. + # + # Set "::" to expose the responder to mesh peers as well (e.g. for + # gateway hosts that resolve .fips on behalf of LAN clients). The + # mesh-interface filter in src/upper/dns.rs will still defend + # /etc/fips/hosts aliases from cross-mesh enumeration. + # bind_addr: "::1" + port: 5354 + +transports: + udp: + # Dual-stack wildcard, not "0.0.0.0": access-SSID clients (phones) learn + # this node's addresses from the mDNS advert and prefer the IPv6 + # link-local — a v4-only bind silently drops their Noise msg1. + # OpenWrt is Linux (bindv6only=0), so "[::]" accepts v4 too. + bind_addr: "[::]:2121" + # advertise_on_nostr: true + # public: false # false => advertise udp:nat; true => advertise bound host:port + # accept_connections: true # default; refuse inbound msg1 when false + # outbound_only: false # true => bind ephemeral, no listener on a + # # known port. Forces advertise_on_nostr=false + # # and accept_connections=false. Pure-client + # # posture; bind_addr is ignored. + + tcp: + # Accepts inbound connections. No static outbound peers. + bind_addr: "0.0.0.0:8443" + # advertise_on_nostr: true + + # Ethernet transport — physical port names, NOT bridge names. + # Run 'ip link show' on the router to identify port names. + ethernet: + wan: + interface: "eth0" + listen: true + announce: true + auto_connect: true + accept_connections: true + wwan: + interface: "phy0-sta0" + listen: true + announce: true + auto_connect: true + accept_connections: true + lan: + interface: "br-lan" + listen: true + announce: true + auto_connect: true + accept_connections: true + + # 802.11s mesh backhaul between FIPS routers. These entries ship + # commented out so a stock install that never creates fips-mesh* + # logs no per-boot "interface missing" bind warning. Running + # 'fips-mesh-setup ' creates the interface AND uncomments the + # matching block here (once per radio; radio0 -> fips-mesh0, radio1 -> + # fips-mesh1); 'fips-mesh-setup remove' re-comments it. Restart fips + # after — a transport whose interface is missing at startup is skipped, + # not retried. Dual-band routers can mesh on both bands at once — + # failover, not multipath: FIPS keeps one active link per peer, the + # other band stands by. The mesh runs OPEN (no SAE) with 802.11s + # forwarding off: FIPS's Noise handshake is the encryption and + # authentication, and FIPS is the routing layer. See + # docs/how-to/set-up-80211s-mesh-backhaul.md. + # mesh0: + # interface: "fips-mesh0" + # listen: true + # announce: true + # auto_connect: true + # accept_connections: true + # mesh1: + # interface: "fips-mesh1" + # listen: true + # announce: true + # auto_connect: true + # accept_connections: true + + # Open "!FIPS" access SSID for phones and laptops running FIPS. These + # entries ship commented out so a stock install that never creates + # fips-ap* logs no per-boot "interface missing" bind warning. Running + # 'fips-ap-setup ' creates the interface AND uncomments the + # matching block here (once per radio; radio0 -> fips-ap0, radio1 -> + # fips-ap1); 'fips-ap-setup remove' re-comments it. Restart fips after + # — a transport whose interface is missing at startup is skipped, not + # retried. The SSID is OPEN and isolated on purpose: FIPS's Noise + # handshake is the only security layer, and associated clients reach + # nothing but the FIPS handshake surface. See + # docs/how-to/set-up-open-access-ssid.md. + # ap0: + # interface: "fips-ap0" + # listen: true + # announce: true + # auto_connect: true + # accept_connections: true + # ap1: + # interface: "fips-ap1" + # listen: true + # announce: true + # auto_connect: true + # accept_connections: true + + # No BLE transport: OpenWrt builds target musl, which has no BlueZ backend. + +# Outbound LAN gateway. dnsmasq forwards .fips queries to listen=[::1]:5353 +# while it runs (configured by the fips-gateway init script). Requires IPv6 +# forwarding enabled. +gateway: + enabled: true + pool: "fd01::/112" + lan_interface: "br-lan" + dns: + listen: "[::1]:5353" + upstream: "[::1]:5354" + ttl: 60 + pool_grace_period: 60 + +peers: [] + # Static peers for bootstrapping (UDP or TCP): + # - npub: "npub1qmc3cvfz0yu2hx96nq3gp55zdan2qclealn7xshgr448d3nh6lks7zel98" + # alias: "gateway" + # via_nostr: true + # addresses: + # - transport: udp + # addr: "test-us01.fips.network:2121" # IP or hostname (e.g., "peer.example.com:2121") + # - transport: udp + # addr: "nat" # Use node.rendezvous.nostr for Nostr/STUN hole punching + # connect_policy: auto_connect diff --git a/testing/openwrt/scenarios.sh b/testing/openwrt/scenarios.sh index 6c7f4007..fe78548f 100755 --- a/testing/openwrt/scenarios.sh +++ b/testing/openwrt/scenarios.sh @@ -28,9 +28,22 @@ RELEASED_PRERM="$REPO/testing/openwrt/fixtures/released-prerm" INIT_GATEWAY="$REPO/packaging/openwrt-ipk/files/etc/init.d/fips-gateway" APK_SCRIPTS="${APK_SCRIPTS:-}" SHIPPED_YAML="$REPO/packaging/openwrt-ipk/files/etc/fips/fips.yaml" +# The fips.yaml every release up to 0.5.1 shipped, from before the gateway's +# default DNS port moved. +RELEASED_YAML="$REPO/testing/openwrt/fixtures/released-fips.yaml" +GATEWAY_RS="$REPO/src/config/gateway.rs" +SETUP_SCRIPT="$REPO/packaging/openwrt-ipk/files/etc/uci-defaults/90-fips-setup" +LEGACY_LISTEN=' listen: "[::1]:5353"' +SHIPPED_LISTEN=' # listen: "[::1]:5365" # the default; the init script points dnsmasq at this port' +MIGRATED_MSG='fips: moved gateway.dns.listen off the mDNS port 5353 to the default [::1]:5365' WORK=/tmp/fips-openwrt-scenarios UPGRADE_MARKER=/tmp/fips-prerm-upgrade +# The uci stub's state and the directory holding the executable stubs. Fixed +# paths, because the .apk scripts run with only PATH in their environment. +UCI_DIR=/tmp/fips-openwrt-uci +STUB_BIN=/tmp/fips-openwrt-bin +DNSMASQ_OPT='dhcp.@dnsmasq[0].server' FAILURES=0 CASES=0 @@ -124,6 +137,27 @@ assert_not_called() { return 0 } +assert_none_called_with_prefix() { + # assert_none_called_with_prefix + # Fails if any recorded call begins with , whatever follows it. + if [ ! -r "$CALLS" ]; then + bad "$2 — the call log $CALLS cannot be read" + return 0 + fi + matched="" + while IFS= read -r line; do + case "$line" in + "$1"*) matched="$matched$line;" ;; + esac + done < "$CALLS" + if [ -n "$matched" ]; then + bad "$2 — called: $matched" + else + ok "$2" + fi + return 0 +} + assert_file_is() { # assert_file_is got="$(cat "$1" 2>/dev/null)" @@ -182,6 +216,76 @@ assert_order() { return 0 } +# Install an executable uci that keeps each option as a file of values, one per +# line, under $UCI_DIR. Like the real uci, "get" prints a list on one line +# separated by single spaces and fails for an option with no values, and +# del_list removes every copy of the value. Other commands are only logged. +install_uci_stub() { + rm -rf "$UCI_DIR" + mkdir -p "$UCI_DIR" "$STUB_BIN" + { + echo '#!/bin/sh' + echo "dir=$UCI_DIR" + cat <<'STUB' +[ "${1:-}" = "-q" ] && shift +cmd="${1:-}" +[ $# -gt 0 ] && shift +echo "uci $cmd $*" >> "$dir/log" +file_of() { + printf '%s/%s' "$dir" "$(printf '%s' "$1" | sed 's/[^A-Za-z0-9._-]/_/g')" +} +case "$cmd" in +get) + f="$(file_of "$1")" + [ -s "$f" ] || exit 1 + tr '\n' ' ' < "$f" | sed 's/ $//' + echo + ;; +add_list) + echo "${1#*=}" >> "$(file_of "${1%%=*}")" + ;; +del_list) + f="$(file_of "${1%%=*}")" + if [ -f "$f" ]; then + grep -vxF -- "${1#*=}" "$f" > "$f.new" + mv "$f.new" "$f" + fi + ;; +esac +exit 0 +STUB + } > "$STUB_BIN/uci" + chmod 0755 "$STUB_BIN/uci" + + cat > /etc/init.d/dnsmasq <<'STUB' +#!/bin/sh +echo "dnsmasq $1" >> "$CALLS" +STUB + chmod 0755 /etc/init.d/dnsmasq + return 0 +} + +uci_seed() { + # uci_seed