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
17 changes: 14 additions & 3 deletions rust/mseries-tailscale/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,23 @@ A native Pantheon window for Tailscale. It replaces the old launcher that opened
| ![Connected](docs/main-light.png) | ![Connected, dark](docs/main-dark.png) |
| **Disconnected** | **A refused change shows the one-time setup** |
| ![Disconnected](docs/stopped-light.png) | ![Setup banner](docs/setup-light.png) |
| **Whole window** | **Dark** |
| ![Whole window](docs/full-light.png) | ![Whole window, dark](docs/full-dark.png) |
| **Logged out** | **Service not running** |
| ![Log in](docs/login-light.png) | ![Not running](docs/notrunning-dark.png) |

## What it does

- **Status card:** connected or disconnected, tailnet and account, and a switch that runs `tailscale up` / `down`.
This device's name and IP, with a copy button.
- **Exit node:** pick any device that offers itself as an exit node (`tailscale set --exit-node=IP`), or None, and
allow local network access while using one.
- **Route All Traffic Through:** pick a tailnet exit node (`tailscale set --exit-node=IP`), or None, and allow
local network access while using one (`--exit-node-allow-lan-access`). The status card then says "All internet
traffic goes through …". With no exit node on the tailnet it explains how to add one
(`sudo tailscale set --advertise-exit-node` on an always-on device, then approve it in the Admin Console).
- **VPN Connections:** the computer's other VPNs from NetworkManager, each with a connect switch and macOS's
**Send all traffic over VPN connection** switch (`ipv4.never-default` / `ipv6.never-default` = no). The card says
which mode is active; a connected VPN shows **Reconnect** after the mode changes. **Add VPN…** opens Network
settings, where new VPNs are created.
- **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`).
Expand All @@ -32,7 +40,10 @@ control springs back to the real state, and the window shows the one-time comman
sudo tailscale set --operator=$USER
```

The window refreshes every 3 s while it is open, after each change, and on Ctrl+R / F5.
The window refreshes every 3 s while it is open, after each change, when the network changes (Wi-Fi switches
make tailscaled reconnect), and on Ctrl+R / F5. Every command runs off the UI thread with a time limit (8 s for
reads, 15–25 s for Tailscale changes, 90 s for VPN changes, which may wait on a password prompt); a command that
runs out of time is stopped and the window says Tailscale isn't answering instead of freezing.

## Build, install, screenshots

Expand Down
Binary file added rust/mseries-tailscale/docs/full-dark.png
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/full-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified rust/mseries-tailscale/docs/main-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified rust/mseries-tailscale/docs/main-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions rust/mseries-tailscale/fixtures/nmcli.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Home Wi-Fi:11111111-aaaa:802-11-wireless:yes
BFS:4a1d6d4d-c5e4:vpn:yes
1 change: 1 addition & 0 deletions rust/mseries-tailscale/fixtures/split-tunnel.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
4a1d6d4d-c5e4
4 changes: 2 additions & 2 deletions rust/mseries-tailscale/snapshot.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ for f in "$here"/data/*.svg; do
install -Dm644 "$f" "$data/icons/hicolor/$dir/apps/$(basename "$f")"
done
for scheme in light dark; do
xvfb-run -a -s "-screen 0 1000x1300x24" env GSK_RENDERER=cairo GDK_BACKEND=x11 \
xvfb-run -a -s "-screen 0 1000x1700x24" env GSK_RENDERER=cairo GDK_BACKEND=x11 \
XDG_DATA_DIRS="$data:/usr/local/share:/usr/share" \
MSERIES_TAILSCALE_FIXTURES="$fixtures" MSERIES_TAILSCALE_DARK=$([[ $scheme == dark ]] && echo 1 || echo 0) \
MSERIES_TAILSCALE_FIXTURES="$fixtures" ${SIZE:+MSERIES_TAILSCALE_SNAPSHOT_SIZE=$SIZE} MSERIES_TAILSCALE_DARK=$([[ $scheme == dark ]] && echo 1 || echo 0) \
MSERIES_TAILSCALE_SNAPSHOT="$out/${PREFIX:-main}-$scheme.png" MSERIES_TAILSCALE_SNAPSHOT_DELAY=${DELAY:-2500} \
dbus-run-session -- "$bin" 2>&1 | grep -viE "egl|mesa|dri3|dbus" || true
done
Expand Down
64 changes: 51 additions & 13 deletions rust/mseries-tailscale/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ pub enum CliError {
NotRunning,
/// Changing settings needs root or the operator setting.
AccessDenied,
/// The command did not finish in time and was stopped.
TimedOut(String),
Failed(String),
}

Expand All @@ -23,6 +25,7 @@ impl CliError {
Self::NotInstalled => "Tailscale isn't installed.".into(),
Self::NotRunning => "The Tailscale service isn't running.".into(),
Self::AccessDenied => "Changing Tailscale settings needs a one-time setup.".into(),
Self::TimedOut(what) => format!("{what} didn't answer in time. Check your network and try again."),
Self::Failed(text) => text.clone(),
}
}
Expand Down Expand Up @@ -69,26 +72,61 @@ pub async fn run(args: &[&str]) -> Result<String, CliError> {
};
}
let bin = binary();
let mut argv: Vec<&std::ffi::OsStr> = vec![bin.as_ref()];
argv.extend(args.iter().map(|a| std::ffi::OsStr::new(*a)));
let proc = gio::Subprocess::newv(&argv, gio::SubprocessFlags::STDOUT_PIPE | gio::SubprocessFlags::STDERR_PIPE)
let mut argv: Vec<&str> = vec![&bin];
argv.extend_from_slice(args);
// Changes can wait on the network (`up` after a link change); reads should be instant.
let limit = match args.first() {
Some(&"up") | Some(&"down") => 25,
Some(&"set") => 15,
_ => 8,
};
match run_program(&argv, limit).await {
Ok((true, out, _)) => Ok(out),
// `status --json` exits non-zero when stopped or logged out but still prints the state.
Ok((false, out, _)) if args.first() == Some(&"status") && !out.trim().is_empty() => Ok(out),
Ok((false, _, err)) => Err(classify(&err)),
Err(CliError::TimedOut(_)) => Err(CliError::TimedOut("Tailscale".into())),
Err(e) => Err(e),
}
}

/// Runs ARGV with a time limit in seconds; returns (succeeded, stdout, stderr). Stops the process on timeout.
pub async fn run_program(argv: &[&str], limit_secs: u64) -> Result<(bool, String, String), CliError> {
if let (Some(dir), Some(&"nmcli")) = (fixtures(), argv.first()) {
// 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();
return match argv.get(1..4) {
Some(["-t", "-f", _]) => Ok((true, list, 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();
Ok((true, if split.lines().any(|l| l == uuid) { "yes" } else { "no" }.into(), String::new()))
}
_ => Ok((false, String::new(), "Error: not allowed in fixture mode".into())),
};
}
let os: Vec<&std::ffi::OsStr> = argv.iter().map(std::ffi::OsStr::new).collect();
let proc = gio::Subprocess::newv(&os, gio::SubprocessFlags::STDOUT_PIPE | gio::SubprocessFlags::STDERR_PIPE)
.map_err(|e| {
if e.matches(gio::IOErrorEnum::NotFound) || e.message().contains("No such file") {
CliError::NotInstalled
} else {
CliError::Failed(e.message().to_string())
}
})?;
let (out, err) = proc.communicate_utf8_future(None).await.map_err(|e| CliError::Failed(e.message().to_string()))?;
let out = out.map(|s| s.to_string()).unwrap_or_default();
let err = err.map(|s| s.to_string()).unwrap_or_default();
if proc.is_successful() {
Ok(out)
} else if args.first() == Some(&"status") && !out.trim().is_empty() {
// `status --json` exits non-zero when stopped or logged out but still prints the state.
Ok(out)
} else {
Err(classify(&err))
let running = proc.communicate_utf8_future(None);
match gtk::glib::future_with_timeout(std::time::Duration::from_secs(limit_secs), running).await {
Err(_) => {
proc.force_exit();
Err(CliError::TimedOut(argv.first().copied().unwrap_or_default().to_string()))
}
Ok(Err(e)) => Err(CliError::Failed(e.message().to_string())),
Ok(Ok((out, err))) => Ok((
proc.is_successful(),
out.map(|s| s.to_string()).unwrap_or_default(),
err.map(|s| s.to_string()).unwrap_or_default(),
)),
}
}

Expand Down
7 changes: 7 additions & 0 deletions rust/mseries-tailscale/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ fn main() -> glib::ExitCode {
return;
}
let win = AppWindow::new(app);
// MSERIES_TAILSCALE_SNAPSHOT_SIZE=560x1500 renders the whole page for screenshots.
if let Some((w, h)) = std::env::var("MSERIES_TAILSCALE_SNAPSHOT_SIZE").ok().and_then(|v| {
let (w, h) = v.split_once('x')?;
Some((w.parse().ok()?, h.parse().ok()?))
}) {
win.window.set_default_size(w, h);
}
win.window.present();
let s = slot.clone();
win.window.connect_destroy(move |_| {
Expand Down
52 changes: 52 additions & 0 deletions rust/mseries-tailscale/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -308,3 +308,55 @@ mod tests {
assert_eq!(presence_text(false, Some(0), 86_400 * 90), "Last seen 3 months ago");
}
}

/// A NetworkManager VPN connection (`nmcli`), with macOS's "Send all traffic over VPN" setting
/// (NetworkManager's `never-default` set to no).
#[derive(Debug, Clone, PartialEq)]
pub struct Vpn {
pub name: String,
pub uuid: String,
pub active: bool,
pub all_traffic: bool,
}

/// Splits one `nmcli -t` line on unescaped colons and unescapes `\:` and `\\`.
pub fn nmcli_fields(line: &str) -> Vec<String> {
let mut fields = vec![String::new()];
let mut chars = line.chars();
while let Some(c) = chars.next() {
match c {
'\\' => {
if let Some(n) = chars.next() {
fields.last_mut().unwrap().push(n);
}
}
':' => fields.push(String::new()),
c => fields.last_mut().unwrap().push(c),
}
}
fields
}

/// VPNs from `nmcli -t -f NAME,UUID,TYPE,ACTIVE connection show` (WireGuard counts; Tailscale's own does not).
pub fn parse_vpn_list(text: &str) -> Vec<Vpn> {
text.lines()
.map(nmcli_fields)
.filter(|f| f.len() >= 4 && (f[2] == "vpn" || f[2] == "wireguard") && f[0] != "tailscale0")
.map(|f| Vpn { name: f[0].clone(), uuid: f[1].clone(), active: f[3] == "yes", all_traffic: false })
.collect()
}

#[cfg(test)]
mod vpn_tests {
use super::*;

#[test]
fn parses_nmcli() {
let text = "Home Wi\\:Fi:aaa:802-11-wireless:yes\nBFS:4a1d:vpn:no\nWork\\\\VPN:bbb:wireguard:yes\nlo:ccc:loopback:yes\n";
let vpns = parse_vpn_list(text);
assert_eq!(vpns.len(), 2);
assert_eq!((vpns[0].name.as_str(), vpns[0].uuid.as_str(), vpns[0].active), ("BFS", "4a1d", false));
assert_eq!((vpns[1].name.as_str(), vpns[1].active), ("Work\\VPN", true));
assert_eq!(nmcli_fields("a\\:b:c"), vec!["a:b", "c"]);
}
}
Loading
Loading