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: "));