From 5d3aadd79d89a54fd5f3c1e3aa2704630b11cd97 Mon Sep 17 00:00:00 2001 From: Techmore Date: Thu, 29 Oct 2026 09:52:09 -0400 Subject: [PATCH] Tailscale app: say when a VPN's passwords aren't saved, offer to save them BFS stores neither its password nor its IPsec key (flags 2, ask every time) and the desktop has no NetworkManager secret agent, so connecting from the app failed with "No agents were available". The VPN card now says so and opens nm-connection-editor for that connection; a connect that fails for missing secrets says why and opens it too. Co-Authored-By: Claude Opus 5.5 --- .../fixtures/unsaved-passwords.txt | 1 + rust/mseries-tailscale/src/cli.rs | 7 ++ rust/mseries-tailscale/src/model.rs | 30 ++++++++- rust/mseries-tailscale/src/ui/window.rs | 64 +++++++++++++++++-- 4 files changed, 96 insertions(+), 6 deletions(-) create mode 100644 rust/mseries-tailscale/fixtures/unsaved-passwords.txt diff --git a/rust/mseries-tailscale/fixtures/unsaved-passwords.txt b/rust/mseries-tailscale/fixtures/unsaved-passwords.txt new file mode 100644 index 0000000..c051887 --- /dev/null +++ b/rust/mseries-tailscale/fixtures/unsaved-passwords.txt @@ -0,0 +1 @@ +4a1d6d4d-c5e4 diff --git a/rust/mseries-tailscale/src/cli.rs b/rust/mseries-tailscale/src/cli.rs index 40ae415..43cde0e 100644 --- a/rust/mseries-tailscale/src/cli.rs +++ b/rust/mseries-tailscale/src/cli.rs @@ -103,6 +103,13 @@ pub async fn run_program(argv: &[&str], limit_secs: u64) -> Result<(bool, String let uuid = argv.last().copied().unwrap_or_default(); Ok((true, if split.lines().any(|l| l == uuid) { "yes" } else { "no" }.into(), String::new())) } + // `unsaved-passwords.txt` lists VPNs whose secrets are "always ask". + Some(["-g", "vpn.data", _]) => { + 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())) + } _ => Ok((false, String::new(), "Error: not allowed in fixture mode".into())), }; } diff --git a/rust/mseries-tailscale/src/model.rs b/rust/mseries-tailscale/src/model.rs index 3286caa..c0397a0 100644 --- a/rust/mseries-tailscale/src/model.rs +++ b/rust/mseries-tailscale/src/model.rs @@ -317,6 +317,23 @@ pub struct Vpn { pub uuid: String, pub active: bool, pub all_traffic: bool, + /// False when a secret is "always ask" (flag 2) and so is never stored. + pub secrets_saved: bool, +} + +/// Reads the `*-flags` entries from `nmcli -g vpn.data` (flags only; secrets are not in vpn.data). +/// Flag 2 means "not saved, ask every time". +pub fn secrets_saved(vpn_data: &str) -> bool { + !vpn_data + .split(',') + .filter_map(|kv| kv.split_once('=')) + .any(|(k, v)| k.trim().ends_with("-flags") && v.trim().parse::().is_ok_and(|f| f & 2 != 0)) +} + +/// Whether an `nmcli connection up` failure means the VPN's passwords are missing. +pub fn needs_secrets(stderr: &str) -> bool { + let l = stderr.to_lowercase(); + l.contains("secrets were required") || l.contains("no agents were available") || l.contains("no secrets") } /// Splits one `nmcli -t` line on unescaped colons and unescapes `\:` and `\\`. @@ -342,7 +359,13 @@ pub fn parse_vpn_list(text: &str) -> Vec { 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 }) + .map(|f| Vpn { + name: f[0].clone(), + uuid: f[1].clone(), + active: f[3] == "yes", + all_traffic: false, + secrets_saved: true, + }) .collect() } @@ -358,5 +381,10 @@ mod vpn_tests { 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"]); + assert!(!secrets_saved("gateway = x, ipsec-psk-flags = 2, password-flags = 2, user = a")); + 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("")); + assert!(needs_secrets("Error: Connection activation failed: Secrets were required, but not provided")); } } diff --git a/rust/mseries-tailscale/src/ui/window.rs b/rust/mseries-tailscale/src/ui/window.rs index ac8c93f..8cd01cd 100644 --- a/rust/mseries-tailscale/src/ui/window.rs +++ b/rust/mseries-tailscale/src/ui/window.rs @@ -854,6 +854,11 @@ impl AppWindow { { vpn.all_traffic = out.trim() == "no"; } + if let Ok((true, out, _)) = + cli::run_program(&["nmcli", "-g", "vpn.data", "connection", "show", &vpn.uuid], 8).await + { + vpn.secrets_saved = model::secrets_saved(&out); + } } let pending = self.vpn_pending.borrow().clone(); let key = format!("{vpns:?}|{pending:?}"); @@ -892,6 +897,30 @@ impl AppWindow { 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 in the VPN's settings.", + )) + .xalign(0.0) + .hexpand(true) + .wrap(true) + .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); + card.append(>k::Separator::new(gtk::Orientation::Horizontal)); + card.append(&line); + let (weak, uuid) = (Rc::downgrade(self), vpn.uuid.clone()); + save.connect_clicked(move |_| { + if let Some(this) = weak.upgrade() { + this.edit_vpn(&uuid); + } + }); + } if needs_reconnect { let line = gtk::Box::builder().spacing(12).build(); let note = gtk::Label::builder() @@ -910,9 +939,10 @@ impl AppWindow { reconnect.connect_clicked(move |_| { if let Some(this) = weak.upgrade() { this.vpn_command( + &uuid, vec!["sh", "-c", "nmcli connection down uuid \"$0\" && nmcli connection up uuid \"$0\"", &uuid], format!("{} {name}", tr("Reconnected")), - Some(uuid.clone()), + true, ); } }); @@ -923,7 +953,12 @@ impl AppWindow { 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_command(vec!["nmcli", "connection", verb, "uuid", &uuid], format!("{done} {name}"), None); + this.vpn_command( + &uuid, + vec!["nmcli", "connection", verb, "uuid", &uuid], + format!("{done} {name}"), + false, + ); } glib::Propagation::Stop }); @@ -940,6 +975,7 @@ impl AppWindow { this.vpn_pending.borrow_mut().insert(uuid.clone()); } this.vpn_command( + &uuid, vec![ "nmcli", "connection", @@ -952,7 +988,7 @@ impl AppWindow { never_default, ], done, - None, + false, ); } glib::Propagation::Stop @@ -960,8 +996,19 @@ impl AppWindow { card } + /// Opens NetworkManager's editor for one connection, where "Store the password for all users" lives. + fn edit_vpn(&self, uuid: &str) { + if let Err(e) = gio::Subprocess::newv( + &[std::ffi::OsStr::new("nm-connection-editor"), "--edit".as_ref(), uuid.as_ref()], + gio::SubprocessFlags::NONE, + ) { + chrome::show_toast(&self.toast, &format!("{} {}", tr("Couldn't open VPN settings:"), e.message())); + } + } + /// Runs an `nmcli` change (which may ask for a password through polkit), then refreshes. - fn vpn_command(self: &Rc, argv: Vec<&str>, done: String, clears_pending: Option) { + fn vpn_command(self: &Rc, uuid: &str, argv: Vec<&str>, done: String, clears_pending: bool) { + let uuid = uuid.to_string(); let argv: Vec = argv.into_iter().map(String::from).collect(); let this = self.clone(); glib::spawn_future_local(async move { @@ -969,11 +1016,18 @@ impl AppWindow { // Connecting an L2TP/IPsec VPN takes a while; a polkit prompt waits for the user. match cli::run_program(&refs, 90).await { Ok((true, _, _)) => { - if let Some(uuid) = clears_pending { + if clears_pending { this.vpn_pending.borrow_mut().remove(&uuid); } chrome::show_toast(&this.toast, &done); } + Ok((false, _, err)) if model::needs_secrets(&err) => { + chrome::show_toast( + &this.toast, + &tr("This VPN needs its passwords saved first. Opening its settings…"), + ); + this.edit_vpn(&uuid); + } 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: "));