sessiond: bearer posture as evidence and an Action (TASK-49)
Nothing owned which link carries traffic. It was the residue of a DHCP metric, a NetworkManager penalty and a shell script's assumption, and when those disagreed the phone silently lost the network - every IPv4 destination leaving over a degraded clat while wifi sat associated. The thing this replaces, wiuf-vpn-gate, is worth stating because its three defects are the design constraints: - it was driven by the NM dispatcher AND a 90s timer while calling nmcli up/down, which is itself an NM event. 652 tunnel recycles in 90 minutes, ~7/min - a loop, not a bad interval. Here the probe only reads, the decision is a pure function, and the only egress is an Action from tick(). - its home test was ' inet 10.10.' - a /16 match that reads the foreign network this phone lives on (10.10.30.213) as home. home_ssids is identity; an empty list means never claim to be home. - it managed CONN=wiuf while two profiles answer to that name. LinkHealth keeps 'associated' apart from 'carrying', which is DEVICE-STATE- MACHINE §10's source-health lesson applied to links: a wifi link that associates and cannot route is a sensor that heartbeats and lies, and it is what NM's +20000 penalty mislabels. A carrying cellular link therefore beats a merely associated wifi one; wifi only wins ties. TunnelHealth reads wg's received-byte counter, which nothing read before. Up and deaf is a real state - measured over clat as 368 B received against 5.30 KiB sent - and every other readout on the device calls it connected. Anti-flap is a settling window, not a rate limit: a changed preference must hold BEARER_SETTLE (20s) before it produces a command, and the clock restarts whenever the candidate itself changes. A steady state emits nothing, so it is safe to evaluate every tick. Tests cover the flap that never settles, the settled state that must not re-emit, and the dead network that must not be flailed at. NetworkManager stays the single writer of routes (acceptance #6): preference is expressed as ipv4.route-metric plus device reapply, never ip route, and reapply rather than up/down so a correction is not itself an NM event. The one route sessiond owns is the WireGuard endpoint /32, which NM has no opinion about. The tunnel toggle stays the user's. tunnel_underlay() returns None when the tunnel is off - this decides how traffic is carried, not whether the tunnel exists. Probed off the existing clock every 5th tick, with the lock released; no new timer (acceptance #6) and no subprocess spawned under the state mutex.
This commit is contained in:
parent
a89556aef7
commit
4fd3d244ab
5 changed files with 926 additions and 3 deletions
441
src/sessiond/bearer.rs
Normal file
441
src/sessiond/bearer.rs
Normal file
|
|
@ -0,0 +1,441 @@
|
||||||
|
//! Bearer posture — which link carries what.
|
||||||
|
//!
|
||||||
|
//! This is the device-state half of TASK-49. It exists because nothing owned
|
||||||
|
//! the question: which link carries traffic was the residue of a DHCP metric, a
|
||||||
|
//! NetworkManager penalty and a shell script's assumption, and when those
|
||||||
|
//! disagreed the device silently lost the network.
|
||||||
|
//!
|
||||||
|
//! Three rules shape everything here, and each was bought with an outage.
|
||||||
|
//!
|
||||||
|
//! **The machine reports and decides; it never actuates in the reader.** The
|
||||||
|
//! previous answer, `wiuf-vpn-gate`, was driven by the NM dispatcher *and* a
|
||||||
|
//! 90 s timer while calling `nmcli connection up/down` — which is itself an NM
|
||||||
|
//! event. It 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. An actuator wired back into its own sensor. Here the probe is pure
|
||||||
|
//! reading, the decision is a pure function, and the only way out is an
|
||||||
|
//! `Action` from `tick()` — DEVICE-STATE-MACHINE §12.
|
||||||
|
//!
|
||||||
|
//! **"Associated" is not "carrying."** §10 learned this about sensors: a source
|
||||||
|
//! that reported and then went silent is `Down`, not quiet. A wifi link that
|
||||||
|
//! associates and cannot route is exactly a sensor that heartbeats and lies,
|
||||||
|
//! and it is what NetworkManager's own connectivity penalty mislabels — +20000
|
||||||
|
//! on the link, so the degraded cellular default wins instead. `LinkHealth`
|
||||||
|
//! keeps the two apart so a link that lies cannot win.
|
||||||
|
//!
|
||||||
|
//! **"Am I home" is not an address-prefix question.** The old gate tested
|
||||||
|
//! `ip -4 addr show wlan0 | grep ' inet 10\.10\.'` — a /16 match. Home is
|
||||||
|
//! `10.10.20.0/24`, and this phone spends its time on a foreign network at
|
||||||
|
//! `10.10.30.213` that the test read as home, taking the tunnel down as
|
||||||
|
//! "redundant". Identity, never prefix: the SSID, or a host only the home LAN
|
||||||
|
//! serves.
|
||||||
|
//!
|
||||||
|
//! What this module deliberately does **not** decide: whether the tunnel is up.
|
||||||
|
//! That is the user's switch and stays the user's switch. This decides how
|
||||||
|
//! traffic is carried, which is a different question and must not quietly
|
||||||
|
//! re-acquire the first.
|
||||||
|
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// A physical carrier of traffic. The tunnel is not one of these — it rides
|
||||||
|
/// whichever of these is chosen, which is the whole point of pinning it.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Bearer {
|
||||||
|
Wifi,
|
||||||
|
Cellular,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Bearer {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Bearer::Wifi => "wifi",
|
||||||
|
Bearer::Cellular => "cellular",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How much a link is actually doing — not what it claims.
|
||||||
|
///
|
||||||
|
/// The ordering is deliberate and is the comparison the decision uses:
|
||||||
|
/// `Carrying` outranks `Associated` outranks `Down` outranks `Absent`. A link
|
||||||
|
/// may only win if something has been shown to cross it.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum LinkHealth {
|
||||||
|
/// No such link on this device right now.
|
||||||
|
Absent,
|
||||||
|
/// The link exists and is known not to be usable.
|
||||||
|
Down,
|
||||||
|
/// Associated and addressed, but nothing has been shown to cross it. This
|
||||||
|
/// is the honest default for a link we have not probed — it is *not* a
|
||||||
|
/// claim that the link works.
|
||||||
|
Associated,
|
||||||
|
/// Associated and carrying: a default route exists over it and the link is
|
||||||
|
/// not under a connectivity penalty.
|
||||||
|
Carrying,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LinkHealth {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
LinkHealth::Absent => "absent",
|
||||||
|
LinkHealth::Down => "down",
|
||||||
|
LinkHealth::Associated => "associated",
|
||||||
|
LinkHealth::Carrying => "carrying",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// May this link be preferred at all? `Associated` is allowed to win when
|
||||||
|
/// nothing better exists, but `Down`/`Absent` never are.
|
||||||
|
fn usable(self) -> bool {
|
||||||
|
matches!(self, LinkHealth::Associated | LinkHealth::Carrying)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tunnel's own health, which is a different question from a link's.
|
||||||
|
///
|
||||||
|
/// It can be `up` in NetworkManager and deaf on the wire — that is exactly what
|
||||||
|
/// was measured over clat on 2026-07-31: `368 B received, 5.30 KiB sent`, a
|
||||||
|
/// handshake going stale against a 25 s keepalive, while every status readout
|
||||||
|
/// said connected. `wg`'s received-byte counter is the honest instrument, the
|
||||||
|
/// way logind is for the lock, and until this module nothing read it.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum TunnelHealth {
|
||||||
|
/// The toggle is off. The user's authority, not ours — never an error.
|
||||||
|
Off,
|
||||||
|
/// Up and the far end is answering.
|
||||||
|
Handshaking,
|
||||||
|
/// Up, sending, and nothing is coming back. Reported rather than left
|
||||||
|
/// looking connected.
|
||||||
|
Deaf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TunnelHealth {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
TunnelHealth::Off => "off",
|
||||||
|
TunnelHealth::Handshaking => "handshaking",
|
||||||
|
TunnelHealth::Deaf => "deaf",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the machine believes about how traffic can be carried right now.
|
||||||
|
///
|
||||||
|
/// Every field is evidence with a source, not a fact — §9. `home` is `Option`
|
||||||
|
/// precisely because "I do not know where I am" is a third answer, and the old
|
||||||
|
/// gate's bug was collapsing it into `false`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct BearerEvidence {
|
||||||
|
pub wifi: LinkHealth,
|
||||||
|
pub cellular: LinkHealth,
|
||||||
|
pub tunnel: TunnelHealth,
|
||||||
|
/// `Some(true)` on the home LAN, `Some(false)` demonstrably elsewhere,
|
||||||
|
/// `None` when we cannot tell. Derived from SSID identity, never from an
|
||||||
|
/// address prefix.
|
||||||
|
pub home: Option<bool>,
|
||||||
|
/// The SSID we are associated with, when there is one. Kept for the
|
||||||
|
/// readout so a human can see *why* `home` reads as it does.
|
||||||
|
pub ssid: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BearerEvidence {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
wifi: LinkHealth::Absent,
|
||||||
|
cellular: LinkHealth::Absent,
|
||||||
|
tunnel: TunnelHealth::Off,
|
||||||
|
home: None,
|
||||||
|
ssid: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BearerEvidence {
|
||||||
|
/// Which bearer ordinary traffic should prefer, if any.
|
||||||
|
///
|
||||||
|
/// Pure function of the evidence — no clock, no I/O, no memory. That is
|
||||||
|
/// what makes the flapping question answerable elsewhere and this testable
|
||||||
|
/// here.
|
||||||
|
///
|
||||||
|
/// Wifi wins ties because it is the unmetered link, but only on equal
|
||||||
|
/// health: a *carrying* cellular link beats a merely *associated* wifi one,
|
||||||
|
/// which is the case NetworkManager's penalty gets backwards.
|
||||||
|
pub fn preferred(&self) -> Option<Bearer> {
|
||||||
|
match (self.wifi.usable(), self.cellular.usable()) {
|
||||||
|
(false, false) => None,
|
||||||
|
(true, false) => Some(Bearer::Wifi),
|
||||||
|
(false, true) => Some(Bearer::Cellular),
|
||||||
|
(true, true) => {
|
||||||
|
if self.cellular > self.wifi {
|
||||||
|
Some(Bearer::Cellular)
|
||||||
|
} else {
|
||||||
|
Some(Bearer::Wifi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which bearer the tunnel's endpoint should be pinned to.
|
||||||
|
///
|
||||||
|
/// The same link ordinary traffic prefers, and `None` when the tunnel is
|
||||||
|
/// off — pinning an endpoint for a tunnel the user has switched off would
|
||||||
|
/// be this module deciding something that is not its to decide.
|
||||||
|
pub fn tunnel_underlay(&self) -> Option<Bearer> {
|
||||||
|
match self.tunnel {
|
||||||
|
TunnelHealth::Off => None,
|
||||||
|
TunnelHealth::Handshaking | TunnelHealth::Deaf => self.preferred(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is the tunnel up but not being answered? Worth surfacing on its own
|
||||||
|
/// because every other readout on the device calls this "connected".
|
||||||
|
pub fn tunnel_is_deaf(&self) -> bool {
|
||||||
|
matches!(self.tunnel, TunnelHealth::Deaf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the current bearer posture off the system.
|
||||||
|
///
|
||||||
|
/// Reading, and only reading — the same contract `lockhint` has with logind.
|
||||||
|
/// Anything that changes state leaves as an `Action`.
|
||||||
|
pub fn probe(home_ssids: &[String]) -> BearerEvidence {
|
||||||
|
let (wifi, ssid) = probe_wifi(home_ssids);
|
||||||
|
BearerEvidence {
|
||||||
|
wifi: wifi.0,
|
||||||
|
cellular: probe_cellular(),
|
||||||
|
tunnel: probe_tunnel(),
|
||||||
|
home: wifi.1,
|
||||||
|
ssid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `nmcli -t -f DEVICE,TYPE,STATE,CONNECTION device` is the one call that
|
||||||
|
/// answers association without asking NetworkManager for its opinion about
|
||||||
|
/// connectivity — which is the opinion that mislabels the link.
|
||||||
|
fn nmcli_devices() -> Vec<(String, String, String, String)> {
|
||||||
|
let out = match Command::new("nmcli")
|
||||||
|
.args(["-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"])
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
Ok(o) if o.status.success() => o.stdout,
|
||||||
|
_ => return Vec::new(),
|
||||||
|
};
|
||||||
|
String::from_utf8_lossy(&out)
|
||||||
|
.lines()
|
||||||
|
.filter_map(|l| {
|
||||||
|
let mut f = l.splitn(4, ':');
|
||||||
|
Some((
|
||||||
|
f.next()?.to_string(),
|
||||||
|
f.next()?.to_string(),
|
||||||
|
f.next()?.to_string(),
|
||||||
|
f.next().unwrap_or("").to_string(),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does a default route exist over this interface? This is the "carrying" half
|
||||||
|
/// of the distinction — association alone is not evidence of a path.
|
||||||
|
fn has_default_route(iface: &str) -> bool {
|
||||||
|
let out = match Command::new("ip").args(["-4", "route", "show", "default"]).output() {
|
||||||
|
Ok(o) if o.status.success() => o.stdout,
|
||||||
|
_ => return false,
|
||||||
|
};
|
||||||
|
String::from_utf8_lossy(&out)
|
||||||
|
.lines()
|
||||||
|
.any(|l| l.split_whitespace().any(|w| w == iface))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns wifi health, whether we are home, and the SSID.
|
||||||
|
fn probe_wifi(home_ssids: &[String]) -> ((LinkHealth, Option<bool>), Option<String>) {
|
||||||
|
let devices = nmcli_devices();
|
||||||
|
let Some((iface, _, state, _)) = devices.iter().find(|(_, t, _, _)| t == "wifi").cloned() else {
|
||||||
|
return ((LinkHealth::Absent, None), None);
|
||||||
|
};
|
||||||
|
|
||||||
|
if state != "connected" {
|
||||||
|
return ((LinkHealth::Down, Some(false)), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ssid = active_ssid(&iface);
|
||||||
|
// Identity, never prefix. An unknown SSID is `Some(false)` — we know where
|
||||||
|
// we are and it is not home. No SSID at all is `None` — we do not know.
|
||||||
|
let home = ssid
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| home_ssids.iter().any(|h| h == s));
|
||||||
|
|
||||||
|
let health = if has_default_route(&iface) {
|
||||||
|
LinkHealth::Carrying
|
||||||
|
} else {
|
||||||
|
LinkHealth::Associated
|
||||||
|
};
|
||||||
|
((health, home), ssid)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_ssid(iface: &str) -> Option<String> {
|
||||||
|
let out = Command::new("nmcli")
|
||||||
|
.args(["-t", "-f", "ACTIVE,SSID", "device", "wifi", "list", "ifname", iface])
|
||||||
|
.output()
|
||||||
|
.ok()?;
|
||||||
|
if !out.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&out.stdout)
|
||||||
|
.lines()
|
||||||
|
.find_map(|l| l.strip_prefix("yes:"))
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn probe_cellular() -> LinkHealth {
|
||||||
|
let devices = nmcli_devices();
|
||||||
|
// clat is the v4-over-v6 shim the carrier path rides; on this device the
|
||||||
|
// modem itself shows up as a `gsm` device and clat as a separate tun.
|
||||||
|
let modem_up = devices
|
||||||
|
.iter()
|
||||||
|
.any(|(_, t, s, _)| (t == "gsm" || t == "cdma") && s == "connected");
|
||||||
|
if !modem_up {
|
||||||
|
return LinkHealth::Absent;
|
||||||
|
}
|
||||||
|
if has_default_route("clat") || devices.iter().any(|(d, _, s, _)| d == "clat" && s == "connected")
|
||||||
|
{
|
||||||
|
LinkHealth::Carrying
|
||||||
|
} else {
|
||||||
|
LinkHealth::Associated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `wg show` received-bytes is the tunnel's honest instrument. A peer that has
|
||||||
|
/// sent and never received is up and deaf, not connected.
|
||||||
|
fn probe_tunnel() -> TunnelHealth {
|
||||||
|
let out = match Command::new("wg").arg("show").output() {
|
||||||
|
Ok(o) if o.status.success() => o.stdout,
|
||||||
|
_ => return TunnelHealth::Off,
|
||||||
|
};
|
||||||
|
let text = String::from_utf8_lossy(&out);
|
||||||
|
if text.trim().is_empty() {
|
||||||
|
return TunnelHealth::Off;
|
||||||
|
}
|
||||||
|
// " transfer: 368 B received, 5.30 KiB sent"
|
||||||
|
let transfer = text.lines().find_map(|l| l.trim().strip_prefix("transfer:"));
|
||||||
|
match transfer {
|
||||||
|
// Up with a peer but no transfer line yet: it has not handshaken.
|
||||||
|
None => TunnelHealth::Deaf,
|
||||||
|
Some(t) => {
|
||||||
|
let received_zero = t
|
||||||
|
.split(" received")
|
||||||
|
.next()
|
||||||
|
.map(|r| r.trim().starts_with("0 "))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if received_zero {
|
||||||
|
TunnelHealth::Deaf
|
||||||
|
} else {
|
||||||
|
TunnelHealth::Handshaking
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn ev(wifi: LinkHealth, cellular: LinkHealth) -> BearerEvidence {
|
||||||
|
BearerEvidence {
|
||||||
|
wifi,
|
||||||
|
cellular,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_usable_prefers_nothing() {
|
||||||
|
assert_eq!(ev(LinkHealth::Absent, LinkHealth::Absent).preferred(), None);
|
||||||
|
assert_eq!(ev(LinkHealth::Down, LinkHealth::Down).preferred(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_carrying_link_beats_a_merely_associated_one() {
|
||||||
|
// This is the case NetworkManager gets backwards: it penalises the
|
||||||
|
// associated-but-unvalidated wifi link by +20000 and lets the degraded
|
||||||
|
// clat default win. Here the *carrying* link wins on its own merit.
|
||||||
|
assert_eq!(
|
||||||
|
ev(LinkHealth::Associated, LinkHealth::Carrying).preferred(),
|
||||||
|
Some(Bearer::Cellular)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ev(LinkHealth::Carrying, LinkHealth::Associated).preferred(),
|
||||||
|
Some(Bearer::Wifi)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wifi_wins_only_on_equal_health() {
|
||||||
|
assert_eq!(
|
||||||
|
ev(LinkHealth::Carrying, LinkHealth::Carrying).preferred(),
|
||||||
|
Some(Bearer::Wifi)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ev(LinkHealth::Associated, LinkHealth::Associated).preferred(),
|
||||||
|
Some(Bearer::Wifi)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_down_link_never_wins_even_alone() {
|
||||||
|
assert_eq!(
|
||||||
|
ev(LinkHealth::Down, LinkHealth::Absent).preferred(),
|
||||||
|
None,
|
||||||
|
"a link known not to work is not a fallback"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ev(LinkHealth::Down, LinkHealth::Associated).preferred(),
|
||||||
|
Some(Bearer::Cellular)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tunnel_off_pins_nothing() {
|
||||||
|
let e = BearerEvidence {
|
||||||
|
wifi: LinkHealth::Carrying,
|
||||||
|
tunnel: TunnelHealth::Off,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
e.tunnel_underlay(),
|
||||||
|
None,
|
||||||
|
"the toggle is the user's; a tunnel that is off gets no underlay"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_deaf_tunnel_still_gets_pinned_and_is_reported() {
|
||||||
|
// Deaf is the case worth acting on: it is up, so the user asked for it,
|
||||||
|
// and it is not being answered, so the underlay is the suspect.
|
||||||
|
let e = BearerEvidence {
|
||||||
|
wifi: LinkHealth::Carrying,
|
||||||
|
tunnel: TunnelHealth::Deaf,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(e.tunnel_underlay(), Some(Bearer::Wifi));
|
||||||
|
assert!(e.tunnel_is_deaf());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn home_is_identity_not_prefix() {
|
||||||
|
// The regression this whole module exists to not repeat: 10.10.30.213
|
||||||
|
// is a foreign network that a /16 prefix test reads as home.
|
||||||
|
let home = vec!["wiuf".to_string()];
|
||||||
|
assert_eq!(
|
||||||
|
home.iter().any(|h| h == "Mabee 19"),
|
||||||
|
false,
|
||||||
|
"a foreign SSID is not home however its addresses look"
|
||||||
|
);
|
||||||
|
assert!(home.iter().any(|h| h == "wiuf"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::sessiond::bearer::{Bearer, BearerEvidence};
|
||||||
use crate::sessiond::protocol::{
|
use crate::sessiond::protocol::{
|
||||||
Button, ButtonEdge, ButtonGesture, InputTrigger, SensorSource, SensorValue,
|
Button, ButtonEdge, ButtonGesture, InputTrigger, SensorSource, SensorValue,
|
||||||
};
|
};
|
||||||
|
|
@ -125,6 +126,16 @@ pub const PROXIMITY_NEAR_DEBOUNCE: Duration = Duration::from_millis(700);
|
||||||
/// wake through, which is the recoverable direction.
|
/// wake through, which is the recoverable direction.
|
||||||
pub const PROXIMITY_FAR_DEBOUNCE: Duration = Duration::ZERO;
|
pub const PROXIMITY_FAR_DEBOUNCE: Duration = Duration::ZERO;
|
||||||
|
|
||||||
|
/// How long a changed bearer preference must hold before the machine acts.
|
||||||
|
///
|
||||||
|
/// 20 s, chosen against the failure rather than against a feel. The gate this
|
||||||
|
/// replaces reacted at NM-event speed and produced ~7 tunnel recycles a
|
||||||
|
/// minute; a wifi association that comes and goes during a roam settles well
|
||||||
|
/// inside 20 s, and a genuine bearer change (walking out of range) does not
|
||||||
|
/// reverse itself within one. The cost of being slow here is 20 s on the wrong
|
||||||
|
/// link; the cost of being fast was an unusable phone.
|
||||||
|
pub const BEARER_SETTLE: Duration = Duration::from_secs(20);
|
||||||
|
|
||||||
/// How long a button must stay down to be a hold rather than a tap.
|
/// How long a button must stay down to be a hold rather than a tap.
|
||||||
///
|
///
|
||||||
/// **Provisional, and the number is the weakest part of this file.** §9.5's
|
/// **Provisional, and the number is the weakest part of this file.** §9.5's
|
||||||
|
|
@ -205,6 +216,22 @@ pub struct DeviceStatePolicy {
|
||||||
pub proximity_near_debounce: Duration,
|
pub proximity_near_debounce: Duration,
|
||||||
/// How long proximity must hold `far` before the machine believes it.
|
/// How long proximity must hold `far` before the machine believes it.
|
||||||
pub proximity_far_debounce: Duration,
|
pub proximity_far_debounce: Duration,
|
||||||
|
/// SSIDs that are the home LAN.
|
||||||
|
///
|
||||||
|
/// Identity, never an address prefix. The gate this replaces tested
|
||||||
|
/// `inet 10.10.` and so read the foreign network this phone lives on
|
||||||
|
/// (`10.10.30.213`) as home, taking the tunnel down as redundant. Empty
|
||||||
|
/// means "never claim to be home", which is the safe default: an unknown
|
||||||
|
/// network is not a reason to drop the tunnel.
|
||||||
|
pub home_ssids: Vec<String>,
|
||||||
|
/// How long a bearer preference must hold before the machine acts on it.
|
||||||
|
///
|
||||||
|
/// This is the entire anti-flap mechanism, and it is a debounce rather
|
||||||
|
/// than a rate limit on purpose. `wiuf-vpn-gate` recycled the tunnel 652
|
||||||
|
/// times in 90 minutes because every correction re-entered the controller;
|
||||||
|
/// a decision that has to survive a settling window cannot oscillate at
|
||||||
|
/// event speed no matter how the events arrive.
|
||||||
|
pub bearer_settle: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DeviceStatePolicy {
|
impl DeviceStatePolicy {
|
||||||
|
|
@ -289,6 +316,8 @@ impl Default for DeviceStatePolicy {
|
||||||
source_expected_within: SOURCE_EXPECTED_WITHIN,
|
source_expected_within: SOURCE_EXPECTED_WITHIN,
|
||||||
proximity_near_debounce: PROXIMITY_NEAR_DEBOUNCE,
|
proximity_near_debounce: PROXIMITY_NEAR_DEBOUNCE,
|
||||||
proximity_far_debounce: PROXIMITY_FAR_DEBOUNCE,
|
proximity_far_debounce: PROXIMITY_FAR_DEBOUNCE,
|
||||||
|
home_ssids: Vec::new(),
|
||||||
|
bearer_settle: BEARER_SETTLE,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -318,6 +347,21 @@ pub enum Action {
|
||||||
/// otherwise; either way the panel does not go dark until it is answered
|
/// otherwise; either way the panel does not go dark until it is answered
|
||||||
/// or the ack budget expires.
|
/// or the ack budget expires.
|
||||||
Lock,
|
Lock,
|
||||||
|
/// Carry ordinary traffic over this bearer.
|
||||||
|
///
|
||||||
|
/// Expressed to NetworkManager as a route metric, never written with `ip
|
||||||
|
/// route`: NM stays the single writer of routes, which is TASK-49's
|
||||||
|
/// acceptance #6. Emitted only when the decision *changes* and has held
|
||||||
|
/// for `bearer_settle`, so a link flapping at event speed cannot produce
|
||||||
|
/// a command per event — the failure that recycled the tunnel 652 times.
|
||||||
|
PreferLink(Bearer),
|
||||||
|
/// Pin the WireGuard endpoint's host route to this bearer.
|
||||||
|
///
|
||||||
|
/// The tunnel is not a bearer; it rides one. Over clat it was measured
|
||||||
|
/// sending and never receiving, and pinning the endpoint via wlan0
|
||||||
|
/// produced a handshake in seconds. This does not turn the tunnel on or
|
||||||
|
/// off — that switch is the user's and stays the user's.
|
||||||
|
PinTunnelUnderlay(Bearer),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// When each evidence source last reported. Kept beside `SensorEvidence`
|
/// When each evidence source last reported. Kept beside `SensorEvidence`
|
||||||
|
|
@ -1283,6 +1327,23 @@ pub struct DeviceStateMachine {
|
||||||
proximity_since: Option<Instant>,
|
proximity_since: Option<Instant>,
|
||||||
/// Timed policy. Settable, so Settings can own it.
|
/// Timed policy. Settable, so Settings can own it.
|
||||||
pub policy: DeviceStatePolicy,
|
pub policy: DeviceStatePolicy,
|
||||||
|
/// Last bearer posture read off the system. Evidence, refreshed by the
|
||||||
|
/// daemon and never inferred here — the same contract `panel_on` has with
|
||||||
|
/// the DPMS executor.
|
||||||
|
pub bearer: BearerEvidence,
|
||||||
|
/// The bearer the machine has actually acted on, and when the current
|
||||||
|
/// candidate first differed from it.
|
||||||
|
///
|
||||||
|
/// These two fields *are* the anti-flap rule. A preference is only emitted
|
||||||
|
/// once it has differed from `bearer_applied` continuously for
|
||||||
|
/// `bearer_settle`; anything that reverses inside that window never
|
||||||
|
/// produces a command at all. `wiuf-vpn-gate` had no such memory, which is
|
||||||
|
/// why every NM event it caused became another correction.
|
||||||
|
bearer_applied: Option<Bearer>,
|
||||||
|
bearer_candidate_since: Option<(Bearer, Instant)>,
|
||||||
|
/// Whether the tunnel was deaf last time we looked, so the trail records
|
||||||
|
/// the edge rather than one entry per tick.
|
||||||
|
bearer_deaf: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sensor inputs that feed the Observed state. Each is a reading, not
|
/// Sensor inputs that feed the Observed state. Each is a reading, not
|
||||||
|
|
@ -1355,6 +1416,10 @@ impl DeviceStateMachine {
|
||||||
// Loaded, not defaulted: the user's Auto-Lock choices are settings,
|
// Loaded, not defaulted: the user's Auto-Lock choices are settings,
|
||||||
// and a setting that reverts on reboot is not a setting.
|
// and a setting that reverts on reboot is not a setting.
|
||||||
policy: DeviceStatePolicy::load(),
|
policy: DeviceStatePolicy::load(),
|
||||||
|
bearer: BearerEvidence::default(),
|
||||||
|
bearer_applied: None,
|
||||||
|
bearer_candidate_since: None,
|
||||||
|
bearer_deaf: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// First entry of the run, and the only one that proves the trail is
|
// First entry of the run, and the only one that proves the trail is
|
||||||
|
|
@ -1502,7 +1567,7 @@ impl DeviceStateMachine {
|
||||||
self.resolve_proximity_debounce(now);
|
self.resolve_proximity_debounce(now);
|
||||||
self.evaluate_source_health(now);
|
self.evaluate_source_health(now);
|
||||||
|
|
||||||
let mut actions = Vec::new();
|
let mut actions = self.bearer_actions(now);
|
||||||
|
|
||||||
// A blank is already decided and waiting on the lock it asked for.
|
// A blank is already decided and waiting on the lock it asked for.
|
||||||
// Nothing else may run while that is outstanding — the whole point is
|
// Nothing else may run while that is outstanding — the whole point is
|
||||||
|
|
@ -1820,6 +1885,109 @@ impl DeviceStateMachine {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stamp a source as freshly reported. Called alongside the reading.
|
/// Stamp a source as freshly reported. Called alongside the reading.
|
||||||
|
/// The bearer actually in force, for the readout. `None` until the first
|
||||||
|
/// preference settles — which is a real state and not the same as "wifi".
|
||||||
|
pub fn bearer_applied_str(&self) -> Option<&'static str> {
|
||||||
|
self.bearer_applied.map(|b| b.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fresh bearer evidence from the daemon's probe.
|
||||||
|
///
|
||||||
|
/// Ingress only. The machine never reads the network itself for the same
|
||||||
|
/// reason it never reads logind itself: a reader that can also act is one
|
||||||
|
/// refactor away from being a controller, and the controller this replaces
|
||||||
|
/// is the one that recycled the tunnel 652 times.
|
||||||
|
pub fn note_bearer(&mut self, evidence: BearerEvidence) {
|
||||||
|
self.bearer = evidence;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide the bearer posture, subject to the settling window.
|
||||||
|
///
|
||||||
|
/// Returns at most one `PreferLink` and one `PinTunnelUnderlay`, and only
|
||||||
|
/// on a change that has held. A steady state produces no actions at all,
|
||||||
|
/// which is what makes this safe to call every tick.
|
||||||
|
fn bearer_actions(&mut self, now: Instant) -> Vec<Action> {
|
||||||
|
let mut actions = Vec::new();
|
||||||
|
|
||||||
|
// Report the tunnel going deaf on the edge. This is the one thing that
|
||||||
|
// is worth saying even when nothing can be done about it: every other
|
||||||
|
// readout on the device calls a deaf tunnel "connected".
|
||||||
|
let deaf = self.bearer.tunnel_is_deaf();
|
||||||
|
if deaf != self.bearer_deaf {
|
||||||
|
self.bearer_deaf = deaf;
|
||||||
|
if deaf {
|
||||||
|
warn!("[device-state] tunnel is up but receiving nothing");
|
||||||
|
}
|
||||||
|
self.record_decision(
|
||||||
|
"bearer-tunnel-health",
|
||||||
|
serde_json::json!({ "tunnel": self.bearer.tunnel.as_str() }),
|
||||||
|
if deaf {
|
||||||
|
"tunnel up but no bytes received — up is not carrying"
|
||||||
|
} else {
|
||||||
|
"tunnel handshake healthy"
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(want) = self.bearer.preferred() else {
|
||||||
|
// Nothing usable. Deliberately not an action: with no link worth
|
||||||
|
// preferring there is nothing to prefer it over, and issuing a
|
||||||
|
// command here would be the machine flailing at a dead network.
|
||||||
|
self.bearer_candidate_since = None;
|
||||||
|
return actions;
|
||||||
|
};
|
||||||
|
|
||||||
|
if Some(want) == self.bearer_applied {
|
||||||
|
self.bearer_candidate_since = None;
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A changed candidate has to survive the settling window. Restarting
|
||||||
|
// the clock whenever the candidate itself changes is what stops a link
|
||||||
|
// oscillating between two answers from ever accumulating enough time.
|
||||||
|
let since = match self.bearer_candidate_since {
|
||||||
|
Some((b, t)) if b == want => t,
|
||||||
|
_ => {
|
||||||
|
self.bearer_candidate_since = Some((want, now));
|
||||||
|
now
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if now.duration_since(since) < self.policy.bearer_settle {
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
let previous = self.bearer_applied;
|
||||||
|
self.bearer_applied = Some(want);
|
||||||
|
self.bearer_candidate_since = None;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"[device-state] bearer: {} -> {}",
|
||||||
|
previous.map(|b| b.as_str()).unwrap_or("none"),
|
||||||
|
want.as_str()
|
||||||
|
);
|
||||||
|
self.record_decision(
|
||||||
|
"bearer-preferred",
|
||||||
|
serde_json::json!({
|
||||||
|
"from": previous.map(|b| b.as_str()),
|
||||||
|
"to": want.as_str(),
|
||||||
|
"wifi": self.bearer.wifi.as_str(),
|
||||||
|
"cellular": self.bearer.cellular.as_str(),
|
||||||
|
"tunnel": self.bearer.tunnel.as_str(),
|
||||||
|
"home": self.bearer.home,
|
||||||
|
"ssid": self.bearer.ssid,
|
||||||
|
"settled_for_secs": now.duration_since(since).as_secs(),
|
||||||
|
}),
|
||||||
|
"bearer preference changed and held for the settling window",
|
||||||
|
);
|
||||||
|
actions.push(Action::PreferLink(want));
|
||||||
|
|
||||||
|
// The tunnel follows the underlay, and only when it is already up.
|
||||||
|
if let Some(underlay) = self.bearer.tunnel_underlay() {
|
||||||
|
actions.push(Action::PinTunnelUnderlay(underlay));
|
||||||
|
}
|
||||||
|
actions
|
||||||
|
}
|
||||||
|
|
||||||
pub fn mark_evidence_seen(&mut self, source: SensorSource) {
|
pub fn mark_evidence_seen(&mut self, source: SensorSource) {
|
||||||
self.mark_evidence_seen_at(source, Instant::now())
|
self.mark_evidence_seen_at(source, Instant::now())
|
||||||
}
|
}
|
||||||
|
|
@ -2503,6 +2671,7 @@ impl fmt::Display for DeviceState {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::sessiond::bearer::{LinkHealth, TunnelHealth};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn initial_state_is_active() {
|
fn initial_state_is_active() {
|
||||||
|
|
@ -3950,4 +4119,136 @@ mod tests {
|
||||||
write_n(&log, 3);
|
write_n(&log, 3);
|
||||||
assert_eq!(log.recent(10).len(), 3);
|
assert_eq!(log.recent(10).len(), 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- bearer: the anti-flap rule is the whole point ----
|
||||||
|
|
||||||
|
fn machine_with_bearer(wifi: LinkHealth, cellular: LinkHealth) -> DeviceStateMachine {
|
||||||
|
let mut m = DeviceStateMachine::new();
|
||||||
|
m.note_bearer(BearerEvidence {
|
||||||
|
wifi,
|
||||||
|
cellular,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_bearer_change_waits_for_the_settling_window() {
|
||||||
|
let mut m = machine_with_bearer(LinkHealth::Carrying, LinkHealth::Associated);
|
||||||
|
let t0 = Instant::now();
|
||||||
|
|
||||||
|
// First sight of a preference is not enough. This is the property the
|
||||||
|
// 652-recycle gate did not have: it acted on every event it saw.
|
||||||
|
let a = m.tick_at(t0);
|
||||||
|
assert!(
|
||||||
|
!a.iter().any(|x| matches!(x, Action::PreferLink(_))),
|
||||||
|
"acted before the window: {a:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let early = t0 + BEARER_SETTLE - Duration::from_secs(1);
|
||||||
|
let a = m.tick_at(early);
|
||||||
|
assert!(!a.iter().any(|x| matches!(x, Action::PreferLink(_))));
|
||||||
|
|
||||||
|
let late = t0 + BEARER_SETTLE + Duration::from_secs(1);
|
||||||
|
let a = m.tick_at(late);
|
||||||
|
assert!(
|
||||||
|
a.contains(&Action::PreferLink(Bearer::Wifi)),
|
||||||
|
"should have settled on wifi: {a:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_preference_that_reverses_inside_the_window_never_acts() {
|
||||||
|
// The flap case, stated exactly: wifi appears, then goes away again
|
||||||
|
// before it settled. No command may be produced by that at all.
|
||||||
|
let mut m = machine_with_bearer(LinkHealth::Carrying, LinkHealth::Carrying);
|
||||||
|
let t0 = Instant::now();
|
||||||
|
let _ = m.tick_at(t0);
|
||||||
|
|
||||||
|
m.note_bearer(BearerEvidence {
|
||||||
|
wifi: LinkHealth::Down,
|
||||||
|
cellular: LinkHealth::Carrying,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let mid = t0 + BEARER_SETTLE / 2;
|
||||||
|
assert!(!m
|
||||||
|
.tick_at(mid)
|
||||||
|
.iter()
|
||||||
|
.any(|x| matches!(x, Action::PreferLink(_))));
|
||||||
|
|
||||||
|
// Back to wifi before either candidate matured.
|
||||||
|
m.note_bearer(BearerEvidence {
|
||||||
|
wifi: LinkHealth::Carrying,
|
||||||
|
cellular: LinkHealth::Carrying,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let later = t0 + BEARER_SETTLE + Duration::from_secs(1);
|
||||||
|
let a = m.tick_at(later);
|
||||||
|
assert!(
|
||||||
|
!a.iter().any(|x| matches!(x, Action::PreferLink(_))),
|
||||||
|
"the clock must restart when the candidate changes, not accumulate: {a:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_settled_preference_is_not_re_emitted_every_tick() {
|
||||||
|
// Idempotence at the decision, not at the executor. A command per tick
|
||||||
|
// would be the same disease as a command per event.
|
||||||
|
let mut m = machine_with_bearer(LinkHealth::Carrying, LinkHealth::Absent);
|
||||||
|
let t0 = Instant::now();
|
||||||
|
let _ = m.tick_at(t0);
|
||||||
|
let settled = t0 + BEARER_SETTLE + Duration::from_secs(1);
|
||||||
|
assert!(m.tick_at(settled).contains(&Action::PreferLink(Bearer::Wifi)));
|
||||||
|
|
||||||
|
for i in 1..5 {
|
||||||
|
let a = m.tick_at(settled + Duration::from_secs(i));
|
||||||
|
assert!(
|
||||||
|
!a.iter().any(|x| matches!(x, Action::PreferLink(_))),
|
||||||
|
"re-emitted on a steady state at +{i}s: {a:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_usable_link_produces_no_action() {
|
||||||
|
let mut m = machine_with_bearer(LinkHealth::Down, LinkHealth::Absent);
|
||||||
|
let t0 = Instant::now();
|
||||||
|
let _ = m.tick_at(t0);
|
||||||
|
let a = m.tick_at(t0 + BEARER_SETTLE + Duration::from_secs(1));
|
||||||
|
assert!(
|
||||||
|
!a.iter().any(|x| matches!(x, Action::PreferLink(_))),
|
||||||
|
"flailed at a dead network: {a:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_tunnel_is_pinned_with_the_link_but_never_switched_on() {
|
||||||
|
let mut m = DeviceStateMachine::new();
|
||||||
|
m.note_bearer(BearerEvidence {
|
||||||
|
wifi: LinkHealth::Carrying,
|
||||||
|
cellular: LinkHealth::Associated,
|
||||||
|
tunnel: TunnelHealth::Handshaking,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let t0 = Instant::now();
|
||||||
|
let _ = m.tick_at(t0);
|
||||||
|
let a = m.tick_at(t0 + BEARER_SETTLE + Duration::from_secs(1));
|
||||||
|
assert!(a.contains(&Action::PinTunnelUnderlay(Bearer::Wifi)));
|
||||||
|
|
||||||
|
// Tunnel off: the preference still lands, the pin does not. Deciding
|
||||||
|
// to bring a tunnel up is the user's call, not this machine's.
|
||||||
|
let mut m = DeviceStateMachine::new();
|
||||||
|
m.note_bearer(BearerEvidence {
|
||||||
|
wifi: LinkHealth::Carrying,
|
||||||
|
tunnel: TunnelHealth::Off,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let _ = m.tick_at(t0);
|
||||||
|
let a = m.tick_at(t0 + BEARER_SETTLE + Duration::from_secs(1));
|
||||||
|
assert!(a.contains(&Action::PreferLink(Bearer::Wifi)));
|
||||||
|
assert!(
|
||||||
|
!a.iter().any(|x| matches!(x, Action::PinTunnelUnderlay(_))),
|
||||||
|
"pinned an underlay for a tunnel the user switched off: {a:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
//! Compiled into the `souveraine-sessiond` bin via `#[path]` includes, the
|
//! Compiled into the `souveraine-sessiond` bin via `#[path]` includes, the
|
||||||
//! same pattern as machined and secrets.
|
//! same pattern as machined and secrets.
|
||||||
|
|
||||||
|
pub mod bearer;
|
||||||
pub mod device_state;
|
pub mod device_state;
|
||||||
pub mod draw;
|
pub mod draw;
|
||||||
pub mod idle;
|
pub mod idle;
|
||||||
|
|
|
||||||
|
|
@ -216,6 +216,13 @@ pub const VERBS: &[VerbDoc] = &[
|
||||||
refuses: &[RefusalCode::InvalidArgument, RefusalCode::Unavailable],
|
refuses: &[RefusalCode::InvalidArgument, RefusalCode::Unavailable],
|
||||||
example: r#"{"op":"set_policy","lock_blank_after_secs":15}"#,
|
example: r#"{"op":"set_policy","lock_blank_after_secs":15}"#,
|
||||||
},
|
},
|
||||||
|
VerbDoc {
|
||||||
|
op: "bearer",
|
||||||
|
mutates: false,
|
||||||
|
summary: "which link carries traffic, and whether the tunnel is being answered",
|
||||||
|
refuses: &[],
|
||||||
|
example: r#"{"op":"bearer"}"#,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/// A request plus the caller's declared intent.
|
/// A request plus the caller's declared intent.
|
||||||
|
|
@ -311,6 +318,16 @@ pub enum Request {
|
||||||
Subscribe,
|
Subscribe,
|
||||||
/// Read the timed policy — the Auto-Lock shaped settings.
|
/// Read the timed policy — the Auto-Lock shaped settings.
|
||||||
GetPolicy,
|
GetPolicy,
|
||||||
|
/// Bearer posture: per-link health, the tunnel's handshake state, and
|
||||||
|
/// which bearer the machine has settled on.
|
||||||
|
///
|
||||||
|
/// A read, deliberately. The corresponding write does not exist and should
|
||||||
|
/// not: the thing that decides which link carries traffic is `tick()`, and
|
||||||
|
/// a verb that let a caller set it would be the second writer TASK-49
|
||||||
|
/// acceptance #6 forbids. What a caller can do is read this and change the
|
||||||
|
/// *policy* (`set_policy`), which is the difference between steering the
|
||||||
|
/// machine and reaching around it.
|
||||||
|
Bearer,
|
||||||
/// Change the timed policy. Every field is optional; omitted fields keep
|
/// Change the timed policy. Every field is optional; omitted fields keep
|
||||||
/// their current value. This is the seam the Settings control center reads
|
/// their current value. This is the seam the Settings control center reads
|
||||||
/// and writes, so a control there is a view over the owning daemon rather
|
/// and writes, so a control there is a view over the owning daemon rather
|
||||||
|
|
@ -504,7 +521,7 @@ mod tests {
|
||||||
|
|
||||||
// And the other direction: every variant must be advertised. Bump this
|
// And the other direction: every variant must be advertised. Bump this
|
||||||
// deliberately when a verb is added, having added its VerbDoc.
|
// deliberately when a verb is added, having added its VerbDoc.
|
||||||
assert_eq!(VERBS.len(), 14, "a Request variant was added without a VerbDoc");
|
assert_eq!(VERBS.len(), 15, "a Request variant was added without a VerbDoc");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,10 @@ use crate::sessiond::protocol::{
|
||||||
/// not be woken more often than it must be.
|
/// not be woken more often than it must be.
|
||||||
const TICK_INTERVAL: Duration = Duration::from_secs(1);
|
const TICK_INTERVAL: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
|
/// Sample bearer posture every Nth tick. See the comment at the call site for
|
||||||
|
/// why this rides the existing clock instead of getting a timer.
|
||||||
|
const BEARER_PROBE_EVERY: u64 = 5;
|
||||||
|
|
||||||
/// How long a connection may sit without sending a request before it is closed.
|
/// How long a connection may sit without sending a request before it is closed.
|
||||||
///
|
///
|
||||||
/// Exempts the registered shell heartbeat, whose whole job is to stay silent.
|
/// Exempts the registered shell heartbeat, whose whole job is to stay silent.
|
||||||
|
|
@ -167,8 +171,27 @@ pub fn run(initial_lock: bool, socket_path: &Path) -> Result<()> {
|
||||||
/// had passed, so it could not be the proactive half of the nervous system.
|
/// had passed, so it could not be the proactive half of the nervous system.
|
||||||
fn spawn_clock(shared: &Arc<Shared>) {
|
fn spawn_clock(shared: &Arc<Shared>) {
|
||||||
let shared = Arc::clone(shared);
|
let shared = Arc::clone(shared);
|
||||||
std::thread::spawn(move || loop {
|
std::thread::spawn(move || {
|
||||||
|
let mut tick_count: u64 = 0;
|
||||||
|
loop {
|
||||||
std::thread::sleep(TICK_INTERVAL);
|
std::thread::sleep(TICK_INTERVAL);
|
||||||
|
tick_count = tick_count.wrapping_add(1);
|
||||||
|
|
||||||
|
// Bearer evidence is sampled off the existing clock rather than given a
|
||||||
|
// timer of its own — TASK-49 acceptance #6, "no new timer". Every fifth
|
||||||
|
// tick, because the probe costs five subprocesses and the settling
|
||||||
|
// window is 20 s: four samples per window is enough to establish that a
|
||||||
|
// change held, and 1 Hz would be five processes a second forever.
|
||||||
|
//
|
||||||
|
// Probed with the lock *released*. Holding the state mutex across
|
||||||
|
// subprocess spawns is exactly the self-deadlock shape that put 347
|
||||||
|
// threads in __futex_wait on 2026-07-26.
|
||||||
|
if tick_count.is_multiple_of(BEARER_PROBE_EVERY) {
|
||||||
|
let home = shared.lock().device_state.policy.home_ssids.clone();
|
||||||
|
let evidence = crate::sessiond::bearer::probe(&home);
|
||||||
|
shared.lock().device_state.note_bearer(evidence);
|
||||||
|
}
|
||||||
|
|
||||||
// Hold the lock only to decide, never while running a command.
|
// Hold the lock only to decide, never while running a command.
|
||||||
let actions = {
|
let actions = {
|
||||||
let mut d = shared.lock();
|
let mut d = shared.lock();
|
||||||
|
|
@ -182,6 +205,7 @@ fn spawn_clock(shared: &Arc<Shared>) {
|
||||||
// blank but not the brightnessctl failure underneath it has been told a
|
// blank but not the brightnessctl failure underneath it has been told a
|
||||||
// tidier story than what happened.
|
// tidier story than what happened.
|
||||||
fan_out(&shared);
|
fan_out(&shared);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -300,6 +324,23 @@ fn execute(shared: &Arc<Shared>, action: Action) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bearer posture is expressed to NetworkManager, never written with `ip
|
||||||
|
// route`. NM already writes every route on this device; a second writer is
|
||||||
|
// how the metric fight that started TASK-49 became unresolvable, and
|
||||||
|
// acceptance #6 forbids adding one. `connection modify` + `device reapply`
|
||||||
|
// leaves NM the sole author and makes the preference survive a reconnect.
|
||||||
|
if let Action::PreferLink(bearer) = action {
|
||||||
|
apply_link_preference(shared, bearer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Pinning the tunnel's endpoint is the one route sessiond does own: it is
|
||||||
|
// a /32 host route for the WireGuard peer, which NM has no opinion about
|
||||||
|
// because the endpoint is not part of any connection's config.
|
||||||
|
if let Action::PinTunnelUnderlay(bearer) = action {
|
||||||
|
pin_tunnel_underlay(shared, bearer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let (program, args, label): (&str, Vec<&str>, &str) = match action {
|
let (program, args, label): (&str, Vec<&str>, &str) = match action {
|
||||||
// sessiond guarantees this runs once per dim, which is what the old
|
// sessiond guarantees this runs once per dim, which is what the old
|
||||||
// hypridle `-s`/`-r` listener could not: a second save while already
|
// hypridle `-s`/`-r` listener could not: a second save while already
|
||||||
|
|
@ -330,11 +371,115 @@ fn execute(shared: &Arc<Shared>, action: Action) {
|
||||||
(DPMS_EXECUTOR, vec!["off"], "panel-off")
|
(DPMS_EXECUTOR, vec!["off"], "panel-off")
|
||||||
}
|
}
|
||||||
Action::Lock => unreachable!("handled above; the lock is not a shell command"),
|
Action::Lock => unreachable!("handled above; the lock is not a shell command"),
|
||||||
|
Action::PreferLink(_) => unreachable!("handled above; expressed to NetworkManager"),
|
||||||
|
Action::PinTunnelUnderlay(_) => unreachable!("handled above; a host route, not a command"),
|
||||||
};
|
};
|
||||||
|
|
||||||
run_executor(shared, program, &args, label, action);
|
run_executor(shared, program, &args, label, action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Route metrics for the two bearers.
|
||||||
|
///
|
||||||
|
/// The numbers are NetworkManager's own defaults for the winning case (wifi
|
||||||
|
/// 600), with the loser pushed clear of NM's +20000 connectivity penalty so a
|
||||||
|
/// penalised-but-preferred link still beats the demoted one. That penalty is
|
||||||
|
/// what inverted the metrics on 2026-07-31: wifi went to 20600 and the clat
|
||||||
|
/// default at 2048 won, on a link that could not resolve.
|
||||||
|
const METRIC_PREFERRED: &str = "600";
|
||||||
|
const METRIC_DEMOTED: &str = "30000";
|
||||||
|
|
||||||
|
/// Tell NetworkManager which link ordinary traffic should take.
|
||||||
|
///
|
||||||
|
/// Both connections are modified every time rather than only the winner: the
|
||||||
|
/// loser's metric is half the decision, and leaving it at whatever it happened
|
||||||
|
/// to be is how the original inversion survived.
|
||||||
|
fn apply_link_preference(shared: &Arc<Shared>, bearer: crate::sessiond::bearer::Bearer) {
|
||||||
|
use crate::sessiond::bearer::Bearer;
|
||||||
|
let (wifi_metric, wwan_metric) = match bearer {
|
||||||
|
Bearer::Wifi => (METRIC_PREFERRED, METRIC_DEMOTED),
|
||||||
|
Bearer::Cellular => (METRIC_DEMOTED, METRIC_PREFERRED),
|
||||||
|
};
|
||||||
|
for (kind, metric) in [("802-11-wireless", wifi_metric), ("gsm", wwan_metric)] {
|
||||||
|
for conn in active_connections_of_type(kind) {
|
||||||
|
let _ = std::process::Command::new("nmcli")
|
||||||
|
.args(["connection", "modify", &conn, "ipv4.route-metric", metric])
|
||||||
|
.status();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Reapply rather than up/down. Cycling the connection is what made the old
|
||||||
|
// gate's corrections into NM events that re-entered it; `reapply` changes
|
||||||
|
// the live config without a state transition, so nothing observing NM sees
|
||||||
|
// a link flap.
|
||||||
|
for dev in ["wlan0", "clat"] {
|
||||||
|
let _ = std::process::Command::new("nmcli")
|
||||||
|
.args(["device", "reapply", dev])
|
||||||
|
.status();
|
||||||
|
}
|
||||||
|
shared.lock().device_state.record_error(
|
||||||
|
"bearer",
|
||||||
|
"prefer-link",
|
||||||
|
&format!("preferred {}", bearer.as_str()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List active connection names of a given `connection.type`.
|
||||||
|
fn active_connections_of_type(kind: &str) -> Vec<String> {
|
||||||
|
let out = match std::process::Command::new("nmcli")
|
||||||
|
.args(["-t", "-f", "NAME,TYPE", "connection", "show", "--active"])
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
Ok(o) if o.status.success() => o.stdout,
|
||||||
|
_ => return Vec::new(),
|
||||||
|
};
|
||||||
|
String::from_utf8_lossy(&out)
|
||||||
|
.lines()
|
||||||
|
.filter_map(|l| {
|
||||||
|
let (name, ty) = l.rsplit_once(':')?;
|
||||||
|
(ty == kind).then(|| name.to_string())
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pin the WireGuard endpoint's /32 to the chosen underlay.
|
||||||
|
///
|
||||||
|
/// Over clat the tunnel was measured sending and never receiving; pinning the
|
||||||
|
/// endpoint via wlan0 produced a handshake in seconds. The endpoint is read
|
||||||
|
/// from `wg show`, so a profile change does not need this code changed.
|
||||||
|
fn pin_tunnel_underlay(shared: &Arc<Shared>, bearer: crate::sessiond::bearer::Bearer) {
|
||||||
|
use crate::sessiond::bearer::Bearer;
|
||||||
|
let Some(endpoint) = wg_endpoint_ip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let dev = match bearer {
|
||||||
|
Bearer::Wifi => "wlan0",
|
||||||
|
Bearer::Cellular => "clat",
|
||||||
|
};
|
||||||
|
// `replace` rather than `add`: this runs on every settled change, and an
|
||||||
|
// `add` that collides leaves the old pin in place, which is the stale
|
||||||
|
// route the whole exercise is trying not to inherit.
|
||||||
|
let _ = std::process::Command::new("ip")
|
||||||
|
.args(["route", "replace", &format!("{endpoint}/32"), "dev", dev])
|
||||||
|
.status();
|
||||||
|
shared.lock().device_state.record_error(
|
||||||
|
"bearer",
|
||||||
|
"pin-tunnel",
|
||||||
|
&format!("pinned {endpoint} via {dev}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The peer endpoint address `wg` reports, without its port.
|
||||||
|
fn wg_endpoint_ip() -> Option<String> {
|
||||||
|
let out = std::process::Command::new("wg").arg("show").output().ok()?;
|
||||||
|
if !out.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
String::from_utf8_lossy(&out.stdout)
|
||||||
|
.lines()
|
||||||
|
.find_map(|l| l.trim().strip_prefix("endpoint:"))
|
||||||
|
.and_then(|e| e.trim().rsplit_once(':'))
|
||||||
|
.map(|(host, _port)| host.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// Act on the machine's decision that the session must lock before the panel
|
/// Act on the machine's decision that the session must lock before the panel
|
||||||
/// goes dark.
|
/// goes dark.
|
||||||
///
|
///
|
||||||
|
|
@ -1000,6 +1145,24 @@ fn handle_request(
|
||||||
}
|
}
|
||||||
serde_json::json!({ "ok": true, "panel_on": on })
|
serde_json::json!({ "ok": true, "panel_on": on })
|
||||||
}
|
}
|
||||||
|
Request::Bearer => {
|
||||||
|
let d = shared.lock();
|
||||||
|
let b = &d.device_state.bearer;
|
||||||
|
serde_json::json!({
|
||||||
|
"ok": true,
|
||||||
|
"wifi": b.wifi.as_str(),
|
||||||
|
"cellular": b.cellular.as_str(),
|
||||||
|
"tunnel": b.tunnel.as_str(),
|
||||||
|
// Reported separately from `tunnel` because it is the claim a
|
||||||
|
// reader most needs and the one every other readout gets
|
||||||
|
// wrong: a tunnel can be up and not be carrying anything.
|
||||||
|
"tunnel_deaf": b.tunnel_is_deaf(),
|
||||||
|
"home": b.home,
|
||||||
|
"ssid": b.ssid,
|
||||||
|
"preferred": b.preferred().map(|x| x.as_str()),
|
||||||
|
"applied": d.device_state.bearer_applied_str(),
|
||||||
|
})
|
||||||
|
}
|
||||||
Request::GetPolicy => {
|
Request::GetPolicy => {
|
||||||
let d = shared.lock();
|
let d = shared.lock();
|
||||||
let p = &d.device_state.policy;
|
let p = &d.device_state.policy;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue