Watch
1
0
Fork
You've already forked SouveraineOS
0
SouveraineOS/docs/tasks/49-bearer-selection-as-device-state.md
Fimeg f5122cb59d docs: retire the task queue's dead branches
Twelve completed or superseded task records leave the live queue; the byte-identical rename duplicate is removed.\n\nThe authority and somatic source records now point at SAF, while tasks 33, 48, 49, 53, 60, and 76 say what is actually left.
2026-08-17 12:27:53 -04:00

23 KiB
Raw Blame History

TASK 49 — Bearer selection and DNS belong to the state machine

Status: machine half landed and exercised on hardware; acceptance 35 and split DNS remain open. Raised by Casey 2026-07-31 after three interlocking failures were measured on the phone. Repo: souveraine (sessiond), Pixel3Arch (blueline-clat.sh).

Goal

Which link carries what, and which resolver answers what, are currently decided by nobody. They are the residue of a DHCP metric, a NetworkManager penalty and a shell script's assumption, and when those disagree the device silently loses the network. Make bearer posture evidence the state machine holds and link selection an Action it emits — DEVICE-STATE-MACHINE §12, the same shape ambient light was given.

Casey, 2026-07-31: "I might need to build out a more sophisticated wifi v lte configuration grid for the device state machine — because I specifically need SOUVERAINE VPN stuff to be part of our system."

What was measured, 2026-07-31

All on the phone, on foreign wifi ("Mabee 19", 10.10.30.213), tunnel up.

1. Every IPv4 destination was leaving over cellular while wifi was associated.

default dev clat              metric  2048     <- won
default via 10.10.30.1 wlan0  metric 20600
1.1.1.1 / 8.8.8.8 / github    -> dev clat

2. The metric inversion is a NetworkManager penalty, not a configuration. blueline-clat.sh states its premise in a comment — "while WiFi is up its default route (metric 600) beats the CLAT default (2048)" — and 600 is indeed NM's default for wifi. The observed 20600 is 600 + 20000: the penalty NM applies to a device whose connectivity check fails. nmcli general status reported limited. So the script's entire premise inverts exactly when connectivity degrades, which is the moment it matters most.

3. The connectivity check failed because DNS was dead, and DNS was dead because of the VPN profile. wiufph carried ipv4.dns=1.1.1.1 with never-default=true, so its own resolver was not reachable through the tunnel — it had to egress via the default route, which was the degraded clat. Every lookup stalled on it. getent hosts archlinux.org returned nothing.

That is a closed loop: VPN DNS unreachable → lookups fail → NM calls wifi limited → wifi penalised +20000 → cellular becomes default → cellular is the degraded path → VPN DNS still unreachable.

4. The WireGuard handshake inherited the same broken path. Endpoint 173.246.142.24:51822 resolved out dev clat; wg show read 368 B received, 5.30 KiB sent with a handshake going stale against a 25 s keepalive. Pinning the endpoint via wlan0 produced a handshake in seconds and 1.45 KiB received. VPN-WIUF.md's claim that this "works on both wifi and cell (clat gives v4-over-v6)" is not true as written — over clat it sent and never received.

Interim state left on the device (neither is durable, both are stopgaps): a pinned host route 173.246.142.24/32 via 10.10.30.1 dev wlan0, and wiufph DNS corrected to 10.10.20.5 / ~wiuf.net at dns-priority 10.

New observation, 2026-08-01 — leaving wifi for LTE did not connect at all

Casey, at the device: "when I left wifi to LTE it did not connect, I had to reboot the phone." Not a slow or degraded path like 2026-07-31 — no attach. A reboot cleared it, which is the signature of a service that dies once and is never re-established, not of a routing-metric fight.

Casey's read is that qcom_pd_mapper is implicated, and there is a real mechanism for it. sdm845_domains[] in drivers/soc/qcom/qcom_pd_mapper.c contains mpss_root_pd and mpss_wlan_pd — on this SoC the modem's protection domains, including the WLAN one, are advertised by that broker. So wifi and cellular are not independent subsystems here; they share a PD service. If the broker stops serving across a bearer transition, the modem has no PD to locate and parks rather than attaching — and only a cold path rebuilds it. slpi.md already records the precedent: when the in-tree sdm845.config carried CONFIG_QCOM_PD_MAPPER=n and won the merge, wifi, sound and SLPI all lost their PD broker at once.

