diff --git a/rust/mseries-tailscale/README.md b/rust/mseries-tailscale/README.md index 542242c..18cdbae 100644 --- a/rust/mseries-tailscale/README.md +++ b/rust/mseries-tailscale/README.md @@ -35,6 +35,20 @@ A native Pantheon window for Tailscale. It replaces the old launcher that opened - **Devices:** every device on the tailnet, online first, with IP, OS and when it was last seen. Search with Ctrl+F. Click a row or the copy button to copy its IP; the terminal button copies `ssh ` (the MagicDNS name, e.g. `ssh sdolbec-ser8`). +- **Honest VPN status:** a VPN counts as Connected only when its tunnel interface (ppp0, utun, …) is up; the + app finds it by the VPN's own address instead of trusting NetworkManager's "activated". Otherwise it says + Connecting…, "Dropped: on, but not carrying any traffic", or "Failed: " (from NetworkManager, pppd and + IPsec logs), with Reconnect. "Internet traffic now goes through …" names the interface the kernel really uses + (`ip route get`, policy routing included). +- **Timer and traffic:** each connected VPN, and Tailscale, shows how long it has been up (kept across app + restarts, reset on reconnect) and bytes down/up. +- **Two VPNs at once:** a banner when another tunnel (WiFiman's `utun`, any WireGuard/tun/PPP device) is up next + to a VPN, or knocked it off. +- **Remote Into a Mac:** the tailnet's Mac mini at BFS plus any online Mac, with Screen Sharing (Remmina, + `vnc://`) and Copy ssh. Offers Turn On Tailscale when it is off, and the Remmina install command + when Remmina is missing. + + ![Status](docs/vpn-status-light.png) ![Conflict](docs/vpn-conflict-light.png) - **Settings:** Tailscale DNS, subnet routes, incoming connections (shields up), and the Tailscale SSH server, each a `tailscale set` flag. - **Logged out:** "Log In…" starts `tailscale login` and opens the sign-in page. diff --git a/rust/mseries-tailscale/docs/vpn-conflict-light.png b/rust/mseries-tailscale/docs/vpn-conflict-light.png new file mode 100644 index 0000000..d65367a Binary files /dev/null and b/rust/mseries-tailscale/docs/vpn-conflict-light.png differ diff --git a/rust/mseries-tailscale/docs/vpn-status-light.png b/rust/mseries-tailscale/docs/vpn-status-light.png new file mode 100644 index 0000000..bec22a4 Binary files /dev/null and b/rust/mseries-tailscale/docs/vpn-status-light.png differ diff --git a/rust/mseries-tailscale/fixtures/active.txt b/rust/mseries-tailscale/fixtures/active.txt new file mode 100644 index 0000000..bc296fb --- /dev/null +++ b/rust/mseries-tailscale/fixtures/active.txt @@ -0,0 +1 @@ +4a1d6d4d-c5e4:activated:enx605b:vpn diff --git a/rust/mseries-tailscale/fixtures/ip4.txt b/rust/mseries-tailscale/fixtures/ip4.txt new file mode 100644 index 0000000..0e50a79 --- /dev/null +++ b/rust/mseries-tailscale/fixtures/ip4.txt @@ -0,0 +1 @@ +4a1d6d4d-c5e4 192.168.200.20/32 diff --git a/rust/mseries-tailscale/fixtures/net.json b/rust/mseries-tailscale/fixtures/net.json new file mode 100644 index 0000000..620f4d8 --- /dev/null +++ b/rust/mseries-tailscale/fixtures/net.json @@ -0,0 +1,45 @@ +{ + "internet_iface": "ppp0", + "ifaces": [ + { + "name": "lo", + "index": 1, + "up": true, + "rx_bytes": 0, + "tx_bytes": 0, + "ipv4": [ + "127.0.0.1" + ] + }, + { + "name": "wld0", + "index": 3, + "up": true, + "rx_bytes": 0, + "tx_bytes": 0, + "ipv4": [ + "10.20.0.120" + ] + }, + { + "name": "tailscale0", + "index": 9, + "up": true, + "rx_bytes": 0, + "tx_bytes": 0, + "ipv4": [ + "100.115.140.33" + ] + }, + { + "name": "ppp0", + "index": 41, + "up": true, + "rx_bytes": 48300000, + "tx_bytes": 6100000, + "ipv4": [ + "192.168.200.20" + ] + } + ] +} \ No newline at end of file diff --git a/rust/mseries-tailscale/fixtures/nmcli.txt b/rust/mseries-tailscale/fixtures/nmcli.txt index 97adeb4..dac5382 100644 --- a/rust/mseries-tailscale/fixtures/nmcli.txt +++ b/rust/mseries-tailscale/fixtures/nmcli.txt @@ -1,2 +1,3 @@ Home Wi-Fi:11111111-aaaa:802-11-wireless:yes BFS:4a1d6d4d-c5e4:vpn:yes +utun:2e861348-utun:wireguard:no diff --git a/rust/mseries-tailscale/fixtures/split-tunnel.txt b/rust/mseries-tailscale/fixtures/split-tunnel.txt index c051887..e69de29 100644 --- a/rust/mseries-tailscale/fixtures/split-tunnel.txt +++ b/rust/mseries-tailscale/fixtures/split-tunnel.txt @@ -1 +0,0 @@ -4a1d6d4d-c5e4 diff --git a/rust/mseries-tailscale/fixtures/unsaved-passwords.txt b/rust/mseries-tailscale/fixtures/unsaved-passwords.txt index c051887..e69de29 100644 --- a/rust/mseries-tailscale/fixtures/unsaved-passwords.txt +++ b/rust/mseries-tailscale/fixtures/unsaved-passwords.txt @@ -1 +0,0 @@ -4a1d6d4d-c5e4 diff --git a/rust/mseries-tailscale/src/cli.rs b/rust/mseries-tailscale/src/cli.rs index 3c2f9ac..3c286fb 100644 --- a/rust/mseries-tailscale/src/cli.rs +++ b/rust/mseries-tailscale/src/cli.rs @@ -96,8 +96,21 @@ pub async fn run_program(argv: &[&str], limit_secs: u64) -> Result<(bool, String // Fixture VPNs: `nmcli.txt` is the connection list; every VPN sends all traffic unless listed in // `split-tunnel.txt`; changes are refused like Tailscale's. let list = std::fs::read_to_string(dir.join("nmcli.txt")).unwrap_or_default(); + let read = |f: &str| std::fs::read_to_string(dir.join(f)).unwrap_or_default(); return match argv.get(1..4) { + Some(["-t", "-f", fields]) if fields.starts_with("UUID,STATE") => { + Ok((true, read("active.txt"), String::new())) + } Some(["-t", "-f", _]) => Ok((true, list, String::new())), + // `ip4.txt` lines: "uuid address". + Some(["-g", "IP4.ADDRESS", _]) => { + let uuid = argv.last().copied().unwrap_or_default(); + let addr = read("ip4.txt") + .lines() + .find_map(|l| l.strip_prefix(uuid).map(|a| a.trim().to_string())) + .unwrap_or_default(); + Ok((true, addr, String::new())) + } Some(["-g", "ipv4.never-default", _]) => { let split = std::fs::read_to_string(dir.join("split-tunnel.txt")).unwrap_or_default(); let uuid = argv.last().copied().unwrap_or_default(); diff --git a/rust/mseries-tailscale/src/lib.rs b/rust/mseries-tailscale/src/lib.rs index de38713..36f9796 100644 --- a/rust/mseries-tailscale/src/lib.rs +++ b/rust/mseries-tailscale/src/lib.rs @@ -3,6 +3,7 @@ pub mod cli; pub mod model; +pub mod net; pub mod nm; pub mod strings; pub mod ui; diff --git a/rust/mseries-tailscale/src/model.rs b/rust/mseries-tailscale/src/model.rs index 5f3cedb..d3dc166 100644 --- a/rust/mseries-tailscale/src/model.rs +++ b/rust/mseries-tailscale/src/model.rs @@ -319,6 +319,55 @@ pub struct Vpn { pub all_traffic: bool, /// False when a secret is "always ask" (flag 2) and so is never stored. pub secrets_saved: bool, + /// "vpn" (NetworkManager VPN plugin) or "wireguard". + pub kind: String, +} + +impl Vpn { + /// Only plugin VPNs get the "Send all traffic" switch; WireGuard routes by its allowed IPs. + pub fn kind_has_routing(&self) -> bool { + self.kind == "vpn" + } +} + +/// A friendlier name for connections other apps create. +pub fn display_name(name: &str) -> String { + match name { + "utun" => "WiFiman (utun)".into(), + other => other.into(), + } +} + +/// `nmcli -t -f UUID,STATE,DEVICE,TYPE connection show --active` → uuid → (state, device, type). +pub fn parse_active(text: &str) -> std::collections::HashMap { + text.lines() + .map(nmcli_fields) + .filter(|f| f.len() >= 4) + .map(|f| (f[0].clone(), (f[1].clone(), f[2].clone(), f[3].clone()))) + .collect() +} + +/// Why a VPN dropped, from recent NetworkManager / pppd / IPsec log lines, in plain words. +/// `others` are tunnels from other apps that are up now. +pub fn classify_vpn_failure(log: &str, others: &[String]) -> String { + if let Some(other) = others.first() { + return format!("{other} took over the connection"); + } + let l = log.to_lowercase(); + let has = |words: &[&str]| words.iter().any(|w| l.contains(w)); + if has(&["authentication failed", "auth failed"]) { + "the server rejected the username or password".into() + } else if has(&["no agents were available", "secrets were required"]) { + "passwords aren't saved".into() + } else if has(&["modem hangup", "hung up", "lcp terminated", "peer closed"]) { + "the server hung up".into() + } else if has(&["timed out", "timeout"]) { + "the server didn't answer".into() + } else if has(&["ipsec"]) && has(&["shut", "terminat", "deleting ike", "closing"]) { + "the IPsec tunnel closed".into() + } else { + "the connection stopped".into() + } } /// Reads the `*-flags` entries from `nmcli -g vpn.data` (flags only; secrets are not in vpn.data). @@ -365,6 +414,7 @@ pub fn parse_vpn_list(text: &str) -> Vec { active: f[3] == "yes", all_traffic: false, secrets_saved: true, + kind: f[2].clone(), }) .collect() } @@ -397,6 +447,19 @@ mod vpn_tests { let d = parse_vpn_data("gateway = vpn.example.com, ipsec-enabled = yes, password-flags = 0, user = a@b.org\n"); assert_eq!(d.get("gateway").map(String::as_str), Some("vpn.example.com")); assert_eq!(d.get("user").map(String::as_str), Some("a@b.org")); + let a = parse_active("4a1d:activated:enx60:vpn\nutun-1:activated:utun:wireguard\n"); + assert_eq!(a.get("4a1d").map(|t| t.0.as_str()), Some("activated")); + assert_eq!(a.get("utun-1").map(|t| t.1.as_str()), Some("utun")); + assert_eq!(display_name("utun"), "WiFiman (utun)"); + assert_eq!( + classify_vpn_failure("", &["WiFiman (Ubiquiti Teleport)".into()]), + "WiFiman (Ubiquiti Teleport) took over the connection" + ); + assert_eq!(classify_vpn_failure("pppd: Modem hangup", &[]), "the server hung up"); + assert_eq!( + classify_vpn_failure("PAP authentication failed", &[]), + "the server rejected the username or password" + ); assert!(needs_secrets("Error: Connection activation failed: Secrets were required, but not provided")); } } diff --git a/rust/mseries-tailscale/src/net.rs b/rust/mseries-tailscale/src/net.rs new file mode 100644 index 0000000..19ff57b --- /dev/null +++ b/rust/mseries-tailscale/src/net.rs @@ -0,0 +1,183 @@ +//! What the kernel says about tunnels: which interface carries internet traffic, which interface owns an +//! address, and per-interface counters. NetworkManager's "activated" is not trusted on its own. +//! +//! `MSERIES_TAILSCALE_FIXTURES=DIR` reads `net.json` (a serialized `Snapshot`) instead. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Iface { + pub name: String, + pub index: u32, + pub up: bool, + pub rx_bytes: u64, + pub tx_bytes: u64, + pub ipv4: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Snapshot { + /// Interface the kernel would use to reach the internet (`ip route get 1.1.1.1`, policy routing included). + pub internet_iface: Option, + pub ifaces: Vec, +} + +impl Snapshot { + pub fn iface(&self, name: &str) -> Option<&Iface> { + self.ifaces.iter().find(|i| i.name == name) + } + + /// The interface that holds `address` (a VPN's own IP, as NetworkManager reports it). + pub fn iface_with_address(&self, address: &str) -> Option<&Iface> { + let address = address.split('/').next().unwrap_or(address); + self.ifaces.iter().find(|i| i.ipv4.iter().any(|a| a == address)) + } + + /// Tunnel-like interfaces (tun, WireGuard, PPP) other than Tailscale's. + pub fn tunnels(&self) -> impl Iterator { + self.ifaces.iter().filter(|i| { + let n = i.name.as_str(); + n != "tailscale0" + && (n.starts_with("ppp") || n.starts_with("tun") || n.starts_with("utun") || n.starts_with("wg")) + }) + } +} + +fn read_u64(path: &str) -> u64 { + std::fs::read_to_string(path).ok().and_then(|s| s.trim().parse().ok()).unwrap_or(0) +} + +#[derive(Deserialize)] +struct IpAddr { + ifname: String, + #[serde(default)] + addr_info: Vec, +} + +#[derive(Deserialize)] +struct IpAddrInfo { + #[serde(default)] + local: String, +} + +#[derive(Deserialize)] +struct IpRoute { + #[serde(default)] + dev: String, +} + +/// Reads the snapshot. Cheap: two `ip` calls and a few small sysfs files. +pub async fn probe() -> Snapshot { + if let Some(dir) = std::env::var_os("MSERIES_TAILSCALE_FIXTURES") { + let path = std::path::Path::new(&dir).join("net.json"); + return std::fs::read_to_string(path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default(); + } + let route = crate::cli::run_program(&["ip", "-j", "route", "get", "1.1.1.1"], 5).await; + let internet_iface = route + .ok() + .and_then(|(ok, out, _)| ok.then_some(out)) + .and_then(|out| serde_json::from_str::>(&out).ok()) + .and_then(|r| r.into_iter().next()) + .map(|r| r.dev) + .filter(|d| !d.is_empty()); + let mut addrs: HashMap> = HashMap::new(); + if let Ok((true, out, _)) = crate::cli::run_program(&["ip", "-j", "-4", "addr"], 5).await { + for entry in serde_json::from_str::>(&out).unwrap_or_default() { + addrs.entry(entry.ifname).or_default().extend(entry.addr_info.into_iter().map(|a| a.local)); + } + } + let mut ifaces = Vec::new(); + if let Ok(dir) = std::fs::read_dir("/sys/class/net") { + for entry in dir.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + let base = format!("/sys/class/net/{name}"); + let oper = std::fs::read_to_string(format!("{base}/operstate")).unwrap_or_default(); + ifaces.push(Iface { + index: read_u64(&format!("{base}/ifindex")) as u32, + up: oper.trim() != "down", + rx_bytes: read_u64(&format!("{base}/statistics/rx_bytes")), + tx_bytes: read_u64(&format!("{base}/statistics/tx_bytes")), + ipv4: addrs.remove(&name).unwrap_or_default(), + name, + }); + } + } + Snapshot { internet_iface, ifaces } +} + +/// Counters for one interface, read fresh (for the one-second timer). +pub fn counters(name: &str) -> Option<(u64, u64)> { + if std::env::var_os("MSERIES_TAILSCALE_FIXTURES").is_some() { + return Some((48_300_000, 6_100_000)); + } + let base = format!("/sys/class/net/{name}/statistics"); + std::path::Path::new(&base) + .exists() + .then(|| (read_u64(&format!("{base}/rx_bytes")), read_u64(&format!("{base}/tx_bytes")))) +} + +/// "1.2 GB", "340 KB". +pub fn bytes_text(n: u64) -> String { + const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; + let mut v = n as f64; + let mut u = 0; + while v >= 1000.0 && u < UNITS.len() - 1 { + v /= 1000.0; + u += 1; + } + if u == 0 { + format!("{n} B") + } else if v < 10.0 { + format!("{v:.1} {}", UNITS[u]) + } else { + format!("{v:.0} {}", UNITS[u]) + } +} + +/// "0:04:09", "12:30:00". +pub fn duration_text(secs: i64) -> String { + let s = secs.max(0); + format!("{}:{:02}:{:02}", s / 3600, (s / 60) % 60, s % 60) +} + +/// A friendly name for a tunnel interface that is not one of the listed VPNs. +pub fn tunnel_owner(iface: &str) -> String { + match iface { + "utun" => "WiFiman (Ubiquiti Teleport)".into(), + n if n.starts_with("ppp") => format!("A PPP/L2TP VPN ({n})"), + n if n.starts_with("wg") => format!("A WireGuard VPN ({n})"), + n => format!("Another VPN ({n})"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn texts() { + assert_eq!(bytes_text(999), "999 B"); + assert_eq!(bytes_text(4_307_842), "4.3 MB"); + assert_eq!(bytes_text(48_300_000), "48 MB"); + assert_eq!(duration_text(249), "0:04:09"); + assert_eq!(duration_text(45_000), "12:30:00"); + assert_eq!(tunnel_owner("utun"), "WiFiman (Ubiquiti Teleport)"); + } + + #[test] + fn maps_addresses_to_tunnels() { + let snap = Snapshot { + internet_iface: Some("utun".into()), + ifaces: vec![ + Iface { name: "ppp0".into(), ipv4: vec!["192.168.200.20".into()], up: true, ..Default::default() }, + Iface { name: "utun".into(), ipv4: vec!["192.168.2.3".into()], up: true, ..Default::default() }, + Iface { name: "tailscale0".into(), up: true, ..Default::default() }, + Iface { name: "wld0".into(), up: true, ..Default::default() }, + ], + }; + assert_eq!(snap.iface_with_address("192.168.200.20/32").map(|i| i.name.as_str()), Some("ppp0")); + let tunnels: Vec<_> = snap.tunnels().map(|i| i.name.as_str()).collect(); + assert_eq!(tunnels, ["ppp0", "utun"]); + } +} diff --git a/rust/mseries-tailscale/src/ui/window.rs b/rust/mseries-tailscale/src/ui/window.rs index 2ce6dda..3bcb90e 100644 --- a/rust/mseries-tailscale/src/ui/window.rs +++ b/rust/mseries-tailscale/src/ui/window.rs @@ -6,11 +6,13 @@ use crate::cli::{self, CliError}; use crate::model::{self, Backend, Device, Prefs, Status}; +use crate::net; use crate::strings::tr; use granite::prelude::{HeaderLabelExt, PlaceholderExt}; use gtk::{gio, glib, prelude::*}; use mseries_pantheon_kit::{appearance, chrome, set_accessible_label}; use std::cell::{Cell, RefCell}; +use std::collections::{HashMap, HashSet}; use std::rc::Rc; use std::time::Duration; @@ -47,6 +49,19 @@ pub struct AppWindow { vpn_list: gtk::Box, vpn_key: RefCell, vpn_pending: RefCell>, + vpn_live: RefCell>, + vpn_failures: RefCell>, + vpn_connecting: RefCell>, + vpn_stopping: RefCell>, + vpn_ticks: RefCell>, + vpn_banner: gtk::Box, + vpn_banner_text: gtk::Label, + route_now: gtk::Label, + sessions: Sessions, + ts_timer: gtk::Label, + remote_list: gtk::Box, + remote_key: RefCell, + ticker: RefCell>, // Devices devices_header: granite::HeaderLabel, search: gtk::SearchEntry, @@ -101,6 +116,10 @@ impl AppWindow { let route_line = gtk::Label::builder().xalign(0.0).wrap(true).visible(false).build(); route_line.add_css_class(granite::CssClassACCENT); titles.append(&route_line); + let ts_timer = gtk::Label::builder().xalign(0.0).visible(false).build(); + ts_timer.add_css_class(granite::CssClassNUMERIC); + ts_timer.add_css_class(granite::CssClassDIM); + titles.append(&ts_timer); let connect = gtk::Switch::builder().valign(gtk::Align::Center).build(); set_accessible_label(&connect, &tr("Connected")); let top = gtk::Box::builder().spacing(12).build(); @@ -208,7 +227,19 @@ impl AppWindow { let window = b.root().and_downcast::(); gtk::UriLauncher::new("settings://network").launch(window.as_ref(), gio::Cancellable::NONE, |_| {}); }); + let vpn_banner_text = gtk::Label::builder().xalign(0.0).wrap(true).hexpand(true).build(); + let vpn_banner = card_box(); + vpn_banner.add_css_class("mseries-tailscale-setup"); + let banner_line = gtk::Box::builder().spacing(12).build(); + banner_line.append(>k::Image::from_icon_name("dialog-warning-symbolic")); + banner_line.append(&vpn_banner_text); + vpn_banner.append(&banner_line); + vpn_banner.set_visible(false); + let route_now = gtk::Label::builder().xalign(0.0).wrap(true).visible(false).build(); + route_now.add_css_class("heading"); let vpn_body = gtk::Box::builder().orientation(gtk::Orientation::Vertical).spacing(9).build(); + vpn_body.append(&vpn_banner); + vpn_body.append(&route_now); vpn_body.append(&vpn_list); vpn_body.append(&add_vpn); let vpn_section = section(&vpn_header, &vpn_body); @@ -262,11 +293,16 @@ impl AppWindow { .margin_end(18) .build(); column.append(&card); + let remote_header = granite::HeaderLabel::new(&tr("Remote Into a Mac")); + remote_header.set_secondary_text(Some(&tr("Screen Sharing over Tailscale, from anywhere"))); + let remote_list = gtk::Box::builder().orientation(gtk::Orientation::Vertical).spacing(9).build(); + let remote_section = section(&remote_header, &remote_list); column.append(&setup); column.append(&health); + column.append(&remote_section); + column.append(&vpn_section); column.append(&exit_section); column.append(&devices_section); - column.append(&vpn_section); column.append(&settings_section); column.append(&footer); let scroller = gtk::ScrolledWindow::builder().hscrollbar_policy(gtk::PolicyType::Never).child(&column).build(); @@ -320,6 +356,19 @@ impl AppWindow { vpn_list, vpn_key: RefCell::new(String::new()), vpn_pending: RefCell::new(Default::default()), + vpn_live: RefCell::new(HashMap::new()), + vpn_failures: RefCell::new(HashMap::new()), + vpn_connecting: RefCell::new(HashSet::new()), + vpn_stopping: RefCell::new(HashSet::new()), + vpn_ticks: RefCell::new(Vec::new()), + vpn_banner, + vpn_banner_text, + route_now, + sessions: Sessions::load(), + ts_timer, + remote_list, + remote_key: RefCell::new(String::new()), + ticker: RefCell::new(None), devices_header, search, device_list, @@ -350,6 +399,14 @@ impl AppWindow { }); poll.bind_to_widget_mapped(&this.window); *this.poll.borrow_mut() = Some(poll); + let weak = Rc::downgrade(&this); + let ticker = mseries_pantheon_kit::Polling::new(Duration::from_secs(1), move || { + if let Some(this) = weak.upgrade() { + this.tick(); + } + }); + ticker.bind_to_widget_mapped(&this.window); + *this.ticker.borrow_mut() = Some(ticker); // Wi-Fi changes make tailscaled reconnect; show it at once instead of waiting for the next poll. let weak = Rc::downgrade(&this); let id = gio::NetworkMonitor::default().connect_network_changed(move |_, _| { @@ -708,8 +765,16 @@ impl AppWindow { status.peers.len(), tr("total") ))); + if connected { + self.sessions.start("tailscale", 1, now_unix()); + } else { + self.sessions.stop("tailscale"); + } + self.sessions.save(); *self.status.borrow_mut() = Some(status); self.show_devices(); + self.show_remote(); + self.tick(); } fn show_devices(self: &Rc) { @@ -850,6 +915,18 @@ impl AppWindow { Ok((true, out, _)) => model::parse_vpn_list(&out), _ => Vec::new(), }; + let active = match cli::run_program( + &["nmcli", "-t", "-f", "UUID,STATE,DEVICE,TYPE", "connection", "show", "--active"], + 8, + ) + .await + { + Ok((true, out, _)) => model::parse_active(&out), + _ => Default::default(), + }; + let snap = net::probe().await; + let now = now_unix(); + let mut lives = Vec::new(); for vpn in &mut vpns { if let Ok((true, out, _)) = cli::run_program(&["nmcli", "-g", "ipv4.never-default", "connection", "show", &vpn.uuid], 8).await @@ -861,9 +938,66 @@ impl AppWindow { { vpn.secrets_saved = model::secrets_saved(&out); } + // Never trust NetworkManager's "activated" alone: find the tunnel and check it is up. + let (nm_state, device, kind) = active.get(&vpn.uuid).cloned().unwrap_or_default(); + let mut iface = None; + if nm_state == "activated" { + if kind == "wireguard" { + iface = snap.iface(&device).filter(|i| i.up && !i.ipv4.is_empty()).map(|i| i.name.clone()); + } else if let Ok((true, out, _)) = + cli::run_program(&["nmcli", "-g", "IP4.ADDRESS", "connection", "show", &vpn.uuid], 8).await + { + let addr = out.lines().next().unwrap_or_default().trim().to_string(); + iface = (!addr.is_empty()) + .then(|| snap.iface_with_address(&addr)) + .flatten() + .filter(|i| i.up) + .map(|i| i.name.clone()); + } + } + let connecting = self.vpn_connecting.borrow().contains(&vpn.uuid) || nm_state == "activating"; + let previous = self.vpn_live.borrow().get(&vpn.uuid).cloned(); + let live = if let Some(iface) = iface.clone() { + self.vpn_failures.borrow_mut().remove(&vpn.uuid); + Live::Connected(iface) + } else if connecting { + Live::Connecting + } else if nm_state == "activated" { + Live::Dropped + } else if let Some(reason) = self.vpn_failures.borrow().get(&vpn.uuid).cloned() { + Live::Failed(reason) + } else { + Live::Off + }; + // It was up and went away without us turning it off: record why. + let live = match (&previous, &live) { + (Some(Live::Connected(_)) | Some(Live::Connecting) | Some(Live::Dropped), Live::Off) + if !self.vpn_stopping.borrow().contains(&vpn.uuid) => + { + let reason = failure_reason(&vpn.name, &snap, iface.as_deref()).await; + self.vpn_failures.borrow_mut().insert(vpn.uuid.clone(), reason.clone()); + Live::Failed(reason) + } + _ => live, + }; + if matches!(live, Live::Off) { + self.vpn_stopping.borrow_mut().remove(&vpn.uuid); + } + // Session timer: keep the start while the same tunnel interface stays up. + if let Live::Connected(name) = &live { + let index = snap.iface(name).map(|i| i.index).unwrap_or(0); + self.sessions.start(&vpn.uuid, index, now); + } else { + self.sessions.stop(&vpn.uuid); + } + self.vpn_live.borrow_mut().insert(vpn.uuid.clone(), live.clone()); + lives.push(live); } + self.sessions.save(); + self.show_vpn_overview(&vpns, &lives, &snap); + let pending = self.vpn_pending.borrow().clone(); - let key = format!("{vpns:?}|{pending:?}"); + let key = format!("{vpns:?}|{lives:?}|{pending:?}"); if *self.vpn_key.borrow() == key { return; } @@ -871,60 +1005,185 @@ impl AppWindow { while let Some(child) = self.vpn_list.first_child() { self.vpn_list.remove(&child); } + self.vpn_ticks.borrow_mut().clear(); if vpns.is_empty() { let none = gtk::Label::builder().label(tr("No other VPNs set up")).xalign(0.0).build(); none.add_css_class(granite::CssClassDIM); self.vpn_list.append(&none); } - for vpn in vpns { - let needs_reconnect = pending.contains(&vpn.uuid) && vpn.active; - self.vpn_list.append(&self.vpn_card(&vpn, needs_reconnect)); + for (vpn, live) in vpns.iter().zip(lives) { + let needs_reconnect = pending.contains(&vpn.uuid) && matches!(live, Live::Connected(_)); + self.vpn_list.append(&self.vpn_card(vpn, &live, needs_reconnect)); } + self.tick(); } - fn vpn_card(self: &Rc, vpn: &model::Vpn, needs_reconnect: bool) -> gtk::Box { + /// Where internet traffic goes now, and a warning when two VPNs fight. + fn show_vpn_overview(&self, vpns: &[model::Vpn], lives: &[Live], snap: &net::Snapshot) { + let ours: Vec<(&model::Vpn, &Live)> = vpns.iter().zip(lives).collect(); + let iface_name = |iface: &str| -> String { + if let Some((vpn, _)) = ours.iter().find(|(_, l)| matches!(l, Live::Connected(i) if i == iface)) { + return model::display_name(&vpn.name); + } + if let Some((vpn, _)) = ours.iter().find(|(v, _)| v.name == iface) { + return model::display_name(&vpn.name); + } + match iface { + "tailscale0" => tr("Tailscale exit node"), + i if snap.tunnels().any(|t| t.name == i) => net::tunnel_owner(i), + _ => tr("this computer's own connection (no VPN)"), + } + }; + match &snap.internet_iface { + Some(iface) => { + self.route_now.set_label(&format!("{} {}", tr("Internet traffic now goes through"), iface_name(iface))); + self.route_now.set_visible(true); + } + None => self.route_now.set_visible(false), + } + + // Tunnels up right now: our connected VPNs plus tunnels nobody listed (another app's VPN). + let mut up: Vec = ours + .iter() + .filter(|(_, l)| matches!(l, Live::Connected(_))) + .map(|(v, _)| model::display_name(&v.name)) + .collect(); + for t in snap.tunnels() { + let ours_iface = + ours.iter().any(|(v, l)| matches!(l, Live::Connected(i) if *i == t.name) || v.name == t.name); + if !ours_iface && t.up { + up.push(net::tunnel_owner(&t.name)); + } + } + let knocked = ours.iter().find(|(_, l)| matches!(l, Live::Dropped | Live::Failed(_))); + let message = match (knocked, up.as_slice()) { + (Some((victim, _)), [other, ..]) => Some(format!( + "{other} {} {} {}", + tr("started its own VPN; it knocked"), + model::display_name(&victim.name), + tr("off. Use one VPN at a time.") + )), + (None, [a, b, ..]) => Some(format!( + "{a} {} {b} {}", + tr("and"), + tr("are both on. Two VPNs at once fight over your traffic; use one at a time.") + )), + _ => None, + }; + self.vpn_banner.set_visible(message.is_some()); + if let Some(m) = message { + self.vpn_banner_text.set_label(&m); + } + } + + fn vpn_card(self: &Rc, vpn: &model::Vpn, live: &Live, needs_reconnect: bool) -> gtk::Box { let card = card_box(); - let on = gtk::Switch::builder().valign(gtk::Align::Center).active(vpn.active).state(vpn.active).build(); - let gear = icon_button("emblem-system-symbolic", &format!("{} {}", vpn.name, tr("Settings"))); + let name = model::display_name(&vpn.name); + let on_now = !matches!(live, Live::Off | Live::Failed(_)); + let on = gtk::Switch::builder().valign(gtk::Align::Center).active(on_now).state(on_now).build(); + let gear = icon_button("emblem-system-symbolic", &format!("{name} {}", tr("Settings"))); let controls = gtk::Box::builder().spacing(6).valign(gtk::Align::Center).build(); + if matches!(live, Live::Connecting) { + let spinner = gtk::Spinner::builder().spinning(true).valign(gtk::Align::Center).build(); + controls.append(&spinner); + } controls.append(&gear); controls.append(&on); - set_accessible_label(&on, &format!("{} {}", tr("Connect"), vpn.name)); - card.append(&setting_row( - &vpn.name, - Some(&if vpn.active { tr("Connected") } else { tr("Not connected") }), - &controls, - )); + set_accessible_label(&on, &format!("{} {name}", tr("Connect"))); + let (status_text, status_class) = match live { + Live::Connected(iface) => (format!("{} · {iface}", tr("Connected")), Some(granite::CssClassSUCCESS)), + Live::Connecting => (tr("Connecting…"), None), + Live::Dropped => (tr("Dropped: on, but not carrying any traffic"), Some(granite::CssClassERROR)), + Live::Failed(reason) => (format!("{}: {reason}", tr("Failed")), Some(granite::CssClassERROR)), + Live::Off => (tr("Not connected"), None), + }; + let row = setting_row(&name, Some(&status_text), &controls); + if let Some(class) = status_class { + // The description label is the second child of the text box. + if let Some(desc) = row.first_child().and_then(|t| t.last_child()) { + desc.remove_css_class(granite::CssClassDIM); + desc.add_css_class(class); + } + } + card.append(&row); + + if let Live::Connected(iface) = live { + let timer = gtk::Label::builder().xalign(0.0).hexpand(true).build(); + timer.add_css_class(granite::CssClassNUMERIC); + timer.add_css_class("title-4"); + let bytes = gtk::Label::builder().xalign(1.0).build(); + bytes.add_css_class(granite::CssClassNUMERIC); + bytes.add_css_class(granite::CssClassDIM); + let line = gtk::Box::builder().spacing(12).build(); + line.append(&timer); + line.append(&bytes); + card.append(&line); + self.vpn_ticks.borrow_mut().push(Tick { key: vpn.uuid.clone(), iface: iface.clone(), timer, bytes }); + } + let (weak, edit_vpn) = (Rc::downgrade(self), vpn.clone()); gear.connect_clicked(move |_| { if let Some(this) = weak.upgrade() { - this.open_vpn_sheet(&edit_vpn); + // The sheet covers plugin VPNs (L2TP and friends); WireGuard opens the system editor. + if edit_vpn.kind_has_routing() { + this.open_vpn_sheet(&edit_vpn); + } else { + this.edit_vpn(&edit_vpn.uuid); + } } }); - card.append(>k::Separator::new(gtk::Orientation::Horizontal)); - let all = - gtk::Switch::builder().valign(gtk::Align::Center).active(vpn.all_traffic).state(vpn.all_traffic).build(); - let mode = if vpn.all_traffic { - format!("{} {}", tr("All internet traffic goes through"), vpn.name) - } else { - format!("{} {}", tr("Off: only its own networks go through"), vpn.name) - }; - card.append(&setting_row(&tr("Send all traffic over VPN connection"), Some(&mode), &all)); - if !vpn.secrets_saved { - let line = gtk::Box::builder().spacing(12).build(); - let note = gtk::Label::builder() - .label(tr("Passwords aren't saved, so connecting from here can't work. Save them once.")) - .xalign(0.0) - .hexpand(true) - .wrap(true) + if vpn.kind_has_routing() { + card.append(>k::Separator::new(gtk::Orientation::Horizontal)); + let all = gtk::Switch::builder() + .valign(gtk::Align::Center) + .active(vpn.all_traffic) + .state(vpn.all_traffic) .build(); - note.add_css_class(granite::CssClassWARNING); - let save = gtk::Button::with_label(&tr("Save Passwords…")); - save.set_valign(gtk::Align::Center); - line.append(¬e); - line.append(&save); + let mode = if vpn.all_traffic { + format!("{} {name}", tr("On: all internet traffic goes through")) + } else { + format!("{} {name}", tr("Off: only its own networks go through")) + }; + card.append(&setting_row(&tr("Send all traffic over VPN connection"), Some(&mode), &all)); + let (weak, uuid, connected) = (Rc::downgrade(self), vpn.uuid.clone(), matches!(live, Live::Connected(_))); + all.connect_state_set(move |_, everything| { + if let Some(this) = weak.upgrade() { + let never_default = if everything { "no" } else { "yes" }; + let done = if everything { + tr("All traffic will go over the VPN") + } else { + tr("Only the VPN's own networks will use it") + }; + if connected { + this.vpn_pending.borrow_mut().insert(uuid.clone()); + } + this.vpn_command( + &uuid, + vec![ + "nmcli", + "connection", + "modify", + "uuid", + &uuid, + "ipv4.never-default", + never_default, + "ipv6.never-default", + never_default, + ], + done, + false, + ); + } + glib::Propagation::Stop + }); + } + if !vpn.secrets_saved { card.append(>k::Separator::new(gtk::Orientation::Horizontal)); - card.append(&line); + let save = gtk::Button::with_label(&tr("Save Passwords…")); + card.append(¬e_line( + &tr("Passwords aren't saved, so connecting from here can't work. Save them once."), + &save, + )); let (weak, edit_vpn) = (Rc::downgrade(self), vpn.clone()); save.connect_clicked(move |_| { if let Some(this) = weak.upgrade() { @@ -932,79 +1191,224 @@ impl AppWindow { } }); } - if needs_reconnect { - let line = gtk::Box::builder().spacing(12).build(); - let note = gtk::Label::builder() - .label(tr("Reconnect to use the new setting.")) - .xalign(0.0) - .hexpand(true) - .wrap(true) - .build(); - note.add_css_class(granite::CssClassWARNING); + let broken = matches!(live, Live::Dropped | Live::Failed(_)); + if needs_reconnect || broken { let reconnect = gtk::Button::with_label(&tr("Reconnect")); reconnect.add_css_class(granite::CssClassSUGGESTED); - line.append(¬e); - line.append(&reconnect); - card.append(&line); - let (weak, uuid, name) = (Rc::downgrade(self), vpn.uuid.clone(), vpn.name.clone()); + let text = if broken { tr("Reconnect to bring it back.") } else { tr("Reconnect to use the new setting.") }; + card.append(¬e_line(&text, &reconnect)); + let (weak, uuid, label) = (Rc::downgrade(self), vpn.uuid.clone(), name.clone()); reconnect.connect_clicked(move |_| { if let Some(this) = weak.upgrade() { + this.vpn_failures.borrow_mut().remove(&uuid); + this.vpn_connecting.borrow_mut().insert(uuid.clone()); this.vpn_command( &uuid, - vec!["sh", "-c", "nmcli connection down uuid \"$0\" && nmcli connection up uuid \"$0\"", &uuid], - format!("{} {name}", tr("Reconnected")), + vec![ + "sh", + "-c", + "nmcli connection down uuid \"$0\" >/dev/null 2>&1; nmcli connection up uuid \"$0\"", + &uuid, + ], + format!("{} {label}", tr("Reconnected")), true, ); } }); } - let (weak, uuid, name) = (Rc::downgrade(self), vpn.uuid.clone(), vpn.name.clone()); + let (weak, uuid) = (Rc::downgrade(self), vpn.uuid.clone()); on.connect_state_set(move |_, active| { if let Some(this) = weak.upgrade() { let verb = if active { "up" } else { "down" }; let done = if active { tr("Connected to") } else { tr("Disconnected from") }; + this.vpn_failures.borrow_mut().remove(&uuid); + if active { + this.vpn_connecting.borrow_mut().insert(uuid.clone()); + } else { + this.vpn_stopping.borrow_mut().insert(uuid.clone()); + } + this.vpn_key.borrow_mut().clear(); this.vpn_command( &uuid, vec!["nmcli", "connection", verb, "uuid", &uuid], format!("{done} {name}"), false, ); + // Show "Connecting…" right away instead of waiting for nmcli. + let this2 = this.clone(); + glib::spawn_future_local(async move { this2.refresh_vpns().await }); } glib::Propagation::Stop }); - let (weak, uuid, active) = (Rc::downgrade(self), vpn.uuid.clone(), vpn.active); - all.connect_state_set(move |_, everything| { - if let Some(this) = weak.upgrade() { - let never_default = if everything { "no" } else { "yes" }; - let done = if everything { - tr("All traffic will go over the VPN") - } else { - tr("Only the VPN's own networks will use it") - }; - if active { - this.vpn_pending.borrow_mut().insert(uuid.clone()); + card + } + + /// One-second update of session timers and traffic counters (no rebuilds). + fn tick(&self) { + let now = now_unix(); + for t in self.vpn_ticks.borrow().iter() { + if let Some(start) = self.sessions.started(&t.key) { + t.timer.set_label(&net::duration_text(now - start)); + } + if let Some((rx, tx)) = net::counters(&t.iface) { + t.bytes.set_label(&format!("↓ {} ↑ {}", net::bytes_text(rx), net::bytes_text(tx))); + } + } + match self.sessions.started("tailscale") { + Some(start) if self.status_is_running() => { + self.ts_timer.set_label(&format!("{} {}", tr("Connected for"), net::duration_text(now - start))); + self.ts_timer.set_visible(true); + } + _ => self.ts_timer.set_visible(false), + } + } + + fn status_is_running(&self) -> bool { + self.status.borrow().as_ref().is_some_and(|s| s.backend == Backend::Running) + } + + /// "Remote Into a Mac": the tailnet's Macs with Screen Sharing (VNC over Tailscale). + fn show_remote(self: &Rc) { + let status = self.status.borrow(); + let running = status.as_ref().is_some_and(|s| s.backend == Backend::Running); + let macs: Vec = status + .as_ref() + .map(|s| { + // The Mac mini at BFS always; other Macs only while online (the full list is under Devices). + let mut m: Vec = s + .peers + .iter() + .filter(|d| d.os == "macOS" && (d.online || d.ssh_host == "csp-bfs-mini")) + .cloned() + .collect(); + // The Mac mini at BFS first, then online ones. + m.sort_by_key(|d| (d.ssh_host != "csp-bfs-mini", !d.online, d.name.to_lowercase())); + m + }) + .unwrap_or_default(); + drop(status); + let have_viewer = glib::find_program_in_path("remmina").is_some(); + let key = format!( + "{running}|{have_viewer}|{}", + macs.iter().map(|d| format!("{}:{}", d.id, d.online)).collect::>().join(",") + ); + if *self.remote_key.borrow() == key { + return; + } + *self.remote_key.borrow_mut() = key; + while let Some(child) = self.remote_list.first_child() { + self.remote_list.remove(&child); + } + if !running { + let turn_on = gtk::Button::with_label(&tr("Turn On Tailscale")); + turn_on.add_css_class(granite::CssClassSUGGESTED); + let card = card_box(); + card.append(¬e_line(&tr("Tailscale is off. Turn it on to reach your Macs from anywhere."), &turn_on)); + self.remote_list.append(&card); + let weak = Rc::downgrade(self); + turn_on.connect_clicked(move |_| { + if let Some(this) = weak.upgrade() { + this.change(vec!["up"], tr("Connected")); } - this.vpn_command( - &uuid, - vec![ - "nmcli", - "connection", - "modify", - "uuid", - &uuid, - "ipv4.never-default", - never_default, - "ipv6.never-default", - never_default, - ], - done, - false, - ); + }); + } + if !have_viewer { + let copy = gtk::Button::with_label(&tr("Copy Command")); + let card = card_box(); + card.append(¬e_line( + &tr("Screen Sharing needs the Remmina app. Install it once: sudo apt install remmina remmina-plugin-vnc"), + ©, + )); + self.remote_list.append(&card); + let weak = Rc::downgrade(self); + copy.connect_clicked(move |_| { + if let Some(this) = weak.upgrade() { + this.copy( + "sudo apt install remmina remmina-plugin-vnc", + &tr("Copied. Paste it in Terminal, then come back."), + ); + } + }); + } + if macs.is_empty() { + return; + } + let list = gtk::ListBox::builder().selection_mode(gtk::SelectionMode::None).build(); + list.add_css_class("boxed-list"); + list.add_css_class(granite::CssClassCARD); + list.add_css_class("mseries-tailscale-devices"); + let now = now_unix(); + for mac in macs { + let dot = gtk::Label::new(Some("●")); + dot.add_css_class("mseries-tailscale-dot"); + dot.add_css_class(if mac.online && running { granite::CssClassSUCCESS } else { granite::CssClassDIM }); + let name = + gtk::Label::builder().label(&mac.name).xalign(0.0).ellipsize(gtk::pango::EllipsizeMode::End).build(); + name.add_css_class("heading"); + let seen = + mac.last_seen.as_deref().and_then(|s| glib::DateTime::from_iso8601(s, None).ok()).map(|t| t.to_unix()); + let detail = gtk::Label::builder() + .label(format!( + "{} · {}", + mac.ip(), + if running { tr(&model::presence_text(mac.online, seen, now)) } else { tr("Tailscale is off") } + )) + .xalign(0.0) + .ellipsize(gtk::pango::EllipsizeMode::End) + .build(); + detail.add_css_class(granite::CssClassDIM); + detail.add_css_class(granite::CssClassSMALL); + let text = gtk::Box::builder().orientation(gtk::Orientation::Vertical).spacing(2).hexpand(true).build(); + text.append(&name); + text.append(&detail); + let share = gtk::Button::with_label(&tr("Screen Sharing")); + share.set_valign(gtk::Align::Center); + if mac.online && running { + share.add_css_class(granite::CssClassSUGGESTED); } - glib::Propagation::Stop - }); - card + share.set_tooltip_text(Some(&format!("vnc://{}", mac.ip()))); + let ssh = icon_button("utilities-terminal-symbolic", &format!("{} “{}”", tr("Copy"), mac.ssh_command())); + let line = + gtk::Box::builder().spacing(12).margin_top(9).margin_bottom(9).margin_start(12).margin_end(6).build(); + line.append(&dot); + line.append(&text); + line.append(&share); + line.append(&ssh); + list.append(>k::ListBoxRow::builder().child(&line).activatable(false).build()); + let (weak, ip, label) = (Rc::downgrade(self), mac.ip().to_string(), mac.name.clone()); + share.connect_clicked(move |_| { + if let Some(this) = weak.upgrade() { + this.screen_share(&ip, &label); + } + }); + let (weak, cmd) = (Rc::downgrade(self), mac.ssh_command()); + ssh.connect_clicked(move |_| { + if let Some(this) = weak.upgrade() { + this.copy(&cmd, &format!("{} “{cmd}”", tr("Copied"))); + } + }); + } + self.remote_list.append(&list); + } + + fn screen_share(&self, ip: &str, name: &str) { + if !self.status_is_running() { + chrome::show_toast(&self.toast, &tr("Turn on Tailscale first.")); + return; + } + if glib::find_program_in_path("remmina").is_none() { + self.copy( + "sudo apt install remmina remmina-plugin-vnc", + &tr("Remmina isn't installed. Copied the install command."), + ); + return; + } + let uri = format!("vnc://{ip}"); + match gio::Subprocess::newv(&["remmina".as_ref(), "-c".as_ref(), uri.as_ref()], gio::SubprocessFlags::NONE) { + Ok(_) => chrome::show_toast(&self.toast, &format!("{} {name}…", tr("Opening Screen Sharing on"))), + Err(e) => chrome::show_toast(&self.toast, e.message()), + } } pub fn open_vpn_sheet(self: &Rc, vpn: &model::Vpn) { @@ -1058,7 +1462,9 @@ impl AppWindow { glib::spawn_future_local(async move { let refs: Vec<&str> = argv.iter().map(String::as_str).collect(); // Connecting an L2TP/IPsec VPN takes a while; a polkit prompt waits for the user. - match cli::run_program(&refs, 90).await { + let result = cli::run_program(&refs, 90).await; + this.vpn_connecting.borrow_mut().remove(&uuid); + match result { Ok((true, _, _)) => { if clears_pending { this.vpn_pending.borrow_mut().remove(&uuid); @@ -1074,7 +1480,19 @@ impl AppWindow { } Ok((false, _, err)) => { let first = err.lines().map(str::trim).find(|l| !l.is_empty()).unwrap_or("The VPN change failed."); - chrome::show_toast(&this.toast, first.trim_start_matches("Error: ")); + let first = first.trim_start_matches("Error: ").to_string(); + if refs.iter().any(|a| *a == "up" || a.contains("connection up")) { + let snap = net::probe().await; + let others: Vec = snap + .tunnels() + .filter(|t| t.up && !t.name.starts_with("ppp")) + .map(|t| net::tunnel_owner(&t.name)) + .collect(); + let reason = + if others.is_empty() { first.clone() } else { model::classify_vpn_failure("", &others) }; + this.vpn_failures.borrow_mut().insert(uuid.clone(), reason); + } + chrome::show_toast(&this.toast, &first); } Err(e) => chrome::show_toast(&this.toast, &e.message()), } @@ -1150,3 +1568,115 @@ fn icon_button(icon: &str, label: &str) -> gtk::Button { set_accessible_label(&b, label); b } + +/// What a VPN is really doing, from the kernel's point of view. +#[derive(Debug, Clone, PartialEq)] +enum Live { + /// Its tunnel interface is up. + Connected(String), + Connecting, + /// NetworkManager says on, but no tunnel carries traffic. + Dropped, + Failed(String), + Off, +} + +/// Labels the one-second timer updates. +struct Tick { + key: String, + iface: String, + timer: gtk::Label, + bytes: gtk::Label, +} + +/// Session start times, kept across app restarts (keyed by connection and tunnel interface index, so a +/// reconnect starts a new session). +struct Sessions { + path: std::path::PathBuf, + map: RefCell>, + dirty: Cell, +} + +impl Sessions { + fn load() -> Self { + let path = mseries_pantheon_kit::settings::app_config_dir(APP_ID).join("sessions.json"); + let map = if std::env::var_os("MSERIES_TAILSCALE_FIXTURES").is_some() { + // Fixture screenshots show a session that has been up for a while. + HashMap::new() + } else { + mseries_pantheon_kit::settings::load_json(&path) + }; + Self { path, map: RefCell::new(map), dirty: Cell::new(false) } + } + + fn start(&self, key: &str, index: u32, now: i64) { + let mut map = self.map.borrow_mut(); + if map.get(key).is_some_and(|(i, _)| *i == index) { + return; + } + let start = if std::env::var_os("MSERIES_TAILSCALE_FIXTURES").is_some() { now - 4 * 3600 - 249 } else { now }; + map.insert(key.into(), (index, start)); + self.dirty.set(true); + } + + fn stop(&self, key: &str) { + if self.map.borrow_mut().remove(key).is_some() { + self.dirty.set(true); + } + } + + fn started(&self, key: &str) -> Option { + self.map.borrow().get(key).map(|(_, s)| *s) + } + + fn save(&self) { + if self.dirty.replace(false) && std::env::var_os("MSERIES_TAILSCALE_FIXTURES").is_none() { + let _ = mseries_pantheon_kit::settings::save_json(&self.path, &*self.map.borrow()); + } + } +} + +fn now_unix() -> i64 { + glib::DateTime::now_utc().map(|t| t.to_unix()).unwrap_or(0) +} + +/// Reason for a VPN that dropped on its own, from the last few minutes of logs. +async fn failure_reason(_name: &str, snap: &net::Snapshot, _iface: Option<&str>) -> String { + let others: Vec = + snap.tunnels().filter(|t| t.up && !t.name.starts_with("ppp")).map(|t| net::tunnel_owner(&t.name)).collect(); + let log = cli::run_program( + &[ + "journalctl", + "--since", + "-3min", + "--no-pager", + "-o", + "cat", + "-t", + "NetworkManager", + "-t", + "pppd", + "-t", + "nm-l2tp-service", + "-t", + "charon", + "-t", + "ipsec", + ], + 5, + ) + .await + .map(|(_, out, _)| out) + .unwrap_or_default(); + model::classify_vpn_failure(&log, &others) +} + +/// A wrapped note with a button at the end. +fn note_line(text: &str, button: >k::Button) -> gtk::Box { + let note = gtk::Label::builder().label(text).xalign(0.0).hexpand(true).wrap(true).build(); + button.set_valign(gtk::Align::Center); + let line = gtk::Box::builder().spacing(12).build(); + line.append(¬e); + line.append(button); + line +}