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
7 changes: 7 additions & 0 deletions rust/mseries-tailscale/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ A native Pantheon window for Tailscale. It replaces the old launcher that opened
**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.
- **VPN settings (gear button):** each VPN row has a gear that opens an edit sheet: server, username, password,
IPsec pre-shared key, "Send all traffic over VPN connection" and **Remember passwords** (on by default; stored
system-wide, readable only by root; off means ask every time). Secret boxes start empty; empty keeps what is
stored. Saving goes through NetworkManager's D-Bus `Update2`, so secrets never appear on a command line or in a
log. A connected VPN offers Reconnect afterwards.

![VPN row](docs/vpn-row-light.png) ![VPN settings sheet](docs/vpn-edit-light.png)
- **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 Down
Binary file added rust/mseries-tailscale/docs/vpn-edit-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 added rust/mseries-tailscale/docs/vpn-row-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 5 additions & 1 deletion rust/mseries-tailscale/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,11 @@ pub async fn run_program(argv: &[&str], limit_secs: u64) -> Result<(bool, String
let unsaved = std::fs::read_to_string(dir.join("unsaved-passwords.txt")).unwrap_or_default();
let uuid = argv.last().copied().unwrap_or_default();
let flags = if unsaved.lines().any(|l| l == uuid) { 2 } else { 0 };
Ok((true, format!("password-flags = {flags}, ipsec-psk-flags = {flags}"), String::new()))
let data = format!(
"gateway = vpn.example.com, ipsec-enabled = yes, ipsec-psk-flags = {flags}, \
password-flags = {flags}, user = name@example.com"
);
Ok((true, data, String::new()))
}
_ => Ok((false, String::new(), "Error: not allowed in fixture mode".into())),
};
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,5 +3,6 @@

pub mod cli;
pub mod model;
pub mod nm;
pub mod strings;
pub mod ui;
39 changes: 29 additions & 10 deletions rust/mseries-tailscale/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,21 @@ fn main() -> glib::ExitCode {
win.window.connect_destroy(move |_| {
s.borrow_mut().take();
});
// MSERIES_TAILSCALE_SNAPSHOT_EDIT=1 opens the first VPN's edit sheet and renders it to <snapshot>-sheet.png.
if std::env::var("MSERIES_TAILSCALE_SNAPSHOT_EDIT").as_deref() == Ok("1") {
let w = win.clone();
glib::timeout_add_local_once(std::time::Duration::from_millis(800), move || w.open_first_vpn_sheet());
let w = win.clone();
if let Ok(path) = std::env::var("MSERIES_TAILSCALE_SNAPSHOT") {
let delay: u64 =
std::env::var("MSERIES_TAILSCALE_SNAPSHOT_DELAY").ok().and_then(|d| d.parse().ok()).unwrap_or(2500);
glib::timeout_add_local_once(std::time::Duration::from_millis(delay.saturating_sub(300)), move || {
if let Some(sheet) = w.sheet() {
render(&sheet, &path.replace(".png", "-sheet.png"));
}
});
}
}
maybe_snapshot(app, &win.window);
*slot.borrow_mut() = Some(win);
});
Expand All @@ -73,16 +88,7 @@ fn maybe_snapshot(app: &gtk::Application, window: &gtk::ApplicationWindow) {
let window = window.clone();
let app = app.clone();
glib::timeout_add_local_once(std::time::Duration::from_millis(delay), move || {
let paintable = gtk::WidgetPaintable::new(Some(&window));
let snapshot = gtk::Snapshot::new();
paintable.snapshot(&snapshot, window.width() as f64, window.height() as f64);
match (snapshot.to_node(), window.native().and_then(|n| n.renderer())) {
(Some(node), Some(renderer)) => match renderer.render_texture(&node, None).save_to_png(&path) {
Ok(()) => eprintln!("snapshot written to {path}"),
Err(e) => eprintln!("snapshot failed: {e}"),
},
_ => eprintln!("nothing to render for the snapshot"),
}
render(window.upcast_ref(), &path);
app.quit();
});
}
Expand All @@ -100,3 +106,16 @@ fn find_switch(widget: &gtk::Widget) -> Option<gtk::Switch> {
}
None
}

fn render(window: &gtk::Window, path: &str) {
let paintable = gtk::WidgetPaintable::new(Some(window));
let snapshot = gtk::Snapshot::new();
paintable.snapshot(&snapshot, window.width() as f64, window.height() as f64);
match (snapshot.to_node(), window.native().and_then(|n| n.renderer())) {
(Some(node), Some(renderer)) => match renderer.render_texture(&node, None).save_to_png(path) {
Ok(()) => eprintln!("snapshot written to {path}"),
Err(e) => eprintln!("snapshot failed: {e}"),
},
_ => eprintln!("nothing to render for the snapshot"),
}
}
12 changes: 12 additions & 0 deletions rust/mseries-tailscale/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,15 @@ pub fn parse_vpn_list(text: &str) -> Vec<Vpn> {
.collect()
}

/// `nmcli -g vpn.data` ("key = value, key = value") as a map. Values with ", " inside are not expected here.
pub fn parse_vpn_data(text: &str) -> std::collections::HashMap<String, String> {
text.trim()
.split(", ")
.filter_map(|kv| kv.split_once('='))
.map(|(k, v)| (k.trim().to_string(), v.trim().to_string()))
.collect()
}

#[cfg(test)]
mod vpn_tests {
use super::*;
Expand All @@ -385,6 +394,9 @@ mod vpn_tests {
assert!(!secrets_saved("password-flags = 0, ipsec-psk-flags = 2"));
assert!(secrets_saved("password-flags = 0, ipsec-psk-flags = 0, user = a"));
assert!(secrets_saved(""));
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"));
assert!(needs_secrets("Error: Connection activation failed: Secrets were required, but not provided"));
}
}
116 changes: 116 additions & 0 deletions rust/mseries-tailscale/src/nm.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
//! Saves a VPN connection through NetworkManager's D-Bus API (`Settings.Connection.Update2`).
//!
//! Secrets travel only over the system bus, never on a command line (where `ps` shows them), and are never
//! printed or logged. A blank secret field keeps the stored value: it is read back with `GetSecrets` and
//! written again, because `Update2` replaces the whole connection.

use gtk::gio;
use gtk::glib::{self, Variant, prelude::*};
use std::collections::HashMap;

const NM: &str = "org.freedesktop.NetworkManager";
const SETTINGS_PATH: &str = "/org/freedesktop/NetworkManager/Settings";
const CONNECTION_IFACE: &str = "org.freedesktop.NetworkManager.Settings.Connection";

type Settings = HashMap<String, HashMap<String, Variant>>;

/// What the edit sheet changes. `None` secrets keep what is stored.
pub struct VpnEdit {
pub server_key: String,
pub server: String,
pub user_key: String,
pub user: String,
pub password: Option<String>,
pub psk: Option<String>,
pub all_traffic: bool,
/// Store secrets (flag 0) or ask every time (flag 2).
pub remember: bool,
}

async fn call(
bus: &gio::DBusConnection,
path: &str,
iface: &str,
method: &str,
args: Option<&Variant>,
) -> Result<Variant, glib::Error> {
bus.call_future(Some(NM), path, iface, method, args, None, gio::DBusCallFlags::NONE, 30_000).await
}

fn friendly(e: &glib::Error) -> String {
let m = e.message();
if m.contains("NotAuthorized") || m.contains("not authorized") || m.contains("Insufficient privileges") {
"You didn't approve the change.".into()
} else {
// D-Bus errors carry the remote error name first; keep the human part.
m.rsplit(": ").next().unwrap_or(m).to_string()
}
}

pub async fn save_vpn(uuid: &str, edit: VpnEdit) -> Result<(), String> {
let bus = gio::bus_get_future(gio::BusType::System).await.map_err(|e| friendly(&e))?;
let found = call(
&bus,
SETTINGS_PATH,
"org.freedesktop.NetworkManager.Settings",
"GetConnectionByUuid",
Some(&(uuid,).to_variant()),
)
.await
.map_err(|e| friendly(&e))?;
let path: glib::variant::ObjectPath = found.child_value(0).get().ok_or("NetworkManager gave no connection")?;
let path = path.as_str().to_string();

let current = call(&bus, &path, CONNECTION_IFACE, "GetSettings", None).await.map_err(|e| friendly(&e))?;
let mut settings: Settings = current.child_value(0).get().ok_or("Couldn't read the VPN's settings")?;

let vpn = settings.entry("vpn".into()).or_default();
let mut data: HashMap<String, String> = vpn.get("data").and_then(|v| v.get()).unwrap_or_default();
let flag = if edit.remember { "0" } else { "2" };
data.insert(edit.server_key.clone(), edit.server.trim().to_string());
data.insert(edit.user_key.clone(), edit.user.trim().to_string());
data.insert("password-flags".into(), flag.into());
let has_psk = data.contains_key("ipsec-psk-flags") || data.get("ipsec-enabled").is_some_and(|v| v == "yes");
if has_psk {
data.insert("ipsec-psk-flags".into(), flag.into());
}

// Keep stored secrets for blank fields: read them back (they never leave this process).
let mut secrets: HashMap<String, String> = HashMap::new();
if edit.remember && (edit.password.is_none() || (has_psk && edit.psk.is_none())) {
// Never wipe a stored secret by accident: if it can't be read back, stop and ask for it.
let stored =
call(&bus, &path, CONNECTION_IFACE, "GetSecrets", Some(&("vpn",).to_variant())).await.map_err(|_| {
"The saved password couldn't be read back. Type the password and key, then save.".to_string()
})?;
let stored: Settings = stored.child_value(0).get().unwrap_or_default();
if let Some(s) =
stored.get("vpn").and_then(|v| v.get("secrets")).and_then(|v| v.get::<HashMap<String, String>>())
{
secrets = s;
}
}
if edit.remember {
if let Some(p) = edit.password {
secrets.insert("password".into(), p);
}
if has_psk && let Some(k) = edit.psk {
secrets.insert("ipsec-psk".into(), k);
}
} else {
secrets.clear();
}
vpn.insert("data".into(), data.to_variant());
vpn.insert("secrets".into(), secrets.to_variant());

// macOS's "Send all traffic over VPN connection" is never-default = false.
for family in ["ipv4", "ipv6"] {
settings.entry(family.into()).or_default().insert("never-default".into(), (!edit.all_traffic).to_variant());
}

// Update2(settings, flags = TO_DISK, args = {}).
let args = (settings.to_variant(), 1u32, HashMap::<String, Variant>::new().to_variant());
let packed = Variant::tuple_from_iter([args.0, args.1.to_variant(), args.2]);
call(&bus, &path, CONNECTION_IFACE, "Update2", Some(&packed)).await.map_err(|e| friendly(&e))?;
Ok(())
}
1 change: 1 addition & 0 deletions rust/mseries-tailscale/src/ui/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
//! The Tailscale window: a status card with the connect switch, exit node, the tailnet's devices and settings.

pub mod vpn_edit;
pub mod window;
Loading