But the stranded mic-fix commit is not the fix. fdb22e5e2 flips CONFIG_QCOM_PD_MAPPER=m=y in arch/arm64/configs/sdm845.config and debian/config/sdm845.config. Our PKGBUILD reads neitherprepare() does cp config-blueline.aarch64 .config then olddefconfig, with no merge_config.sh anywhere. That commit is inert in this build, which is why it was left stranded. The merge-order hazard it was written for belongs to the pmOS/pmaports era.

What is not settled is whether =m is the right value now. Our shipping config has CONFIG_QCOM_PD_MAPPER=m. A module can be unbound, unloaded, or re-probed; a built-in cannot, and is present earlier in boot. If the failure is the broker going away, =y in config-blueline.aarch64 is a one-line test — but it is a hypothesis, not a diagnosis, and it should not be flipped before the evidence below is taken.

Capture this at the moment it recurs, before rebooting:

lsmod | grep -E 'qcom_pd_mapper|qcom_q6v5|qrtr'
cat /sys/class/remoteproc/remoteproc*/state          # is MPSS still running?
dmesg | grep -iE 'pd_mapper|pdr|servreg|mpss|qrtr|glink' | tail -50
qrtr-lookup                                          # does the modem still publish?
mmcli -m 0 | grep -iE 'state|failed'

If MPSS is running but qrtr-lookup shows no modem services, the PD/servreg layer is the gap and this is the right thread. If MPSS itself has crashed or is offline, it is a remoteproc-recovery problem and PD mapper is a symptom. Distinguishing those two is the whole next step — do not flip the config first, because a reboot destroys the evidence either way.

What wiuf-vpn-gate actually got wrong — read before designing the Action

The script that produced the 652 recycles was found still installed on the phone 2026-08-01, in no repo and in no repo's history — it was written straight onto the device. Removed then; its two defects are the durable part, and both are traps the state-machine version can walk into just as easily.

1. It was driven by the NM dispatcher and a 90 s timer, and it changed the thing the dispatcher watches. nmcli connection up/down is itself an NetworkManager event, so every correction re-entered the controller. 652 recycles in 90 minutes is ~7/minute — far faster than the 90 s timer alone can explain, and the signature of a feedback loop rather than a bad interval. This is the same shape as the hypridle lock_cmd self-loop: an actuator wired back into its own sensor. In the §12 form the state machine is the only writer and tick() is the only clock, which forecloses it by construction — but only if bearer changes enter through sensor_input and never call back in.

2. Its home-LAN test is wrong on exactly the network this phone uses.

on_home_wifi() { ip -4 addr show wlan0 | grep -q ' inet 10\.10\.'; }

That matches 10.10. — so it treats any 10.10/16 address as home and takes the tunnel down as "redundant". The phone sat on 10.10.30.213 on foreign wifi while this was installed, and TASK-49's own 2026-07-31 measurements were taken at 10.10.30.213 on "Mabee 19". Home is 10.10.20.0/24. A /16-wide prefix match cannot tell the home LAN from a foreign network that happens to share the first two octets, and this one does.

3. It managed the wrong profile. CONN=wiuf, but the phone carries two WireGuard connections — wiuf and wiufph — and wiufph is the one with the 10.10.20.6 identity. Both are autoconnect=no, which is the real reason the tunnel does not come back after a reboot; the gate was the thing papering over that, badly. Deciding which profile is authoritative, and deleting the other, is a prerequisite to the grid — an Action cannot target "the VPN" while two profiles answer to that name.

The lesson for the grid below: "am I home" is not an address-prefix question. It needs identity — the gateway's MAC, the SSID, or a reachability probe against something only the home LAN serves — and it is a piece of evidence with a confidence, not a boolean derived from a regex.

Operational proof — 2026-08-07

At the foreign 10.10.30.x location, the phone correctly had no route to the LAN-only package archive at 10.10.20.120 until Casey deliberately toggled wiufph on. Afterward, ip route get 10.10.20.120 selected wiufph with source 10.8.0.6, Gitea returned HTTP 200, and the ordinary pacman -Syu completed. No VPN had been active before the toggle and no site-to-site route exists at this location.

That is the UX requirement in one incident: the toggle must stay deliberate, but the surface should make the missing private route obvious when a known home service is requested. wiufph remains split-tunnel; do not turn this convenience into an implicit 0.0.0.0/0 posture.

The constraints any design must respect

  • Not a daemon, not a dispatcher hook. VPN-WIUF.md is explicit, and it was bought with an outage: the previous autonomous gate recycled the tunnel 652 times in 90 minutes, through doze tiers whose contract is "network fetchers stopped", and the user could not switch it off. Posture that must be automatic belongs in sessiond as an Action.
  • The VPN toggle stays deliberate. The user's switch is the authority for whether the tunnel is up. This task decides how traffic is carried, which is a different question and must not quietly re-acquire the first.
  • Carrier services must ride the carrier. MMS is the concrete case: the proxy 205.151.11.13 is reachable only through clat (blueline-clat.sh pins a /32), and Fido's own resolvers must answer Fido names. Casey, 2026-07-31: "fido dns is needed for fido services, mms etc and those services NEED to be carried by the fido network." A design that prefers wifi globally and evicts the carrier's DNS breaks MMS — negative ipv4.dns-priority does exactly that, because in NetworkManager negative means exclusive, and it was observed emptying resolv.conf down to a single nameserver.
  • Split DNS needs a resolver that can route per-domain. resolv.conf is NetworkManager-generated today (mode: foreign), which is a flat ordered list; glibc tries entries in order and nothing binds a domain to a link. ~wiuf.net on the WireGuard profile therefore means nothing at present. This needs dns=systemd-resolved — already on the open list before today.
  • Public DNS is not a substitute for the tunnel. dns.wiuf.net behind the proxy was considered and rejected in VPN-WIUF.md, and the reason holds: it resolves names, it does not route to them. Answering gitea.wiuf.net = 10.10.20.120 is useless without a path to 10.10.20.120. Exposing individual services through Pangolin is a real alternative for those services; raw 10.10.x still requires the tunnel.

Prior art — read on this machine, 2026-07-31

Casey: "PRIOR ART. We need to stop winging this. Sailfish, LineageOS BOTH are on THIS machine now." Both trees are local, so this is read rather than recalled. §9's warning still applies at the end: measure before adopting.

Android / LineageOS (~/Downloads/lineage-blueline-kernel/system.img)

debugfs reads the image with no root and no copy. Two findings, both decisive:

There is no /etc/resolv.conf. Not empty — absent. ls /system/etc has no such file. The flat, ordered, global resolver list this device is fighting is not a thing Android has at all.

DNS is bound per network. From strings on /system/bin/netd:

DNS: netId %d servers [%s]
dnsproxyd
failed to add fallthrough to %s for VPN netId %u
Destroyed %d sockets for netId %d permission=%d in %ldus
cannot set default to non-physical network with netId %u

Every network is a netId carrying its own resolvers; a local proxy (dnsproxyd) routes each query to the right set. A VPN is its own netId with an explicit fallthrough rule, not a set of nameservers poured into a file shared with the carrier. And when a network changes, sockets bound to it are destroyed rather than left to hang — which is what "tunnel up but deaf" needs and what a stale WireGuard handshake currently does not get.

This answers the MMS constraint structurally: an MMS request asks for a network with that capability, gets that netId, and its lookups go to that network's resolvers over that link. Nothing has to be ordered correctly in a shared file, because there is no shared file.

Sailfish / connman (~/phone-qml-research/)

The tree is built on connman's object model — NetworkTechnology (94 references) and NetworkService (70), plus connman.vpn.Agent. Same three shapes as Android by different names:

  • a service per link, ranked by technology preference and by state;
  • per-service nameservers, with connman's dnsproxy owning resolv.conf and routing queries per service;
  • VPN as a first-class service with its own agent, not a route hack.

connman also distinguishes ready from online, where online requires its own connectivity probe to pass.

What both agree on, and where we differ

Android connman this device today
link preference score, and only if validated technology order, and only if online a DHCP metric plus an NM penalty
DNS routing per-netId via dnsproxyd per-service via dnsproxy one flat ordered list
VPN own netId + fallthrough own service + agent nameservers appended to the shared list
link that lies not validated → does not win not online → not preferred wins, and takes the tunnel down with it

Both mature stacks solved this with a local resolver that routes per link and a probe that decides whether a link may win at all. We have neither, and the two failures compound: the flat list is why the VPN's resolver could poison carrier lookups, and the missing probe is why an associated-but-useless wifi got demoted by NM instead of the clat default being demoted.

The Linux equivalent of dnsproxyd/dnsproxy already exists and is already on the open list: dns=systemd-resolved, which does per-link DNS with routing domains — the ~wiuf.net on the WireGuard profile becomes meaningful the moment resolved owns resolv.conf, and means nothing until then.

Where the constraints differ, per §9's lesson. Do not adopt Android's scores. Its validation probe assumes a general-internet URL is reachable and meaningful; ours is a split tunnel whose point is a private 10.10.0.0/16 that no public probe can see. A wifi link that fails a public probe may be exactly the link we want for the tunnel. The probe we need is per-consumer — "can this link carry this" — not "is this link on the internet". wg's received-byte counter is that probe for the tunnel, and it is already there, unread.

The grid

The thing to design is small and mostly a table. Inputs the machine can already observe or cheaply learn:

evidence source today
wifi associated + has v4 default NetworkManager yes
wifi actually carries traffic connectivity probe NM's, and it is what mislabels the link
cellular bearer up / clat present NetworkManager, blueline-clat.sh yes
tunnel up + handshaking wg show received-bytes not read by anything
link is metered NM connection.metered not read

Outputs, as Actions from tick() through the existing executor table:

  • prefer-link (set route metrics deliberately rather than inheriting them)
  • pin the tunnel endpoint to the chosen underlay
  • keep carrier /32s and carrier DNS on the carrier, always

The load-bearing distinction is that "associated" is not "carrying" — the same lesson §10 learned about sensors, where a source that reported and then went silent is Down rather than quiet. A wifi link that associates and cannot route is exactly a sensor that heartbeats and lies. wg's received-byte counter is the honest instrument for the tunnel, the way logind is for the lock.

What landed 2026-08-01 — the machine half

souveraine/src/sessiond/bearer.rs, plus two Actions and a bearer verb. The decision layer is done and tested; the acceptance list below is still open, because none of it has run on the phone.

Evidence. LinkHealth is Absent < Down < Associated < Carrying, and the ordering is the rule: a link may only win if something has been shown to cross it. TunnelHealth reads wg's received-byte counter — the instrument §10 says exists and nothing read — so Deaf (up, sending, nothing back) is a first-class state rather than something that reports as connected. home is Option<bool> over home_ssids, identity not prefix, and empty means "never claim to be home".

Decision. preferred() is a pure function of the evidence: a carrying cellular link beats a merely associated wifi one, which is the case NM's +20000 penalty gets backwards; wifi wins only on equal health.

Anti-flap is a settling window, not a rate limit. A changed preference must hold bearer_settle (20 s) before it emits anything, and the clock restarts whenever the candidate itself changes — so a link oscillating between two answers never accumulates enough time to act. A steady state emits nothing at all, which is what makes it safe to evaluate every tick. Three tests pin this: the flap that never settles, the settled state that must not re-emit, and the dead network that must not be flailed at.

Single writer, no new timer. Preference is expressed as ipv4.route-metric + device reapply, never ip route — NM stays the sole author, acceptance #6. reapply rather than up/down, so a correction is not itself an NM event, which is the loop that produced 652 recycles. The one route sessiond owns is the WireGuard endpoint /32. The probe rides the existing 1 Hz clock every fifth tick with the state lock released.

The toggle stays the user's. tunnel_underlay() returns None when the tunnel is off. This decides how traffic is carried, never whether the tunnel exists.

