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:
parent
0f7fe27c62
commit
ac084b461a
2 changed files with 75 additions and 33 deletions
|
|
@ -310,35 +310,50 @@ fn probe_cellular() -> LinkHealth {
|
|||
}
|
||||
}
|
||||
|
||||
/// `wg show` received-bytes is the tunnel's honest instrument. A peer that has
|
||||
/// sent and never received is up and deaf, not connected.
|
||||
/// Received bytes are the tunnel's honest instrument: a peer that has sent and
|
||||
/// 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 {
|
||||
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() {
|
||||
let Ok(entries) = std::fs::read_dir("/sys/class/net") else {
|
||||
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 "))
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
// DEVTYPE is how the kernel names the link's family; NM tears the
|
||||
// interface down entirely when the profile goes down, so a wireguard
|
||||
// DEVTYPE present at all means a tunnel is configured and up.
|
||||
let is_wg = std::fs::read_to_string(path.join("uevent"))
|
||||
.map(|s| s.lines().any(|l| l == "DEVTYPE=wireguard"))
|
||||
.unwrap_or(false);
|
||||
if received_zero {
|
||||
TunnelHealth::Deaf
|
||||
} else {
|
||||
if !is_wg {
|
||||
continue;
|
||||
}
|
||||
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)]
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
let out = std::process::Command::new("wg").arg("show").output().ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
let out = std::process::Command::new("nmcli")
|
||||
.args(["-g", "wireguard.peers", "connection", "show", "--active"])
|
||||
.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)
|
||||
.lines()
|
||||
.find_map(|l| l.trim().strip_prefix("endpoint:"))
|
||||
.and_then(|e| e.trim().rsplit_once(':'))
|
||||
.map(|(host, _port)| host.to_string())
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// Act on the machine's decision that the session must lock before the panel
|
||||
|
|
|
|||
Loading…
Reference in a new issue