Access layer for phones and laptops to reach FIPS routers on OpenWrt,
stacked on the 802.11s mesh backhaul from #123. Squashed from five
commits by Arjen (Origami74); their original messages follow.
* feat(openwrt): open !FIPS access SSID — fips-ap-setup helper, default transport binding, how-to
Client access layer for phones and laptops: every FIPS router
broadcasts the same open SSID ('!FIPS' — the leading '!' sorts it to
the top of alphabetically ordered network pickers), forming one
standard ESS. Clients save it once and roam between all FIPS routers
natively, with FIPS's Noise IK handshake as the only security layer:
- fips-ap-setup: opt-in UCI helper that creates the 'fips-ap0' open AP
(encryption none — security type must be uniform across routers or
clients treat the ESS as different saved networks), an isolated
network with a static ULA /64, RA-only odhcpd addressing (stateless
SLAAC, no DHCP — the minimum that satisfies Android's provisioning
check; no internet by design, so phones keep cellular as default
route), and a locked-down fips_ap firewall zone (no path to br-lan
or the WAN; only ICMPv6, mDNS, and the FIPS transports reachable).
'remove' subcommand undoes it. Radio setup stays opt-in; a package
must not commandeer radios on install.
- fips.yaml: ship 'ap0'/'ap1' Ethernet-transport entries commented out
(matching the 802.11s mesh backhaul) so a stock install that never
creates fips-ap* logs no per-boot "interface missing" bind warning;
fips-ap-setup uncomments the matching block when it creates the
interface and re-comments it on remove.
- Regression test: extend shipped_openwrt_config_parses to assert the
ap0/ap1 entries ship commented out and still parse once uncommented,
alongside mesh0/mesh1.
- Packaging: install the helper in ipk/apk/buildroot (three synced
copies), extend CI structural checks and shellcheck targets.
- docs/how-to/set-up-open-access-ssid.md: full guide, including the
one-time 'no internet, stay connected' acceptance (stored per SSID,
covers every FIPS router) and the security-type-uniformity
constraint.
* docs(openwrt): correct open-SSID/mesh security framing — peering is open by design
The fips-ap-setup/fips-mesh-setup comments and both how-tos claimed a
stranger "cannot pass the FIPS handshake" / "their frames die at the
handshake" / the handshake surface "drops them". That is wrong: FIPS
peer admission is open. An inbound handshake from any net-new identity
is promoted (node::handlers::handshake::promote_connection), gated only
by the daemon's max-peers cap — there is no allowlist, no PSK, and the
AuthChallenge path is not wired to admission. The Noise IK handshake
provides authentication (no impersonation of another identity, no MITM),
not authorization.
Restate the model accurately in all five places: a stranger on the open
SSID (or the open mesh) can associate AND form a FIPS peer link — that
is the point of open access. Containment is the isolated fips_ap zone
(no path to br-lan or the WAN) plus the max-peers cap, not the
handshake. Clarify that AP client isolation is an L2 control only: a
peered stranger is an overlay peer like any other, so the FIPS overlay,
not L2, is the trust boundary between clients.
* feat(openwrt): serve DHCPv4 on the access SSID from a fixed roamable subnet
RA-only addressing satisfied Android's provisioning check but left
anything expecting IPv4 with a self-assigned address and a "no IP"
complaint. dnsmasq now leases out of 10.21.<N>.0/24 (N = radio index;
prefix echoes FIPS port 2121), deliberately identical on every router:
a roaming client keeps its lease across the ESS, and dnsmasq's
authoritative mode — the OpenWrt default, pinned by the helper — ACKs
the renew a foreign router never issued. Lease collisions across
routers surface as a NAK on renew and the client re-DHCPs.
The dhcp section's 'dhcpv4 server' is read by both dnsmasq (default
images) and odhcpd (only with maindhcp), so either arrangement serves.
A DHCPv4/udp-67 accept rule joins the fips_ap zone; DHCPv6 stays off,
the ULA RA stays as-is, and nothing depends on an upstream. The zone
remains isolated — no forwardings, no internet.
* feat(openwrt): enable mDNS rendezvous from fips-ap-setup
Phone FIPS apps cannot open raw-Ethernet sockets, so DNS-SD is how they
find the router's daemon — but node.rendezvous.lan defaults to off. Ship
the lan block commented in fips.yaml (consistent with the apN transport
entries) and have fips-ap-setup uncomment it when creating the access
SSID. The awk match is scoped to node.rendezvous because
transports.ethernet carries a 'lan' entry at the same indent. The switch
is daemon-wide, so 'remove' deliberately leaves it on rather than guess
whether other transports rely on it.
* fix(openwrt): bind UDP dual-stack [::]:2121 so access-SSID clients reach it
The shipped router config bound the UDP transport "0.0.0.0:2121" (IPv4
wildcard) while the mDNS LAN advert announces every interface address,
including the router's IPv6 link-local — which phones on the !FIPS
access SSID rightly prefer (their cellular default route swallows v4,
and fd00::/8 is captured by the Myco mesh TUN). Result: the client's
Noise msg1 arrives on an unbound v6 port and is silently lost; the
handshake resends and times out.
Symptom chain (observed on-device): mDNS resolve OK, platform push OK,
"Sent Noise handshake message 1" to [fe80::…%N]:2121, four resends, no
reply, 30 s stale-timeout.
OpenWrt is Linux (bindv6only=0), so "[::]" accepts IPv4 via v4-mapped
addresses too — nothing is lost. packaging/common is deliberately left
on "0.0.0.0" for now: Windows defaults IPV6_V6ONLY=1, where "[::]"
would drop v4 instead.
Make the FIPS core build and run as an embedded Android library. The host
app owns the TUN (e.g. an Android VpnService) and FIPS performs no
system-TUN or CAP_NET_ADMIN operations.
Squashed from the following changes:
- gate desktop transports/TUN by target_os, not features: a plain
`cargo build` now compiles for every target with no flags. Ethernet (raw
AF_PACKET / BPF) is gated to linux/macos, so Android (target_os =
"android", not "linux") self-excludes it as Windows already did; real
system-TUN ops are gated per linux/macos and Android gets a no-op stub;
the ipi6_ifindex cast handles it being i32 on Android vs u32 on macOS. No
Cargo features are introduced; desktop builds are unchanged.
- app-owned TUN seam: Node::enable_app_owned_tun() lets an embedder that
owns the TUN fd exchange IPv6 packet bytes with FIPS over channels
instead of FIPS creating a system TUN device. It returns (app_outbound_tx,
app_inbound_rx): the embedder pushes packets read from its fd into the
outbound sender (app -> mesh) and pulls packets destined for its fd from
the inbound receiver (mesh -> app). start() gates system-TUN creation on
tun_tx being unset, so with the channels pre-installed it skips device
creation and does no system-TUN ops; both directions reuse the existing
inbound-shim and run_rx_loop wiring. Packets entering via app_outbound_tx
bypass handle_tun_packet, so the embedder must push only fd00::/8-destined
packets and clamp TCP MSS on outbound SYNs; the rustdoc and the
IPv6-adapter design doc spell this out.
- keep the android target warning-clean so the cross-compile check passes
clippy -D warnings.
- add an Android cross-compile CI check: cross-compile the library for
aarch64-linux-android via cargo-ndk and run clippy -D warnings. Android
ships as an embedded library (the host app owns the TUN), so there is no
daemon binary to package; this is a check job, not a packaging one.
- docs: list Android as a supported platform.
Tests: app_owned_tun_seam_wires_channels covers the channel round-trip and
the Active state; start_skips_system_tun_when_app_owned runs start() and
asserts no named system device is created.
Router-to-router radio backhaul over an open 802.11s mesh interface,
with FIPS providing all encryption (Noise IK), authentication, and
routing on top of bare L2 neighbor links. The mesh runs OPEN with
mesh_fwding 0 — SAE would duplicate the Noise layer and force ath10k
raw mode, and FIPS is the routing layer — so the Noise handshake is the
real auth/encryption boundary and FIPS's spanning tree does the routing.
fips-mesh-setup: an opt-in UCI helper that creates a per-radio
mesh-point interface (radio0 -> fips-mesh0, radio1 -> fips-mesh1;
trailing-digit derivation with a free-index fallback and a collision
guard). Radio setup stays opt-in — a package must not commandeer radios
on install. 'remove' takes an optional radio and otherwise removes all
instances. Dual-band routers get one instance per radio; FIPS treats the
two backhaul paths as failover, not multipath: it keeps one active link
per peer (cross-connection resolution picks a single winner), and the
second band stands by, re-establishing the peer after keepalive timeout —
traffic never uses both bands at once.
fips.yaml ships the mesh0/mesh1 Ethernet-transport entries commented
out, so a stock install that never creates fips-mesh* logs no per-boot
"interface missing" bind warning. fips-mesh-setup uncomments the matching
meshN block when it creates the interface and re-comments it on remove,
so the flash-and-drop-in flow needs no manual config edit. The file is
rewritten 0600-first (it may hold an inline nsec) via an atomic replace.
Two field-found silent non-peering causes are surfaced by the helper and
the guide:
- Same channel: mesh points only peer on a shared channel, and 'auto'
lets each radio pick its own. The helper prints the radio's
band/channel and warns loudly on 'auto' with the exact uci command to
pin one; the how-to gains an ordered no-peers triage (channel mismatch,
on-air scan check, DFS CAC wait, regdomain).
- STA channel capture: a client (sta) interface drags the whole radio to
its upstream AP's channel, so a mesh pinned elsewhere never joins and
does not recover until the STA disconnects. The helper warns when the
target radio carries a STA; the guide documents the incompatibility of a
roaming uplink with a fixed-channel mesh on the same radio.
Both the create and remove paths run 'wifi reload', which briefly drops
every client AP on all radios; the how-to sets that expectation.
Regression test: the shipped OpenWrt fips.yaml must parse via the real
Config deserializer in both states — as shipped (mesh inactive) and after
the uncomment the helper performs.
Packaging: the helper is installed across the ipk/apk/buildroot paths
(three synced copies), with the CI structural checks and shellcheck
targets extended to cover it. Full guide in
docs/how-to/set-up-80211s-mesh-backhaul.md.
The shared fips.yaml ships ethernet.wan.interface: "eth0", the default
WAN port on OpenWrt 24 and earlier. OpenWrt 25 (DSA) boards, which the
.apk package targets, name the WAN port "wan" instead. Rewrite the
staged copy at build time so the as-installed config binds the Ethernet
transport to the right port out of the box, without maintaining a second
copy of the config file. The .ipk package keeps "eth0".
Update the openwrt README to document eth0 (24) vs wan (25/DSA) and add
a CHANGELOG entry.
Add OpenWrt .apk packaging for OpenWrt 25+, where apk-tools is the
mandatory package manager. The existing .ipk continues to cover OpenWrt
24.x and earlier. Built SDK-free like the .ipk: it reuses the
cargo-zigbuild cross-compile and the shared installed-filesystem payload
under openwrt-ipk/files, and assembles the ADB container with the
official `apk mkpkg` applet from apk-tools 3.0.5 built from source, so no
OpenWrt SDK image is needed.
The package-openwrt workflow is refactored so a single compile-binaries
job cross-compiles and strips each arch once; both the .ipk and .apk
packagers consume the binaries via a new --bin-dir flag instead of each
recompiling. A build-apk job (aarch64, x86_64) builds apk-tools,
packages, and structurally verifies the .apk with `apk adbdump`. Releases
now publish .apk artifacts and checksums alongside .ipk; the release
download is scoped to fips_* so the shared raw-binary artifacts are not
swept into the published release. apk-version.sh maps a release tag or
commit height to an apk-tools-valid version, covered by a case-table test.
Packages are unsigned, installed with `apk add --allow-untrusted`,
matching the .ipk posture.
Also fix the OpenWrt control socket: the init script now pre-creates
/run/fips before starting the daemon, the procd equivalent of the systemd
unit's RuntimeDirectory=fips. Without it, on a fresh boot the daemon
resolves its control socket to /tmp (since /run/fips does not yet exist),
while fips-gateway later creates /run/fips for its own gateway.sock,
leaving fipsctl/fipstop resolving a /run/fips/control.sock the daemon
never bound.
Self-addressed TCP/UDP connections to a node's own <npub>.fips address
half-opened and hung on macOS. macOS routes self-traffic as loopback (a
LOCAL route via lo0), which defers the transport TX checksum, but the
point-to-point utun then egresses the packet into the daemon with only
the pseudo-header partial checksum present. The hairpin path added in
9a9e90a re-injected these verbatim, so the local stack dropped every
segment whose checksum MSS clamping didn't happen to rewrite: the
SYN/SYN-ACK got through (clamping recomputes them) but the bare ACK,
data, and FIN were dropped for a bad checksum, leaving the listener
stuck in SYN_RCVD.
Recompute the TCP/UDP checksum for self-addressed packets on the hairpin
path before re-injection, completing the self-delivery 9a9e90a started
(which only covered ICMP and the TCP handshake). Linux is unaffected: it
loops self-traffic via lo before the TUN, so the hairpin branch never
fires and checksums are already valid.
Confirmed on macOS: a self-connect that previously timed out now
completes in ~9ms with payload delivered.
A packet destined for our own mesh address reached the TUN reader on
macOS (utun egresses self-traffic into the daemon despite the lo0 host
route) and was pushed onto the mesh outbound path, where it was dropped
for lack of a session/route to self. Hairpin self-addressed packets back
to the TUN writer instead, so ping6 and connections to our own
<npub>.fips address are delivered locally.
On Linux the kernel already loops self-traffic via `lo` before it reaches
the TUN, so the branch never fires there; the check is kept unconditional
as a daemon-level delivery invariant and to keep it covered by Linux CI.
Before bf77ece (Fix DNS responder silent-drop on systemd-resolved
deployments, 2026-04-29) the daemon defaulted dns.bind_addr to "::"
(wildcard, accepted v4 traffic too), so the macOS pkg's resolver shim
of `nameserver 127.0.0.1` reached the daemon fine over v4 loopback.
That commit tightened the default to "::1" — IPv6 loopback only,
which on Linux/macOS does not accept v4-mapped traffic — to defuse a
mesh-interface filter / IPV6_PKTINFO bug that was silently dropping
.fips queries on systemd-resolved hosts. The Linux side was updated
in the same commit: fips-dns-setup now writes [::1]:5354 in every
backend, and the gateway's DEFAULT_DNS_UPSTREAM moved to [::1]:5354
with an inline comment about the v4/v6 mismatch.
The macOS resolver shim in packaging/macos/build-pkg.sh was missed in
that sweep. Since 2026-04-29, every macOS install has shipped
/etc/resolver/fips with `nameserver 127.0.0.1` while the daemon
listened on `::1`, so .fips hostnames don't resolve via getaddrinfo
(ping6, curl, etc.) even though `dig @::1 -p 5354 …` works.
The mismatch is easy to miss: mDNSResponder swallows the timeout,
VPN clients that hijack DNS (NetworkExtension match-domain : *) mask
it entirely, and the symptom looks like "discovery hasn't found the
peer yet". Switch the shim to nameserver ::1 to match the daemon.
The v0.3.0 stable AUR push silently failed: with updpkgsums: true,
makepkg downloaded fips-<ver>.tar.gz into the AUR working tree, where
it was then staged by the deploy action and rejected by AUR's 488 KiB
max-blob hook.
Fetch the upstream source tarball and compute its b2sum in CI, patch
pkgver and the b2sums SKIP placeholder in PKGBUILD in-place, then
publish with updpkgsums: false so the AUR clone stays metadata-only.
Recompute and patch the fips.sysusers / fips.tmpfiles asset b2sums in
the same step so they stay in sync with the local files; this safety
net was previously provided by updpkgsums.
Add aur-publish-git.yml for the VCS fips-git PKGBUILD, triggered on
master pushes that touch PKGBUILD-git or its companion files plus
workflow_dispatch. pkgver is computed at build time by the PKGBUILD's
pkgver() function, so this workflow is not tied to release tags.
Add a workflow_dispatch tag input on the stable workflow so historical
release tags can be re-published manually, and drop continue-on-error:
true so future regressions surface in CI.
Operator-facing IPs in user-visible configs/docs (examples, tutorials,
packaging, sidecar templates) are now the resolvable hostnames of the
public test fleet (test-us01.fips.network, etc.) so they keep working
without baking specific addresses into examples.
Doc-comment and test fixtures in src/config/transport.rs use RFC 5737
TEST-NET-2 (198.51.100.1) so they cannot accidentally point at a real
host.
Also resyncs the openwrt-ipk fips.yaml with the common reference
(merge from master) and applies the same DNS-name swap there.
Bring the OpenWrt-shipped fips.yaml back into line with
packaging/common/fips.yaml, which had drifted: the OpenWrt copy was
missing the Nostr discovery, BLE, and TCP reference comment blocks, so
operators had no in-config hint that Nostr-mediated discovery existed.
Take common/fips.yaml verbatim and apply just the OpenWrt-specific
active overrides:
- ethernet: uncomment with wan/wwan/lan defaults (eth0, phy0-sta0,
br-lan)
- gateway: uncomment with lan_interface=br-lan and dns.listen on
[::1]:5353 (matches the dnsmasq forwarder the init script wires up)
Identity stays ephemeral by default (no persistent override), matching
common.
- Ethernet ioctl: cfg-gate the ioctl request parameter type — c_int on
musl, c_ulong on gnu — so the same code compiles on both
x86_64-unknown-linux-gnu and x86_64-unknown-linux-musl targets
- Chaos harness macOS support: replace direct host 'ip link' invocations
with a privileged Docker container helper (--net=host --pid=host) that
shares the Docker VM's namespaces, making veth pair setup work on both
Linux and macOS (where host 'ip' is unavailable and container PIDs
live inside the Docker Desktop VM)
- Python 3.9 compat: add 'from __future__ import annotations' to
docker_exec.py for X|Y union syntax support
- Add deploy/ to .gitignore
Co-authored-by: origami74 <origami74@gmail.com>