Watch
1
0
Fork
You've already forked souveraine
0

sessiond: read tunnel health from sysfs, not wg show

Found on the phone: the bearer verb reported tunnel "off" while wiufph was up
and handshaking (4.11 KiB received). wg show needs CAP_NET_ADMIN for
WG_CMD_GET_DEVICE and sessiond is a systemd *user* unit, so it gets "Unable to
access interface wiufph: Operation not permitted" and every tunnel reads off,
forever. A daemon that cannot see the instrument it is built around.

/sys/class/net/<if>/statistics/rx_bytes is world-readable and is the same
number without the parse or the privilege; DEVTYPE=wireguard in uevent finds
the interface, and NM removes the link entirely when the profile goes down, so
its presence is the up/down signal.

The endpoint lookup had the same defect. NetworkManager hands wireguard.peers
to an unprivileged caller, and the endpoint is configured as a hostname
(sub.wiuf.net:51822), not the address the old gate hardcoded - so it is
resolved before pinning, and a failed resolution writes no pin rather than a
wrong one.
This commit is contained in:
Fimeg 2026-08-01 14:44:32 -04:00
commit ac084b461a
2 changed files with 75 additions and 33 deletions

View file

@ -310,35 +310,50 @@ fn probe_cellular() -> LinkHealth {
} }
} }
/// `wg show` received-bytes is the tunnel's honest instrument. A peer that has /// Received bytes are the tunnel's honest instrument: a peer that has sent and
/// sent and never received is up and deaf, not connected. /// never received is up and deaf, not connected.
///
/// Read from sysfs, not from `wg show`. `wg show` needs `CAP_NET_ADMIN` for
/// `WG_CMD_GET_DEVICE`, and sessiond is a systemd *user* unit — measured on the
/// phone 2026-08-01, it gets "Unable to access interface wiufph: Operation not
/// permitted" and every tunnel therefore read `off` while one was up and
/// handshaking. `/sys/class/net/*/statistics/rx_bytes` is world-readable and is
/// the same number, without the parse or the privilege.
fn probe_tunnel() -> TunnelHealth { fn probe_tunnel() -> TunnelHealth {
let out = match Command::new("wg").arg("show").output() { let Ok(entries) = std::fs::read_dir("/sys/class/net") else {
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; return TunnelHealth::Off;
} };
// " transfer: 368 B received, 5.30 KiB sent" for entry in entries.flatten() {
let transfer = text.lines().find_map(|l| l.trim().strip_prefix("transfer:")); let path = entry.path();
match transfer { // DEVTYPE is how the kernel names the link's family; NM tears the
// Up with a peer but no transfer line yet: it has not handshaken. // interface down entirely when the profile goes down, so a wireguard
None => TunnelHealth::Deaf, // DEVTYPE present at all means a tunnel is configured and up.
Some(t) => { let is_wg = std::fs::read_to_string(path.join("uevent"))
let received_zero = t .map(|s| s.lines().any(|l| l == "DEVTYPE=wireguard"))
.split(" received") .unwrap_or(false);
.next() if !is_wg {
.map(|r| r.trim().starts_with("0 ")) continue;
.unwrap_or(false);
if received_zero {
TunnelHealth::Deaf
} else {
TunnelHealth::Handshaking
}
} }
let rx = read_counter(&path.join("statistics/rx_bytes"));
// Nonzero received is the only positive evidence there is. Zero covers
// both "up and ignored" and the brief window after activation before
// anything has been sent; both are honestly "not answered yet", and
// the settling window is longer than that window, so a transient
// cannot produce an action on its own.
return if rx > 0 {
TunnelHealth::Handshaking
} else {
TunnelHealth::Deaf
};
} }
TunnelHealth::Off
}
fn read_counter(path: &std::path::Path) -> u64 {
std::fs::read_to_string(path)
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0)
} }
#[cfg(test)] #[cfg(test)]

View file

@ -467,17 +467,44 @@ fn pin_tunnel_underlay(shared: &Arc<Shared>, bearer: crate::sessiond::bearer::Be
); );
} }
/// The peer endpoint address `wg` reports, without its port. /// The WireGuard peer's endpoint address, as an IP.
///
/// Not from `wg show`: that needs `CAP_NET_ADMIN` and sessiond is a user unit,
/// so it can only ever report "Operation not permitted" here. NetworkManager
/// will hand the peer line to an unprivileged caller, and the endpoint is not
/// a secret — the key on the same line is, which is why only the endpoint is
/// taken.
///
/// The endpoint is configured as a *hostname* (`sub.wiuf.net:51822`), so it has
/// to be resolved before it can be pinned. That is the one circular step in
/// this file: if DNS is broken — which is precisely the failure this task
/// exists to fix — resolution fails and no pin is written. Skipping is the
/// right failure: a pin to a wrong or stale address is worse than none, and
/// the tunnel still works over whatever the default route is.
fn wg_endpoint_ip() -> Option<String> { fn wg_endpoint_ip() -> Option<String> {
let out = std::process::Command::new("wg").arg("show").output().ok()?; let out = std::process::Command::new("nmcli")
if !out.status.success() { .args(["-g", "wireguard.peers", "connection", "show", "--active"])
return None; .output()
.ok()?;
let text = String::from_utf8_lossy(&out.stdout);
let host = text.lines().find_map(|l| {
l.split_whitespace()
.find_map(|f| f.strip_prefix("endpoint="))
.and_then(|e| e.replace("\\:", ":").rsplit_once(':').map(|(h, _)| h.to_string()))
})?;
// Already an address? Then there is nothing to resolve.
if host.parse::<std::net::IpAddr>().is_ok() {
return Some(host);
} }
let out = std::process::Command::new("getent")
.args(["ahostsv4", &host])
.output()
.ok()?;
String::from_utf8_lossy(&out.stdout) String::from_utf8_lossy(&out.stdout)
.lines() .split_whitespace()
.find_map(|l| l.trim().strip_prefix("endpoint:")) .next()
.and_then(|e| e.trim().rsplit_once(':')) .map(|s| s.to_string())
.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