Skip to content
This repository was archived by the owner on Nov 12, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions rust/mseries-tailscale/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>`
(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: <reason>" (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://<tailscale-ip>`) 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.
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added rust/mseries-tailscale/docs/vpn-status-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions rust/mseries-tailscale/fixtures/active.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
4a1d6d4d-c5e4:activated:enx605b:vpn
1 change: 1 addition & 0 deletions rust/mseries-tailscale/fixtures/ip4.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
4a1d6d4d-c5e4 192.168.200.20/32
45 changes: 45 additions & 0 deletions rust/mseries-tailscale/fixtures/net.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
]
}
1 change: 1 addition & 0 deletions rust/mseries-tailscale/fixtures/nmcli.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
Home Wi-Fi:11111111-aaaa:802-11-wireless:yes
BFS:4a1d6d4d-c5e4:vpn:yes
utun:2e861348-utun:wireguard:no
1 change: 0 additions & 1 deletion rust/mseries-tailscale/fixtures/split-tunnel.txt
Original file line number Diff line number Diff line change
@@ -1 +0,0 @@
4a1d6d4d-c5e4
1 change: 0 additions & 1 deletion rust/mseries-tailscale/fixtures/unsaved-passwords.txt
Original file line number Diff line number Diff line change
@@ -1 +0,0 @@
4a1d6d4d-c5e4
13 changes: 13 additions & 0 deletions rust/mseries-tailscale/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions rust/mseries-tailscale/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

pub mod cli;
pub mod model;
pub mod net;
pub mod nm;
pub mod strings;
pub mod ui;
63 changes: 63 additions & 0 deletions rust/mseries-tailscale/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, (String, String, String)> {
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).
Expand Down Expand Up @@ -365,6 +414,7 @@ pub fn parse_vpn_list(text: &str) -> Vec<Vpn> {
active: f[3] == "yes",
all_traffic: false,
secrets_saved: true,
kind: f[2].clone(),
})
.collect()
}
Expand Down Expand Up @@ -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"));
}
}
183 changes: 183 additions & 0 deletions rust/mseries-tailscale/src/net.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}

#[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<String>,
pub ifaces: Vec<Iface>,
}

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<Item = &Iface> {
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<IpAddrInfo>,
}

#[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::<Vec<IpRoute>>(&out).ok())
.and_then(|r| r.into_iter().next())
.map(|r| r.dev)
.filter(|d| !d.is_empty());
let mut addrs: HashMap<String, Vec<String>> = HashMap::new();
if let Ok((true, out, _)) = crate::cli::run_program(&["ip", "-j", "-4", "addr"], 5).await {
for entry in serde_json::from_str::<Vec<IpAddr>>(&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"]);
}
}
Loading