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
1 change: 1 addition & 0 deletions rust/mseries-tailscale/fixtures/unsaved-passwords.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
4a1d6d4d-c5e4
7 changes: 7 additions & 0 deletions rust/mseries-tailscale/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())),
};
}
Expand Down
30 changes: 29 additions & 1 deletion rust/mseries-tailscale/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<u32>().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 `\\`.
Expand All @@ -342,7 +359,13 @@ 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 })
.map(|f| Vpn {
name: f[0].clone(),
uuid: f[1].clone(),
active: f[3] == "yes",
all_traffic: false,
secrets_saved: true,
})
.collect()
}

Expand All @@ -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"));
}
}
64 changes: 59 additions & 5 deletions rust/mseries-tailscale/src/ui/window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:?}");
Expand Down Expand Up @@ -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(&note);
line.append(&save);
card.append(&gtk::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()
Expand All @@ -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,
);
}
});
Expand All @@ -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
});
Expand All @@ -940,6 +975,7 @@ impl AppWindow {
this.vpn_pending.borrow_mut().insert(uuid.clone());
}
this.vpn_command(
&uuid,
vec![
"nmcli",
"connection",
Expand All @@ -952,28 +988,46 @@ impl AppWindow {
never_default,
],
done,
None,
false,
);
}
glib::Propagation::Stop
});
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<Self>, argv: Vec<&str>, done: String, clears_pending: Option<String>) {
fn vpn_command(self: &Rc<Self>, uuid: &str, argv: Vec<&str>, done: String, clears_pending: bool) {
let uuid = uuid.to_string();
let argv: Vec<String> = argv.into_iter().map(String::from).collect();
let this = self.clone();
glib::spawn_future_local(async move {
let refs: Vec<&str> = argv.iter().map(String::as_str).collect();
// 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: "));
Expand Down