The duplicate profile is resolved. wiufph is authoritative and wiuf is deleted (backed up to /root/nm-profile-backup/ on the phone). The two were not near-copies — wiuf carried ipv4.addresses 10.8.0.5/24, which is the laptop's tunnel address (wiufarch), while wiufph carries 10.8.0.6, the phone's. Bringing wiuf up would have put two devices on one WireGuard address. wiufph also has the dns-priority 10 correction from 2026-07-31; wiuf was still at 0.

wiufph is autoconnect=no, and that is left alone deliberately. It is the real reason the tunnel does not return after a reboot, and flipping it is the user's call, not this task's — the constraint above says the switch stays the user's authority. Worth a decision, not a silent default.

Measured on the phone, 2026-08-01, running the real thing

home_ssids is set to ["Samaritan Solutions", "TheTholianWeb"]. It needed to be a list: both lease from the home LAN — Samaritan Solutions gave 10.10.20.234 (the phone's documented home address) and TheTholianWeb gave 10.10.20.245. A single-SSID answer would have been wrong half the time. The evidence is in NM's own lease files under /var/lib/NetworkManager/, which is the identity check the /16 prefix test should always have been.

Acceptance #1 holds. On Mabee 19 with cellular also up: default via 10.10.30.1 dev wlan0 metric 600, wifi profile at 600 and the readout at applied: wifi, home: false, ssid: "Mabee 19". ip route get 1.1.1.1 leaves by wlan0. That is the 2026-07-31 inversion corrected.

Acceptance #2 was broken by the first action and is now fixed. Two defects, both only visible on hardware:

  1. nmcli device reapply clat destroys the carrier's routes. After one PreferLink the clat device was up with its 192.0.0.1/32 and zero routes — no default, and no 205.151.11.13/32. ip route get 205.151.11.13 went out wlan0. reapply resets a device to its connection's config, and every clat route is written by the CLAT daemon outside NM. Restored with systemctl restart blueline-clat.service; the code now reapplies only wlan0. Never reapply a device whose routes NM did not write.
  2. The gsm connection is the wrong lever. The modem is v6-only; the v4 default for the carrier is default dev clat scope link metric 2048, installed by the CLAT daemon. A route-metric on the gsm connection is a number nothing reads. Wifi's metric is the only lever there is — under 2048 wifi wins, over it the carrier does — which is also why 2048 belongs in the code as a named reference rather than a guess.

And a privilege defect the tests could not catch. The tunnel read off while wiufph was up and handshaking (4.11 KiB received). wg show needs CAP_NET_ADMIN and sessiond is a systemd user unit, so it can never read peer data — "Unable to access interface wiufph: Operation not permitted". Replaced with /sys/class/net/*/statistics/rx_bytes plus DEVTYPE=wireguard, both world-readable and the same number. The endpoint lookup had it too, and is now read from nmcli -g wireguard.peers — where it turns out to be a hostname (sub.wiuf.net:51822), not the address the old gate hardcoded.

Still open: acceptance #3#5, and dns=systemd-resolved, which nothing here touches and which is what makes ~wiuf.net mean anything. dns=systemd-resolved remains untouched and is what makes ~wiuf.net mean anything; nothing here addresses split DNS.

Acceptance

  1. On foreign wifi with cellular also up, ordinary traffic leaves by wifi and ip route get 1.1.1.1 says so.
  2. MMS still works: 205.151.11.13 stays on clat and Fido's resolvers remain in the resolver set.
  3. gitea.wiuf.net resolves to 10.10.20.120 (the Pi-hole answer), not a public one, whenever the tunnel is up.
  4. Dropping wifi mid-session moves traffic to cellular and back without the user touching anything, and without the tunnel being recycled.
  5. wg show received-bytes is non-zero within 30 s of the toggle going on, and a tunnel that is up but deaf is reported rather than left looking connected.
  6. No new timer, no new dispatcher hook, no second writer of route metrics.

Connects to

docs/substrate/VPN-WIUF.md (correct its "works on cell" claim when this lands), NET_LAYER_PLAN.md (the per-consumer policy engine this is the device-state half of; the plan predates doctrine §13 and is unreconciled), DEVICE-STATE-MACHINE.md §10 (source health — "associated" vs "carrying") and §12 (Action, not daemon), TASK-08 (the state machine itself), TASK-19 (a bearer readout belongs on the same page as the sensor readout).