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
|
/// 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")
|
|
||||||
.next()
|
|
||||||
.map(|r| r.trim().starts_with("0 "))
|
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if received_zero {
|
if !is_wg {
|
||||||
TunnelHealth::Deaf
|
continue;
|
||||||
} else {
|
}
|
||||||
|
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
|
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)]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue