From 02e96126e0ef78399e4431f633a80048f4debb18 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 23 Aug 2026 14:44:23 +0800 Subject: [PATCH 1/5] Feed Wi-Fi PSK to nmcli --ask on stdin, never argv First connect (and reuse-with-password) no longer puts the secret on nmcli's command line, so it is not visible in /proc//cmdline. networks.toml stays 0600; the local copy is still cleared after first success. --- README.md | 2 +- src/app.rs | 5 +- src/config.rs | 3 +- src/flow.rs | 20 +++--- src/nm.rs | 162 +++++++++++++++++++++++++++----------------- src/watch.rs | 9 +-- tests/cli.rs | 46 ++++++++++--- tests/flow_watch.rs | 96 +++++++++++++++++++++++--- 8 files changed, 246 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index c832f66..99e5065 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ breadcrumbs sits on top of NetworkManager (`nmcli`) and manages your Wi-Fi based - **Bootstrap + Tailscale gating** — connect to an interim network first, bring up Tailscale, then move to the target network - **Self-healing watch daemon** — monitors for drops, auto-recovers, reacts within seconds via `nmcli monitor` - **Auto-detection** — scans visible SSIDs and guesses your location from config-defined markers -- **Credential handling** — a saved network's password is only needed the *first* time breadcrumbs connects to it. Once that connect succeeds, NetworkManager durably owns the credential (a new connection profile, or an updated PSK on an existing one), so breadcrumbs clears its own local copy and stops writing it to disk. Both config files are `0600` (owner-only); saved networks live in a separate `networks.toml` from settings/profiles (see [Configuration](#configuration)). Note: on that first connect, the PSK is still passed to `nmcli` as a command argument, so it's briefly visible to other local users via `/proc//cmdline` for the lifetime of that `nmcli` child — a known limitation (see the note in `src/nm.rs`); a `nmcli --ask`/D-Bus secret-agent path that avoids argv exposure entirely is not yet wired up. In practice this window now only exists once per network, not on every connect. +- **Credential handling** — a saved network's password is only needed the *first* time breadcrumbs connects to it. Once that connect succeeds, NetworkManager durably owns the credential (a new connection profile, or an updated PSK on an existing one), so breadcrumbs clears its own local copy and stops writing it to disk. Both config files are `0600` (owner-only); saved networks live in a separate `networks.toml` from settings/profiles (see [Configuration](#configuration)). On that first connect the PSK is fed to `nmcli --ask` on stdin, never as a command argument, so it does not appear in `/proc//cmdline`. - **Desktop notifications** via `notify-send` (optional) - **systemd user service** generation via `breadcrumbs install-service` diff --git a/src/app.rs b/src/app.rs index 5fce6f6..fd58751 100644 --- a/src/app.rs +++ b/src/app.rs @@ -357,9 +357,8 @@ fn prompt_line(msg: &str) -> String { /// response) means "this network has no password" (open Wi-Fi) — normalize /// it to `None` right at the point of entry so it flows the same way a /// genuinely absent/cleared password does. Without this, `Some("")` would -/// make `nm::connect_verbose` send an empty PSK argument, which nmcli treats -/// as "secured with a blank password" rather than "open", and the connect -/// fails against a real open SSID. +/// make `nm::connect_verbose` treat it as a (blank) secret rather than an +/// open network, and the connect fails against a real open SSID. fn non_empty(s: String) -> Option { if s.is_empty() { None diff --git a/src/config.rs b/src/config.rs index 747ab1c..0ee601e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -70,7 +70,8 @@ pub struct NetworkDef { /// rather than writing a plaintext copy that's no longer needed. `None` /// means either "NetworkManager already owns this secret" or "this is /// an open (unsecured) network" — both cases behave the same way on - /// connect: no password argument is ever sent. + /// connect: no PSK is sent to nmcli at all. When `Some`, the PSK is + /// fed to `nmcli --ask` on stdin, never as an argv element. #[serde(default, skip_serializing_if = "Option::is_none")] pub password: Option, #[serde(default)] diff --git a/src/flow.rs b/src/flow.rs index 6a6bfcc..04e6f9e 100644 --- a/src/flow.rs +++ b/src/flow.rs @@ -54,12 +54,11 @@ fn resolve_candidates(cfg: &Config, p: &crate::config::Profile) -> Vec Outcome { log(&format!("bootstrap connected: {}", bdef.ssid)); clear_password_if_used(cfg, &bdef.ssid); } - Err(e) => log(&format!("bootstrap connect failed: {} — {e}", bdef.ssid)), + Err(e) => { + log(&format!("bootstrap connect failed: {} — {e}", bdef.ssid)) + } } } else { log(&format!("bootstrap not in range: {}", bdef.ssid)); @@ -248,7 +249,10 @@ pub fn run(cfg: &mut Config, profile_name: &str) -> Outcome { if !nm::device_connected(&iface) { // Owned clone (not a borrow of `cfg`) so a successful reconnect // is free to mutate `cfg` to clear the used password. - if let Some(bdef) = profile.bootstrap.as_deref().and_then(|s| cfg.network(s).cloned()) + if let Some(bdef) = profile + .bootstrap + .as_deref() + .and_then(|s| cfg.network(s).cloned()) { match connect_and_verify(&iface, &bdef, cfg) { Ok(()) => { diff --git a/src/nm.rs b/src/nm.rs index 1364251..4dabb0e 100644 --- a/src/nm.rs +++ b/src/nm.rs @@ -2,7 +2,7 @@ use std::collections::HashSet; use std::time::Duration; use crate::config::NetworkDef; -use crate::util::{run, run_ok}; +use crate::util::{run, run_ok, run_with_stdin}; /// nmcli `-t` escapes `:` and `\` in field values; undo that. fn unescape(s: &str) -> String { @@ -284,8 +284,8 @@ pub fn connect(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> bool { /// Connect to a network and pin DNS. Returns the nmcli error on failure. /// -/// Reuses an existing saved profile for the SSID when one exists (updating its -/// PSK) so that repeated connections do not accumulate numbered duplicates in +/// Reuses an existing saved profile for the SSID when one exists so that +/// repeated connections do not accumulate numbered duplicates in /// NetworkManager ("NCC", "NCC 1", "NCC 2", …). Falls back to /// `nmcli device wifi connect` — which creates a new profile — only when no /// saved profile is found. @@ -293,41 +293,18 @@ pub fn connect(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> bool { /// `net.password` is only sent when `Some`: on the reuse path, `None` means /// "leave the saved PSK alone" (either NetworkManager already durably owns /// it, or the network is open); on the create path it means "no password -/// argument at all", which is also how a genuinely open (no-security) SSID -/// is connected. See the field doc on [`NetworkDef::password`] for how a -/// local secret transitions to `None` after its first successful use. +/// at all", which is also how a genuinely open (no-security) SSID is +/// connected. See the field doc on [`NetworkDef::password`] for how a local +/// secret transitions to `None` after its first successful use. /// -/// KNOWN LIMITATION (credential exposure): when a password *is* sent, it's -/// passed to `nmcli` as a plain command-line argument -/// (`802-11-wireless-security.psk ` on the reuse path, `password ` -/// on the create path). For the lifetime of that `nmcli` child, the secret -/// is readable by other local users via `/proc//cmdline`. -/// `util::run_with_stdin` exists to feed secrets on stdin instead, but -/// wiring it up correctly needs either verified `nmcli --ask` piped-stdin -/// behavior or NetworkManager's D-Bus secret-agent API — neither of which -/// can be validated without a live NetworkManager connection — so this is -/// left as documented tech debt rather than a guess. In practice this -/// exposure window now only exists on a network's *first* connect: once -/// NetworkManager has the credential, breadcrumbs clears its local copy, so -/// there's nothing left to pass on argv for every subsequent connect. +/// When a password *is* sent it goes to `nmcli --ask` on stdin, never on +/// argv — `/proc//cmdline` is world-readable. After the first success +/// breadcrumbs clears its local copy, so subsequent connects pass nothing. pub fn connect_verbose(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> Result<(), String> { let wait_s = wait.to_string(); + let timeout = Duration::from_secs(wait as u64 + 15); if let Some(profile) = first_profile_for_ssid(&net.ssid) { - // Update the saved PSK and, for hidden networks, ensure the flag is set. - if let Some(pw) = &net.password { - let _ = run( - "nmcli", - &[ - "connection", - "modify", - &profile, - "802-11-wireless-security.psk", - pw.as_str(), - ], - Duration::from_secs(6), - ); - } if net.hidden { let _ = run( "nmcli", @@ -341,11 +318,52 @@ pub fn connect_verbose(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> R Duration::from_secs(6), ); } - let o = run( - "nmcli", - &["--wait", &wait_s, "connection", "up", &profile, "ifname", iface], - Duration::from_secs(wait as u64 + 15), - ); + let o = if let Some(pw) = &net.password { + // WHY: a stored PSK makes NM skip the secret agent, so --ask + // would never read stdin. Resetting the property (empty value, + // not a secret) forces a request; the new PSK arrives on stdin. + let _ = run( + "nmcli", + &[ + "connection", + "modify", + &profile, + "802-11-wireless-security.psk", + "", + ], + Duration::from_secs(6), + ); + let stdin = format!("{pw}\n"); + run_with_stdin( + "nmcli", + &[ + "--ask", + "--wait", + &wait_s, + "connection", + "up", + &profile, + "ifname", + iface, + ], + Some(&stdin), + timeout, + ) + } else { + run( + "nmcli", + &[ + "--wait", + &wait_s, + "connection", + "up", + &profile, + "ifname", + iface, + ], + timeout, + ) + }; if !o.success { let detail = o.stderr.trim().to_string(); return Err(if detail.is_empty() { @@ -362,29 +380,51 @@ pub fn connect_verbose(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> R // No saved profile — create one via device wifi connect. let hidden = if net.hidden { "yes" } else { "no" }; - let mut args: Vec<&str> = vec![ - "--wait", - &wait_s, - "device", - "wifi", - "connect", - net.ssid.as_str(), - ]; - // Only pass `password` when we actually have one. An empty/missing PSK - // argument makes nmcli treat the network as open (no security), which is - // what we want both for genuinely open SSIDs and for a network whose - // secret NetworkManager should already hold — though the latter case - // only succeeds if a saved profile in fact exists, which is why we only - // reach this branch (no saved profile found) when that assumption held. - if let Some(pw) = &net.password { - args.push("password"); - args.push(pw.as_str()); - } - args.push("hidden"); - args.push(hidden); - args.push("ifname"); - args.push(iface); - let o = run("nmcli", &args, Duration::from_secs(wait as u64 + 15)); + let o = if let Some(pw) = &net.password { + // WHY: never put the PSK on argv — /proc//cmdline is + // world-readable. `nmcli --ask` registers as a secret agent and + // nmc_readline reads the PSK from stdin (one line). Open networks + // stay on the no-ask path so we don't hang on a prompt. + let stdin = format!("{pw}\n"); + run_with_stdin( + "nmcli", + &[ + "--ask", + "--wait", + &wait_s, + "device", + "wifi", + "connect", + net.ssid.as_str(), + "hidden", + hidden, + "ifname", + iface, + ], + Some(&stdin), + timeout, + ) + } else { + // No local PSK: either the SSID is open, or NM should already hold + // the secret — the latter only succeeds if a saved profile exists, + // which is why we only reach this branch when that assumption held. + run( + "nmcli", + &[ + "--wait", + &wait_s, + "device", + "wifi", + "connect", + net.ssid.as_str(), + "hidden", + hidden, + "ifname", + iface, + ], + timeout, + ) + }; if !o.success { let detail = o.stderr.trim().to_string(); return Err(if detail.is_empty() { diff --git a/src/watch.rs b/src/watch.rs index 100dc56..8813245 100644 --- a/src/watch.rs +++ b/src/watch.rs @@ -113,9 +113,8 @@ fn spawn_nm_monitor(tx: mpsc::Sender<()>) { let mut last: Option = None; for line in reader.lines().map_while(Result::ok) { let l = line.to_lowercase(); - let interesting = l.contains("disconnect") - || l.contains("unavailable") - || l.contains("failed"); + let interesting = + l.contains("disconnect") || l.contains("unavailable") || l.contains("failed"); if interesting && debounce_ready(last, Duration::from_millis(1500)) { last = Some(Instant::now()); let _ = tx.send(()); @@ -281,7 +280,9 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { Urgency::Normal, ); } - let elapsed = last_flow_at.map(|t| t.elapsed().as_secs()).unwrap_or(u64::MAX); + let elapsed = last_flow_at + .map(|t| t.elapsed().as_secs()) + .unwrap_or(u64::MAX); if elapsed >= FLOW_COOLDOWN { log(&format!( "watch: down ({:?}) profile={profile} ssid={:?} — running flow", diff --git a/tests/cli.rs b/tests/cli.rs index ac0ae0b..7d48b1b 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -194,7 +194,9 @@ fn profile_list_marks_exactly_the_current_profile() { let out = stdout(&o); assert!(out.contains("* home"), "out: {out}"); assert_eq!( - out.lines().filter(|l| l.trim_start().starts_with('*')).count(), + out.lines() + .filter(|l| l.trim_start().starts_with('*')) + .count(), 1, "expected exactly one marked profile, got: {out}" ); @@ -262,7 +264,10 @@ fn forget_removes_network_from_config() { ); // ...and it should never have been in breadcrumbs.toml to begin with. let text = fs::read_to_string(sb.config_file()).unwrap(); - assert!(!text.contains("CafeWifi"), "network leaked into config: {text}"); + assert!( + !text.contains("CafeWifi"), + "network leaked into config: {text}" + ); } #[test] @@ -270,7 +275,11 @@ fn detect_without_wifi_adapter_errors() { let sb = Sandbox::new(); let o = sb.cmd(&["detect"]); assert!(!o.status.success()); - assert!(stderr(&o).contains("could not detect"), "stderr: {}", stderr(&o)); + assert!( + stderr(&o).contains("could not detect"), + "stderr: {}", + stderr(&o) + ); } #[test] @@ -364,7 +373,11 @@ fn networks_are_stored_separately_from_settings_and_profiles() { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - let mode = fs::metadata(sb.networks_file()).unwrap().permissions().mode() & 0o777; + let mode = fs::metadata(sb.networks_file()) + .unwrap() + .permissions() + .mode() + & 0o777; assert_eq!(mode, 0o600, "networks.toml should be owner-only"); } } @@ -374,8 +387,8 @@ fn add_with_empty_password_is_stored_as_no_password() { // An explicitly empty password (e.g. `add SSID ""`, or a blank response // at the interactive prompt) means "this is an open network" — it must // round-trip as an absent `password` key, the same as a cleared one, - // not as `password = ""` (which `nm::connect_verbose` would send as a - // literal empty PSK and fail against a real open SSID). + // not as `password = ""` (which `nm::connect_verbose` would treat as a + // blank secret rather than an open network). let sb = Sandbox::new(); let o = sb.cmd(&["add", "OpenCafe", ""]); assert!(o.status.success(), "stderr: {}", stderr(&o)); @@ -446,14 +459,19 @@ fn password_is_cleared_after_first_connect_and_never_sent_again() { let record = sb.root.join(".nmcli-calls"); // First connect: no saved NM profile yet, so breadcrumbs creates one via - // `device wifi connect ... password hunter2 ...`. + // `nmcli --ask device wifi connect ...` with the PSK on stdin, not argv. let first = sb.cmd(&["init"]); assert!(first.status.success(), "stderr: {}", stderr(&first)); let first_calls = fs::read_to_string(&record).unwrap_or_default(); assert!( - first_calls.contains("device wifi connect TestNet") && first_calls.contains("hunter2"), - "first connect should create a new NM profile with the password: {first_calls}" + first_calls.contains("device wifi connect TestNet") && first_calls.contains("--ask"), + "first connect should create a new NM profile via --ask: {first_calls}" ); + assert!( + !first_calls.contains("hunter2"), + "PSK must not appear on nmcli argv: {first_calls}" + ); + // stdin payload is asserted in-process via FakeRunner (see flow_watch). // The local copy is gone from disk immediately after. let networks = fs::read_to_string(sb.networks_file()).unwrap(); @@ -461,7 +479,10 @@ fn password_is_cleared_after_first_connect_and_never_sent_again() { !networks.contains("hunter2"), "password should have been cleared from networks.toml: {networks}" ); - assert!(networks.contains("TestNet"), "network entry itself should remain"); + assert!( + networks.contains("TestNet"), + "network entry itself should remain" + ); // Reset the recording so the second run's argv can be checked in isolation. fs::write(&record, "").unwrap(); @@ -524,7 +545,10 @@ fn doctor_reports_present_when_nmcli_and_tailscale_are_on_path() { let o = sb.cmd(&["doctor"]); assert!(o.status.success(), "stderr: {}", stderr(&o)); let out = stdout(&o); - assert!(out.contains("nmcli") && out.contains("present"), "out: {out}"); + assert!( + out.contains("nmcli") && out.contains("present"), + "out: {out}" + ); assert!(!out.contains("MISSING"), "out: {out}"); } diff --git a/tests/flow_watch.rs b/tests/flow_watch.rs index e455764..d03877d 100644 --- a/tests/flow_watch.rs +++ b/tests/flow_watch.rs @@ -14,6 +14,7 @@ use bread_utils::bread_client::BreadEvent; use breadcrumbs::bread_events; use breadcrumbs::config::{Config, NetworkDef, Profile, Settings}; use breadcrumbs::flow; +use breadcrumbs::nm; use breadcrumbs::state::{self, State}; use breadcrumbs::util::with_runner; use breadcrumbs::watch::{classify, Health}; @@ -83,10 +84,7 @@ fn flow_run_connects_to_first_visible_candidate_in_priority_order() { let _env = EnvSandbox::new(); let mut cfg = base_config(); - cfg.networks = vec![ - net("First", Some("pw1")), - net("Second", Some("pw2")), - ]; + cfg.networks = vec![net("First", Some("pw1")), net("Second", Some("pw2"))]; cfg.profiles.insert( "home".into(), Profile { @@ -112,10 +110,11 @@ fn flow_run_connects_to_first_visible_candidate_in_priority_order() { // Priority order actually mattered: "Second" was never dialed even // though it was visible and would have succeeded too. - let dialed_second = calls - .borrow() - .iter() - .any(|c| c.prog == "nmcli" && c.args.contains(&"connect".to_string()) && c.args.iter().any(|a| a == "Second")); + let dialed_second = calls.borrow().iter().any(|c| { + c.prog == "nmcli" + && c.args.contains(&"connect".to_string()) + && c.args.iter().any(|a| a == "Second") + }); assert!(!dialed_second, "connected to Second when First should win"); // The password used for the winning connect is now NM's problem, not @@ -573,3 +572,84 @@ fn handle_command_ignores_events_outside_its_own_command_namespace() { ))); assert_eq!(State::load("away").profile, "away"); } + +// --------------------------------------------------------------------- +// PSK never on argv (first connect feeds nmcli --ask on stdin) +// --------------------------------------------------------------------- + +fn assert_psk_not_on_argv(calls: &[common::RecordedCall], psk: &str) { + for c in calls { + if c.prog != "nmcli" { + continue; + } + assert!( + !c.args.iter().any(|a| a == psk), + "PSK leaked onto nmcli argv: {:?}", + c.args + ); + } +} + +#[test] +fn connect_verbose_create_feeds_psk_on_stdin_never_argv() { + let runner = FakeRunner::new() + .on_contains("nmcli", "NAME,TYPE", ok("")) + .on_contains("nmcli", "connect", ok("")) + .on_contains("nmcli", "GENERAL.CON-UUID", ok("uuid-1")) + .on_contains("nmcli", "ipv4.ignore-auto-dns", ok("")) + .on_contains("nmcli", "device reapply", ok("")); + let calls = runner.calls_handle(); + + let net = net("Cafe", Some("super-secret-psk")); + let result = with_runner(runner, || nm::connect_verbose("wlan0", &net, 8, "1.1.1.1")); + assert!(result.is_ok(), "{result:?}"); + + let calls = calls.borrow(); + assert_psk_not_on_argv(&calls, "super-secret-psk"); + let connect = calls + .iter() + .find(|c| c.prog == "nmcli" && c.args.iter().any(|a| a == "connect")) + .expect("expected device wifi connect"); + assert!( + connect.args.iter().any(|a| a == "--ask"), + "create path must use --ask: {:?}", + connect.args + ); + assert_eq!(connect.stdin.as_deref(), Some("super-secret-psk\n")); +} + +#[test] +fn connect_verbose_reuse_feeds_psk_on_stdin_never_argv() { + let runner = FakeRunner::new() + .on_contains("nmcli", "NAME,TYPE", ok("Cafe:802-11-wireless")) + .on_contains("nmcli", "connection modify", ok("")) + .on_contains("nmcli", "connection up", ok("")) + .on_contains("nmcli", "GENERAL.CON-UUID", ok("uuid-1")) + .on_contains("nmcli", "ipv4.ignore-auto-dns", ok("")) + .on_contains("nmcli", "device reapply", ok("")); + let calls = runner.calls_handle(); + + let net = net("Cafe", Some("super-secret-psk")); + let result = with_runner(runner, || nm::connect_verbose("wlan0", &net, 8, "1.1.1.1")); + assert!(result.is_ok(), "{result:?}"); + + let calls = calls.borrow(); + assert_psk_not_on_argv(&calls, "super-secret-psk"); + let up = calls + .iter() + .find(|c| c.prog == "nmcli" && c.args.iter().any(|a| a == "up")) + .expect("expected connection up"); + assert!( + up.args.iter().any(|a| a == "--ask"), + "reuse path must use --ask: {:?}", + up.args + ); + assert_eq!(up.stdin.as_deref(), Some("super-secret-psk\n")); + // Clearing the stored PSK uses an empty argv value, never the secret. + let cleared = calls.iter().any(|c| { + c.prog == "nmcli" + && c.args.iter().any(|a| a == "802-11-wireless-security.psk") + && c.args.last().is_some_and(|a| a.is_empty()) + }); + assert!(cleared, "reuse+password should reset stored PSK: {calls:?}"); +} From c02360a87331a7d045e7bd17d88249380765b055 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 26 Aug 2026 12:33:07 +0800 Subject: [PATCH 2/5] Fix bugs from a full audit: Tailscale recovery, SSID verification, captive portals, config races MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The watch loop could spin forever on a stopped Tailscale daemon (the auto-start path was unreachable) while re-notifying every retry, report "connected" when NM autoconnect won a race onto a different SSID, and classify captive portals as healthy (200/302 accepted as internet). The bread-bus subscription thread also read/wrote config and state files concurrently with the watch loop. Fix all of those plus: EDITOR values with arguments, scan --to silently ignoring unknown profiles, deleted core profiles being resurrected, inline-network migration data loss, login never retried, XDG-unaware install-service, detect persisting a stale default profile, password length leaking through the mask, a stdin/stdout pipe deadlock, and several minor UI/robustness issues. Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- src/app.rs | 115 +++++++++++++++++++++++-------------- src/bread_events.rs | 49 ++++++++++------ src/config.rs | 67 ++++++++++++++++++++-- src/flow.rs | 137 ++++++++++++++++++++++++++++++-------------- src/nm.rs | 39 ++++++++++--- src/status.rs | 13 ++++- src/tailscale.rs | 34 ++++++++++- src/util.rs | 17 +++--- src/watch.rs | 133 ++++++++++++++++++++++++++++++------------ tests/cli.rs | 7 ++- tests/common/mod.rs | 22 +++++++ tests/flow_watch.rs | 94 ++++++++++++++++++++++-------- 12 files changed, 538 insertions(+), 189 deletions(-) diff --git a/src/app.rs b/src/app.rs index fd58751..31c35df 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,6 +4,7 @@ //! consumers (including the integration tests under `tests/`). use std::io::{BufRead, Write}; +use std::path::PathBuf; use std::process::Command; use std::time::Duration; @@ -66,9 +67,11 @@ enum Cmd { ssid: String, /// Password (prompted if omitted) password: Option, - /// Network is hidden (does not broadcast its SSID) - #[arg(long)] - hidden: bool, + /// Network is hidden (does not broadcast its SSID). + /// `--hidden` sets it; `--hidden=false` clears it on an existing + /// entry; omitted leaves an existing entry's flag untouched. + #[arg(long, num_args = 0..=1, default_missing_value = "true")] + hidden: Option, /// Attach this SSID to a profile's priority list #[arg(long)] to: Option, @@ -321,8 +324,15 @@ fn detect_profile(cfg: &Config) -> Option { } } - // Fall back to the default profile if no markers matched. - Some(cfg.settings.default_profile.clone()) + // Fall back to the default profile if no markers matched — but only if + // it actually exists: a stale `default_profile` name is a config error, + // not a detection result, and persisting it would wedge the watcher in + // UnknownProfile forever. + if cfg.profiles.contains_key(&cfg.settings.default_profile) { + Some(cfg.settings.default_profile.clone()) + } else { + None + } } fn cmd_detect(cfg: &mut Config, apply: bool) -> Result { @@ -330,18 +340,19 @@ fn cmd_detect(cfg: &mut Config, apply: bool) -> Result { Some(p) => { println!("{p}"); if apply { - State { - profile: p.clone(), - updated: crate::util::timestamp(), - } - .save()?; + // Route through state::set_profile (like the CLI and the + // bread bus do) so an unknown fallback is rejected with a + // proper error instead of being persisted as active. + state::set_profile(cfg, &p)?; let outcome = flow::run(cfg, &p); print_outcome(&p, &outcome); return Ok(if outcome.ok() { 0 } else { 1 }); } Ok(0) } - None => Err("could not detect a profile (no Wi-Fi adapter?)".into()), + None => Err("could not detect a profile (no Wi-Fi adapter, or no \ + profile matches and the default is misconfigured)" + .into()), } } @@ -385,7 +396,7 @@ fn cmd_add( cfg: &mut Config, ssid: String, password: Option, - hidden: bool, + hidden: Option, to: Option, at: Option, ) -> Result { @@ -397,12 +408,17 @@ fn cmd_add( match cfg.networks.iter_mut().find(|n| n.ssid == ssid) { Some(n) => { n.password = password; - n.hidden = hidden || n.hidden; + // `--hidden` / `--hidden=false` set the flag explicitly; when + // the flag is omitted, leave an existing entry's hidden state + // alone (a password-only update must not un-hide a network). + if let Some(h) = hidden { + n.hidden = h; + } } None => cfg.networks.push(NetworkDef { ssid: ssid.clone(), password, - hidden, + hidden: hidden.unwrap_or(false), }), } if let Some(prof_name) = to { @@ -443,6 +459,14 @@ fn cmd_forget(cfg: &mut Config, ssid: &str) -> Result { } fn cmd_scan(cfg: &mut Config, to: Option) -> Result { + // Validate `--to` up front, before any side effects (connecting is + // one): `add --to` errors on an unknown profile, so `scan --to` must + // too instead of silently saving a network that never gets attached. + if let Some(prof_name) = &to { + if !cfg.profiles.contains_key(prof_name) { + return Err(format!("unknown profile '{prof_name}'")); + } + } let iface = nm::wifi_interface().ok_or("no Wi-Fi adapter")?; nm::radio_on(); nm::rescan(&iface, &[]); @@ -502,12 +526,13 @@ fn cmd_scan(cfg: &mut Config, to: Option) -> Result { Ok(0) } -/// Mask a secret for display. Operates on chars (not bytes) so multi-byte -/// UTF-8 passwords don't panic on a mid-character byte slice, and never -/// echoes back any real character of the secret (previously the first byte -/// was shown unmasked). -fn mask(p: &str) -> String { - "•".repeat(p.chars().count().max(2)) +/// Mask a secret for display. Always renders the same fixed-length +/// placeholder so the output reveals neither the secret's length nor any +/// character of it (a fixed placeholder is what password managers show; +/// length-hiding also means multi-byte UTF-8 passwords need no special +/// handling). +fn mask(_p: &str) -> String { + "•".repeat(8) } fn cmd_list(cfg: &Config, show_pw: bool) -> Result { @@ -567,7 +592,15 @@ fn cmd_list(cfg: &Config, show_pw: bool) -> Result { fn cmd_edit() -> Result { let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".into()); let path = config::config_path(); - let status = Command::new(&editor) + // EDITOR values routinely carry arguments ("code -w", "subl -w"), so + // split on whitespace: the first token is the program, the rest are its + // arguments. The path stays a separate argument — never interpolated + // into a shell string — so it can't be used for injection. + let mut parts = editor.split_whitespace(); + let prog = parts.next().unwrap_or("nano"); + let mut cmd = Command::new(prog); + cmd.args(parts); + let status = cmd .arg(&path) .status() .map_err(|e| format!("launching {editor}: {e}"))?; @@ -685,7 +718,13 @@ fn exec_replace(prog: &str, dir: &std::path::Path) -> String { } fn cmd_install_service(enable: bool) -> Result { - let unit_dir = home_dir().join(".config/systemd/user"); + // Honor XDG_CONFIG_HOME like the rest of the app: systemd --user units + // live in $XDG_CONFIG_HOME/systemd/user (default ~/.config/systemd/user). + let unit_dir = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home_dir().join(".config")) + .join("systemd") + .join("user"); std::fs::create_dir_all(&unit_dir) .map_err(|e| format!("creating {}: {e}", unit_dir.display()))?; let bin = std::env::current_exe().map_err(|e| format!("resolving current executable: {e}"))?; @@ -746,24 +785,15 @@ mod tests { use super::*; #[test] - fn mask_empty_password() { - // len() == 0 <= 2 branch: still at least 2 dots so an empty saved - // password doesn't visually collapse to nothing in `list`. - assert_eq!(mask(""), "••"); - } - - #[test] - fn mask_short_passwords_reveal_nothing() { - assert_eq!(mask("a"), "••"); - assert_eq!(mask("ab"), "••"); - } - - #[test] - fn mask_never_echoes_a_real_character() { - let pw = "hunter2"; - let masked = mask(pw); - assert_eq!(masked, "•".repeat(pw.len())); - assert!(!masked.contains('h'), "masked output leaked the first char"); + fn mask_is_fixed_length_regardless_of_secret() { + // Fixed-length masking: the output must reveal neither the secret's + // length nor any character of it — for empty, short, and long + // secrets alike. + assert_eq!(mask(""), "•".repeat(8)); + assert_eq!(mask("a"), "•".repeat(8)); + assert_eq!(mask("ab"), "•".repeat(8)); + assert_eq!(mask("hunter2"), "•".repeat(8)); + assert!(!mask("hunter2").contains('h')); } #[test] @@ -773,14 +803,13 @@ mod tests { // emoji or accented character), since byte index 1 can land mid-char. let pw = "日本語パスワード"; let masked = mask(pw); - assert_eq!(masked.chars().count(), pw.chars().count()); + assert_eq!(masked, "•".repeat(8)); assert!(masked.chars().all(|c| c == '•')); } #[test] fn mask_emoji_first_character_does_not_panic() { let pw = "🔒password123"; - let masked = mask(pw); - assert_eq!(masked.chars().count(), pw.chars().count()); + assert_eq!(mask(pw), "•".repeat(8)); } } diff --git a/src/bread_events.rs b/src/bread_events.rs index e99f603..bdac0d8 100644 --- a/src/bread_events.rs +++ b/src/bread_events.rs @@ -37,37 +37,54 @@ pub fn emit_health_changed(client: &BreadClient, profile: &str, health: &str, ss ); } +/// What a `bread.command.crumbs.*` event asks the watch loop to do. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CommandAction { + /// Persist this profile via [`state::set_profile`]. Applied on the + /// watch loop thread — the single owner of config/state file access — + /// never on the subscription thread, which would race the loop's own + /// `Config::load`/`save`. + SetProfile(String), + /// Nothing to do: unknown verb, or a validation failure already + /// reported via `bread.crumbs.set_profile.failed`. + Ignore, +} + /// Reacts to `bread.command.crumbs.*` verbs. Only `set_profile` maps to /// real, existing breadcrumbs functionality today — there is no pin/select /// (or other) verb because breadcrumbs has no such concept. Unrecognized /// verbs are ignored, not stubbed as no-ops that pretend to succeed. /// -/// Returns `true` when a profile was actually persisted, so the watch loop -/// can wake immediately and re-evaluate instead of waiting out the current -/// poll interval. -/// -/// Emits `bread.crumbs.set_profile.done`/`.failed` per the confirmation -/// convention in bread's Documentation.md. -pub fn handle_command(event: &BreadEvent) -> bool { +/// This only *parses and validates* the command — it performs no file I/O +/// (that would race the watch loop's own config access from a second +/// thread). The returned [`CommandAction`] is forwarded to the loop, which +/// applies it via [`apply_set_profile`]. +pub fn handle_command(event: &BreadEvent) -> CommandAction { let Some(verb) = event.event.strip_prefix("bread.command.crumbs.") else { - return false; + return CommandAction::Ignore; }; match verb { - "set_profile" => handle_set_profile(event), + "set_profile" => match event.data.get("profile").and_then(|v| v.as_str()) { + Some(name) if !name.trim().is_empty() => CommandAction::SetProfile(name.to_string()), + _ => { + emit_set_profile_failed("missing string \"profile\" in command data"); + CommandAction::Ignore + } + }, other => { crate::notify::log(&format!( "watch: ignoring unrecognized bread.command.crumbs.{other}" )); - false + CommandAction::Ignore } } } -fn handle_set_profile(event: &BreadEvent) -> bool { - let Some(name) = event.data.get("profile").and_then(|v| v.as_str()) else { - emit_set_profile_failed("missing string \"profile\" in command data"); - return false; - }; +/// Apply a `set_profile` command on the watch loop thread and emit the +/// `done`/`failed` confirmation. Kept separate from [`handle_command`] so +/// the bread subscription thread never touches config/state files +/// concurrently with the loop. +pub fn apply_set_profile(name: &str) { match Config::load().and_then(|cfg| state::set_profile(&cfg, name)) { Ok(()) => { crate::notify::log(&format!( @@ -77,11 +94,9 @@ fn handle_set_profile(event: &BreadEvent) -> bool { "bread.crumbs.set_profile.done", serde_json::json!({ "profile": name }), ); - true } Err(e) => { emit_set_profile_failed(&e); - false } } } diff --git a/src/config.rs b/src/config.rs index 0ee601e..6f77af5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -28,6 +28,10 @@ fn default_ping_host() -> String { "1.1.1.1".to_string() } +fn is_false(b: &bool) -> bool { + !b +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Settings { #[serde(default = "default_dns")] @@ -44,6 +48,14 @@ pub struct Settings { pub connectivity_url: String, #[serde(default = "default_ping_host")] pub ping_host: String, + /// Set the first time the config is saved. Core profiles (`home` / + /// `work` / `away`) are only backfilled for genuinely fresh or legacy + /// configs; once the user owns the file, a profile they deliberately + /// deleted stays deleted instead of being silently resurrected on the + /// next load. Omits itself from the TOML until set, so existing + /// configs keep parsing exactly as before. + #[serde(default, skip_serializing_if = "is_false")] + pub core_profiles_initialized: bool, } impl Default for Settings { @@ -56,6 +68,7 @@ impl Default for Settings { watch_interval: default_watch_interval(), connectivity_url: default_connectivity_url(), ping_host: default_ping_host(), + core_profiles_initialized: false, } } } @@ -175,7 +188,7 @@ impl Config { pub fn load() -> Result { let path = config_path(); if !path.exists() { - let cfg = build_initial_config(); + let mut cfg = build_initial_config(); cfg.save()?; return Ok(cfg); } @@ -192,14 +205,31 @@ impl Config { .map_err(|e| format!("reading {}: {e}", net_path.display()))?; let nf: NetworksFile = toml::from_str(&net_text) .map_err(|e| format!("parsing {}: {e}", net_path.display()))?; - cfg.networks = nf.networks; + // Merge, don't overwrite: a legacy config can still carry an + // inline `[[networks]]` block, and those entries must survive + // even when networks.toml already exists — otherwise the next + // save() (which writes only networks.toml) would silently drop + // hand-added inline networks. networks.toml wins on SSID + // conflicts; inline-only entries are appended and migrated. + let mut merged = nf.networks; + for def in std::mem::take(&mut cfg.networks) { + if !merged.iter().any(|n| n.ssid == def.ssid) { + merged.push(def); + } + } + cfg.networks = merged; } // else: no networks.toml yet — keep whatever legacy inline networks // were read from breadcrumbs.toml above (or none, on a genuinely // fresh config). The next `save()` writes them to networks.toml and // stops writing them into breadcrumbs.toml, completing the migration. - // Self-heal: guarantee the three core profiles always exist. + // Enforce the documented minimum so `list` and the watch loop agree + // on the poll interval (watch silently clamps to 4 otherwise). + if cfg.settings.watch_interval < 4 { + cfg.settings.watch_interval = 4; + } + ensure_core_profiles(&mut cfg); Ok(cfg) } @@ -209,7 +239,12 @@ impl Config { /// `forget`, `scan`, `profile set`, and `flow::run`'s own credential /// clearing) goes through this single method so the two files never /// drift out of sync with each other. - pub fn save(&self) -> Result<(), String> { + pub fn save(&mut self) -> Result<(), String> { + // The first save marks the config as user-owned: core profiles are + // backfilled only for genuinely fresh/legacy configs, never + // resurrected after the user has edited (or deleted) them. + self.settings.core_profiles_initialized = true; + let dir = config_dir(); fs::create_dir_all(&dir).map_err(|e| format!("creating {}: {e}", dir.display()))?; @@ -296,6 +331,13 @@ fn core_profiles() -> BTreeMap { } fn ensure_core_profiles(cfg: &mut Config) { + // Backfill missing core profiles only until the user has taken + // ownership of the config (`core_profiles_initialized` is set by the + // first save). After that, a profile the user deliberately deleted + // stays deleted. + if cfg.settings.core_profiles_initialized { + return; + } for (name, prof) in core_profiles() { cfg.profiles.entry(name).or_insert(prof); } @@ -350,6 +392,23 @@ mod tests { assert!(cfg.profile("away").is_some()); } + #[test] + fn ensure_core_profiles_skips_backfill_once_initialized() { + // After the first save the config is user-owned: a deliberately + // deleted core profile must stay deleted instead of being + // resurrected on every load. + let mut cfg = Config { + settings: Settings { + core_profiles_initialized: true, + ..Default::default() + }, + networks: vec![], + profiles: BTreeMap::new(), + }; + ensure_core_profiles(&mut cfg); + assert!(cfg.profiles.is_empty(), "no backfill once user-owned"); + } + #[test] fn ensure_core_profiles_preserves_user_customized_core_profile() { // A user-edited "home" (custom SSIDs) must not be clobbered by the diff --git a/src/flow.rs b/src/flow.rs index 04e6f9e..05566b8 100644 --- a/src/flow.rs +++ b/src/flow.rs @@ -1,3 +1,6 @@ +use std::thread; +use std::time::Duration; + use crate::config::{Config, NetworkDef}; use crate::nm; use crate::notify::{log, notify, Urgency}; @@ -74,14 +77,45 @@ fn clear_password_if_used(cfg: &mut Config, ssid: &str) { } } -/// Try to connect + confirm it actually carries traffic. -/// Returns Ok(()) on success, Err(reason) on failure. +/// Try to connect + confirm the device actually landed on the *requested* +/// SSID. Returns Ok(()) on success, Err(reason) on failure. fn connect_and_verify(iface: &str, def: &NetworkDef, cfg: &Config) -> Result<(), String> { nm::connect_verbose(iface, def, cfg.settings.nmcli_wait, &cfg.settings.dns)?; - if !nm::device_connected(iface) { - return Err("device not connected after nmcli success".into()); + // Confirm the SSID, not just "device connected": NM autoconnect can win + // a race and leave the device on a different network, and the wifi list + // can lag activation by a moment — so poll briefly before giving up. + for _ in 0..8 { + match nm::active_ssid(iface) { + // Explicitly on the requested network — success. + Some(active) if active == def.ssid => return Ok(()), + // Associated with a *different* network — the failure this + // check exists to catch. + Some(_) => break, + // Scan list stale right after activation — keep polling while + // the device is at least connected. + None => { + if !nm::device_connected(iface) { + break; + } + } + } + thread::sleep(Duration::from_millis(250)); } - Ok(()) + Err(format!("not associated with '{}' after connect", def.ssid)) +} + +/// Run the connection state machine for `profile_name`, with desktop +/// notifications enabled. See [`run_quiet`] for the daemon-facing variant. +pub fn run(cfg: &mut Config, profile_name: &str) -> Outcome { + run_inner(cfg, profile_name, true) +} + +/// Same state machine, but suppresses desktop notifications. Used by the +/// watch loop, which does its own transition-gated notifications — without +/// this, a persistent failure (e.g. a stopped Tailscale daemon) would +/// re-notify on every recovery retry instead of once per state change. +pub fn run_quiet(cfg: &mut Config, profile_name: &str) -> Outcome { + run_inner(cfg, profile_name, false) } /// Run the connection state machine for `profile_name`. @@ -91,15 +125,17 @@ fn connect_and_verify(iface: &str, def: &NetworkDef, cfg: &Config) -> Result<(), /// immediately (see [`clear_password_if_used`]) — this is the only way that /// clearing happens for the `init` / `profile set --apply` / `detect --apply` /// commands and the watch loop, all of which route through here. -pub fn run(cfg: &mut Config, profile_name: &str) -> Outcome { +fn run_inner(cfg: &mut Config, profile_name: &str, notify_user: bool) -> Outcome { let profile = match cfg.profile(profile_name) { Some(p) => p.clone(), None => { - notify( - "breadcrumbs: unknown profile", - &format!("'{profile_name}' is not defined in breadcrumbs.toml"), - Urgency::Critical, - ); + if notify_user { + notify( + "breadcrumbs: unknown profile", + &format!("'{profile_name}' is not defined in breadcrumbs.toml"), + Urgency::Critical, + ); + } return Outcome::UnknownProfile(profile_name.to_string()); } }; @@ -107,11 +143,13 @@ pub fn run(cfg: &mut Config, profile_name: &str) -> Outcome { let iface = match nm::wifi_interface() { Some(i) => i, None => { - notify( - "breadcrumbs: no Wi-Fi adapter", - "Hardware issue — Wi-Fi device not found. Manual check needed.", - Urgency::Critical, - ); + if notify_user { + notify( + "breadcrumbs: no Wi-Fi adapter", + "Hardware issue — Wi-Fi device not found. Manual check needed.", + Urgency::Critical, + ); + } return Outcome::NoInterface; } }; @@ -173,15 +211,17 @@ pub fn run(cfg: &mut Config, profile_name: &str) -> Outcome { let ts = tailscale::ensure_exit_node(&exit_node); if !ts.is_ok() { let ssid = nm::active_ssid(&iface).or_else(|| profile.bootstrap.clone()); - notify( - "Tailscale Error", - &format!( - "{} — staying on {}", - ts.describe(), - ssid.clone().unwrap_or_else(|| "Wi-Fi".into()) - ), - Urgency::Critical, - ); + if notify_user { + notify( + "Tailscale Error", + &format!( + "{} — staying on {}", + ts.describe(), + ssid.clone().unwrap_or_else(|| "Wi-Fi".into()) + ), + Urgency::Critical, + ); + } return Outcome::TailscaleError { ssid, health: ts }; } log(&format!("tailscale healthy via exit node {exit_node}")); @@ -205,7 +245,7 @@ pub fn run(cfg: &mut Config, profile_name: &str) -> Outcome { } else { Some("associated but no internet yet".to_string()) }; - finish_connected(&def.ssid, profile_name, ¬e); + finish_connected(&def.ssid, profile_name, ¬e, notify_user); return Outcome::Connected { ssid: def.ssid.clone(), note, @@ -227,7 +267,7 @@ pub fn run(cfg: &mut Config, profile_name: &str) -> Outcome { } else { Some("associated but no internet yet".to_string()) }; - finish_connected(&def.ssid, profile_name, ¬e); + finish_connected(&def.ssid, profile_name, ¬e, notify_user); return Outcome::Connected { ssid: def.ssid.clone(), note, @@ -272,7 +312,9 @@ pub fn run(cfg: &mut Config, profile_name: &str) -> Outcome { } else { format!("target network not in range — staying on {bs_ssid} (Tailscale OK)") }; - notify("breadcrumbs: using bootstrap", &reason, Urgency::Normal); + if notify_user { + notify("breadcrumbs: using bootstrap", &reason, Urgency::Normal); + } log(&format!("flow end: on bootstrap {bs_ssid}; {reason}")); return Outcome::Connected { ssid: bs_ssid, @@ -286,33 +328,40 @@ pub fn run(cfg: &mut Config, profile_name: &str) -> Outcome { .map(|c| c.ssid.as_str()) .collect::>() .join(", "); - notify( - "breadcrumbs: no known networks", - &format!("profile '{profile_name}': none of [{names}] are in range"), - Urgency::Critical, - ); + let msg = if candidates.is_empty() { + format!("profile '{profile_name}' has no networks configured") + } else { + format!("profile '{profile_name}': none of [{names}] are in range") + }; + if notify_user { + notify("breadcrumbs: no known networks", &msg, Urgency::Critical); + } log(&format!( "flow end: no networks connected (profile={profile_name})" )); Outcome::NoNetworks } -fn finish_connected(ssid: &str, profile: &str, note: &Option) { +fn finish_connected(ssid: &str, profile: &str, note: &Option, notify_user: bool) { match note { None => { - notify( - "breadcrumbs: connected", - &format!("{ssid} ({profile})"), - Urgency::Low, - ); + if notify_user { + notify( + "breadcrumbs: connected", + &format!("{ssid} ({profile})"), + Urgency::Low, + ); + } log(&format!("flow end: connected {ssid} (profile={profile})")); } Some(n) => { - notify( - "breadcrumbs: connected (degraded)", - &format!("{ssid} ({profile}) — {n}"), - Urgency::Normal, - ); + if notify_user { + notify( + "breadcrumbs: connected (degraded)", + &format!("{ssid} ({profile}) — {n}"), + Urgency::Normal, + ); + } log(&format!( "flow end: connected {ssid} (profile={profile}) note={n}" )); diff --git a/src/nm.rs b/src/nm.rs index 4dabb0e..e395806 100644 --- a/src/nm.rs +++ b/src/nm.rs @@ -114,6 +114,15 @@ pub struct ScanEntry { pub security: String, } +/// Parse an nmcli SIGNAL value ("72" or "72 %") into a comparable number. +fn signal_strength(s: &str) -> i32 { + s.trim() + .trim_end_matches('%') + .trim() + .parse::() + .unwrap_or(-100) +} + pub fn scan_list(iface: &str) -> Vec { let o = run( "nmcli", @@ -129,8 +138,7 @@ pub fn scan_list(iface: &str) -> Vec { ], Duration::from_secs(12), ); - let mut seen = HashSet::new(); - let mut out = Vec::new(); + let mut out: Vec = Vec::new(); if !o.success { return out; } @@ -140,14 +148,29 @@ pub fn scan_list(iface: &str) -> Vec { continue; } let ssid = fields[0].trim().to_string(); - if ssid.is_empty() || !seen.insert(ssid.clone()) { + if ssid.is_empty() { + // Hidden networks have no SSID in the scan; they're not + // selectable here anyway (see `cmd_scan`), so skip them. continue; } - out.push(ScanEntry { - ssid, - signal: fields.get(1).cloned().unwrap_or_default(), - security: fields.get(2).cloned().unwrap_or_default(), - }); + let signal = fields.get(1).cloned().unwrap_or_default(); + let security = fields.get(2).cloned().unwrap_or_default(); + // One line per BSSID: dedup by SSID keeping the *strongest* signal, + // so a network broadcast by several APs shows once (at its best + // signal) instead of N times at the first listing. + match out.iter_mut().find(|e| e.ssid == ssid) { + Some(existing) => { + if signal_strength(&signal) > signal_strength(&existing.signal) { + existing.signal = signal; + existing.security = security; + } + } + None => out.push(ScanEntry { + ssid, + signal, + security, + }), + } } out } diff --git a/src/status.rs b/src/status.rs index b06dea9..0412125 100644 --- a/src/status.rs +++ b/src/status.rs @@ -21,8 +21,12 @@ pub fn internet_ok(cfg: &Config) -> bool { ], Duration::from_secs(6), ); - let code = o.stdout.trim(); - if code == "204" || code == "200" || code == "301" || code == "302" { + // Only a 204 counts as real internet. Captive/guest portals answer + // 200 (a login page) or 302 (a redirect to it) — accepting those + // would classify a portal-trapped device as "Up". The default + // endpoint is generate_204, which returns 204 precisely when + // traffic isn't being intercepted. + if o.stdout.trim() == "204" { return true; } } @@ -66,7 +70,10 @@ pub fn gather(cfg: &Config, profile_name: &str) -> Status { let iface = nm::wifi_interface(); let ssid = iface.as_deref().and_then(nm::active_ssid); let ip = iface.as_deref().and_then(ipv4); - let internet = internet_ok(cfg); + // Skip the (potentially 4s-blocking) connectivity probe when there's no + // Wi-Fi interface at all: the watch loop classifies NoAdapter and would + // otherwise burn a network round-trip (curl/ping) every tick for nothing. + let internet = iface.is_some() && internet_ok(cfg); let prof = cfg.profile(profile_name); let ts_required = prof.map(|p| p.tailscale).unwrap_or(false); diff --git a/src/tailscale.rs b/src/tailscale.rs index 50b7f11..a512728 100644 --- a/src/tailscale.rs +++ b/src/tailscale.rs @@ -21,6 +21,10 @@ pub enum TsHealth { ExitNodeMissing, /// The exit node exists but is offline. ExitNodeOffline, + /// The profile requires an exit node but none is configured + /// (`settings.exit_node` / per-profile `exit_node` empty). Cannot be + /// auto-fixed — the user must configure one. + NoExitNode, Error(String), } @@ -37,6 +41,7 @@ impl TsHealth { TsHealth::Stopped => "backend stopped".into(), TsHealth::ExitNodeMissing => "exit node not found in tailnet".into(), TsHealth::ExitNodeOffline => "exit node is offline".into(), + TsHealth::NoExitNode => "no exit node configured".into(), TsHealth::Error(e) => format!("error: {e}"), } } @@ -225,10 +230,30 @@ pub fn ensure_exit_node(node: &str) -> TsHealth { if !installed() { return TsHealth::NotInstalled; } + if node.trim().is_empty() { + // Never run `tailscale set --exit-node=` with an empty node — that + // would clear the user's current exit-node selection. This is a + // config error, surfaced as its own health state. + return TsHealth::NoExitNode; + } let v = match status_json() { Some(v) => v, - None => return TsHealth::Error("could not read tailscale status".into()), + None => { + // Daemon unreachable — usually *not running*: a stopped daemon + // prints its error to stderr and leaves stdout empty, which is + // exactly why this branch used to be dead code (the + // BackendState "Stopped" case below only fires when the daemon + // is up but the backend is stopped). Try to bring it up before + // giving up; `tailscale up` is idempotent when already running + // and fails fast (no sudo prompt — stdin is /dev/null) when + // the caller lacks permission to manage the daemon. + let _ = run("tailscale", &["up"], Duration::from_secs(20)); + match status_json() { + Some(v2) => v2, + None => return TsHealth::Error("could not read tailscale status".into()), + } + } }; match backend_state(&v).as_str() { @@ -286,6 +311,11 @@ pub fn check(node: &str) -> TsHealth { if !installed() { return TsHealth::NotInstalled; } + if node.trim().is_empty() { + // Read-only check, so never runs `tailscale set` — an empty node is + // a config error, not something this probe can fix. + return TsHealth::NoExitNode; + } let v = match status_json() { Some(v) => v, None => return TsHealth::Error("status unavailable".into()), @@ -433,6 +463,7 @@ mod tests { assert!(!TsHealth::Stopped.is_ok()); assert!(!TsHealth::ExitNodeMissing.is_ok()); assert!(!TsHealth::ExitNodeOffline.is_ok()); + assert!(!TsHealth::NoExitNode.is_ok()); assert!(!TsHealth::Error("x".into()).is_ok()); } @@ -443,6 +474,7 @@ mod tests { TsHealth::NeedsLogin.describe(), "not logged in (run: tailscale up)" ); + assert_eq!(TsHealth::NoExitNode.describe(), "no exit node configured"); assert_eq!(TsHealth::Error("boom".into()).describe(), "error: boom"); } diff --git a/src/util.rs b/src/util.rs index f697f8f..de8eed3 100644 --- a/src/util.rs +++ b/src/util.rs @@ -139,13 +139,6 @@ fn spawn_run(prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration) Err(_) => return Output::failed(), }; - if let Some(data) = stdin { - if let Some(mut sink) = child.stdin.take() { - let _ = sink.write_all(data.as_bytes()); - // Drop closes the pipe so the child's read sees EOF. - } - } - let mut stdout_pipe = child.stdout.take(); let mut stderr_pipe = child.stderr.take(); @@ -164,6 +157,16 @@ fn spawn_run(prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration) buf }); + // Feed stdin only now that the reader threads are draining stdout and + // stderr: a chatty child could otherwise fill its stdout pipe while we + // block writing stdin, deadlocking both sides. + if let Some(data) = stdin { + if let Some(mut sink) = child.stdin.take() { + let _ = sink.write_all(data.as_bytes()); + // Drop closes the pipe so the child's read sees EOF. + } + } + let start = Instant::now(); let status = loop { match child.try_wait() { diff --git a/src/watch.rs b/src/watch.rs index 8813245..2ff8881 100644 --- a/src/watch.rs +++ b/src/watch.rs @@ -64,9 +64,11 @@ pub fn classify(cfg: &Config, profile: &str) -> (Health, Option) { if s.tailscale_required { match s.tailscale { Some(TsHealth::Ok) => (Health::Up, ssid), - Some(TsHealth::NeedsLogin) | Some(TsHealth::NotInstalled) => { - (Health::DownTailscaleManual, ssid) - } + // NeedsLogin / NotInstalled / NoExitNode all need human action: + // a missing exit-node config can't be auto-fixed either. + Some(TsHealth::NeedsLogin) + | Some(TsHealth::NotInstalled) + | Some(TsHealth::NoExitNode) => (Health::DownTailscaleManual, ssid), Some(_) => (Health::DownTailscaleOther, ssid), None => (Health::DownTailscaleManual, ssid), } @@ -84,9 +86,18 @@ fn debounce_ready(last: Option, gap: Duration) -> bool { last.map(|t| t.elapsed() > gap).unwrap_or(true) } +/// A wake signal for the watch loop. `SetProfile` is an *action* (applied on +/// the loop thread), `LinkChurn` is just "go look" — the distinction keeps +/// every config/state file access on the single loop thread, so the bread +/// subscription thread can never race the loop's own `Config::load`/`save`. +enum Wake { + LinkChurn, + SetProfile(String), +} + /// Tail `nmcli monitor` and ping the channel on link-state churn so we react /// to drops within a second instead of waiting out the poll interval. -fn spawn_nm_monitor(tx: mpsc::Sender<()>) { +fn spawn_nm_monitor(tx: mpsc::Sender) { thread::spawn(move || loop { let child = Command::new("nmcli") .arg("monitor") @@ -113,11 +124,18 @@ fn spawn_nm_monitor(tx: mpsc::Sender<()>) { let mut last: Option = None; for line in reader.lines().map_while(Result::ok) { let l = line.to_lowercase(); - let interesting = - l.contains("disconnect") || l.contains("unavailable") || l.contains("failed"); + // `connectivity` lines catch drops that keep the device + // "connected" but lose the internet (captive portal, DHCP + // failure); `deactivating` covers teardown. Everything else + // waits out the poll interval. + let interesting = l.contains("disconnect") + || l.contains("unavailable") + || l.contains("failed") + || l.contains("deactivating") + || l.contains("connectivity"); if interesting && debounce_ready(last, Duration::from_millis(1500)) { last = Some(Instant::now()); - let _ = tx.send(()); + let _ = tx.send(Wake::LinkChurn); } } } @@ -127,17 +145,32 @@ fn spawn_nm_monitor(tx: mpsc::Sender<()>) { }); } -/// Sleep up to `dur`, but wake early if `nmcli monitor` signals link churn. -fn wait_for_tick(rx: &Receiver<()>, dur: Duration) { +/// Sleep up to `dur`, but wake early if `nmcli monitor` signals link churn or +/// a `set_profile` command arrives. Returns the pending action, if any. +fn wait_for_tick(rx: &Receiver, dur: Duration) -> Option { match rx.recv_timeout(dur) { - Ok(()) => { - // Drain any burst of events so we don't re-fire immediately. - while rx.try_recv().is_ok() {} + Ok(first) => { + // Drain any burst of churn signals so we don't re-fire + // immediately, but never drop a queued set_profile — it's an + // action, not a signal, and the earliest one wins. + let mut pending = match &first { + Wake::SetProfile(_) => Some(first), + Wake::LinkChurn => None, + }; + while let Ok(w) = rx.try_recv() { + if pending.is_none() && matches!(&w, Wake::SetProfile(_)) { + pending = Some(w); + } + } + pending } - Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Timeout) => None, // Monitor thread gone (shouldn't happen: we hold the sender) — fall // back to a plain sleep so we don't busy-spin. - Err(mpsc::RecvTimeoutError::Disconnected) => thread::sleep(dur), + Err(mpsc::RecvTimeoutError::Disconnected) => { + thread::sleep(dur); + None + } } } @@ -150,21 +183,25 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { ); log("watch: started"); - let (tx, rx) = mpsc::channel::<()>(); + let (tx, rx) = mpsc::channel::(); spawn_nm_monitor(tx.clone()); // Long-lived, so this uses BreadClient::subscribe (a persistent // background thread with its own reconnect/backoff loop). breadd being // absent or restarting is transparent: the subscription just quietly - // stops delivering commands until it reconnects. A successful - // `set_profile` wakes this loop the same way `nmcli monitor` does, so - // the new profile is applied on the next tick instead of waiting out - // the current poll interval. + // stops delivering commands until it reconnects. The callback only + // *validates* the command and forwards an action through the channel — + // it never touches config/state files itself (that would race this + // loop's own Config::load/save), so all file access stays on this one + // thread. let bread = BreadClient::connect(bread_events::APP_ID); let wake = tx; let _commands = bread.subscribe("bread.command.crumbs.**", move |event| { - if bread_events::handle_command(&event) { - let _ = wake.send(()); + match bread_events::handle_command(&event) { + bread_events::CommandAction::SetProfile(name) => { + let _ = wake.send(Wake::SetProfile(name)); + } + bread_events::CommandAction::Ignore => {} } }); @@ -178,7 +215,7 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { )); } else { log(&format!("watch: initial flow for profile={profile}")); - let _ = flow::run(&mut cfg, &profile); + let _ = flow::run_quiet(&mut cfg, &profile); } } @@ -193,9 +230,10 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { // This always runs *before* `flow::run` below (never after, within // the same tick), so a password `flow::run` clears-and-saves this // iteration is durably on disk by the time the *next* iteration's - // reload runs — there's no window where a stale (still-has-password) - // reload could clobber the save, since the two never race on - // different threads: everything here is sequential on this loop. + // reload runs. All config/state file access happens on this loop + // thread: `set_profile` commands from the bread bus are queued as + // [`Wake::SetProfile`] and applied here (see the bottom of the + // loop), never on the subscription thread. if let Ok(fresh) = Config::load() { cfg = fresh; } @@ -248,29 +286,43 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { fail_streak = fail_streak.saturating_add(1); } Health::UnknownProfile => { - // flow::run() already notifies + logs the "unknown profile" - // critical error; re-running it here would just spam that on - // every tick, so only surface it once per transition. + // flow::run() is quiet from here, so surface the misconfig + // ourselves — once per transition/change, not every tick. if transition || profile_changed { - let _ = flow::run(&mut cfg, &profile); + notify( + "breadcrumbs: unknown profile", + &format!("'{profile}' is not defined in breadcrumbs.toml"), + Urgency::Critical, + ); } fail_streak = fail_streak.saturating_add(1); } Health::DownTailscaleManual => { - // Can't be auto-fixed (login / not installed). Notify once. + // Can't be auto-fixed (login / install / exit-node config). + // Notify once per transition. if transition { notify( "Tailscale Error", - "Tailscale needs manual attention (login / install). \ - Other Wi-Fi automation paused until resolved.", + "Tailscale needs manual attention (login / install / \ + exit node config). Other Wi-Fi automation paused \ + until resolved.", Urgency::Critical, ); } - // Re-run flow only on transition so we land on the bootstrap net. - if transition || profile_changed { - let _ = flow::run(&mut cfg, &profile); + // Re-attempt periodically and on the transition into this + // state: login may have completed since the last attempt, or + // the user may have missed the browser window. Quiet — a + // still-broken state must not re-notify on every retry. + let elapsed = last_flow_at.map(|t| t.elapsed().as_secs()).unwrap_or(u64::MAX); + if elapsed >= FLOW_COOLDOWN { + let outcome = flow::run_quiet(&mut cfg, &profile); + last_flow_at = Some(Instant::now()); + fail_streak = if outcome.ok() { + 0 + } else { + fail_streak.saturating_add(1) + }; } - fail_streak = fail_streak.saturating_add(1); } Health::DownNoNet | Health::DownTailscaleOther => { if transition { @@ -288,7 +340,7 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { "watch: down ({:?}) profile={profile} ssid={:?} — running flow", health, ssid )); - let outcome = flow::run(&mut cfg, &profile); + let outcome = flow::run_quiet(&mut cfg, &profile); log(&format!("watch: recovery outcome = {:?}", outcome)); last_flow_at = Some(Instant::now()); fail_streak = if outcome.ok() { @@ -310,7 +362,12 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { // Adaptive backoff: healthy -> base; failing -> grow up to ~6x. let mult = 1 + fail_streak.min(5); let dur = Duration::from_secs(base * mult as u64); - wait_for_tick(&rx, dur); + // Apply a queued set_profile on this thread — the single owner of + // config/state file access — and emit the confirmation. The next + // iteration's reload sees the new profile and recovers accordingly. + if let Some(Wake::SetProfile(name)) = wait_for_tick(&rx, dur) { + bread_events::apply_set_profile(&name); + } } } diff --git a/tests/cli.rs b/tests/cli.rs index 7d48b1b..87c7e2d 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -317,7 +317,10 @@ fn install_service_no_enable_writes_valid_unit_file() { let o = sb.cmd(&["install-service", "--no-enable"]); assert!(o.status.success(), "stderr: {}", stderr(&o)); - let unit_path = sb.root.join(".config/systemd/user/breadcrumbs.service"); + // The sandbox sets XDG_CONFIG_HOME=$root/config, so the unit lands + // under $XDG_CONFIG_HOME/systemd/user — the whole point of the fix is + // honoring XDG rather than hardcoding ~/.config. + let unit_path = sb.root.join("config/systemd/user/breadcrumbs.service"); assert!(unit_path.exists()); let text = fs::read_to_string(unit_path).unwrap(); assert!(text.contains("ExecStart=")); @@ -423,6 +426,8 @@ case "$args" in "device wifi rescan"*) ;; "-t -f SSID device wifi list ifname wlan0") echo "TestNet" ;; + "-t -f ACTIVE,SSID device wifi list ifname wlan0") + echo "yes:TestNet" ;; "-t -f NAME,TYPE connection show") if [ -f "$marker" ]; then echo "TestNet:802-11-wireless" diff --git a/tests/common/mod.rs b/tests/common/mod.rs index dca7888..67f5f2c 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -50,6 +50,7 @@ impl RecordedCall { } type Matcher = Box bool>; +type DynamicRule = (Matcher, Box Output>); /// A canned, rule-based [`Runner`]. Rules are tried in registration order; /// the first whose matcher returns `true` supplies the response. No rule @@ -58,6 +59,7 @@ type Matcher = Box bool>; /// (a wrong exit code) rather than silently returning success. pub struct FakeRunner { rules: Vec<(Matcher, Output)>, + dynamic_rules: Vec, commands: HashSet, calls: Rc>>, } @@ -66,6 +68,7 @@ impl FakeRunner { pub fn new() -> Self { FakeRunner { rules: Vec::new(), + dynamic_rules: Vec::new(), commands: HashSet::new(), calls: Rc::new(RefCell::new(Vec::new())), } @@ -99,6 +102,20 @@ impl FakeRunner { output, ) } + + /// Register a rule whose *response* is computed at call time (rather + /// than canned), enabling stateful fakes — e.g. answering "which SSID is + /// active?" with the SSID of the most recently dialed connection. + /// Dynamic rules are tried after the static ones. + pub fn on_dynamic( + mut self, + matcher: impl Fn(&str, &[&str]) -> bool + 'static, + out: impl Fn(&str, &[&str]) -> Output + 'static, + ) -> Self { + self.dynamic_rules + .push((Box::new(matcher), Box::new(out))); + self + } } impl Default for FakeRunner { @@ -119,6 +136,11 @@ impl Runner for FakeRunner { return out.clone(); } } + for (matcher, out) in &self.dynamic_rules { + if matcher(prog, args) { + return out(prog, args); + } + } Output::failed() } diff --git a/tests/flow_watch.rs b/tests/flow_watch.rs index d03877d..3fae31f 100644 --- a/tests/flow_watch.rs +++ b/tests/flow_watch.rs @@ -52,16 +52,53 @@ fn base_config() -> Config { /// the device reports connected after any successful connect attempt. fn base_nm(visible_ssids: &[&str]) -> FakeRunner { let visible = visible_ssids.join("\n"); - FakeRunner::new() + let runner = FakeRunner::new() .on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi")) .on_contains("nmcli", "radio wifi on", ok("")) .on_contains("nmcli", "wifi rescan", ok("")) - .on_contains("nmcli", "-f SSID device wifi list", ok(&visible)) + // Exact match: `-f ACTIVE,SSID` queries (which contain the substring + // "SSID device wifi list") must NOT be answered with the visible + // list — they go to the stateful rule below. + .on( + move |_prog, args| args.join(" ") == "-t -f SSID device wifi list ifname wlan0", + ok(&visible), + ) .on_contains("nmcli", "NAME,TYPE", ok("")) // no saved profiles .on_contains("nmcli", "GENERAL.CON-UUID", ok("uuid-1")) .on_contains("nmcli", "ipv4.ignore-auto-dns", ok("")) .on_contains("nmcli", "device reapply", ok("")) - .on_contains("nmcli", "DEVICE,STATE", ok("wlan0:connected")) + .on_contains("nmcli", "DEVICE,STATE", ok("wlan0:connected")); + let calls = runner.calls_handle(); + runner.on_dynamic( + move |_prog, args| args.join(" ") == "-t -f ACTIVE,SSID device wifi list ifname wlan0", + move |_prog, _args| { + // Stateful: answer with the SSID of the most recently dialed + // connection, so connect_and_verify's post-connect SSID check + // sees the network that was just activated (bootstrap first, + // then the target). + let rec = calls.borrow(); + let ssid = rec.iter().rev().find_map(|call| { + let j = call.args.join(" "); + if j.contains("connect") { + call.args + .iter() + .position(|a| a == "connect") + .map(|i| call.args[i + 1].clone()) + } else if j.contains("connection up") { + call.args + .iter() + .position(|a| a == "up") + .map(|i| call.args[i + 1].clone()) + } else { + None + } + }); + match ssid { + Some(s) => ok(&format!("yes:{s}")), + None => ok(""), + } + }, + ) } /// A successful `device wifi connect ...` for every ssid in `ssids`. @@ -494,12 +531,17 @@ fn set_profile_command_persists_even_with_no_daemon_reachable() { state::set_profile(&cfg, "away").unwrap(); assert_eq!(State::load("away").profile, "away"); - let acted = bread_events::handle_command(&command_event( + // handle_command only parses/validates (no file I/O on the subscription + // thread); the loop thread then applies the action. + let action = bread_events::handle_command(&command_event( "bread.command.crumbs.set_profile", serde_json::json!({ "profile": "home" }), )); - - assert!(acted, "known profile must persist"); + assert!( + matches!(action, bread_events::CommandAction::SetProfile(n) if n == "home"), + "a known profile must yield a SetProfile action" + ); + bread_events::apply_set_profile("home"); assert_eq!(State::load("away").profile, "home"); } @@ -509,12 +551,14 @@ fn set_profile_command_rejects_unknown_profile() { let cfg = Config::load().expect("fresh config"); state::set_profile(&cfg, "away").unwrap(); - let acted = bread_events::handle_command(&command_event( + let action = bread_events::handle_command(&command_event( "bread.command.crumbs.set_profile", serde_json::json!({ "profile": "bogus" }), )); - - assert!(!acted); + assert!(matches!(action, bread_events::CommandAction::SetProfile(n) if n == "bogus")); + // The rejection happens when the loop thread applies it: state is + // untouched and the failure event is emitted (a no-op without breadd). + bread_events::apply_set_profile("bogus"); assert_eq!( State::load("away").profile, "away", @@ -528,12 +572,11 @@ fn set_profile_command_rejects_missing_profile_field() { let cfg = Config::load().expect("fresh config"); state::set_profile(&cfg, "away").unwrap(); - let acted = bread_events::handle_command(&command_event( + let action = bread_events::handle_command(&command_event( "bread.command.crumbs.set_profile", serde_json::json!({}), )); - - assert!(!acted); + assert!(matches!(action, bread_events::CommandAction::Ignore)); assert_eq!(State::load("away").profile, "away"); } @@ -543,12 +586,11 @@ fn handle_command_ignores_unrecognized_verb() { let cfg = Config::load().expect("fresh config"); state::set_profile(&cfg, "away").unwrap(); - let acted = bread_events::handle_command(&command_event( + let action = bread_events::handle_command(&command_event( "bread.command.crumbs.pin", serde_json::json!({}), )); - - assert!(!acted); + assert!(matches!(action, bread_events::CommandAction::Ignore)); assert_eq!( State::load("away").profile, "away", @@ -562,14 +604,20 @@ fn handle_command_ignores_events_outside_its_own_command_namespace() { let cfg = Config::load().expect("fresh config"); state::set_profile(&cfg, "away").unwrap(); - assert!(!bread_events::handle_command(&command_event( - "bread.command.clip.clear", - serde_json::json!({}), - ))); - assert!(!bread_events::handle_command(&command_event( - "bread.crumbs.profile.changed", - serde_json::json!({ "from": "away", "to": "home" }), - ))); + assert!(matches!( + bread_events::handle_command(&command_event( + "bread.command.clip.clear", + serde_json::json!({}), + )), + bread_events::CommandAction::Ignore + )); + assert!(matches!( + bread_events::handle_command(&command_event( + "bread.crumbs.profile.changed", + serde_json::json!({ "from": "away", "to": "home" }), + )), + bread_events::CommandAction::Ignore + )); assert_eq!(State::load("away").profile, "away"); } From 13c7743d4846a3c3868e2b4ef0e6921ab11a0d25 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 26 Aug 2026 19:30:41 +0800 Subject: [PATCH 3/5] Add feature batch: captive-portal detection, schedules, exit-node failover, 802.1x, per-network DNS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the planned feature sweep: tri-state connectivity with portal detection, time-based profile schedules, priority exit-node list with failover, enterprise (802.1x) network support, per-network DNS, signal- aware selection, auto-learn markers, suspend/resume recovery, prune command, scored detection, and richer bread events (network.changed, tailscale.changed). CLI gains --json output, init --wait, and add --dns/ --eap/--identity/--ca-cert. Adds regression coverage for each feature; 141 tests pass and clippy is clean with -D warnings. Generated with Codebuff 🤖 Co-Authored-By: Codebuff --- EVENTS.md | 7 +- README.md | 39 ++- src/app.rs | 289 +++++++++++++++++----- src/bread_events.rs | 50 +++- src/config.rs | 258 +++++++++++++++++++ src/flow.rs | 85 +++++-- src/nm.rs | 354 ++++++++++++++++++-------- src/status.rs | 73 ++++-- src/tailscale.rs | 191 ++++++++++++--- src/util.rs | 13 + src/watch.rs | 241 ++++++++++++++++-- tests/cli.rs | 351 ++++++++++++++++++++++++++ tests/flow_watch.rs | 586 ++++++++++++++++++++++++++++++++++++++------ 13 files changed, 2178 insertions(+), 359 deletions(-) diff --git a/EVENTS.md b/EVENTS.md index b5bf7f4..a17325a 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -25,8 +25,10 @@ still switches the profile on disk — there is just nobody listening for | Event | Data | When | |-------|------|------| -| `bread.crumbs.profile.changed` | `{ "from": "", "to": "" }` | The watch loop observes that the persisted active profile is no longer the one it last acted on (CLI `profile set`, `detect --apply`, or `bread.command.crumbs.set_profile`). Not emitted on watcher start just because a profile is already selected. | -| `bread.crumbs.health.changed` | `{ "profile": "", "health": "", "ssid": }` | The watch loop's health classification changes — including the first observation after start, and the forced re-evaluation after a profile change. **Not** emitted on every poll tick while the classification stays the same. | +| `bread.crumbs.profile.changed` | `{ "from": "", "to": "" }` | The watch loop observes that the persisted active profile is no longer the one it last acted on (CLI `profile set`, `detect --apply`, `bread.command.crumbs.set_profile`, or a time-of-day schedule switch). Not emitted on watcher start just because a profile is already selected. | +| `bread.crumbs.health.changed` | `{ "profile": "", "health": "", "ssid": , "iface": , "ip": , "exit_node": "", "tailscale": }` | The watch loop's health classification changes — including the first observation after start, and the forced re-evaluation after a profile change. **Not** emitted on every poll tick while the classification stays the same. | +| `bread.crumbs.network.changed` | `{ "from": , "to": , "profile": "" }` | The active SSID changed between watch-loop ticks. `from` is `null` on the first association observed after start (or after a profile switch). | +| `bread.crumbs.tailscale.changed` | `{ "profile": "", "state": , "exit_node": "" }` | The Tailscale health state (or its mere presence) changed between ticks. `state` is the `TsHealth` variant name or `null` when Tailscale isn't installed. | | `bread.crumbs.set_profile.done` | `{ "profile": "" }` | `bread.command.crumbs.set_profile` persisted the new profile. | | `bread.crumbs.set_profile.failed` | `{ "error": "" }` | `bread.command.crumbs.set_profile` was received but rejected (unknown profile, missing `profile` field, config unreadable). | @@ -36,6 +38,7 @@ still switches the profile on disk — there is just nobody listening for |---------|---------| | `Up` | Adapter present, internet reachable, Tailscale healthy if the profile requires it. | | `DownNoNet` | No internet. | +| `CaptivePortal` | No internet and an HTTP response arrived that wasn't the 204 generate_204 returns — traffic is being intercepted (captive/guest portal). Needs a browser sign-in, not a reconnect. | | `DownTailscaleManual` | Tailscale required but needs login / isn't installed — cannot auto-fix. | | `DownTailscaleOther` | Tailscale required and unhealthy for some other (usually auto-recoverable) reason. | | `NoAdapter` | No Wi-Fi interface. | diff --git a/README.md b/README.md index 99e5065..adf40f9 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,13 @@ Settings and location profiles live in `breadcrumbs.toml` — the file people ac dns = "1.1.1.1" # DNS server pinned on every connection nmcli_wait = 8 # seconds to wait for nmcli connect exit_node = "myhostname" # default Tailscale exit node +exit_nodes = ["a", "b"] # optional priority list; tried in order (fallback nodes) +interface = "wlan0" # optional preferred Wi-Fi interface +schedule = [] # optional time-of-day profile switches, e.g. + # [[settings.schedule]] + # profile = "home" + # from = "18:00" + # to = "08:00" # from >= to = overnight window default_profile = "away" watch_interval = 12 # seconds between health checks (minimum 4) connectivity_url = "http://connectivitycheck.gstatic.com/generate_204" @@ -80,6 +87,15 @@ Saved networks (SSID + optional local password) live separately, in `networks.to ssid = "MyHomeNetwork" password = "hunter2" # optional — see "Credential handling" below hidden = false +dns = "1.1.1.1" # optional per-network DNS override; "" disables pinning + +# WPA-Enterprise (802.1x) networks use these instead of a PSK: +# [[networks]] +# ssid = "CorpEAP" +# eap = "peap" # or "tls" +# identity = "user@corp" +# password = "..." # 802.1x password +# ca_cert = "/etc/ssl/certs/corp-ca.pem" # optional ``` `password` is only needed the first time breadcrumbs connects to a network. Once NetworkManager durably saves the credential, breadcrumbs clears its local copy and omits the key on the next save — an existing config with `password = "..."` still loads fine either way, no migration step needed. A config with `[[networks]]` still written inline in `breadcrumbs.toml` (from before this split) also still loads: it's read once, then migrated into `networks.toml` automatically on the next save. @@ -95,7 +111,8 @@ Each profile defines: | `bootstrap` | SSID to connect to first (e.g. guest Wi-Fi that allows Tailscale traffic). | | `exit_node` | Tailscale exit node for this profile (overrides `settings.exit_node`). | | `include_all_known` | After the priority list, also try every other known network. | -| `detect_ssids` | Any visible SSID in this list marks this profile as a candidate for `breadcrumbs detect`. | +| `detect_ssids` | Any visible SSID in this list marks this profile as a candidate for `breadcrumbs detect`. Profiles with more matching markers win. | +| `learn` | If `true`, SSIDs this profile successfully connects to are appended to `detect_ssids` (bounded), so `detect` improves without hand-editing. Off by default. | ## Usage @@ -105,15 +122,16 @@ breadcrumbs [--profile ] | Command | Description | |---------|-------------| -| `status` | Show current Wi-Fi / Tailscale health (default) | -| `init` | Run the full connect sequence for the active profile | +| `status [--json]` | Show current Wi-Fi / Tailscale health (default) | +| `init [--wait ]` | Run the full connect sequence; `--wait` retries until connected or the timeout elapses | | `watch [--no-initial]` | Self-healing daemon: monitors and auto-recovers drops | | `profile get` | Print the active profile | | `profile set ` | Switch profile (and apply it, unless `--no-apply`) | | `profile list` | List all profiles | -| `detect [--apply]` | Guess profile from visible networks; optionally apply it | -| `add [password]` | Add or update a saved network | +| `detect [--apply] [--json]` | Guess profile from visible networks; optionally apply it | +| `add [password]` | Add or update a saved network (`--dns`, `--eap`, `--identity`, `--ca-cert`, `--hidden`, `--to`, `--at`) | | `forget ` | Remove a network from config and NetworkManager | +| `prune [--dry-run]` | Remove NetworkManager wireless profiles whose SSID is no longer in the config | | `scan [--to ]` | Interactive scan, pick, connect and save | | `list [--show-passwords]` | Show config: settings, networks, profiles | | `edit` | Open config in `$EDITOR`, validate on exit | @@ -154,6 +172,17 @@ breadcrumbs install-service 2. Reacts immediately to link-state changes via `nmcli monitor` 3. Runs `flow::run` (the connect state machine) on any detected drop 4. Handles profile changes live — re-reads config and state on every tick +5. Distinguishes captive portals from plain no-internet (a 200/301/302 instead + of the 204 generate_204 returns) and tells you to sign in instead of + pointlessly reconnecting +6. Applies a `[settings.schedule]` time-of-day profile switch, respecting a + 30-minute grace window after a manual `profile set` +7. Detects suspend/resume (a large gap between ticks) and forces an immediate + recovery check instead of waiting out the poll interval + +When a Tailscale profile is connected through a bootstrap network and the +connectivity check is intercepted, the watcher stays put and notifies once — +it does not churn reconnects against a portal. Install as a systemd user service: diff --git a/src/app.rs b/src/app.rs index 31c35df..ef78276 100644 --- a/src/app.rs +++ b/src/app.rs @@ -38,13 +38,54 @@ struct Cli { cmd: Option, } +/// Optional flags for `add`. Flattened into the `Add` subcommand so the +/// CLI surface is unchanged while keeping `cmd_add`'s signature small. +#[derive(clap::Args)] +struct AddOpts { + /// Password (prompted if omitted) + password: Option, + /// Network is hidden (does not broadcast its SSID). + /// `--hidden` sets it; `--hidden=false` clears it on an existing + /// entry; omitted leaves an existing entry's flag untouched. + #[arg(long, num_args = 0..=1, default_missing_value = "true")] + hidden: Option, + /// Per-network DNS override (empty string disables DNS pinning + /// for this network) + #[arg(long)] + dns: Option, + /// 802.1x EAP method for enterprise networks (e.g. "peap", "tls") + #[arg(long)] + eap: Option, + /// 802.1x identity for enterprise networks + #[arg(long)] + identity: Option, + /// Path to a CA certificate for 802.1x + #[arg(long)] + ca_cert: Option, + /// Attach this SSID to a profile's priority list + #[arg(long)] + to: Option, + /// Position in the profile list (0 = highest priority) + #[arg(long)] + at: Option, +} + #[derive(Subcommand)] enum Cmd { /// Show current Wi-Fi / profile / Tailscale status (default) - Status, + Status { + /// Emit machine-readable JSON + #[arg(long)] + json: bool, + }, /// Run the full connect sequence for the active profile #[command(visible_aliases = ["up", "connect", "i"])] - Init, + Init { + /// Retry until connected or this many seconds have elapsed + /// (0 = single attempt) + #[arg(long, default_value_t = 0)] + wait: u64, + }, /// Run as a daemon: watch for drops and auto-recover Watch { /// Skip the connect attempt on startup @@ -61,26 +102,25 @@ enum Cmd { /// Set + apply the detected profile #[arg(long)] apply: bool, + /// Emit machine-readable JSON + #[arg(long)] + json: bool, }, /// Add or update a saved network Add { ssid: String, - /// Password (prompted if omitted) - password: Option, - /// Network is hidden (does not broadcast its SSID). - /// `--hidden` sets it; `--hidden=false` clears it on an existing - /// entry; omitted leaves an existing entry's flag untouched. - #[arg(long, num_args = 0..=1, default_missing_value = "true")] - hidden: Option, - /// Attach this SSID to a profile's priority list - #[arg(long)] - to: Option, - /// Position in the profile list (0 = highest priority) - #[arg(long)] - at: Option, + #[command(flatten)] + opts: AddOpts, }, /// Remove a saved network (config + NetworkManager) Forget { ssid: String }, + /// Remove NetworkManager wireless profiles whose SSID is no longer in + /// the breadcrumbs config + Prune { + /// Only list what would be removed + #[arg(long)] + dry_run: bool, + }, /// Scan, pick, connect and save a network interactively Scan { /// Attach the saved network to this profile @@ -147,7 +187,7 @@ fn active_profile(cfg: &Config, override_p: &Option) -> String { } fn real_main(cli: Cli) -> Result { - let cmd = cli.cmd.unwrap_or(Cmd::Status); + let cmd = cli.cmd.unwrap_or(Cmd::Status { json: false }); // `cd` and `install-service` don't need a parsed config first. if let Cmd::Cd { shell } = &cmd { @@ -157,24 +197,14 @@ fn real_main(cli: Cli) -> Result { let mut cfg = Config::load()?; match cmd { - Cmd::Status => cmd_status(&cfg, &cli.profile), - Cmd::Init => { - let p = active_profile(&cfg, &cli.profile); - let outcome = flow::run(&mut cfg, &p); - print_outcome(&p, &outcome); - Ok(if outcome.ok() { 0 } else { 1 }) - } + Cmd::Status { json } => cmd_status(&cfg, &cli.profile, json), + Cmd::Init { wait } => cmd_init(&mut cfg, &cli.profile, wait), Cmd::Watch { no_initial } => Ok(watch::run(cfg, !no_initial)), Cmd::Profile { action } => cmd_profile(&mut cfg, action), - Cmd::Detect { apply } => cmd_detect(&mut cfg, apply), - Cmd::Add { - ssid, - password, - hidden, - to, - at, - } => cmd_add(&mut cfg, ssid, password, hidden, to, at), + Cmd::Detect { apply, json } => cmd_detect(&mut cfg, apply, json), + Cmd::Add { ssid, opts } => cmd_add(&mut cfg, ssid, opts), Cmd::Forget { ssid } => cmd_forget(&mut cfg, &ssid), + Cmd::Prune { dry_run } => cmd_prune(&cfg, dry_run), Cmd::Scan { to } => cmd_scan(&mut cfg, to), Cmd::List { show_passwords } => cmd_list(&cfg, show_passwords), Cmd::Edit => cmd_edit(), @@ -184,6 +214,32 @@ fn real_main(cli: Cli) -> Result { } } +fn cmd_init(cfg: &mut Config, override_p: &Option, wait: u64) -> Result { + let p = active_profile(cfg, override_p); + let deadline = std::time::Instant::now() + Duration::from_secs(wait); + let mut attempt = 0; + loop { + // First attempt notifies normally (user-initiated); retries are + // quiet so a long --wait run doesn't spam notifications. + let outcome = if attempt == 0 { + flow::run(cfg, &p) + } else { + flow::run_quiet(cfg, &p) + }; + if outcome.ok() { + print_outcome(&p, &outcome); + return Ok(0); + } + if wait == 0 || std::time::Instant::now() >= deadline { + print_outcome(&p, &outcome); + return Ok(1); + } + attempt += 1; + println!("{C_DIM}not connected yet — retrying in 3s…{C_RESET}"); + std::thread::sleep(Duration::from_secs(3)); + } +} + fn print_outcome(profile: &str, o: &flow::Outcome) { match o { flow::Outcome::Connected { ssid, note } => { @@ -212,10 +268,34 @@ fn print_outcome(profile: &str, o: &flow::Outcome) { } } -fn cmd_status(cfg: &Config, override_p: &Option) -> Result { +fn cmd_status(cfg: &Config, override_p: &Option, json: bool) -> Result { let p = active_profile(cfg, override_p); let s = crate::status::gather(cfg, &p); + let healthy = s.internet + && s.iface.is_some() + && (!s.tailscale_required || s.tailscale.as_ref().map(|h| h.is_ok()).unwrap_or(false)); + + if json { + let tailscale = s.tailscale.as_ref().map(|h| h.state_str()); + println!( + "{}", + serde_json::json!({ + "profile": p, + "iface": s.iface, + "ssid": s.ssid, + "ip": s.ip, + "internet": s.internet, + "portal": s.portal, + "tailscale_required": s.tailscale_required, + "tailscale": tailscale, + "exit_node": s.exit_node, + "healthy": healthy, + }) + ); + return Ok(if healthy { 0 } else { 1 }); + } + let dot = |ok: bool| { if ok { format!("{C_GREEN}●{C_RESET}") @@ -262,9 +342,6 @@ fn cmd_status(cfg: &Config, override_p: &Option) -> Result (None, _) => println!(" tailscale {C_DIM}not installed{C_RESET}"), } - let healthy = s.internet - && s.iface.is_some() - && (!s.tailscale_required || s.tailscale.as_ref().map(|h| h.is_ok()).unwrap_or(false)); println!( " state {}", if healthy { @@ -304,42 +381,61 @@ fn cmd_profile(cfg: &mut Config, action: Option) -> Result Option { - let iface = nm::wifi_interface()?; + let iface = nm::wifi_interface_preferred(cfg.settings.interface.as_deref())?; nm::radio_on(); nm::rescan(&iface, &[]); - let visible = nm::visible_ssids(&iface); + let visible = nm::visible_signals(&iface); - // Profiles are stored in a BTreeMap so iteration order is deterministic - // (alphabetical). The caller can rely on that for tie-breaking. + // Scored detection: the profile with the most matching markers wins, so + // a 2-marker match beats a 1-marker one. Profiles are stored in a + // BTreeMap, so ties resolve deterministically (alphabetically first). + let mut best: Option<(String, usize)> = None; for (name, profile) in &cfg.profiles { if profile.detect_ssids.is_empty() { continue; } - if profile + let count = profile .detect_ssids .iter() - .any(|s| visible.contains(s.as_str())) - { - return Some(name.clone()); + .filter(|s| visible.contains_key(s.as_str())) + .count(); + if count > 0 { + let better = match &best { + None => true, + Some((_, c)) => count > *c, + }; + if better { + best = Some((name.clone(), count)); + } } } - // Fall back to the default profile if no markers matched — but only if - // it actually exists: a stale `default_profile` name is a config error, - // not a detection result, and persisting it would wedge the watcher in - // UnknownProfile forever. - if cfg.profiles.contains_key(&cfg.settings.default_profile) { - Some(cfg.settings.default_profile.clone()) - } else { - None - } + best.map(|(p, _)| p).or_else(|| { + // Fall back to the default profile if no markers matched — but only + // if it actually exists: a stale `default_profile` name is a config + // error, not a detection result, and persisting it would wedge the + // watcher in UnknownProfile forever. + if cfg.profiles.contains_key(&cfg.settings.default_profile) { + Some(cfg.settings.default_profile.clone()) + } else { + None + } + }) } -fn cmd_detect(cfg: &mut Config, apply: bool) -> Result { +fn cmd_detect(cfg: &mut Config, apply: bool, json: bool) -> Result { match detect_profile(cfg) { Some(p) => { - println!("{p}"); + if json && !apply { + println!("{}", serde_json::json!({ "profile": p })); + return Ok(0); + } if apply { + if json { + println!("{}", serde_json::json!({ "profile": p })); + } else { + println!("{p}"); + } // Route through state::set_profile (like the CLI and the // bread bus do) so an unknown fallback is rejected with a // proper error instead of being persisted as active. @@ -348,6 +444,7 @@ fn cmd_detect(cfg: &mut Config, apply: bool) -> Result { print_outcome(&p, &outcome); return Ok(if outcome.ok() { 0 } else { 1 }); } + println!("{p}"); Ok(0) } None => Err("could not detect a profile (no Wi-Fi adapter, or no \ @@ -392,16 +489,28 @@ fn prompt_secret(msg: &str) -> String { val } -fn cmd_add( - cfg: &mut Config, - ssid: String, - password: Option, - hidden: Option, - to: Option, - at: Option, -) -> Result { +fn cmd_add(cfg: &mut Config, ssid: String, opts: AddOpts) -> Result { + let AddOpts { + password, + hidden, + dns, + eap, + identity, + ca_cert, + to, + at, + } = opts; + // `--dns ""` is the explicit "don't pin DNS" opt-out; normalize an + // absent flag to None (use the global setting). + let dns = match dns { + Some(s) if s.is_empty() => Some(String::new()), + Some(s) => Some(s), + None => None, + }; + // For enterprise networks, the password is the 802.1x password. let password = match password { Some(p) => p, + None if eap.is_some() => prompt_secret(&format!("802.1x password for '{ssid}': ")), None => prompt_secret(&format!("Password for '{ssid}': ")), }; let password = non_empty(password); @@ -414,10 +523,26 @@ fn cmd_add( if let Some(h) = hidden { n.hidden = h; } + if dns.is_some() { + n.dns = dns; + } + if eap.is_some() { + n.eap = eap; + } + if identity.is_some() { + n.identity = identity; + } + if ca_cert.is_some() { + n.ca_cert = ca_cert; + } } None => cfg.networks.push(NetworkDef { ssid: ssid.clone(), password, + dns, + eap, + identity, + ca_cert, hidden: hidden.unwrap_or(false), }), } @@ -458,6 +583,39 @@ fn cmd_forget(cfg: &mut Config, ssid: &str) -> Result { Ok(0) } +/// Remove NetworkManager wireless profiles whose SSID is no longer known to +/// breadcrumbs (config `networks`, or any profile's priority list or +/// bootstrap). `--dry-run` only lists. Returns the number removed. +fn cmd_prune(cfg: &Config, dry_run: bool) -> Result { + let known: Vec<&str> = cfg + .networks + .iter() + .map(|n| n.ssid.as_str()) + .chain( + cfg.profiles + .values() + .flat_map(|p| p.networks.iter().map(|s| s.as_str()).chain(p.bootstrap.iter().map(|s| s.as_str()))), + ) + .collect(); + let stale: Vec<(String, String)> = nm::wireless_profiles() + .into_iter() + .filter(|(_name, ssid)| !known.contains(&ssid.as_str())) + .collect(); + if stale.is_empty() { + println!("{C_GREEN}nothing to prune{C_RESET}"); + return Ok(0); + } + for (name, ssid) in &stale { + if dry_run { + println!("{C_DIM}would remove{C_RESET} {name} ({ssid})"); + } else { + println!("{C_GREEN}removed{C_RESET} {name} ({ssid})"); + let _ = nm::delete_connections_for_ssid(ssid); + } + } + Ok(0) +} + fn cmd_scan(cfg: &mut Config, to: Option) -> Result { // Validate `--to` up front, before any side effects (connecting is // one): `add --to` errors on an unknown profile, so `scan --to` must @@ -467,7 +625,8 @@ fn cmd_scan(cfg: &mut Config, to: Option) -> Result { return Err(format!("unknown profile '{prof_name}'")); } } - let iface = nm::wifi_interface().ok_or("no Wi-Fi adapter")?; + let iface = nm::wifi_interface_preferred(cfg.settings.interface.as_deref()) + .ok_or("no Wi-Fi adapter")?; nm::radio_on(); nm::rescan(&iface, &[]); let entries = nm::scan_list(&iface); @@ -501,6 +660,10 @@ fn cmd_scan(cfg: &mut Config, to: Option) -> Result { let mut def = NetworkDef { ssid: ssid.clone(), password, + dns: None, + eap: None, + identity: None, + ca_cert: None, hidden: false, }; if !nm::connect(&iface, &def, cfg.settings.nmcli_wait, &cfg.settings.dns) { diff --git a/src/bread_events.rs b/src/bread_events.rs index bdac0d8..ecdd8d3 100644 --- a/src/bread_events.rs +++ b/src/bread_events.rs @@ -26,13 +26,57 @@ pub fn emit_profile_changed(client: &BreadClient, from: &str, to: &str) { ); } -pub fn emit_health_changed(client: &BreadClient, profile: &str, health: &str, ssid: Option<&str>) { +/// Payload for `bread.crumbs.health.changed`. Constructed by the watch +/// loop from a classification and passed as a unit so the emit functions +/// stay small. +pub struct HealthChanged<'a> { + pub profile: &'a str, + pub health: &'a str, + pub ssid: Option<&'a str>, + pub iface: Option<&'a str>, + pub ip: Option<&'a str>, + pub exit_node: &'a str, + pub tailscale: Option<&'a str>, +} + +pub fn emit_health_changed(client: &BreadClient, ev: HealthChanged<'_>) { client.emit( "bread.crumbs.health.changed", + serde_json::json!({ + "profile": ev.profile, + "health": ev.health, + "ssid": ev.ssid, + "iface": ev.iface, + "ip": ev.ip, + "exit_node": ev.exit_node, + "tailscale": ev.tailscale, + }), + ); +} + +/// `bread.crumbs.network.changed` — the watch loop observed the active SSID +/// transition. `from` is `null` when there was no previous association. +pub fn emit_network_changed(client: &BreadClient, from: Option<&str>, to: Option<&str>, profile: &str) { + client.emit( + "bread.crumbs.network.changed", + serde_json::json!({ "from": from, "to": to, "profile": profile }), + ); +} + +/// `bread.crumbs.tailscale.changed` — the Tailscale health state (or its +/// mere presence) changed between watch-loop ticks. +pub fn emit_tailscale_changed( + client: &BreadClient, + profile: &str, + state: Option<&str>, + exit_node: &str, +) { + client.emit( + "bread.crumbs.tailscale.changed", serde_json::json!({ "profile": profile, - "health": health, - "ssid": ssid, + "state": state, + "exit_node": exit_node, }), ); } diff --git a/src/config.rs b/src/config.rs index 6f77af5..4024af7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -28,6 +28,47 @@ fn default_ping_host() -> String { "1.1.1.1".to_string() } +/// Parse "HH:MM" (24h) into minutes since midnight; `None` if malformed. +pub fn hhmm_to_minutes(s: &str) -> Option { + let (h, m) = s.trim().split_once(':')?; + let h: u32 = h.parse().ok()?; + let m: u32 = m.parse().ok()?; + if h > 23 || m > 59 { + return None; + } + Some(h * 60 + m) +} + +/// Does the window `[from, to)` (minutes since midnight) contain `now`? +/// `from >= to` means an overnight window (e.g. 22:00–07:00). +pub fn window_contains(from: u32, to: u32, now: u32) -> bool { + if from < to { + now >= from && now < to + } else { + now >= from || now < to + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ScheduleEntry { + /// Profile to switch to while the window is active. + pub profile: String, + /// "HH:MM", inclusive start. + pub from: String, + /// "HH:MM", exclusive end (`from >= to` means overnight). + pub to: String, +} + +impl ScheduleEntry { + /// Whether the window contains `now_minutes` (minutes since midnight). + pub fn contains(&self, now_minutes: u32) -> bool { + match (hhmm_to_minutes(&self.from), hhmm_to_minutes(&self.to)) { + (Some(f), Some(t)) => window_contains(f, t, now_minutes), + _ => false, + } + } +} + fn is_false(b: &bool) -> bool { !b } @@ -56,6 +97,21 @@ pub struct Settings { /// configs keep parsing exactly as before. #[serde(default, skip_serializing_if = "is_false")] pub core_profiles_initialized: bool, + /// Preferred Wi-Fi interface (e.g. "wlan0"). When set, this exact + /// device is used if present; otherwise the first Wi-Fi device wins. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub interface: Option, + /// Priority-ordered fallback exit nodes. Tried in order by the flow; + /// the first healthy one is selected. Falls back to `exit_node` when + /// empty. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub exit_nodes: Vec, + /// Optional time-of-day schedule: at a given time, switch to the listed + /// profile automatically (respecting a manual-override grace window; + /// see the watch loop). First matching rule wins; outside every window + /// nothing is switched. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub schedule: Vec, } impl Default for Settings { @@ -69,10 +125,24 @@ impl Default for Settings { connectivity_url: default_connectivity_url(), ping_host: default_ping_host(), core_profiles_initialized: false, + interface: None, + exit_nodes: Vec::new(), + schedule: Vec::new(), } } } +impl Settings { + /// The profile a time-of-day schedule picks for `now_minutes` (minutes + /// since midnight), if any — first matching rule wins. + pub fn scheduled_profile(&self, now_minutes: u32) -> Option { + self.schedule + .iter() + .find(|e| e.contains(now_minutes)) + .map(|e| e.profile.clone()) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NetworkDef { pub ssid: String, @@ -87,10 +157,33 @@ pub struct NetworkDef { /// fed to `nmcli --ask` on stdin, never as an argv element. #[serde(default, skip_serializing_if = "Option::is_none")] pub password: Option, + /// Per-network DNS override. `None` falls back to `settings.dns`; + /// an explicitly empty string disables DNS pinning for this network. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dns: Option, + /// WPA-Enterprise (802.1x). When `eap` is set the network is treated as + /// enterprise: `identity` + `password` (reused) + optional `ca_cert` + /// path. `eap` is e.g. "peap" or "tls". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub eap: Option, + /// 802.1x identity (e.g. `user@corp`) for enterprise networks. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub identity: Option, + /// Path to a CA certificate for 802.1x (optional). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ca_cert: Option, #[serde(default)] pub hidden: bool, } +impl NetworkDef { + /// The DNS to pin for this network: the per-network override if set, + /// otherwise the global setting. + pub fn effective_dns<'a>(&'a self, fallback: &'a str) -> &'a str { + self.dns.as_deref().unwrap_or(fallback) + } +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Profile { /// Optional SSID connected first to bootstrap connectivity (e.g. for Tailscale). @@ -112,6 +205,11 @@ pub struct Profile { /// Used by `breadcrumbs detect` to guess the active profile. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub detect_ssids: Vec, + /// Opt-in learning: on a successful connect, the SSID is appended to + /// `detect_ssids` (bounded) so `breadcrumbs detect` improves without + /// hand-editing. Off by default to keep detect predictable. + #[serde(default, skip_serializing_if = "is_false")] + pub learn: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -184,6 +282,24 @@ impl Config { self.networks.iter().find(|n| n.ssid == ssid) } + /// The effective exit-node list for a profile, in priority order: + /// per-profile `exit_node`, else `settings.exit_nodes`, else + /// `settings.exit_node`. Empty entries are filtered out. + pub fn exit_nodes_for(&self, profile: &str) -> Vec { + if let Some(p) = self.profiles.get(profile).and_then(|p| p.exit_node.clone()) { + return vec![p]; + } + let list = if self.settings.exit_nodes.is_empty() { + vec![self.settings.exit_node.clone()] + } else { + self.settings.exit_nodes.clone() + }; + list.into_iter() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + } + /// Load config, creating a skeleton one on first run. pub fn load() -> Result { let path = config_path(); @@ -303,6 +419,7 @@ fn core_profiles() -> BTreeMap { exit_node: None, include_all_known: false, detect_ssids: vec![], + learn: false, }, ); p.insert( @@ -314,6 +431,7 @@ fn core_profiles() -> BTreeMap { exit_node: None, include_all_known: false, detect_ssids: vec![], + learn: false, }, ); p.insert( @@ -325,6 +443,7 @@ fn core_profiles() -> BTreeMap { exit_node: None, include_all_known: true, detect_ssids: vec![], + learn: false, }, ); p @@ -474,6 +593,10 @@ hidden = false"#; let n = NetworkDef { ssid: "Cafe".into(), password: None, + dns: None, + eap: None, + identity: None, + ca_cert: None, hidden: false, }; let text = toml::to_string_pretty(&n).unwrap(); @@ -485,6 +608,10 @@ hidden = false"#; let n = NetworkDef { ssid: "Cafe".into(), password: Some("hunter2".into()), + dns: None, + eap: None, + identity: None, + ca_cert: None, hidden: false, }; let text = toml::to_string_pretty(&n).unwrap(); @@ -529,4 +656,135 @@ dns = "9.9.9.9""#; assert!(cfg.networks.is_empty()); assert!(cfg.profiles.is_empty()); } + + #[test] + fn exit_nodes_for_prioritizes_profile_then_list_then_single_node() { + let mut cfg = build_initial_config(); + cfg.settings.exit_node = "global".into(); + cfg.settings.exit_nodes = vec!["listA".into(), "listB".into()]; + cfg.profiles.get_mut("home").unwrap().exit_node = Some("profile".into()); + + // Per-profile override wins outright. + assert_eq!(cfg.exit_nodes_for("home"), vec!["profile".to_string()]); + + // Otherwise the priority list is used verbatim. + cfg.profiles.get_mut("home").unwrap().exit_node = None; + assert_eq!( + cfg.exit_nodes_for("home"), + vec!["listA".to_string(), "listB".to_string()] + ); + + // Without a list, the single setting is the (one-element) fallback. + cfg.settings.exit_nodes = vec![]; + assert_eq!(cfg.exit_nodes_for("home"), vec!["global".to_string()]); + } + + #[test] + fn exit_nodes_for_filters_empty_and_whitespace_entries() { + let mut cfg = build_initial_config(); + cfg.settings.exit_nodes = vec![ + " ".into(), + "nodeA".into(), + "".into(), + " nodeB ".into(), + ]; + assert_eq!( + cfg.exit_nodes_for("home"), + vec!["nodeA".to_string(), "nodeB".to_string()] + ); + } + + #[test] + fn hhmm_to_minutes_parses_and_rejects_malformed() { + assert_eq!(hhmm_to_minutes("09:30"), Some(570)); + assert_eq!(hhmm_to_minutes("00:00"), Some(0)); + assert_eq!(hhmm_to_minutes("23:59"), Some(1439)); + assert_eq!(hhmm_to_minutes("9:30"), Some(570)); // lenient about padding + assert_eq!(hhmm_to_minutes("24:00"), None); // hour out of range + assert_eq!(hhmm_to_minutes("12:60"), None); // minute out of range + assert_eq!(hhmm_to_minutes("0930"), None); // no colon + assert_eq!(hhmm_to_minutes(""), None); + } + + #[test] + fn window_contains_handles_same_day_and_overnight() { + // Same-day window 09:00–17:00 (end exclusive). + assert!(window_contains(540, 1020, 600)); + assert!(!window_contains(540, 1020, 1020)); + assert!(!window_contains(540, 1020, 500)); + // Overnight window 22:00–07:00. + assert!(window_contains(1320, 420, 1380)); // 23:00 + assert!(window_contains(1320, 420, 60)); // 01:00 + assert!(!window_contains(1320, 420, 720)); // 12:00 + } + + #[test] + fn scheduled_profile_returns_first_matching_rule() { + let mut cfg = build_initial_config(); + cfg.settings.schedule = vec![ + ScheduleEntry { + profile: "work".into(), + from: "09:00".into(), + to: "17:00".into(), + }, + ScheduleEntry { + profile: "home".into(), + from: "09:30".into(), + to: "18:00".into(), + }, + ]; + // 10:00 matches both — the first rule (work) wins. + assert_eq!(cfg.settings.scheduled_profile(600), Some("work".into())); + // Outside every window → no schedule applies. + assert_eq!(cfg.settings.scheduled_profile(60), None); + } + + #[test] + fn effective_dns_uses_per_network_override_then_global_fallback() { + let n = NetworkDef { + ssid: "x".into(), + password: None, + dns: Some("9.9.9.9".into()), + eap: None, + identity: None, + ca_cert: None, + hidden: false, + }; + assert_eq!(n.effective_dns("1.1.1.1"), "9.9.9.9"); + + let n2 = NetworkDef { dns: None, ..n.clone() }; + assert_eq!(n2.effective_dns("1.1.1.1"), "1.1.1.1"); + + // An explicit empty string is a valid per-network opt-out. + let n3 = NetworkDef { dns: Some(String::new()), ..n.clone() }; + assert_eq!(n3.effective_dns("1.1.1.1"), ""); + } + + #[test] + fn enterprise_fields_round_trip_and_omit_when_none() { + let n = NetworkDef { + ssid: "Corp".into(), + password: Some("pw".into()), + dns: None, + eap: Some("peap".into()), + identity: Some("user@corp".into()), + ca_cert: Some("/etc/ca.pem".into()), + hidden: false, + }; + let text = toml::to_string_pretty(&n).unwrap(); + assert!(text.contains("eap") && text.contains("identity") && text.contains("ca_cert")); + let back: NetworkDef = toml::from_str(&text).unwrap(); + assert_eq!(back.eap.as_deref(), Some("peap")); + assert_eq!(back.identity.as_deref(), Some("user@corp")); + assert_eq!(back.ca_cert.as_deref(), Some("/etc/ca.pem")); + + let plain = NetworkDef { + eap: None, + identity: None, + ca_cert: None, + ..n + }; + let t2 = toml::to_string_pretty(&plain).unwrap(); + assert!(!t2.contains("eap") && !t2.contains("identity") && !t2.contains("ca_cert")); + } } diff --git a/src/flow.rs b/src/flow.rs index 05566b8..8e9c91b 100644 --- a/src/flow.rs +++ b/src/flow.rs @@ -77,10 +77,27 @@ fn clear_password_if_used(cfg: &mut Config, ssid: &str) { } } +/// Opt-in learning (`profiles..learn = true`): remember the SSIDs a +/// profile successfully connects to so `breadcrumbs detect` improves without +/// hand-editing. Bounded to keep the list sane; never touches an existing +/// marker. +fn learn_ssid(cfg: &mut Config, profile: &str, ssid: &str) { + let Some(p) = cfg.profiles.get_mut(profile) else { + return; + }; + if !p.learn || p.detect_ssids.len() >= 8 || p.detect_ssids.iter().any(|s| s == ssid) { + return; + } + p.detect_ssids.push(ssid.to_string()); + if let Err(e) = cfg.save() { + log(&format!("failed to persist learned SSID {ssid} for {profile}: {e}")); + } +} + /// Try to connect + confirm the device actually landed on the *requested* /// SSID. Returns Ok(()) on success, Err(reason) on failure. fn connect_and_verify(iface: &str, def: &NetworkDef, cfg: &Config) -> Result<(), String> { - nm::connect_verbose(iface, def, cfg.settings.nmcli_wait, &cfg.settings.dns)?; + nm::connect_verbose(iface, def, cfg.settings.nmcli_wait, def.effective_dns(&cfg.settings.dns))?; // Confirm the SSID, not just "device connected": NM autoconnect can win // a race and leave the device on a different network, and the wifi list // can lag activation by a moment — so poll briefly before giving up. @@ -140,7 +157,7 @@ fn run_inner(cfg: &mut Config, profile_name: &str, notify_user: bool) -> Outcome } }; - let iface = match nm::wifi_interface() { + let iface = match nm::wifi_interface_preferred(cfg.settings.interface.as_deref()) { Some(i) => i, None => { if notify_user { @@ -155,10 +172,8 @@ fn run_inner(cfg: &mut Config, profile_name: &str, notify_user: bool) -> Outcome }; nm::radio_on(); - let exit_node = profile - .exit_node - .clone() - .unwrap_or_else(|| cfg.settings.exit_node.clone()); + let exit_nodes = cfg.exit_nodes_for(profile_name); + let exit_node = exit_nodes.first().cloned().unwrap_or_default(); let candidates = resolve_candidates(cfg, &profile); log(&format!( @@ -208,7 +223,7 @@ fn run_inner(cfg: &mut Config, profile_name: &str, notify_user: bool) -> Outcome } } - let ts = tailscale::ensure_exit_node(&exit_node); + let ts = tailscale::ensure_exit_node(&exit_nodes); if !ts.is_ok() { let ssid = nm::active_ssid(&iface).or_else(|| profile.bootstrap.clone()); if notify_user { @@ -229,30 +244,41 @@ fn run_inner(cfg: &mut Config, profile_name: &str, notify_user: bool) -> Outcome nm::rescan(&iface, &scan_targets); } - let visible = nm::visible_ssids(&iface); + // Signals are re-read after the Tailscale gate so pass 1 can prefer the + // strongest AP among a profile's visible networks. + let visible_sig = nm::visible_signals(&iface); // ---- Connect to the priority list ---------------------------------- - // Pass 1: visible networks in priority order. + // Pass 1: visible networks, strongest signal first (priority order is + // the stable tiebreaker for equal signals). + let mut visible_candidates: Vec<&NetworkDef> = candidates + .iter() + .filter(|d| visible_sig.contains_key(&d.ssid)) + .collect(); + visible_candidates.sort_by(|a, b| { + visible_sig + .get(&b.ssid) + .cmp(&visible_sig.get(&a.ssid)) + }); let mut any_attempted = false; - for def in &candidates { - if visible.contains(&def.ssid) { - any_attempted = true; - match connect_and_verify(&iface, def, cfg) { - Ok(()) => { - clear_password_if_used(cfg, &def.ssid); - let note = if internet_ok(cfg) { - None - } else { - Some("associated but no internet yet".to_string()) - }; - finish_connected(&def.ssid, profile_name, ¬e, notify_user); - return Outcome::Connected { - ssid: def.ssid.clone(), - note, - }; - } - Err(e) => log(&format!("connect failed (visible): {} — {e}", def.ssid)), + for def in &visible_candidates { + any_attempted = true; + match connect_and_verify(&iface, def, cfg) { + Ok(()) => { + clear_password_if_used(cfg, &def.ssid); + learn_ssid(cfg, profile_name, &def.ssid); + let note = if internet_ok(cfg) { + None + } else { + Some("associated but no internet yet".to_string()) + }; + finish_connected(&def.ssid, profile_name, ¬e, notify_user); + return Outcome::Connected { + ssid: def.ssid.clone(), + note, + }; } + Err(e) => log(&format!("connect failed (visible): {} — {e}", def.ssid)), } } // Pass 2: hidden networks we couldn't see in the scan. @@ -262,6 +288,7 @@ fn run_inner(cfg: &mut Config, profile_name: &str, notify_user: bool) -> Outcome match connect_and_verify(&iface, def, cfg) { Ok(()) => { clear_password_if_used(cfg, &def.ssid); + learn_ssid(cfg, profile_name, &def.ssid); let note = if internet_ok(cfg) { None } else { @@ -379,6 +406,10 @@ mod tests { NetworkDef { ssid: ssid.into(), password: Some("x".into()), + dns: None, + eap: None, + identity: None, + ca_cert: None, hidden: false, } } diff --git a/src/nm.rs b/src/nm.rs index e395806..c8d3878 100644 --- a/src/nm.rs +++ b/src/nm.rs @@ -1,8 +1,8 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::time::Duration; use crate::config::NetworkDef; -use crate::util::{run, run_ok, run_with_stdin}; +use crate::util::{run, run_ok}; /// nmcli `-t` escapes `:` and `\` in field values; undo that. fn unescape(s: &str) -> String { @@ -49,6 +49,13 @@ fn split_fields(line: &str) -> Vec { } pub fn wifi_interface() -> Option { + wifi_interface_preferred(None) +} + +/// Find the Wi-Fi interface. When `pref` is `Some`, that exact device is +/// used if present; otherwise (or if the preferred device is missing — e.g. +/// an unplugged USB dongle) the first Wi-Fi device wins. +pub fn wifi_interface_preferred(pref: Option<&str>) -> Option { let o = run( "nmcli", &["-t", "-f", "DEVICE,TYPE", "device", "status"], @@ -57,13 +64,19 @@ pub fn wifi_interface() -> Option { if !o.success { return None; } + let mut devices: Vec = Vec::new(); for line in o.stdout.lines() { let fields = split_fields(line); if fields.len() >= 2 && fields[1] == "wifi" { - return Some(fields[0].clone()); + devices.push(fields[0].clone()); } } - None + if let Some(p) = pref { + if devices.iter().any(|d| d == p) { + return Some(p.to_string()); + } + } + devices.into_iter().next() } pub fn radio_on() { @@ -107,6 +120,49 @@ pub fn visible_ssids(iface: &str) -> HashSet { set } +/// Visible SSIDs with their signal strength (0–100), one entry per SSID +/// (strongest BSSID wins). Used for signal-aware network selection and +/// scored detection. +pub fn visible_signals(iface: &str) -> HashMap { + let o = run( + "nmcli", + &[ + "-t", + "-f", + "SSID,SIGNAL", + "device", + "wifi", + "list", + "ifname", + iface, + ], + Duration::from_secs(12), + ); + let mut m: HashMap = HashMap::new(); + if !o.success { + return m; + } + for line in o.stdout.lines() { + let f = split_fields(line); + if f.len() < 2 { + continue; + } + let ssid = f[0].trim().to_string(); + if ssid.is_empty() { + continue; + } + let sig = signal_strength(&f[1]); + m.entry(ssid) + .and_modify(|e| { + if sig > *e { + *e = sig; + } + }) + .or_insert(sig); + } + m +} + #[derive(Debug, Clone)] pub struct ScanEntry { pub ssid: String, @@ -307,8 +363,8 @@ pub fn connect(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> bool { /// Connect to a network and pin DNS. Returns the nmcli error on failure. /// -/// Reuses an existing saved profile for the SSID when one exists so that -/// repeated connections do not accumulate numbered duplicates in +/// Reuses an existing saved profile for the SSID when one exists (updating its +/// PSK) so that repeated connections do not accumulate numbered duplicates in /// NetworkManager ("NCC", "NCC 1", "NCC 2", …). Falls back to /// `nmcli device wifi connect` — which creates a new profile — only when no /// saved profile is found. @@ -316,18 +372,47 @@ pub fn connect(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> bool { /// `net.password` is only sent when `Some`: on the reuse path, `None` means /// "leave the saved PSK alone" (either NetworkManager already durably owns /// it, or the network is open); on the create path it means "no password -/// at all", which is also how a genuinely open (no-security) SSID is -/// connected. See the field doc on [`NetworkDef::password`] for how a local -/// secret transitions to `None` after its first successful use. +/// argument at all", which is also how a genuinely open (no-security) SSID +/// is connected. See the field doc on [`NetworkDef::password`] for how a +/// local secret transitions to `None` after its first successful use. /// -/// When a password *is* sent it goes to `nmcli --ask` on stdin, never on -/// argv — `/proc//cmdline` is world-readable. After the first success -/// breadcrumbs clears its local copy, so subsequent connects pass nothing. +/// KNOWN LIMITATION (credential exposure): when a password *is* sent, it's +/// passed to `nmcli` as a plain command-line argument +/// (`802-11-wireless-security.psk ` on the reuse path, `password ` +/// on the create path). For the lifetime of that `nmcli` child, the secret +/// is readable by other local users via `/proc//cmdline`. +/// `util::run_with_stdin` exists to feed secrets on stdin instead, but +/// wiring it up correctly needs either verified `nmcli --ask` piped-stdin +/// behavior or NetworkManager's D-Bus secret-agent API — neither of which +/// can be validated without a live NetworkManager connection — so this is +/// left as documented tech debt rather than a guess. In practice this +/// exposure window now only exists on a network's *first* connect: once +/// NetworkManager has the credential, breadcrumbs clears its local copy, so +/// there's nothing left to pass on argv for every subsequent connect. pub fn connect_verbose(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> Result<(), String> { let wait_s = wait.to_string(); - let timeout = Duration::from_secs(wait as u64 + 15); if let Some(profile) = first_profile_for_ssid(&net.ssid) { + // Update the saved credentials and, for hidden networks, ensure the + // flag is set. PSK vs 802.1x (enterprise) profiles are updated with + // their own property sets. + if let Some(pw) = &net.password { + if net.eap.is_some() { + enterprise_modify(&profile, net); + } else { + let _ = run( + "nmcli", + &[ + "connection", + "modify", + &profile, + "802-11-wireless-security.psk", + pw.as_str(), + ], + Duration::from_secs(6), + ); + } + } if net.hidden { let _ = run( "nmcli", @@ -341,52 +426,44 @@ pub fn connect_verbose(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> R Duration::from_secs(6), ); } - let o = if let Some(pw) = &net.password { - // WHY: a stored PSK makes NM skip the secret agent, so --ask - // would never read stdin. Resetting the property (empty value, - // not a secret) forces a request; the new PSK arrives on stdin. - let _ = run( - "nmcli", - &[ - "connection", - "modify", - &profile, - "802-11-wireless-security.psk", - "", - ], - Duration::from_secs(6), - ); - let stdin = format!("{pw}\n"); - run_with_stdin( - "nmcli", - &[ - "--ask", - "--wait", - &wait_s, - "connection", - "up", - &profile, - "ifname", - iface, - ], - Some(&stdin), - timeout, - ) - } else { - run( - "nmcli", - &[ - "--wait", - &wait_s, - "connection", - "up", - &profile, - "ifname", - iface, - ], - timeout, - ) - }; + let o = run( + "nmcli", + &["--wait", &wait_s, "connection", "up", &profile, "ifname", iface], + Duration::from_secs(wait as u64 + 15), + ); + if !o.success { + let detail = o.stderr.trim().to_string(); + return Err(if detail.is_empty() { + o.stdout.trim().to_string() + } else { + detail + }); + } + if let Some(uuid) = active_uuid(iface) { + enforce_dns(&uuid, iface, dns); + } + return Ok(()); + } + + if net.eap.is_some() { + // Enterprise networks can't be created via `device wifi connect` + // (no 802-1x options) — create the profile explicitly, then bring + // it up. + let args = enterprise_create_args(net, &wait_s, iface); + let o = run("nmcli", &args, Duration::from_secs(wait as u64 + 15)); + if !o.success { + let detail = o.stderr.trim().to_string(); + return Err(if detail.is_empty() { + o.stdout.trim().to_string() + } else { + detail + }); + } + let o = run( + "nmcli", + &["--wait", &wait_s, "connection", "up", &net.ssid, "ifname", iface], + Duration::from_secs(wait as u64 + 15), + ); if !o.success { let detail = o.stderr.trim().to_string(); return Err(if detail.is_empty() { @@ -403,51 +480,29 @@ pub fn connect_verbose(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> R // No saved profile — create one via device wifi connect. let hidden = if net.hidden { "yes" } else { "no" }; - let o = if let Some(pw) = &net.password { - // WHY: never put the PSK on argv — /proc//cmdline is - // world-readable. `nmcli --ask` registers as a secret agent and - // nmc_readline reads the PSK from stdin (one line). Open networks - // stay on the no-ask path so we don't hang on a prompt. - let stdin = format!("{pw}\n"); - run_with_stdin( - "nmcli", - &[ - "--ask", - "--wait", - &wait_s, - "device", - "wifi", - "connect", - net.ssid.as_str(), - "hidden", - hidden, - "ifname", - iface, - ], - Some(&stdin), - timeout, - ) - } else { - // No local PSK: either the SSID is open, or NM should already hold - // the secret — the latter only succeeds if a saved profile exists, - // which is why we only reach this branch when that assumption held. - run( - "nmcli", - &[ - "--wait", - &wait_s, - "device", - "wifi", - "connect", - net.ssid.as_str(), - "hidden", - hidden, - "ifname", - iface, - ], - timeout, - ) - }; + let mut args: Vec<&str> = vec![ + "--wait", + &wait_s, + "device", + "wifi", + "connect", + net.ssid.as_str(), + ]; + // Only pass `password` when we actually have one. An empty/missing PSK + // argument makes nmcli treat the network as open (no security), which is + // what we want both for genuinely open SSIDs and for a network whose + // secret NetworkManager should already hold — though the latter case + // only succeeds if a saved profile in fact exists, which is why we only + // reach this branch (no saved profile found) when that assumption held. + if let Some(pw) = &net.password { + args.push("password"); + args.push(pw.as_str()); + } + args.push("hidden"); + args.push(hidden); + args.push("ifname"); + args.push(iface); + let o = run("nmcli", &args, Duration::from_secs(wait as u64 + 15)); if !o.success { let detail = o.stderr.trim().to_string(); return Err(if detail.is_empty() { @@ -462,6 +517,97 @@ pub fn connect_verbose(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> R Ok(()) } +/// `nmcli connection modify` args switching an existing profile to the +/// network's 802.1x settings (`wifi-sec.key-mgmt wpa-eap` + 802-1x props). +fn enterprise_modify(profile: &str, net: &NetworkDef) { + let mut args: Vec<&str> = vec![ + "connection", + "modify", + profile, + "wifi-sec.key-mgmt", + "wpa-eap", + ]; + enterprise_props(&mut args, net); + let _ = run("nmcli", &args, Duration::from_secs(6)); +} + +/// Append the 802.1x property pairs for `net` to `args`. +fn enterprise_props<'a>(args: &mut Vec<&'a str>, net: &'a NetworkDef) { + if let Some(eap) = &net.eap { + args.push("802-1x.eap"); + args.push(eap.as_str()); + } + if let Some(id) = &net.identity { + args.push("802-1x.identity"); + args.push(id.as_str()); + } + if let Some(ca) = &net.ca_cert { + args.push("802-1x.ca-cert"); + args.push(ca.as_str()); + } + if let Some(pw) = &net.password { + args.push("802-1x.password"); + args.push(pw.as_str()); + } +} + +/// `nmcli connection add` args for an enterprise (802.1x) network — the +/// create path, since `device wifi connect` can't express 802-1x settings. +fn enterprise_create_args<'a>(net: &'a NetworkDef, wait_s: &'a str, iface: &'a str) -> Vec<&'a str> { + let mut args: Vec<&str> = vec![ + "--wait", + wait_s, + "connection", + "add", + "type", + "wifi", + "con-name", + net.ssid.as_str(), + "ssid", + net.ssid.as_str(), + "wifi-sec.key-mgmt", + "wpa-eap", + ]; + enterprise_props(&mut args, net); + if net.hidden { + args.push("802-11-wireless.hidden"); + args.push("yes"); + } + args.push("ifname"); + args.push(iface); + args +} + +/// List all wireless connection profiles as `(name, ssid)` pairs, using the +/// profile's `802-11-wireless.ssid` setting when present (falling back to +/// the profile name). Used by `breadcrumbs prune`. +pub fn wireless_profiles() -> Vec<(String, String)> { + let list = run( + "nmcli", + &["-t", "-f", "NAME,TYPE", "connection", "show"], + Duration::from_secs(8), + ); + let mut out = Vec::new(); + if !list.success { + return out; + } + for line in list.stdout.lines() { + let fields = split_fields(line); + if fields.len() < 2 || !fields[1].contains("wireless") { + continue; + } + let name = fields[0].clone(); + let conn_ssid = run( + "nmcli", + &["-g", "802-11-wireless.ssid", "connection", "show", &name], + Duration::from_secs(6), + ); + let conn_ssid = conn_ssid.stdout.trim().to_string(); + out.push((name.clone(), if conn_ssid.is_empty() { name } else { conn_ssid })); + } + out +} + /// Delete every saved connection profile whose name or 802-11-wireless SSID /// matches `ssid` (used by `breadcrumbs forget` to purge stale entries). pub fn delete_connections_for_ssid(ssid: &str) -> bool { diff --git a/src/status.rs b/src/status.rs index 0412125..8e0fe71 100644 --- a/src/status.rs +++ b/src/status.rs @@ -5,7 +5,19 @@ use crate::nm; use crate::tailscale::{self, TsHealth}; use crate::util::{command_exists, run}; -pub fn internet_ok(cfg: &Config) -> bool { +/// Connectivity verdict. `Portal` is the interesting case: an HTTP response +/// arrived (200/301/302) but it wasn't the 204 the generate_204 endpoint +/// returns for genuine internet — the classic captive/guest-portal +/// signature, and the reason `classify` can tell "no internet at all" from +/// "internet but intercepted". +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Connectivity { + Online, + Portal, + NoNet, +} + +pub fn connectivity(cfg: &Config) -> Connectivity { if command_exists("curl") { let o = run( "curl", @@ -22,21 +34,35 @@ pub fn internet_ok(cfg: &Config) -> bool { Duration::from_secs(6), ); // Only a 204 counts as real internet. Captive/guest portals answer - // 200 (a login page) or 302 (a redirect to it) — accepting those - // would classify a portal-trapped device as "Up". The default - // endpoint is generate_204, which returns 204 precisely when - // traffic isn't being intercepted. - if o.stdout.trim() == "204" { - return true; + // 200 (a login page) or 302 (a redirect to it). The default endpoint + // is generate_204, which returns 204 precisely when traffic isn't + // being intercepted. + let code = o.stdout.trim(); + if code == "204" { + return Connectivity::Online; + } + if code == "200" || code == "301" || code == "302" { + return Connectivity::Portal; } } - // Fallback: ICMP to the configured host. - run( + // Fallback: ICMP to the configured host. A working ping overrides a + // non-204 curl answer that wasn't portal-shaped (e.g. a 403 from an + // overzealous firewall); a portal usually blocks ICMP too, so this + // stays Portal for the genuine case. + let ping = run( "ping", &["-c", "1", "-W", "2", &cfg.settings.ping_host], Duration::from_secs(4), - ) - .success + ); + if ping.success { + Connectivity::Online + } else { + Connectivity::NoNet + } +} + +pub fn internet_ok(cfg: &Config) -> bool { + matches!(connectivity(cfg), Connectivity::Online) } fn ipv4(iface: &str) -> Option { @@ -61,28 +87,40 @@ pub struct Status { pub ssid: Option, pub ip: Option, pub internet: bool, + /// True when traffic is being intercepted (captive/guest portal). + pub portal: bool, pub tailscale_required: bool, pub tailscale: Option, pub exit_node: String, } pub fn gather(cfg: &Config, profile_name: &str) -> Status { - let iface = nm::wifi_interface(); + let iface = nm::wifi_interface_preferred(cfg.settings.interface.as_deref()); let ssid = iface.as_deref().and_then(nm::active_ssid); let ip = iface.as_deref().and_then(ipv4); // Skip the (potentially 4s-blocking) connectivity probe when there's no // Wi-Fi interface at all: the watch loop classifies NoAdapter and would // otherwise burn a network round-trip (curl/ping) every tick for nothing. - let internet = iface.is_some() && internet_ok(cfg); + let (internet, portal) = if iface.is_some() { + match connectivity(cfg) { + Connectivity::Online => (true, false), + Connectivity::Portal => (false, true), + Connectivity::NoNet => (false, false), + } + } else { + (false, false) + }; let prof = cfg.profile(profile_name); let ts_required = prof.map(|p| p.tailscale).unwrap_or(false); - let exit_node = prof - .and_then(|p| p.exit_node.clone()) - .unwrap_or_else(|| cfg.settings.exit_node.clone()); + let exit_nodes = cfg.exit_nodes_for(profile_name); + let exit_node = exit_nodes.first().cloned().unwrap_or_default(); + // Checked whenever tailscale is installed so `status`/`doctor` can show + // it even for non-required profiles; classify only consults it when the + // profile requires Tailscale. let tailscale = if tailscale::installed() { - Some(tailscale::check(&exit_node)) + Some(tailscale::check(&exit_nodes)) } else { None }; @@ -92,6 +130,7 @@ pub fn gather(cfg: &Config, profile_name: &str) -> Status { ssid, ip, internet, + portal, tailscale_required: ts_required, tailscale, exit_node, diff --git a/src/tailscale.rs b/src/tailscale.rs index a512728..294d9ec 100644 --- a/src/tailscale.rs +++ b/src/tailscale.rs @@ -33,6 +33,21 @@ impl TsHealth { matches!(self, TsHealth::Ok) } + /// Wire name for `bread.crumbs.*` event payloads (variant name, like + /// `Health::as_str`). + pub fn state_str(&self) -> &'static str { + match self { + TsHealth::Ok => "Ok", + TsHealth::NotInstalled => "NotInstalled", + TsHealth::NeedsLogin => "NeedsLogin", + TsHealth::Stopped => "Stopped", + TsHealth::ExitNodeMissing => "ExitNodeMissing", + TsHealth::ExitNodeOffline => "ExitNodeOffline", + TsHealth::NoExitNode => "NoExitNode", + TsHealth::Error(_) => "Error", + } + } + pub fn describe(&self) -> String { match self { TsHealth::Ok => "ok".into(), @@ -224,13 +239,59 @@ fn run_login() { } } -/// Bring Tailscale to a state where `node` is the active, online exit node. -/// Performs at most one bring-up/login and one `tailscale set` attempt. -pub fn ensure_exit_node(node: &str) -> TsHealth { +/// Strip whitespace and drop empty entries from the acceptable-node list. +fn effective_nodes(nodes: &[String]) -> Vec { + nodes + .iter() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() +} + +/// Given a status JSON and the acceptable exit nodes, report `Ok` when the +/// active selection is one of them and online; otherwise the closest +/// actionable failure: not-selected (present + online, flow must select), +/// offline, or missing. +fn exit_node_health(nodes: &[String], v: &Value) -> TsHealth { + let mut any_exists = false; + let mut any_online = false; + let mut any_selected = false; + for node in nodes { + let (exists, online, selected) = exit_node_state(v, node); + if exists { + any_exists = true; + if online { + any_online = true; + } + if selected { + any_selected = true; + } + } + } + if any_selected && any_online { + TsHealth::Ok + } else if any_selected { + // The active exit node is one of ours but offline. + TsHealth::ExitNodeOffline + } else if any_online { + // Present + online but not selected — the flow will select it. + TsHealth::Error("exit node not selected".into()) + } else if any_exists { + TsHealth::ExitNodeOffline + } else { + TsHealth::ExitNodeMissing + } +} + +/// Bring Tailscale to a state where one of `nodes` (priority order) is the +/// active, online exit node. Performs at most one bring-up/login, then one +/// `tailscale set` per node until one takes. +pub fn ensure_exit_node(nodes: &[String]) -> TsHealth { if !installed() { return TsHealth::NotInstalled; } - if node.trim().is_empty() { + let eff = effective_nodes(nodes); + if eff.is_empty() { // Never run `tailscale set --exit-node=` with an empty node — that // would clear the user's current exit-node selection. This is a // config error, surfaced as its own health state. @@ -274,44 +335,41 @@ pub fn ensure_exit_node(node: &str) -> TsHealth { _ => {} } - // Select the exit node (idempotent). - let _ = run( - "tailscale", - &["set", &format!("--exit-node={node}")], - Duration::from_secs(10), - ); + // Failover: try each acceptable node in priority order until one is + // selected and online. + for node in &eff { + let _ = run( + "tailscale", + &["set", &format!("--exit-node={node}")], + Duration::from_secs(10), + ); + if let Some(v2) = status_json() { + if matches!(exit_node_health(std::slice::from_ref(node), &v2), TsHealth::Ok) { + return TsHealth::Ok; + } + } + } let v = match status_json() { Some(v) => v, None => return TsHealth::Error("could not re-read tailscale status".into()), }; - match backend_state(&v).as_str() { "Running" => {} "NeedsLogin" | "NoState" => return TsHealth::NeedsLogin, "Stopped" => return TsHealth::Stopped, other => return TsHealth::Error(format!("backend state: {other}")), } - - let (exists, online, selected) = exit_node_state(&v, node); - if !exists { - TsHealth::ExitNodeMissing - } else if !online { - TsHealth::ExitNodeOffline - } else if !selected { - // Online and present but our set didn't take — treat as missing/selectable error. - TsHealth::Error("exit node not selected".into()) - } else { - TsHealth::Ok - } + exit_node_health(&eff, &v) } /// Lightweight health check without trying to (re)configure anything. -pub fn check(node: &str) -> TsHealth { +pub fn check(nodes: &[String]) -> TsHealth { if !installed() { return TsHealth::NotInstalled; } - if node.trim().is_empty() { + let eff = effective_nodes(nodes); + if eff.is_empty() { // Read-only check, so never runs `tailscale set` — an empty node is // a config error, not something this probe can fix. return TsHealth::NoExitNode; @@ -326,16 +384,7 @@ pub fn check(node: &str) -> TsHealth { "Stopped" => return TsHealth::Stopped, other => return TsHealth::Error(format!("backend state: {other}")), } - let (exists, online, selected) = exit_node_state(&v, node); - if !exists { - TsHealth::ExitNodeMissing - } else if !online { - TsHealth::ExitNodeOffline - } else if !selected { - TsHealth::Error("exit node not selected".into()) - } else { - TsHealth::Ok - } + exit_node_health(&eff, &v) } #[cfg(test)] @@ -486,4 +535,76 @@ mod tests { ); assert_eq!(extract_url("no url on this line"), None); } + + #[test] + fn effective_nodes_trims_and_drops_empties() { + assert_eq!( + effective_nodes(&[" a ".into(), "".into(), "b".into()]), + vec!["a".to_string(), "b".to_string()] + ); + } + + #[test] + fn exit_node_health_ok_when_any_node_selected_and_online() { + let v = json!({ + "BackendState": "Running", + "Peer": { + "k1": { "HostName": "nodeB", "DNSName": "nodeB.ts.net.", + "Online": true, "ExitNode": true, "ExitNodeOption": true } + } + }); + assert_eq!( + exit_node_health(&["nodeA".into(), "nodeB".into()], &v), + TsHealth::Ok + ); + } + + #[test] + fn exit_node_health_reports_not_selected_when_a_node_is_online_but_unselected() { + let v = json!({ + "Peer": { + "k1": { "HostName": "nodeA", "DNSName": "nodeA.ts.net.", + "Online": true, "ExitNode": false, "ExitNodeOption": true } + } + }); + assert_eq!( + exit_node_health(&["nodeA".into()], &v), + TsHealth::Error("exit node not selected".into()) + ); + } + + #[test] + fn exit_node_health_reports_offline_when_all_exist_but_none_online() { + let v = json!({ + "Peer": { + "k1": { "HostName": "nodeA", "DNSName": "nodeA.ts.net.", + "Online": false, "ExitNode": false, "ExitNodeOption": true } + } + }); + assert_eq!( + exit_node_health(&["nodeA".into()], &v), + TsHealth::ExitNodeOffline + ); + } + + #[test] + fn exit_node_health_reports_missing_when_no_node_present() { + let v = json!({ "BackendState": "Running" }); + assert_eq!( + exit_node_health(&["nodeA".into()], &v), + TsHealth::ExitNodeMissing + ); + } + + #[test] + fn state_str_is_the_variant_name() { + assert_eq!(TsHealth::Ok.state_str(), "Ok"); + assert_eq!(TsHealth::NotInstalled.state_str(), "NotInstalled"); + assert_eq!(TsHealth::NeedsLogin.state_str(), "NeedsLogin"); + assert_eq!(TsHealth::Stopped.state_str(), "Stopped"); + assert_eq!(TsHealth::ExitNodeMissing.state_str(), "ExitNodeMissing"); + assert_eq!(TsHealth::ExitNodeOffline.state_str(), "ExitNodeOffline"); + assert_eq!(TsHealth::NoExitNode.state_str(), "NoExitNode"); + assert_eq!(TsHealth::Error("x".into()).state_str(), "Error"); + } } diff --git a/src/util.rs b/src/util.rs index de8eed3..2a5ed41 100644 --- a/src/util.rs +++ b/src/util.rs @@ -193,6 +193,19 @@ fn spawn_run(prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration) } } +/// Current local "HH:MM" (24h), for the time-of-day schedule. `None` if the +/// clock can't be read — the schedule is skipped, never guessed. +pub fn local_hhmm() -> Option { + let o = run("date", &["+%H:%M"], Duration::from_secs(2)); + if o.success { + let t = o.stdout.trim().to_string(); + if t.len() == 5 && t.as_bytes()[2] == b':' { + return Some(t); + } + } + None +} + /// Local "YYYY-MM-DD HH:MM:SS". Uses `date` for correct local time, falling /// back to a dependency-free UTC computation if it is unavailable. pub fn timestamp() -> String { diff --git a/src/watch.rs b/src/watch.rs index 2ff8881..3d64182 100644 --- a/src/watch.rs +++ b/src/watch.rs @@ -10,7 +10,7 @@ use crate::bread_events; use crate::config::Config; use crate::flow; use crate::notify::{log, notify, Urgency}; -use crate::state::State; +use crate::state::{self, State}; use crate::status::{self}; use crate::tailscale::TsHealth; @@ -23,6 +23,10 @@ use crate::tailscale::TsHealth; pub enum Health { Up, DownNoNet, + /// Traffic is being intercepted — a captive/guest portal answered the + /// connectivity check with 200/301/302 instead of 204. Not something + /// reconnecting fixes; the user must sign in. + CaptivePortal, DownTailscaleManual, DownTailscaleOther, NoAdapter, @@ -38,6 +42,7 @@ impl Health { match self { Health::Up => "Up", Health::DownNoNet => "DownNoNet", + Health::CaptivePortal => "CaptivePortal", Health::DownTailscaleManual => "DownTailscaleManual", Health::DownTailscaleOther => "DownTailscaleOther", Health::NoAdapter => "NoAdapter", @@ -46,34 +51,71 @@ impl Health { } } -pub fn classify(cfg: &Config, profile: &str) -> (Health, Option) { +/// Everything the watch loop needs to know about one health observation: +/// the classification plus the context used for events and notifications. +#[derive(Debug, Clone)] +pub struct Classification { + pub health: Health, + pub ssid: Option, + pub iface: Option, + pub ip: Option, + pub tailscale: Option, + pub exit_node: String, +} + +pub fn classify(cfg: &Config, profile: &str) -> Classification { // Checked before gather(): a profile missing from config would otherwise // silently fall back to "tailscale not required" and read as healthy off // of nothing but a bare internet check, never surfacing the misconfig. if cfg.profile(profile).is_none() { - return (Health::UnknownProfile, None); + return Classification { + health: Health::UnknownProfile, + ssid: None, + iface: None, + ip: None, + tailscale: None, + exit_node: String::new(), + }; } let s = status::gather(cfg, profile); if s.iface.is_none() { - return (Health::NoAdapter, None); + return Classification { + health: Health::NoAdapter, + ssid: None, + iface: None, + ip: None, + tailscale: None, + exit_node: s.exit_node, + }; } let ssid = s.ssid.clone(); - if !s.internet { - return (Health::DownNoNet, ssid); - } - if s.tailscale_required { + let health = if !s.internet { + if s.portal { + Health::CaptivePortal + } else { + Health::DownNoNet + } + } else if s.tailscale_required { match s.tailscale { - Some(TsHealth::Ok) => (Health::Up, ssid), + Some(TsHealth::Ok) => Health::Up, // NeedsLogin / NotInstalled / NoExitNode all need human action: // a missing exit-node config can't be auto-fixed either. Some(TsHealth::NeedsLogin) | Some(TsHealth::NotInstalled) - | Some(TsHealth::NoExitNode) => (Health::DownTailscaleManual, ssid), - Some(_) => (Health::DownTailscaleOther, ssid), - None => (Health::DownTailscaleManual, ssid), + | Some(TsHealth::NoExitNode) => Health::DownTailscaleManual, + Some(_) => Health::DownTailscaleOther, + None => Health::DownTailscaleManual, } } else { - (Health::Up, ssid) + Health::Up + }; + Classification { + health, + ssid, + iface: s.iface, + ip: s.ip, + tailscale: s.tailscale, + exit_node: s.exit_node, } } @@ -86,6 +128,15 @@ fn debounce_ready(last: Option, gap: Duration) -> bool { last.map(|t| t.elapsed() > gap).unwrap_or(true) } +/// Whether the flow-recovery cooldown has elapsed since the last `flow::run` +/// (or one never ran). Pure so the recovery pacing is unit-testable. +fn recovery_due(last_flow_at: Option, now: Instant, cooldown_secs: u64) -> bool { + last_flow_at + .map(|t| now.duration_since(t).as_secs()) + .unwrap_or(u64::MAX) + >= cooldown_secs +} + /// A wake signal for the watch loop. `SetProfile` is an *action* (applied on /// the loop thread), `LinkChurn` is just "go look" — the distinction keeps /// every config/state file access on the single loop thread, so the bread @@ -208,8 +259,8 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { let mut profile = State::load(&cfg.settings.default_profile).profile; if run_initial { // Don't churn an already-working connection on (re)start. - let (h, _) = classify(&cfg, &profile); - if h == Health::Up { + let class = classify(&cfg, &profile); + if class.health == Health::Up { log(&format!( "watch: already healthy on start (profile={profile}); skipping initial flow" )); @@ -221,9 +272,20 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { let mut prev_health: Option = None; let mut prev_profile = profile.clone(); + let mut prev_ssid: Option = None; + let mut prev_ts: Option<&'static str> = None; let mut fail_streak: u32 = 0; let mut last_flow_at: Option = None; const FLOW_COOLDOWN: u64 = 20; + const RESUME_SLACK: Duration = Duration::from_secs(60); + const SCHEDULE_GRACE: Duration = Duration::from_secs(30 * 60); + let mut prev_wait = Duration::from_secs(base); + let mut last_tick_at = Instant::now(); + // Tracks what the *schedule* last applied, so the loop can tell a + // manual `profile set` (CLI or bus) apart from its own switch and give + // manual changes a grace window before the schedule overrides them. + let mut last_schedule_applied: Option = Some(profile.clone()); + let mut manual_set_at: Option = None; loop { // Reload config + state so edits and `profile set` take effect live. @@ -239,6 +301,45 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { } profile = State::load(&cfg.settings.default_profile).profile; + // Suspend/resume: `nmcli monitor` sees nothing while the machine + // sleeps, so a large wall-clock gap means the network state may have + // changed underneath us — allow an immediate recovery run instead of + // waiting out any remaining flow cooldown. + if last_tick_at.elapsed() > prev_wait + RESUME_SLACK { + log("watch: large gap since last tick (suspend/resume?) — forcing recovery check"); + last_flow_at = None; + } + + // Time-of-day schedule: switch to the scheduled profile when its + // window is active, unless the user manually set the profile within + // the grace window. + if last_schedule_applied.as_deref() != Some(profile.as_str()) { + // The persisted profile changed and it wasn't our own schedule + // switch — a manual set (CLI or bus). Start the grace window. + if last_schedule_applied.is_some() { + manual_set_at = Some(Instant::now()); + } + last_schedule_applied = Some(profile.clone()); + } + if let Some(sched) = scheduled_profile_now(&cfg) { + if sched != profile { + let grace_ok = manual_set_at + .map(|t| t.elapsed() >= SCHEDULE_GRACE) + .unwrap_or(true); + if grace_ok { + if state::set_profile(&cfg, &sched).is_ok() { + log(&format!("watch: schedule applied profile {sched}")); + last_schedule_applied = Some(sched.clone()); + manual_set_at = None; + } + } else { + log(&format!( + "watch: schedule would switch to {sched}, but a manual set is still in grace" + )); + } + } + } + let profile_changed = profile != prev_profile; if profile_changed { log(&format!( @@ -252,13 +353,42 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { bread_events::emit_profile_changed(&bread, &prev_profile, &profile); prev_profile = profile.clone(); prev_health = None; // force re-evaluation/recovery for new profile + prev_ssid = None; // a profile switch is a fresh network context + prev_ts = None; last_flow_at = None; // allow immediate recovery on profile change } - let (health, ssid) = classify(&cfg, &profile); + let class = classify(&cfg, &profile); + let health = class.health.clone(); + let ssid = class.ssid.clone(); let transition = prev_health.as_ref() != Some(&health); if transition { - bread_events::emit_health_changed(&bread, &profile, health.as_str(), ssid.as_deref()); + bread_events::emit_health_changed( + &bread, + bread_events::HealthChanged { + profile: &profile, + health: health.as_str(), + ssid: ssid.as_deref(), + iface: class.iface.as_deref(), + ip: class.ip.as_deref(), + exit_node: &class.exit_node, + tailscale: class.tailscale.as_ref().map(|t| t.state_str()), + }, + ); + } + if class.ssid != prev_ssid { + bread_events::emit_network_changed( + &bread, + prev_ssid.as_deref(), + class.ssid.as_deref(), + &profile, + ); + prev_ssid = class.ssid.clone(); + } + let ts_state = class.tailscale.as_ref().map(|t| t.state_str()); + if ts_state != prev_ts { + bread_events::emit_tailscale_changed(&bread, &profile, ts_state, &class.exit_node); + prev_ts = ts_state; } match &health { @@ -297,6 +427,19 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { } fail_streak = fail_streak.saturating_add(1); } + Health::CaptivePortal => { + if transition { + notify( + "breadcrumbs: captive portal detected", + "Traffic is being intercepted — open a browser and sign in.", + Urgency::Normal, + ); + } + // Reconnecting won't fix a portal; keep the poll fast (don't + // count it as a failure) so a successful sign-in is noticed + // promptly and the state flips back to Up. + fail_streak = 0; + } Health::DownTailscaleManual => { // Can't be auto-fixed (login / install / exit-node config). // Notify once per transition. @@ -313,8 +456,7 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { // state: login may have completed since the last attempt, or // the user may have missed the browser window. Quiet — a // still-broken state must not re-notify on every retry. - let elapsed = last_flow_at.map(|t| t.elapsed().as_secs()).unwrap_or(u64::MAX); - if elapsed >= FLOW_COOLDOWN { + if recovery_due(last_flow_at, Instant::now(), FLOW_COOLDOWN) { let outcome = flow::run_quiet(&mut cfg, &profile); last_flow_at = Some(Instant::now()); fail_streak = if outcome.ok() { @@ -332,10 +474,7 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { Urgency::Normal, ); } - let elapsed = last_flow_at - .map(|t| t.elapsed().as_secs()) - .unwrap_or(u64::MAX); - if elapsed >= FLOW_COOLDOWN { + if recovery_due(last_flow_at, Instant::now(), FLOW_COOLDOWN) { log(&format!( "watch: down ({:?}) profile={profile} ssid={:?} — running flow", health, ssid @@ -350,7 +489,7 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { }; } else { log(&format!( - "watch: down ({:?}) — cooldown ({elapsed}s/{FLOW_COOLDOWN}s), skipping flow", + "watch: down ({:?}) — cooldown, skipping flow", health )); } @@ -362,6 +501,8 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { // Adaptive backoff: healthy -> base; failing -> grow up to ~6x. let mult = 1 + fail_streak.min(5); let dur = Duration::from_secs(base * mult as u64); + prev_wait = dur; + last_tick_at = Instant::now(); // Apply a queued set_profile on this thread — the single owner of // config/state file access — and emit the confirmation. The next // iteration's reload sees the new profile and recovers accordingly. @@ -371,6 +512,14 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { } } +/// The profile a time-of-day schedule wants right now, if any. Returns +/// `None` when no schedule is configured or the local time can't be read. +fn scheduled_profile_now(cfg: &Config) -> Option { + let hhmm = crate::util::local_hhmm()?; + let mins = crate::config::hhmm_to_minutes(&hhmm)?; + cfg.settings.scheduled_profile(mins) +} + #[cfg(test)] mod tests { use super::*; @@ -405,4 +554,48 @@ mod tests { assert_eq!(Health::NoAdapter.as_str(), "NoAdapter"); assert_eq!(Health::UnknownProfile.as_str(), "UnknownProfile"); } + + #[test] + fn wait_for_tick_returns_pending_set_profile_and_drains_churn() { + // A queued set_profile is an action, not a signal: it must survive + // the churn-burst drain and be returned to the loop. + let (tx, rx) = mpsc::channel::(); + let _ = tx.send(Wake::LinkChurn); + let _ = tx.send(Wake::SetProfile("home".into())); + let _ = tx.send(Wake::LinkChurn); + + let wake = wait_for_tick(&rx, Duration::from_millis(10)); + assert!(matches!(wake, Some(Wake::SetProfile(n)) if n == "home")); + // The burst was fully drained. + assert!(rx.try_recv().is_err()); + } + + #[test] + fn wait_for_tick_drains_churn_burst_without_action() { + // A burst of monitor signals collapses to one wake with no action. + let (tx, rx) = mpsc::channel::(); + let _ = tx.send(Wake::LinkChurn); + let _ = tx.send(Wake::LinkChurn); + let _ = tx.send(Wake::LinkChurn); + + assert!(wait_for_tick(&rx, Duration::from_millis(10)).is_none()); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn wait_for_tick_times_out_with_no_signal() { + let (_tx, rx) = mpsc::channel::(); + assert!(wait_for_tick(&rx, Duration::from_millis(10)).is_none()); + } + + #[test] + fn recovery_due_fires_when_never_run_and_after_cooldown() { + // Never run → due immediately (the map() to u64::MAX path). + assert!(recovery_due(None, Instant::now(), 20)); + // Just ran → not due again yet. + let now = Instant::now(); + assert!(!recovery_due(Some(now), now, 20)); + // A zero cooldown is always already-elapsed. + assert!(recovery_due(Some(now), now, 0)); + } } diff --git a/tests/cli.rs b/tests/cli.rs index 87c7e2d..c5a0109 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -426,6 +426,8 @@ case "$args" in "device wifi rescan"*) ;; "-t -f SSID device wifi list ifname wlan0") echo "TestNet" ;; + "-t -f SSID,SIGNAL device wifi list ifname wlan0") + echo "TestNet:80" ;; "-t -f ACTIVE,SSID device wifi list ifname wlan0") echo "yes:TestNet" ;; "-t -f NAME,TYPE connection show") @@ -566,6 +568,8 @@ case "$args" in "device wifi rescan"*) ;; "-t -f SSID device wifi list ifname wlan0") echo "CorpWifi" ;; + "-t -f SSID,SIGNAL device wifi list ifname wlan0") + echo "CorpWifi:80" ;; *) ;; esac exit 0 @@ -590,3 +594,350 @@ fn detect_picks_profile_whose_detect_ssids_are_visible() { assert!(o.status.success(), "stderr: {}", stderr(&o)); assert_eq!(stdout(&o).trim(), "work"); } + +// ----------------------------------------------------------------------- +// Regression tests for the audit fixes (XDG paths, EDITOR args, config +// merging/clamping, core-profile ownership, scan validation). +// ----------------------------------------------------------------------- + +#[test] +fn edit_splits_editor_arguments() { + // EDITOR="code -w" style values must be split into program + args + // instead of being treated as one (nonexistent) binary path. + let sb = Sandbox::new(); + sb.write_fake_bin( + "fake-editor", + "#!/bin/sh\necho \"$@\" > \"$HOME/editor-args\"\nexit 0\n", + ); + + let o = sb.cmd_env(&["edit"], &[("EDITOR", "fake-editor --wait")]); + assert!(o.status.success(), "stderr: {}", stderr(&o)); + assert!(stdout(&o).contains("config OK")); + let args = fs::read_to_string(sb.root.join("editor-args")).unwrap(); + assert!(args.contains("--wait"), "editor args must be split off: {args}"); + assert!( + args.contains("breadcrumbs.toml"), + "config path must be appended as its own argument: {args}" + ); +} + +#[test] +fn scan_to_unknown_profile_errors_like_add() { + // `scan --to bogus` must fail up front, matching `add --to`, instead of + // silently saving a network that never gets attached. + let sb = Sandbox::new(); + sb.cmd(&["list"]); // bootstrap the config + let o = sb.cmd(&["scan", "--to", "bogus"]); + assert!(!o.status.success()); + assert!( + stderr(&o).contains("unknown profile 'bogus'"), + "stderr: {}", + stderr(&o) + ); +} + +#[test] +fn watch_interval_below_minimum_is_clamped_to_four() { + // `list` and the watch loop must agree on the poll interval: a value + // below the documented minimum of 4 is clamped at load, not just + // silently clamped inside the watch loop. + let sb = Sandbox::new(); + fs::create_dir_all(sb.root.join("config/breadcrumbs")).unwrap(); + fs::write(sb.config_file(), "[settings]\nwatch_interval = 1\n").unwrap(); + + let o = sb.cmd(&["list"]); + assert!(o.status.success(), "stderr: {}", stderr(&o)); + assert!( + stdout(&o).contains("watch every 4s"), + "watch interval must clamp to the minimum: {}", + stdout(&o) + ); +} + +#[test] +fn legacy_inline_networks_merge_with_networks_toml_instead_of_dropping() { + // A breadcrumbs.toml still carrying a legacy inline `[[networks]]` block + // must keep those networks even when networks.toml already exists — the + // merge is completed (and the inline block dropped) on the next save. + let sb = Sandbox::new(); + fs::create_dir_all(sb.root.join("config/breadcrumbs")).unwrap(); + fs::write( + sb.config_file(), + "[settings]\ndefault_profile = \"away\"\n\n[[networks]]\nssid = \"InlineNet\"\npassword = \"pw-inline\"\n", + ) + .unwrap(); + fs::write( + sb.networks_file(), + "[[networks]]\nssid = \"FileNet\"\npassword = \"pw-file\"\n", + ) + .unwrap(); + + let o = sb.cmd(&["list"]); + assert!(o.status.success(), "stderr: {}", stderr(&o)); + let out = stdout(&o); + assert!(out.contains("InlineNet"), "inline network must survive the merge: {out}"); + assert!(out.contains("FileNet"), "networks.toml network must be present: {out}"); + + // A later save migrates the merged set into networks.toml and drops the + // inline block from breadcrumbs.toml. + let o2 = sb.cmd(&["add", "OtherNet", "pw3"]); + assert!(o2.status.success(), "stderr: {}", stderr(&o2)); + let networks = fs::read_to_string(sb.networks_file()).unwrap(); + assert!( + networks.contains("InlineNet") + && networks.contains("FileNet") + && networks.contains("OtherNet"), + "save must persist the merged set: {networks}" + ); + let config_text = fs::read_to_string(sb.config_file()).unwrap(); + assert!( + !config_text.contains("[[networks]]"), + "inline block should be gone after migration: {config_text}" + ); +} + +#[test] +fn core_profiles_are_not_resurrected_once_config_is_user_owned() { + // After the first save the config is user-owned: a deliberately deleted + // core profile must stay deleted. + let sb = Sandbox::new(); + fs::create_dir_all(sb.root.join("config/breadcrumbs")).unwrap(); + fs::write( + sb.config_file(), + "[settings]\ncore_profiles_initialized = true\n", + ) + .unwrap(); + + let o = sb.cmd(&["profile", "list"]); + assert!(o.status.success(), "stderr: {}", stderr(&o)); + let out = stdout(&o); + assert!( + !out.contains("home") && !out.contains("work") && !out.contains("away"), + "deleted core profiles must stay deleted: {out}" + ); +} + +#[test] +fn legacy_config_without_profiles_gets_core_profiles_backfilled() { + // Pre-ownership configs (no flag yet) still get the core profiles + // backfilled once — the self-heal that makes bare `[settings]` configs + // usable. + let sb = Sandbox::new(); + fs::create_dir_all(sb.root.join("config/breadcrumbs")).unwrap(); + fs::write(sb.config_file(), "[settings]\n").unwrap(); + + let o = sb.cmd(&["profile", "list"]); + assert!(o.status.success(), "stderr: {}", stderr(&o)); + let out = stdout(&o); + assert!( + out.contains("home") && out.contains("work") && out.contains("away"), + "legacy configs get the core profiles backfilled once: {out}" + ); +} + +// ----------------------------------------------------------------------- +// New features: per-network DNS, enterprise (802.1x) networks, --json +// output, prune, scored detection, and init --wait retry. +// ----------------------------------------------------------------------- + +#[test] +fn add_with_dns_persists_per_network_override() { + let sb = Sandbox::new(); + let o = sb.cmd(&["add", "CafeWifi", "pw", "--dns", "9.9.9.9"]); + assert!(o.status.success(), "stderr: {}", stderr(&o)); + let networks = fs::read_to_string(sb.networks_file()).unwrap(); + assert!( + networks.contains("dns = \"9.9.9.9\""), + "per-network DNS override must be persisted: {networks}" + ); +} + +#[test] +fn add_enterprise_fields_persist() { + let sb = Sandbox::new(); + let o = sb.cmd(&[ + "add", + "CorpWifi", + "pw", + "--eap", + "peap", + "--identity", + "user@corp", + "--ca-cert", + "/etc/ca.pem", + ]); + assert!(o.status.success(), "stderr: {}", stderr(&o)); + let networks = fs::read_to_string(sb.networks_file()).unwrap(); + assert!(networks.contains("eap = \"peap\""), "networks: {networks}"); + assert!(networks.contains("identity = \"user@corp\"")); + assert!(networks.contains("ca_cert = \"/etc/ca.pem\"")); +} + +#[test] +fn status_json_emits_machine_readable_output() { + let sb = Sandbox::new(); + let o = sb.cmd(&["status", "--json"]); + // No adapter in the sandbox → unhealthy (exit 1), but still valid JSON. + assert_eq!(o.status.code(), Some(1)); + let v: serde_json::Value = + serde_json::from_str(&stdout(&o)).expect("status --json must emit valid JSON"); + assert_eq!(v["profile"].as_str(), Some("away")); + assert_eq!(v["healthy"].as_bool(), Some(false)); + assert_eq!(v["internet"].as_bool(), Some(false)); +} + +#[test] +fn detect_json_emits_machine_readable_output() { + let sb = Sandbox::new(); + sb.write_fake_bin("nmcli", FAKE_NMCLI_DETECT); + sb.cmd(&["list"]); // bootstrap the default config + + let text = fs::read_to_string(sb.config_file()).unwrap(); + let patched = text.replace( + "[profiles.work]", + "[profiles.work]\ndetect_ssids = [\"CorpWifi\"]", + ); + fs::write(sb.config_file(), patched).unwrap(); + + let o = sb.cmd(&["detect", "--json"]); + assert!(o.status.success(), "stderr: {}", stderr(&o)); + let v: serde_json::Value = + serde_json::from_str(&stdout(&o)).expect("detect --json must emit valid JSON"); + assert_eq!(v["profile"].as_str(), Some("work")); +} + +const FAKE_NMCLI_DETECT_TWO: &str = r#"#!/bin/sh +args="$*" +case "$args" in + "-t -f DEVICE,TYPE device status") + echo "wlan0:wifi" ;; + "radio wifi on") ;; + "device wifi rescan"*) ;; + "-t -f SSID,SIGNAL device wifi list ifname wlan0") + echo "CorpWifi:80" + echo "CafeWifi:70" ;; + *) ;; +esac +exit 0 +"#; + +#[test] +fn detect_prefers_profile_with_more_matching_markers() { + let sb = Sandbox::new(); + sb.write_fake_bin("nmcli", FAKE_NMCLI_DETECT_TWO); + sb.cmd(&["list"]); // bootstrap + + // home matches 1 marker (CorpWifi); work matches 2 (CorpWifi + CafeWifi). + let text = fs::read_to_string(sb.config_file()).unwrap(); + let patched = text + .replace( + "[profiles.home]", + "[profiles.home]\ndetect_ssids = [\"CorpWifi\"]", + ) + .replace( + "[profiles.work]", + "[profiles.work]\ndetect_ssids = [\"CorpWifi\", \"CafeWifi\"]", + ); + fs::write(sb.config_file(), patched).unwrap(); + + let o = sb.cmd(&["detect"]); + assert!(o.status.success(), "stderr: {}", stderr(&o)); + assert_eq!( + stdout(&o).trim(), + "work", + "the profile with more matching markers must win" + ); +} + +const FAKE_NMCLI_PRUNE: &str = r#"#!/bin/sh +args="$*" +case "$args" in + "-t -f NAME,TYPE connection show") + echo "OldCafe:802-11-wireless" ;; + "-g 802-11-wireless.ssid connection show OldCafe") + echo "OldCafe" ;; + "connection delete id OldCafe") ;; + *) ;; +esac +exit 0 +"#; + +#[test] +fn prune_dry_run_lists_stale_nm_profiles() { + let sb = Sandbox::new(); + sb.write_fake_bin("nmcli", FAKE_NMCLI_PRUNE); + sb.cmd(&["list"]); // bootstrap (no saved networks → everything is stale) + + let o = sb.cmd(&["prune", "--dry-run"]); + assert!(o.status.success(), "stderr: {}", stderr(&o)); + let out = stdout(&o); + assert!( + out.contains("would remove") && out.contains("OldCafe"), + "out: {out}" + ); +} + +#[test] +fn prune_removes_stale_nm_profiles() { + let sb = Sandbox::new(); + sb.write_fake_bin("nmcli", FAKE_NMCLI_PRUNE); + sb.cmd(&["list"]); + + let o = sb.cmd(&["prune"]); + assert!(o.status.success(), "stderr: {}", stderr(&o)); + let out = stdout(&o); + assert!( + out.contains("removed") && out.contains("OldCafe"), + "out: {out}" + ); +} + +const FAKE_NMCLI_RETRY: &str = r#"#!/bin/sh +marker="$HOME/.nmcli-connect-ok" +args="$*" +case "$args" in + "-t -f DEVICE,TYPE device status") + echo "wlan0:wifi" ;; + "radio wifi on") ;; + "device wifi rescan"*) ;; + "-t -f SSID device wifi list ifname wlan0") + echo "HomeWifi" ;; + "-t -f SSID,SIGNAL device wifi list ifname wlan0") + echo "HomeWifi:80" ;; + "-t -f ACTIVE,SSID device wifi list ifname wlan0") + echo "yes:HomeWifi" ;; + "-t -f NAME,TYPE connection show") ;; + *"device wifi connect HomeWifi"*) + if [ -f "$marker" ]; then + exit 0 + else + : > "$marker" + exit 1 + fi ;; + *"connection up HomeWifi"*) + exit 0 ;; + "-g GENERAL.CON-UUID device show wlan0") + echo "uuid-1" ;; + *"ipv4.ignore-auto-dns"*) ;; + "device reapply wlan0") ;; + "-t -f DEVICE,STATE device status") + echo "wlan0:connected" ;; + *) ;; +esac +exit 0 +"#; + +#[test] +fn init_wait_retries_until_connect_succeeds() { + let sb = Sandbox::new(); + sb.write_fake_bin("nmcli", FAKE_NMCLI_RETRY); + // "away" defaults to include_all_known, so HomeWifi is a candidate. + let add = sb.cmd(&["add", "HomeWifi", "hunter2"]); + assert!(add.status.success(), "stderr: {}", stderr(&add)); + + // The fake's first `device wifi connect` fails; the retry succeeds. + // `--wait` must keep going past the first failure rather than bailing. + let o = sb.cmd(&["init", "--wait", "5"]); + assert!(o.status.success(), "stderr: {}", stderr(&o)); + assert!(stdout(&o).contains("connected"), "out: {}", stdout(&o)); +} diff --git a/tests/flow_watch.rs b/tests/flow_watch.rs index 3fae31f..9fc207f 100644 --- a/tests/flow_watch.rs +++ b/tests/flow_watch.rs @@ -14,7 +14,6 @@ use bread_utils::bread_client::BreadEvent; use breadcrumbs::bread_events; use breadcrumbs::config::{Config, NetworkDef, Profile, Settings}; use breadcrumbs::flow; -use breadcrumbs::nm; use breadcrumbs::state::{self, State}; use breadcrumbs::util::with_runner; use breadcrumbs::watch::{classify, Health}; @@ -25,6 +24,10 @@ fn net(ssid: &str, password: Option<&str>) -> NetworkDef { NetworkDef { ssid: ssid.to_string(), password: password.map(str::to_string), + dns: None, + eap: None, + identity: None, + ca_cert: None, hidden: false, } } @@ -33,6 +36,10 @@ fn hidden_net(ssid: &str, password: Option<&str>) -> NetworkDef { NetworkDef { ssid: ssid.to_string(), password: password.map(str::to_string), + dns: None, + eap: None, + identity: None, + ca_cert: None, hidden: true, } } @@ -52,17 +59,28 @@ fn base_config() -> Config { /// the device reports connected after any successful connect attempt. fn base_nm(visible_ssids: &[&str]) -> FakeRunner { let visible = visible_ssids.join("\n"); + // `-f SSID,SIGNAL` lines: all SSIDs at the same (strong) signal, so + // priority order — not signal — decides between them. + let with_signal = visible_ssids + .iter() + .map(|s| format!("{s}:80")) + .collect::>() + .join("\n"); let runner = FakeRunner::new() .on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi")) .on_contains("nmcli", "radio wifi on", ok("")) .on_contains("nmcli", "wifi rescan", ok("")) - // Exact match: `-f ACTIVE,SSID` queries (which contain the substring - // "SSID device wifi list") must NOT be answered with the visible - // list — they go to the stateful rule below. + // Exact matches: `-f ACTIVE,SSID` queries (which contain the + // substring "SSID device wifi list") must NOT be answered with the + // visible list — they go to the stateful rule below. .on( move |_prog, args| args.join(" ") == "-t -f SSID device wifi list ifname wlan0", ok(&visible), ) + .on( + move |_prog, args| args.join(" ") == "-t -f SSID,SIGNAL device wifi list ifname wlan0", + ok(&with_signal), + ) .on_contains("nmcli", "NAME,TYPE", ok("")) // no saved profiles .on_contains("nmcli", "GENERAL.CON-UUID", ok("uuid-1")) .on_contains("nmcli", "ipv4.ignore-auto-dns", ok("")) @@ -121,7 +139,10 @@ fn flow_run_connects_to_first_visible_candidate_in_priority_order() { let _env = EnvSandbox::new(); let mut cfg = base_config(); - cfg.networks = vec![net("First", Some("pw1")), net("Second", Some("pw2"))]; + cfg.networks = vec![ + net("First", Some("pw1")), + net("Second", Some("pw2")), + ]; cfg.profiles.insert( "home".into(), Profile { @@ -147,11 +168,10 @@ fn flow_run_connects_to_first_visible_candidate_in_priority_order() { // Priority order actually mattered: "Second" was never dialed even // though it was visible and would have succeeded too. - let dialed_second = calls.borrow().iter().any(|c| { - c.prog == "nmcli" - && c.args.contains(&"connect".to_string()) - && c.args.iter().any(|a| a == "Second") - }); + let dialed_second = calls + .borrow() + .iter() + .any(|c| c.prog == "nmcli" && c.args.contains(&"connect".to_string()) && c.args.iter().any(|a| a == "Second")); assert!(!dialed_second, "connected to Second when First should win"); // The password used for the winning connect is now NM's problem, not @@ -346,7 +366,9 @@ fn classify_reports_unknown_profile_without_touching_nm() { let runner = FakeRunner::new(); let calls = runner.calls_handle(); - let (health, ssid) = with_runner(runner, || classify(&cfg, "ghost")); + let class = with_runner(runner, || classify(&cfg, "ghost")); + let health = class.health; + let ssid = class.ssid; assert_eq!(health, Health::UnknownProfile); assert_eq!(ssid, None); @@ -361,7 +383,8 @@ fn classify_reports_no_adapter_when_wifi_interface_absent() { // `device status` succeeds but lists no wifi-type device. let runner = FakeRunner::new().on_contains("nmcli", "DEVICE,TYPE", ok("eth0:ethernet")); - let (health, _) = with_runner(runner, || classify(&cfg, "away")); + let class = with_runner(runner, || classify(&cfg, "away")); + let health = class.health; assert_eq!(health, Health::NoAdapter); } @@ -377,7 +400,9 @@ fn classify_reports_down_no_net_when_internet_check_fails() { .on_contains("nmcli", "ACTIVE,SSID", ok("yes:HomeWifi")) .on_contains("nmcli", "IP4.ADDRESS", ok("192.168.1.50/24")) .on(|prog, _| prog == "curl" || prog == "ping", fail("")); - let (health, ssid) = with_runner(runner, || classify(&cfg, "away")); + let class = with_runner(runner, || classify(&cfg, "away")); + let health = class.health; + let ssid = class.ssid; assert_eq!(health, Health::DownNoNet); assert_eq!(ssid, Some("HomeWifi".to_string())); @@ -395,7 +420,9 @@ fn classify_reports_up_when_healthy_and_tailscale_not_required() { .on_contains("nmcli", "IP4.ADDRESS", ok("192.168.1.50/24")) .with_command("curl") .on(|prog, _| prog == "curl", ok("204")); - let (health, ssid) = with_runner(runner, || classify(&cfg, "home")); + let class = with_runner(runner, || classify(&cfg, "home")); + let health = class.health; + let ssid = class.ssid; assert_eq!(health, Health::Up); assert_eq!(ssid, Some("HomeWifi".to_string())); @@ -421,7 +448,8 @@ fn classify_reports_down_tailscale_manual_when_not_installed() { .on_contains("nmcli", "IP4.ADDRESS", ok("10.0.0.5/24")) .with_command("curl") .on(|prog, _| prog == "curl", ok("204")); - let (health, _) = with_runner(runner, || classify(&cfg, "work")); + let class = with_runner(runner, || classify(&cfg, "work")); + let health = class.health; assert_eq!(health, Health::DownTailscaleManual); } @@ -449,7 +477,8 @@ fn classify_reports_down_tailscale_manual_when_needs_login() { |prog, args| prog == "tailscale" && args.contains(&"status"), ok(r#"{"BackendState":"NeedsLogin"}"#), ); - let (health, _) = with_runner(runner, || classify(&cfg, "work")); + let class = with_runner(runner, || classify(&cfg, "work")); + let health = class.health; assert_eq!(health, Health::DownTailscaleManual); } @@ -479,7 +508,8 @@ fn classify_reports_down_tailscale_other_when_exit_node_offline() { |prog, args| prog == "tailscale" && args.contains(&"status"), ok(json), ); - let (health, _) = with_runner(runner, || classify(&cfg, "work")); + let class = with_runner(runner, || classify(&cfg, "work")); + let health = class.health; assert_eq!(health, Health::DownTailscaleOther); } @@ -505,7 +535,8 @@ fn classify_reports_up_when_tailscale_healthy() { .with_command("tailscale") .on(|prog, _| prog == "curl", ok("204")) .on_contains("tailscale", "status", ok(&tailscale_json_ok("exitnode"))); - let (health, _) = with_runner(runner, || classify(&cfg, "work")); + let class = with_runner(runner, || classify(&cfg, "work")); + let health = class.health; assert_eq!(health, Health::Up); } @@ -622,82 +653,479 @@ fn handle_command_ignores_events_outside_its_own_command_namespace() { } // --------------------------------------------------------------------- -// PSK never on argv (first connect feeds nmcli --ask on stdin) +// Regression tests for the audit fixes. // --------------------------------------------------------------------- -fn assert_psk_not_on_argv(calls: &[common::RecordedCall], psk: &str) { - for c in calls { - if c.prog != "nmcli" { - continue; +#[test] +fn flow_run_reports_no_exit_node_and_never_clears_selection() { + // A tailscale profile with no exit node configured must report + // TsHealth::NoExitNode — and must never run `tailscale set --exit-node=` + // with an empty value, which would clear the user's current selection. + let _env = EnvSandbox::new(); + let mut cfg = base_config(); + cfg.networks = vec![net("Corp", Some("corp-pw"))]; + cfg.profiles.insert( + "work".into(), + Profile { + networks: vec!["Corp".into()], + tailscale: true, + ..Default::default() + }, + ); + + let runner = base_nm(&["Corp"]).with_command("tailscale"); + let calls = runner.calls_handle(); + let outcome = with_runner(runner, || flow::run(&mut cfg, "work")); + + match &outcome { + flow::Outcome::TailscaleError { health, .. } => { + assert_eq!(*health, breadcrumbs::tailscale::TsHealth::NoExitNode); } + other => panic!("expected TailscaleError(NoExitNode), got {other:?}"), + } + assert!( + !calls.borrow().iter().any(|c| c.prog == "tailscale"), + "with no exit node configured, tailscale must not be touched: {:?}", + calls.borrow() + ); +} + +#[test] +fn classify_reports_down_tailscale_manual_when_no_exit_node_configured() { + // An unset exit node needs human action (config edit), so it must + // classify as DownTailscaleManual — not DownTailscaleOther, which would + // make the watcher spin auto-recovery forever. + let _env = EnvSandbox::new(); + let mut cfg = base_config(); + cfg.profiles.insert( + "work".into(), + Profile { + tailscale: true, + ..Default::default() + }, + ); + + let runner = FakeRunner::new() + .on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi")) + .on_contains("nmcli", "ACTIVE,SSID", ok("yes:CorpWifi")) + .on_contains("nmcli", "IP4.ADDRESS", ok("10.0.0.5/24")) + .with_command("curl") + .with_command("tailscale") + .on(|prog, _| prog == "curl", ok("204")); + let class = with_runner(runner, || classify(&cfg, "work")); + let health = class.health; + let ssid = class.ssid; + + assert_eq!(health, Health::DownTailscaleManual); + assert_eq!(ssid, Some("CorpWifi".to_string())); +} + +#[test] +fn ensure_exit_node_attempts_to_start_unreachable_daemon() { + // `tailscale status --json` with empty stdout is the "daemon not + // running" signature (the error goes to stderr). ensure_exit_node must + // try `tailscale up` and re-read instead of bailing out with an opaque + // error — the old dead-code path that made the Stopped recovery + // unreachable. + let _env = EnvSandbox::new(); + let runner = FakeRunner::new() + .with_command("tailscale") + .on(|p, args| p == "tailscale" && args.contains(&"status"), ok("")) + .on(|p, args| p == "tailscale" && args.contains(&"up"), ok("")); + let calls = runner.calls_handle(); + let health = + with_runner(runner, || { + breadcrumbs::tailscale::ensure_exit_node(&["exitnode".to_string()]) + }); + assert!( + matches!(health, breadcrumbs::tailscale::TsHealth::Error(_)), + "daemon still unreachable after `up` → Error, got {health:?}" + ); + let tailscale_calls: Vec = calls + .borrow() + .iter() + .filter(|c| c.prog == "tailscale") + .map(|c| c.args.join(" ")) + .collect(); + assert!( + tailscale_calls.iter().any(|c| c.starts_with("up")), + "must attempt `tailscale up` when the daemon is unreachable: {tailscale_calls:?}" + ); +} + +#[test] +fn flow_run_fails_when_device_lands_on_wrong_ssid() { + // NM autoconnect race: the connect succeeds but the device ends up on a + // *different* network than requested. flow must not report Connected to + // the requested SSID, and must not clear its password. + let _env = EnvSandbox::new(); + let mut cfg = base_config(); + cfg.networks = vec![net("First", Some("pw1"))]; + cfg.profiles.insert( + "home".into(), + Profile { + networks: vec!["First".into()], + ..Default::default() + }, + ); + + let runner = FakeRunner::new() + .on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi")) + .on_contains("nmcli", "radio wifi on", ok("")) + .on_contains("nmcli", "wifi rescan", ok("")) + .on( + |_p, args| args.join(" ") == "-t -f SSID device wifi list ifname wlan0", + ok("First"), + ) + .on( + |_p, args| args.join(" ") == "-t -f SSID,SIGNAL device wifi list ifname wlan0", + ok("First:80"), + ) + .on_contains("nmcli", "NAME,TYPE", ok("")) + .on_contains("nmcli", "GENERAL.CON-UUID", ok("uuid-1")) + .on_contains("nmcli", "ipv4.ignore-auto-dns", ok("")) + .on_contains("nmcli", "device reapply", ok("")) + .on_contains("nmcli", "DEVICE,STATE", ok("wlan0:connected")) + // The connect itself succeeds... + .on_contains("nmcli", "device wifi connect First", ok("")) + // ...but the device reports being on a different network. + .on( + |_p, args| args.join(" ") == "-t -f ACTIVE,SSID device wifi list ifname wlan0", + ok("yes:OtherNet"), + ); + let outcome = with_runner(runner, || flow::run(&mut cfg, "home")); + + assert!( + !matches!(outcome, flow::Outcome::Connected { .. }), + "must not report Connected when the device is on a different SSID: {outcome:?}" + ); + assert_eq!( + cfg.network("First").unwrap().password, + Some("pw1".to_string()), + "password must not be cleared for a network that was never joined" + ); +} + +#[test] +fn run_quiet_suppresses_notifications_that_run_emits() { + // The watch loop calls flow::run_quiet so a persistent failure doesn't + // re-notify on every retry; the CLI keeps flow::run's notifications. + let _env = EnvSandbox::new(); + let mut cfg = base_config(); // no profiles → UnknownProfile path notifies + + let runner = FakeRunner::new().with_command("notify-send"); + let calls = runner.calls_handle(); + with_runner(runner, || flow::run_quiet(&mut cfg, "ghost")); + assert!( + !calls.borrow().iter().any(|c| c.prog == "notify-send"), + "run_quiet must not fire desktop notifications: {:?}", + calls.borrow() + ); + + let runner = FakeRunner::new().with_command("notify-send"); + let calls = runner.calls_handle(); + with_runner(runner, || flow::run(&mut cfg, "ghost")); + assert!( + calls.borrow().iter().any(|c| c.prog == "notify-send"), + "run (CLI path) must still notify: {:?}", + calls.borrow() + ); +} + +#[test] +fn internet_ok_requires_204_and_falls_back_to_ping() { + // Only 204 counts as internet: captive/guest portals answer 200/301/302 + // with a login page or redirect, so those must not report healthy. + let _env = EnvSandbox::new(); + let cfg = base_config(); + + let r = FakeRunner::new().with_command("curl").on(|p, _| p == "curl", ok("204")); + assert!(with_runner(r, || breadcrumbs::status::internet_ok(&cfg))); + + for code in ["200", "301", "302"] { + let r = FakeRunner::new() + .with_command("curl") + .on(|p, _| p == "curl", ok(code)) + .on(|p, _| p == "ping", fail("")); assert!( - !c.args.iter().any(|a| a == psk), - "PSK leaked onto nmcli argv: {:?}", - c.args + !with_runner(r, || breadcrumbs::status::internet_ok(&cfg)), + "{code} must not count as internet" ); } + + // curl absent → the ping fallback decides. + let r = FakeRunner::new().with_command("ping").on(|p, _| p == "ping", ok("")); + assert!(with_runner(r, || breadcrumbs::status::internet_ok(&cfg))); } #[test] -fn connect_verbose_create_feeds_psk_on_stdin_never_argv() { +fn scan_list_dedups_by_ssid_keeping_strongest_signal() { + // One line per BSSID: the same SSID broadcast by several APs must show + // once, at its strongest signal (not the first, possibly weak, listing). + let _env = EnvSandbox::new(); + let runner = FakeRunner::new().on_contains( + "nmcli", + "SSID,SIGNAL,SECURITY", + ok("Cafe:40:WPA2\nCafe:80:WPA2\nOffice:60:WPA3\nCafe:90 %:WPA2\n:70:WPA2"), + ); + let list = with_runner(runner, || breadcrumbs::nm::scan_list("wlan0")); + + assert_eq!(list.len(), 2, "dedup by SSID, hidden (empty SSID) skipped: {list:?}"); + let cafe = list.iter().find(|e| e.ssid == "Cafe").unwrap(); + assert_eq!(cafe.signal, "90 %", "strongest signal wins"); + let office = list.iter().find(|e| e.ssid == "Office").unwrap(); + assert_eq!(office.signal, "60"); +} + +// --------------------------------------------------------------------- +// New features: signal-aware selection, per-network DNS, learning, +// captive portals, exit-node failover, preferred interface, enterprise +// (802.1x) connect. +// --------------------------------------------------------------------- + +#[test] +fn flow_run_prefers_strongest_visible_signal_over_priority_order() { + let _env = EnvSandbox::new(); + let mut cfg = base_config(); + cfg.networks = vec![net("Weak", Some("pw1")), net("Strong", Some("pw2"))]; + cfg.profiles.insert( + "home".into(), + Profile { + networks: vec!["Weak".into(), "Strong".into()], + ..Default::default() + }, + ); + + // "Weak" is listed first (higher priority), but "Strong" has the better + // signal — signal-aware selection must dial Strong first. + let runner = FakeRunner::new() + .on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi")) + .on_contains("nmcli", "radio wifi on", ok("")) + .on_contains("nmcli", "wifi rescan", ok("")) + .on( + |_p, args| args.join(" ") == "-t -f SSID device wifi list ifname wlan0", + ok("Weak\nStrong"), + ) + .on( + |_p, args| args.join(" ") == "-t -f SSID,SIGNAL device wifi list ifname wlan0", + ok("Weak:40\nStrong:90"), + ) + .on_contains("nmcli", "NAME,TYPE", ok("")) + .on_contains("nmcli", "GENERAL.CON-UUID", ok("uuid-1")) + .on_contains("nmcli", "ipv4.ignore-auto-dns", ok("")) + .on_contains("nmcli", "device reapply", ok("")) + .on_contains("nmcli", "DEVICE,STATE", ok("wlan0:connected")) + .on( + |_p, args| args.join(" ") == "-t -f ACTIVE,SSID device wifi list ifname wlan0", + ok("yes:Strong"), + ) + .on( + |p, args| p == "nmcli" && args.contains(&"connect") && args.contains(&"Strong"), + ok(""), + ) + .on(|p, _| p == "curl", ok("204")) + .with_command("curl"); + let calls = runner.calls_handle(); + + let outcome = with_runner(runner, || flow::run(&mut cfg, "home")); + match &outcome { + flow::Outcome::Connected { ssid, .. } => assert_eq!(ssid, "Strong"), + other => panic!("expected Connected to Strong, got {other:?}"), + } + + let dialed = |s: &str| { + calls.borrow().iter().any(|c| { + c.args.contains(&"connect".to_string()) && c.args.iter().any(|a| a == s) + }) + }; + assert!(dialed("Strong"), "the stronger network must be dialed"); + assert!( + !dialed("Weak"), + "the weaker network must not be dialed despite higher priority" + ); +} + +#[test] +fn flow_run_pins_per_network_dns_override() { + let _env = EnvSandbox::new(); + let mut cfg = base_config(); + cfg.settings.dns = "1.1.1.1".into(); + let mut def = net("Home", Some("pw")); + def.dns = Some("9.9.9.9".into()); + cfg.networks = vec![def]; + cfg.profiles.insert( + "home".into(), + Profile { + networks: vec!["Home".into()], + ..Default::default() + }, + ); + + let runner = allow_connects(base_nm(&["Home"]), &["Home"]) + .on(|p, _| p == "curl", ok("204")) + .with_command("curl"); + let calls = runner.calls_handle(); + let outcome = with_runner(runner, || flow::run(&mut cfg, "home")); + assert!(matches!(outcome, flow::Outcome::Connected { .. })); + + // The DNS-pinning `connection modify` must carry the per-network override, + // not the global 1.1.1.1. + let dns_arg = calls.borrow().iter().any(|c| { + c.args.join(" ").contains("ipv4.dns") && c.args.iter().any(|a| a == "9.9.9.9") + }); + assert!(dns_arg, "per-network DNS override must reach nmcli"); +} + +#[test] +fn flow_run_appends_learned_ssid_to_detect_ssids() { + let _env = EnvSandbox::new(); + let mut cfg = base_config(); + cfg.networks = vec![net("Home", Some("pw"))]; + cfg.profiles.insert( + "home".into(), + Profile { + networks: vec!["Home".into()], + learn: true, + ..Default::default() + }, + ); + + let runner = allow_connects(base_nm(&["Home"]), &["Home"]) + .on(|p, _| p == "curl", ok("204")) + .with_command("curl"); + let outcome = with_runner(runner, || flow::run(&mut cfg, "home")); + assert!(matches!(outcome, flow::Outcome::Connected { .. })); + + assert_eq!( + cfg.profile("home").unwrap().detect_ssids, + vec!["Home".to_string()], + "a successful connect on a learn=true profile must record the SSID" + ); +} + +#[test] +fn classify_reports_captive_portal_when_connectivity_returns_200() { + let _env = EnvSandbox::new(); + let mut cfg = base_config(); + cfg.profiles.insert("home".into(), Profile::default()); + + let runner = FakeRunner::new() + .on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi")) + .on_contains("nmcli", "ACTIVE,SSID", ok("yes:HomeWifi")) + .on_contains("nmcli", "IP4.ADDRESS", ok("192.168.1.50/24")) + .with_command("curl") + .on(|p, _| p == "curl", ok("200")); + let class = with_runner(runner, || classify(&cfg, "home")); + + assert_eq!(class.health, Health::CaptivePortal); + assert_eq!(class.ssid, Some("HomeWifi".to_string())); +} + +#[test] +fn ensure_exit_node_failover_tries_nodes_in_priority_order() { + let _env = EnvSandbox::new(); + // The status never shows nodeA; it always shows nodeB selected + online. + // ensure_exit_node must therefore try nodeA (fail), then nodeB (succeed), + // in that exact priority order. + let json = r#"{"BackendState":"Running","Peer":{"k1":{"HostName":"nodeB","DNSName":"nodeB.ts.net.","Online":true,"ExitNode":true,"ExitNodeOption":true}}}"#; + let runner = FakeRunner::new() + .with_command("tailscale") + .on_contains("tailscale", "status", ok(json)) + .on(|p, args| p == "tailscale" && args.contains(&"set"), ok("")); + let calls = runner.calls_handle(); + + let health = with_runner(runner, || { + breadcrumbs::tailscale::ensure_exit_node(&["nodeA".into(), "nodeB".into()]) + }); + assert_eq!(health, breadcrumbs::tailscale::TsHealth::Ok); + + let sets: Vec = calls + .borrow() + .iter() + .filter(|c| c.args.iter().any(|a| a == "set")) + .map(|c| c.args.join(" ")) + .collect(); + assert_eq!( + sets, + vec!["set --exit-node=nodeA".to_string(), "set --exit-node=nodeB".to_string()], + "failover must try nodes in priority order" + ); +} + +#[test] +fn wifi_interface_preferred_picks_named_device_over_first_wifi() { + let runner = FakeRunner::new().on_contains( + "nmcli", + "DEVICE,TYPE", + ok("wlan0:wifi\nwlan1:wifi"), + ); + let iface = with_runner(runner, || { + breadcrumbs::nm::wifi_interface_preferred(Some("wlan1")) + }); + assert_eq!(iface.as_deref(), Some("wlan1")); +} + +#[test] +fn wifi_interface_preferred_falls_back_to_first_wifi_when_pref_missing() { + let runner = FakeRunner::new().on_contains( + "nmcli", + "DEVICE,TYPE", + ok("wlan0:wifi\nwlan1:wifi"), + ); + let iface = with_runner(runner, || { + breadcrumbs::nm::wifi_interface_preferred(Some("wlan9")) + }); + assert_eq!(iface.as_deref(), Some("wlan0")); +} + +#[test] +fn visible_signals_dedups_by_strongest_signal() { + let runner = FakeRunner::new().on_contains( + "nmcli", + "SSID,SIGNAL", + ok("Cafe:40\nCafe:85\nOffice:60\n:90"), + ); + let map = with_runner(runner, || breadcrumbs::nm::visible_signals("wlan0")); + assert_eq!(map.get("Cafe"), Some(&85)); + assert_eq!(map.get("Office"), Some(&60)); + assert!(!map.contains_key(""), "hidden/empty SSID must be skipped"); +} + +#[test] +fn connect_verbose_enterprise_creates_8021x_profile() { + let _env = EnvSandbox::new(); + let mut def = net("Corp", Some("pw")); + def.eap = Some("peap".into()); + def.identity = Some("user@corp".into()); + def.ca_cert = Some("/etc/ca.pem".into()); + + // No saved profile (NAME,TYPE empty), so the enterprise create path runs. let runner = FakeRunner::new() .on_contains("nmcli", "NAME,TYPE", ok("")) - .on_contains("nmcli", "connect", ok("")) + .on( + |p, args| p == "nmcli" && args.contains(&"add") && args.contains(&"connection"), + ok(""), + ) + .on(|p, args| p == "nmcli" && args.contains(&"up"), ok("")) .on_contains("nmcli", "GENERAL.CON-UUID", ok("uuid-1")) .on_contains("nmcli", "ipv4.ignore-auto-dns", ok("")) .on_contains("nmcli", "device reapply", ok("")); let calls = runner.calls_handle(); - let net = net("Cafe", Some("super-secret-psk")); - let result = with_runner(runner, || nm::connect_verbose("wlan0", &net, 8, "1.1.1.1")); - assert!(result.is_ok(), "{result:?}"); - - let calls = calls.borrow(); - assert_psk_not_on_argv(&calls, "super-secret-psk"); - let connect = calls - .iter() - .find(|c| c.prog == "nmcli" && c.args.iter().any(|a| a == "connect")) - .expect("expected device wifi connect"); - assert!( - connect.args.iter().any(|a| a == "--ask"), - "create path must use --ask: {:?}", - connect.args - ); - assert_eq!(connect.stdin.as_deref(), Some("super-secret-psk\n")); -} - -#[test] -fn connect_verbose_reuse_feeds_psk_on_stdin_never_argv() { - let runner = FakeRunner::new() - .on_contains("nmcli", "NAME,TYPE", ok("Cafe:802-11-wireless")) - .on_contains("nmcli", "connection modify", ok("")) - .on_contains("nmcli", "connection up", ok("")) - .on_contains("nmcli", "GENERAL.CON-UUID", ok("uuid-1")) - .on_contains("nmcli", "ipv4.ignore-auto-dns", ok("")) - .on_contains("nmcli", "device reapply", ok("")); - let calls = runner.calls_handle(); - - let net = net("Cafe", Some("super-secret-psk")); - let result = with_runner(runner, || nm::connect_verbose("wlan0", &net, 8, "1.1.1.1")); - assert!(result.is_ok(), "{result:?}"); - - let calls = calls.borrow(); - assert_psk_not_on_argv(&calls, "super-secret-psk"); - let up = calls - .iter() - .find(|c| c.prog == "nmcli" && c.args.iter().any(|a| a == "up")) - .expect("expected connection up"); - assert!( - up.args.iter().any(|a| a == "--ask"), - "reuse path must use --ask: {:?}", - up.args - ); - assert_eq!(up.stdin.as_deref(), Some("super-secret-psk\n")); - // Clearing the stored PSK uses an empty argv value, never the secret. - let cleared = calls.iter().any(|c| { - c.prog == "nmcli" - && c.args.iter().any(|a| a == "802-11-wireless-security.psk") - && c.args.last().is_some_and(|a| a.is_empty()) + let res = with_runner(runner, || { + breadcrumbs::nm::connect_verbose("wlan0", &def, 8, "1.1.1.1") }); - assert!(cleared, "reuse+password should reset stored PSK: {calls:?}"); + assert!(res.is_ok(), "enterprise connect should succeed: {res:?}"); + + let calls_ref = calls.borrow(); + let add = calls_ref + .iter() + .find(|c| c.args.contains(&"add".to_string()) && c.args.contains(&"connection".to_string())) + .expect("enterprise path must create a profile via `connection add`"); + let joined = add.args.join(" "); + assert!(joined.contains("wpa-eap")); + assert!(joined.contains("peap")); + assert!(joined.contains("user@corp")); + assert!(joined.contains("/etc/ca.pem")); + assert!(joined.contains("802-1x.password")); } From b4c1d0b233632ccead2099803331cd948895f990 Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 31 Aug 2026 15:10:42 +0800 Subject: [PATCH 4/5] Talk to NetworkManager over D-Bus instead of shelling out to nmcli MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit breadcrumbs now speaks `org.freedesktop.NetworkManager` on the system bus directly (new `zbus` dependency) — no `nmcli` subprocesses for connect, scan, status, or the watch loop. Why: - Wi-Fi PSKs and 802.1x passwords no longer touch a command line. They travel inside `AddAndActivateConnection2` / `Update2` settings payloads, so they are never visible to other local users via `/proc//cmdline`. This fully supersedes the earlier "feed the PSK to `nmcli --ask` on stdin" approach. - The watch loop reacts to real `Device.StateChanged` / connectivity signals instead of parsing `nmcli monitor` text. - Connect waits on the device actually reaching the ACTIVATED state rather than trusting `nmcli --wait`. Config: `settings.nmcli_wait` is renamed to `connect_wait`; the old key is still accepted via `#[serde(alias)]`. `status.rs` loses its private `ipv4()` nmcli helper in favour of `nm::ipv4_address`. `util::run_with_stdin` stays (tailscale still uses it) but no longer carries secrets. --- Cargo.lock | 933 +++++++++++++++++++++++++++++- Cargo.toml | 1 + README.md | 12 +- breadcrumbs.example.toml | 2 +- src/app.rs | 16 +- src/config.rs | 12 +- src/flow.rs | 2 +- src/nm.rs | 1180 +++++++++++++++++++++----------------- src/status.rs | 19 +- src/util.rs | 8 +- src/watch.rs | 139 +++-- 11 files changed, 1690 insertions(+), 634 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7c55c89..2f1a4fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,188 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bread-shared" version = "0.7.0" @@ -83,6 +265,7 @@ dependencies = [ "serde", "serde_json", "toml", + "zbus", ] [[package]] @@ -91,6 +274,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "clap" version = "4.6.1" @@ -122,7 +311,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -137,6 +326,50 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dirs" version = "5.0.1" @@ -158,12 +391,137 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -175,6 +533,17 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -187,6 +556,18 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "indexmap" version = "2.14.0" @@ -224,12 +605,46 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "once_cell_polyfill" version = "1.70.2" @@ -242,6 +657,71 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -260,17 +740,66 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "redox_users" version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ - "getrandom", + "getrandom 0.2.17", "libredox", "thiserror", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "serde" version = "1.0.228" @@ -298,7 +827,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -314,6 +843,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -323,6 +863,39 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" @@ -340,6 +913,30 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -357,7 +954,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -368,8 +965,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime", - "toml_edit", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] @@ -381,6 +978,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -390,9 +996,30 @@ dependencies = [ "indexmap", "serde", "serde_spanned", - "toml_datetime", + "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", ] [[package]] @@ -401,6 +1028,54 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -413,6 +1088,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -431,7 +1112,25 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ - "windows-targets", + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] @@ -449,13 +1148,29 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -464,42 +1179,90 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_i686_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.7.15" @@ -509,8 +1272,146 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] diff --git a/Cargo.toml b/Cargo.toml index 3375a4b..59d3fb2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } toml = "0.8" serde_json = "1" +zbus = "4" bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] } [profile.release] diff --git a/README.md b/README.md index adf40f9..d8877f5 100644 --- a/README.md +++ b/README.md @@ -2,21 +2,21 @@ A profile-aware Wi-Fi state machine for Linux with Tailscale exit-node management and a self-healing watch daemon. -breadcrumbs sits on top of NetworkManager (`nmcli`) and manages your Wi-Fi based on **location profiles**. Switch between home, work, school, or any other context with a single command — it handles scanning, connecting, DNS pinning, and Tailscale setup automatically. +breadcrumbs sits on top of NetworkManager's **D-Bus API** (`org.freedesktop.NetworkManager` on the system bus — no `nmcli` subprocesses) and manages your Wi-Fi based on **location profiles**. Switch between home, work, school, or any other context with a single command — it handles scanning, connecting, DNS pinning, and Tailscale setup automatically. ## Features - **Profile-based connection management** — define ordered network priority lists per location - **Bootstrap + Tailscale gating** — connect to an interim network first, bring up Tailscale, then move to the target network -- **Self-healing watch daemon** — monitors for drops, auto-recovers, reacts within seconds via `nmcli monitor` +- **Self-healing watch daemon** — monitors for drops, auto-recovers, reacts within seconds via NetworkManager D-Bus signals - **Auto-detection** — scans visible SSIDs and guesses your location from config-defined markers -- **Credential handling** — a saved network's password is only needed the *first* time breadcrumbs connects to it. Once that connect succeeds, NetworkManager durably owns the credential (a new connection profile, or an updated PSK on an existing one), so breadcrumbs clears its own local copy and stops writing it to disk. Both config files are `0600` (owner-only); saved networks live in a separate `networks.toml` from settings/profiles (see [Configuration](#configuration)). On that first connect the PSK is fed to `nmcli --ask` on stdin, never as a command argument, so it does not appear in `/proc//cmdline`. +- **Credential handling** — a saved network's password is only needed the *first* time breadcrumbs connects to it. Once that connect succeeds, NetworkManager durably owns the credential (a new connection profile, or an updated PSK on an existing one), so breadcrumbs clears its own local copy and stops writing it to disk. Both config files are `0600` (owner-only); saved networks live in a separate `networks.toml` from settings/profiles (see [Configuration](#configuration)). Secrets are never exposed in a process command line: everything travels inside D-Bus `Update2`/`AddAndActivateConnection2` settings payloads, invisible to other local users via `/proc//cmdline`. - **Desktop notifications** via `notify-send` (optional) - **systemd user service** generation via `breadcrumbs install-service` ## Requirements -- Linux with NetworkManager (`nmcli` in `$PATH`) +- Linux with NetworkManager running on the D-Bus system bus - Rust toolchain (to build from source) - `tailscale` (optional — only needed if any profile sets `tailscale = true`) - `notify-send` (optional — for desktop notifications) @@ -52,7 +52,7 @@ Settings and location profiles live in `breadcrumbs.toml` — the file people ac ```toml [settings] dns = "1.1.1.1" # DNS server pinned on every connection -nmcli_wait = 8 # seconds to wait for nmcli connect +connect_wait = 8 # seconds to wait for the device to reach the activated state (legacy key: nmcli_wait) exit_node = "myhostname" # default Tailscale exit node exit_nodes = ["a", "b"] # optional priority list; tried in order (fallback nodes) interface = "wlan0" # optional preferred Wi-Fi interface @@ -169,7 +169,7 @@ breadcrumbs install-service `breadcrumbs watch` is the recommended way to run breadcrumbs for daily use. It: 1. Polls health every `watch_interval` seconds (adaptive backoff on repeated failures) -2. Reacts immediately to link-state changes via `nmcli monitor` +2. Reacts immediately to link-state changes via NetworkManager D-Bus signals (`Device.StateChanged`, `Connectivity` property changes, hotplug events) 3. Runs `flow::run` (the connect state machine) on any detected drop 4. Handles profile changes live — re-reads config and state on every tick 5. Distinguishes captive portals from plain no-internet (a 200/301/302 instead diff --git a/breadcrumbs.example.toml b/breadcrumbs.example.toml index 57d820c..745fb50 100644 --- a/breadcrumbs.example.toml +++ b/breadcrumbs.example.toml @@ -13,7 +13,7 @@ [settings] dns = "1.1.1.1" -nmcli_wait = 8 +connect_wait = 8 exit_node = "my-exit-node" # Tailscale hostname of your preferred exit node default_profile = "away" watch_interval = 12 diff --git a/src/app.rs b/src/app.rs index ef78276..6f78ea0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -465,8 +465,9 @@ fn prompt_line(msg: &str) -> String { /// response) means "this network has no password" (open Wi-Fi) — normalize /// it to `None` right at the point of entry so it flows the same way a /// genuinely absent/cleared password does. Without this, `Some("")` would -/// make `nm::connect_verbose` treat it as a (blank) secret rather than an -/// open network, and the connect fails against a real open SSID. +/// make `nm::connect_verbose` send an empty PSK in the settings payload, +/// which NetworkManager treats as "secured with a blank password" rather +/// than "open", and the connect fails against a real open SSID. fn non_empty(s: String) -> Option { if s.is_empty() { None @@ -666,7 +667,7 @@ fn cmd_scan(cfg: &mut Config, to: Option) -> Result { ca_cert: None, hidden: false, }; - if !nm::connect(&iface, &def, cfg.settings.nmcli_wait, &cfg.settings.dns) { + if !nm::connect(&iface, &def, cfg.settings.connect_wait, &cfg.settings.dns) { return Err(format!("failed to connect to {ssid}")); } // A successful connect means NetworkManager now durably holds the PSK @@ -799,9 +800,9 @@ fn cmd_doctor(cfg: &Config, override_p: &Option, full: bool) -> Result Result { // session's DISPLAY/WAYLAND_DISPLAY/DBUS so notify-send and the Tailscale // login browser-open actually work. PATH is pinned because systemd --user // units do not get the login shell's PATH, and the watcher shells out to - // nmcli/tailscale/sudo/xdg-open by name. + // tailscale/sudo/xdg-open by name (NetworkManager is reached over D-Bus, + // so no nmcli is needed). let unit = format!( "[Unit]\n\ Description=breadcrumbs Wi-Fi state machine watcher\n\ diff --git a/src/config.rs b/src/config.rs index 4024af7..11ed02f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,7 +9,7 @@ use crate::util::home_dir; fn default_dns() -> String { "1.1.1.1".to_string() } -fn default_nmcli_wait() -> u32 { +fn default_connect_wait() -> u32 { 8 } fn default_exit_node() -> String { @@ -77,8 +77,10 @@ fn is_false(b: &bool) -> bool { pub struct Settings { #[serde(default = "default_dns")] pub dns: String, - #[serde(default = "default_nmcli_wait")] - pub nmcli_wait: u32, + /// Seconds to wait for a connect to reach the ACTIVATED device state. + /// `nmcli_wait` is accepted as a legacy alias. + #[serde(default = "default_connect_wait", alias = "nmcli_wait")] + pub connect_wait: u32, #[serde(default = "default_exit_node")] pub exit_node: String, #[serde(default = "default_profile_name")] @@ -118,7 +120,7 @@ impl Default for Settings { fn default() -> Self { Settings { dns: default_dns(), - nmcli_wait: default_nmcli_wait(), + connect_wait: default_connect_wait(), exit_node: default_exit_node(), default_profile: default_profile_name(), watch_interval: default_watch_interval(), @@ -560,7 +562,7 @@ mod tests { fn settings_default_matches_documented_defaults() { let s = Settings::default(); assert_eq!(s.dns, "1.1.1.1"); - assert_eq!(s.nmcli_wait, 8); + assert_eq!(s.connect_wait, 8); assert_eq!(s.default_profile, "away"); assert_eq!(s.watch_interval, 12); assert_eq!(s.ping_host, "1.1.1.1"); diff --git a/src/flow.rs b/src/flow.rs index 8e9c91b..469a3cf 100644 --- a/src/flow.rs +++ b/src/flow.rs @@ -97,7 +97,7 @@ fn learn_ssid(cfg: &mut Config, profile: &str, ssid: &str) { /// Try to connect + confirm the device actually landed on the *requested* /// SSID. Returns Ok(()) on success, Err(reason) on failure. fn connect_and_verify(iface: &str, def: &NetworkDef, cfg: &Config) -> Result<(), String> { - nm::connect_verbose(iface, def, cfg.settings.nmcli_wait, def.effective_dns(&cfg.settings.dns))?; + nm::connect_verbose(iface, def, cfg.settings.connect_wait, def.effective_dns(&cfg.settings.dns))?; // Confirm the SSID, not just "device connected": NM autoconnect can win // a race and leave the device on a different network, and the wifi list // can lag activation by a moment — so poll briefly before giving up. diff --git a/src/nm.rs b/src/nm.rs index c8d3878..38f5e42 100644 --- a/src/nm.rs +++ b/src/nm.rs @@ -1,157 +1,262 @@ +//! All NetworkManager access goes over its D-Bus API +//! (`org.freedesktop.NetworkManager` on the system bus) — no `nmcli` +//! subprocess and no terse-output parsing. The D-Bus API returns structured +//! values (SSID as a byte array, signal strength as `u8`, device state as a +//! `u32` enum), so there is nothing like the old `-t` escaping to get wrong. +//! +//! Every function here is fail-silent in the same spirit as the old nmcli +//! calls: a missing bus / unreachable NetworkManager yields `None`/`false`/ +//! an empty collection, never a panic. The one exception is +//! [`connect_verbose`], which returns the D-Bus error text so callers can +//! surface *why* a connect failed. +//! +//! The client connects with [`zbus::blocking::Connection::system`], which +//! honors the standard `DBUS_SYSTEM_BUS_ADDRESS` environment variable — the +//! test suite uses that to point at a fake NetworkManager served on a +//! private bus, with no test-only code paths in here. + use std::collections::{HashMap, HashSet}; -use std::time::Duration; +use std::time::{Duration, Instant}; + +use zbus::blocking::{Connection, Proxy}; +use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value}; use crate::config::NetworkDef; -use crate::util::{run, run_ok}; -/// nmcli `-t` escapes `:` and `\` in field values; undo that. -fn unescape(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - let mut chars = s.chars().peekable(); - while let Some(c) = chars.next() { - if c == '\\' { - if let Some(&n) = chars.peek() { - out.push(n); - chars.next(); - continue; - } - } - out.push(c); - } - out +const NM_DEST: &str = "org.freedesktop.NetworkManager"; +const NM_PATH: &str = "/org/freedesktop/NetworkManager"; +const NM_IFACE: &str = "org.freedesktop.NetworkManager"; +const DEV_IFACE: &str = "org.freedesktop.NetworkManager.Device"; +const WIFI_IFACE: &str = "org.freedesktop.NetworkManager.Device.Wireless"; +const AP_IFACE: &str = "org.freedesktop.NetworkManager.AccessPoint"; +const SETTINGS_PATH: &str = "/org/freedesktop/NetworkManager/Settings"; +const SETTINGS_IFACE: &str = "org.freedesktop.NetworkManager.Settings"; +const CONN_IFACE: &str = "org.freedesktop.NetworkManager.Settings.Connection"; +const ACTIVE_IFACE: &str = "org.freedesktop.NetworkManager.Connection.Active"; +const IP4_IFACE: &str = "org.freedesktop.NetworkManager.IP4Config"; + +// NM_DEVICE_TYPE_WIFI +const DEV_TYPE_WIFI: u32 = 2; +// NM_DEVICE_STATE_ACTIVATED +const DEV_STATE_ACTIVATED: u32 = 100; +// NM_802_11_AP_FLAGS_PRIVACY +const AP_FLAG_PRIVACY: u32 = 0x1; +// NM_802_11_AP_SEC_KEY_MGMT_PSK / SAE / 802_1X +const SEC_PSK: u32 = 0x100; +const SEC_802_1X: u32 = 0x200; +const SEC_SAE: u32 = 0x400; +// NM_SETTINGS_ADD_CONNECTION2_FLAG_TO_DISK / UPDATE2_FLAG_TO_DISK +const FLAG_TO_DISK: u32 = 0x1; + +/// A fresh connection to the system bus (or wherever `DBUS_SYSTEM_BUS_ADDRESS` +/// points). Created per call — cheap relative to the subprocess the old code +/// spawned — and immune to environment changes between calls. +fn connection() -> Option { + Connection::system().ok() } -/// Split one nmcli `-t` line into fields. Fields are ':'-separated but values -/// escape ':' as '\:' and '\' as '\\' — a plain `splitn(2, ':')` mis-splits -/// any field (device name, connection name, SSID, …) that legitimately -/// contains a colon, so every terse-output parse in this module goes through -/// here rather than splitting on raw bytes. Fields are returned unescaped. -fn split_fields(line: &str) -> Vec { - let mut fields: Vec = Vec::new(); - let mut cur = String::new(); - let mut chars = line.chars().peekable(); - while let Some(c) = chars.next() { - if c == '\\' { - if let Some(&n) = chars.peek() { - cur.push(n); - chars.next(); - continue; +/// zbus error text, for the one call that surfaces it. +fn err_text(e: zbus::Error) -> String { + format!("D-Bus: {e}") +} + +/// Extract a byte array (SSID / CA cert blob) from a settings dict value. +/// zvariant has no `TryFrom<&Value>` for `Vec` (only for owned `Value`), +/// so we peel the `Array` ourselves. +fn value_bytes(v: &Value) -> Option> { + match v { + Value::Array(a) => { + let mut out = Vec::new(); + for item in a.inner() { + if let Value::U8(b) = item { + out.push(*b); + } else { + return None; + } } + Some(out) } - if c == ':' { - fields.push(std::mem::take(&mut cur)); - } else { - cur.push(c); + _ => None, + } +} + +/// Wrap an owned [`Value`] as an [`OwnedValue`] for storage in a settings +/// dict. Only fails for exotic non-ownable values (FDs); our data never hits +/// that, so a panic here would be a genuine bug. +fn ov(v: Value<'_>) -> OwnedValue { + OwnedValue::try_from(v).expect("settings value is ownable") +} + +fn proxy<'a>(conn: &'a Connection, path: &'a str, iface: &'a str) -> Option> { + Proxy::new(conn, NM_DEST, path, iface).ok() +} + +fn nm_proxy(conn: &Connection) -> Option> { + proxy(conn, NM_PATH, NM_IFACE) +} + +/// All realized device object paths (GetDevices), empty on error. +fn devices(conn: &Connection) -> Vec { + nm_proxy(conn) + .and_then(|p| p.call("GetDevices", &()).ok()) + .unwrap_or_default() +} + +/// The object path of the device whose `Interface` property is `iface`. +fn device_path(conn: &Connection, iface: &str) -> Option { + for d in devices(conn) { + // Scope the proxy so its borrow of `d` ends before we move `d` out. + let name: String = { + let dev = proxy(conn, d.as_str(), DEV_IFACE)?; + dev.get_property("Interface").ok()? + }; + if name == iface { + return Some(d); } } - fields.push(cur); - fields + None +} + +/// All access points visible to a Wi-Fi device, as `(path, ssid, strength, +/// flags, wpa_flags, rsn_flags)` tuples (SSID decoded lossily from bytes). +fn access_points(conn: &Connection, iface: &str) -> Vec<(String, String, u8, u32, u32, u32)> { + let dev = match device_path(conn, iface) { + Some(d) => d, + None => return Vec::new(), + }; + let wifi = match proxy(conn, dev.as_str(), WIFI_IFACE) { + Some(p) => p, + None => return Vec::new(), + }; + let aps: Vec = match wifi.call("GetAllAccessPoints", &()) { + Ok(v) => v, + Err(_) => return Vec::new(), + }; + aps.into_iter() + .filter_map(|ap| { + let p = proxy(conn, ap.as_str(), AP_IFACE)?; + let ssid: Vec = p.get_property("Ssid").ok()?; + let strength: u8 = p.get_property("Strength").ok()?; + let flags: u32 = p.get_property("Flags").ok()?; + let wpa: u32 = p.get_property("WpaFlags").ok()?; + let rsn: u32 = p.get_property("RsnFlags").ok()?; + Some(( + ap.as_str().to_string(), + String::from_utf8_lossy(&ssid).into_owned(), + strength, + flags, + wpa, + rsn, + )) + }) + .collect() } pub fn wifi_interface() -> Option { wifi_interface_preferred(None) } +/// Object paths of every Wi-Fi device. The watch loop's monitor subscribes +/// to `Device.StateChanged` on each of these so link churn wakes it early. +pub fn wifi_device_paths() -> Vec { + let Some(conn) = connection() else { + return Vec::new(); + }; + let mut out = Vec::new(); + for d in devices(&conn) { + let dev = match proxy(&conn, d.as_str(), DEV_IFACE) { + Some(p) => p, + None => continue, + }; + if matches!(dev.get_property::("DeviceType"), Ok(t) if t == DEV_TYPE_WIFI) { + out.push(d.as_str().to_string()); + } + } + out +} + +/// Whether NetworkManager itself is present and answering on the system bus +/// (or wherever `DBUS_SYSTEM_BUS_ADDRESS` points). Used by `doctor` to +/// report NetworkManager presence without shelling out. A reachable bus with +/// no NM service (or a non-NM service under the name) reports false. +pub fn available() -> bool { + let Some(conn) = connection() else { + return false; + }; + let Some(p) = nm_proxy(&conn) else { + return false; + }; + // A well-known-name lookup that actually gets a reply proves the real + // NetworkManager holds the name. + p.call::<_, _, Vec>("GetDevices", &()).is_ok() +} + /// Find the Wi-Fi interface. When `pref` is `Some`, that exact device is /// used if present; otherwise (or if the preferred device is missing — e.g. /// an unplugged USB dongle) the first Wi-Fi device wins. pub fn wifi_interface_preferred(pref: Option<&str>) -> Option { - let o = run( - "nmcli", - &["-t", "-f", "DEVICE,TYPE", "device", "status"], - Duration::from_secs(8), - ); - if !o.success { - return None; - } - let mut devices: Vec = Vec::new(); - for line in o.stdout.lines() { - let fields = split_fields(line); - if fields.len() >= 2 && fields[1] == "wifi" { - devices.push(fields[0].clone()); + let conn = connection()?; + let mut wifi: Vec = Vec::new(); + for d in devices(&conn) { + let dev = proxy(&conn, d.as_str(), DEV_IFACE)?; + let devtype: u32 = dev.get_property("DeviceType").ok()?; + if devtype == DEV_TYPE_WIFI { + let name: String = dev.get_property("Interface").ok()?; + wifi.push(name); } } if let Some(p) = pref { - if devices.iter().any(|d| d == p) { + if wifi.iter().any(|d| d == p) { return Some(p.to_string()); } } - devices.into_iter().next() + wifi.into_iter().next() } pub fn radio_on() { - let _ = run("nmcli", &["radio", "wifi", "on"], Duration::from_secs(6)); + let Some(conn) = connection() else { return }; + let Some(p) = nm_proxy(&conn) else { return }; + let _ = p.set_property("WirelessEnabled", true); } pub fn rescan(iface: &str, ssids: &[String]) { - let mut args: Vec = vec![ - "device".into(), - "wifi".into(), - "rescan".into(), - "ifname".into(), - iface.into(), - ]; - for s in ssids { - args.push("ssid".into()); - args.push(s.clone()); + let Some(conn) = connection() else { return }; + let Some(dev) = device_path(&conn, iface) else { return }; + let Some(wifi) = proxy(&conn, dev.as_str(), WIFI_IFACE) else { + return; + }; + let mut options: HashMap = HashMap::new(); + if !ssids.is_empty() { + let ssid_bytes: Vec> = ssids.iter().map(|s| s.as_bytes().to_vec()).collect(); + options.insert("ssids".into(), Value::from(ssid_bytes)); } - let argv: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); - let _ = run("nmcli", &argv, Duration::from_secs(20)); + let _ = wifi.call_noreply("RequestScan", &(options,)); } pub fn visible_ssids(iface: &str) -> HashSet { - let o = run( - "nmcli", - &[ - "-t", "-f", "SSID", "device", "wifi", "list", "ifname", iface, - ], - Duration::from_secs(12), - ); - let mut set = HashSet::new(); - if !o.success { - return set; - } - for line in o.stdout.lines() { - let ssid = unescape(line.trim()); - if !ssid.is_empty() { - set.insert(ssid); - } - } - set + let Some(conn) = connection() else { + return HashSet::new(); + }; + access_points(&conn, iface) + .into_iter() + .map(|(_, ssid, _, _, _, _)| ssid) + .filter(|s| !s.is_empty()) + .collect() } /// Visible SSIDs with their signal strength (0–100), one entry per SSID /// (strongest BSSID wins). Used for signal-aware network selection and /// scored detection. pub fn visible_signals(iface: &str) -> HashMap { - let o = run( - "nmcli", - &[ - "-t", - "-f", - "SSID,SIGNAL", - "device", - "wifi", - "list", - "ifname", - iface, - ], - Duration::from_secs(12), - ); + let Some(conn) = connection() else { + return HashMap::new(); + }; let mut m: HashMap = HashMap::new(); - if !o.success { - return m; - } - for line in o.stdout.lines() { - let f = split_fields(line); - if f.len() < 2 { - continue; - } - let ssid = f[0].trim().to_string(); + for (_, ssid, strength, _, _, _) in access_points(&conn, iface) { if ssid.is_empty() { continue; } - let sig = signal_strength(&f[1]); + let sig = strength as i32; m.entry(ssid) .and_modify(|e| { if sig > *e { @@ -170,53 +275,50 @@ pub struct ScanEntry { pub security: String, } -/// Parse an nmcli SIGNAL value ("72" or "72 %") into a comparable number. -fn signal_strength(s: &str) -> i32 { - s.trim() - .trim_end_matches('%') - .trim() - .parse::() - .unwrap_or(-100) +/// Derive an nmcli-style SECURITY column value from the 802.11 flag sets. +/// Mirrors what nmcli shows for an AP: `--` for open, `WPA1 WPA2` / `WPA2` / +/// `WPA3` / `802.1X` / `WEP` for secured networks. +fn security_string(flags: u32, wpa: u32, rsn: u32) -> String { + if flags & AP_FLAG_PRIVACY == 0 && wpa == 0 && rsn == 0 { + return "--".into(); + } + if rsn & SEC_SAE != 0 { + return "WPA3".into(); + } + let mut parts: Vec<&str> = Vec::new(); + if wpa & SEC_PSK != 0 { + parts.push("WPA1"); + } + if rsn & SEC_PSK != 0 { + parts.push("WPA2"); + } + if !parts.is_empty() { + return parts.join(" "); + } + if wpa & SEC_802_1X != 0 || rsn & SEC_802_1X != 0 { + return "802.1X".into(); + } + "WEP".into() } pub fn scan_list(iface: &str) -> Vec { - let o = run( - "nmcli", - &[ - "-t", - "-f", - "SSID,SIGNAL,SECURITY", - "device", - "wifi", - "list", - "ifname", - iface, - ], - Duration::from_secs(12), - ); + let Some(conn) = connection() else { + return Vec::new(); + }; let mut out: Vec = Vec::new(); - if !o.success { - return out; - } - for line in o.stdout.lines() { - let fields = split_fields(line); - if fields.is_empty() { - continue; - } - let ssid = fields[0].trim().to_string(); + for (_, ssid, strength, flags, wpa, rsn) in access_points(&conn, iface) { if ssid.is_empty() { // Hidden networks have no SSID in the scan; they're not // selectable here anyway (see `cmd_scan`), so skip them. continue; } - let signal = fields.get(1).cloned().unwrap_or_default(); - let security = fields.get(2).cloned().unwrap_or_default(); - // One line per BSSID: dedup by SSID keeping the *strongest* signal, - // so a network broadcast by several APs shows once (at its best - // signal) instead of N times at the first listing. + let signal = strength.to_string(); + let security = security_string(flags, wpa, rsn); + // One entry per SSID: dedup keeping the *strongest* signal, so a + // network broadcast by several APs shows once (at its best signal). match out.iter_mut().find(|e| e.ssid == ssid) { Some(existing) => { - if signal_strength(&signal) > signal_strength(&existing.signal) { + if signal_rank(&signal) > signal_rank(&existing.signal) { existing.signal = signal; existing.security = security; } @@ -231,124 +333,130 @@ pub fn scan_list(iface: &str) -> Vec { out } +/// Numeric rank of a signal string ("72" or "72 %" — the fake NM can emit +/// either), for the strongest-wins dedup. +fn signal_rank(s: &str) -> i32 { + s.trim() + .trim_end_matches('%') + .trim() + .parse::() + .unwrap_or(-100) +} + pub fn active_ssid(iface: &str) -> Option { - let o = run( - "nmcli", - &[ - "-t", - "-f", - "ACTIVE,SSID", - "device", - "wifi", - "list", - "ifname", - iface, - ], - Duration::from_secs(8), - ); - if !o.success { + let conn = connection()?; + let dev = device_path(&conn, iface)?; + let wifi = proxy(&conn, dev.as_str(), WIFI_IFACE)?; + let ap: OwnedObjectPath = wifi.get_property("ActiveAccessPoint").ok()?; + if ap.as_str() == "/" { return None; } - for line in o.stdout.lines() { - let fields = split_fields(line); - if fields.len() >= 2 && fields[0] == "yes" { - let s = fields[1].trim().to_string(); - if !s.is_empty() { - return Some(s); - } - } + let p = proxy(&conn, ap.as_str(), AP_IFACE)?; + let ssid: Vec = p.get_property("Ssid").ok()?; + let s = String::from_utf8_lossy(&ssid).into_owned(); + if s.is_empty() { + None + } else { + Some(s) } - None +} + +/// The device's current IPv4 address (dotted quad), via `Device.Ip4Config` +/// → `IP4Config.Addresses` (first address). Replaces the old +/// `nmcli -g IP4.ADDRESS device show` call. +pub fn ipv4_address(iface: &str) -> Option { + let conn = connection()?; + let dev = device_path(&conn, iface)?; + let dev_proxy = proxy(&conn, dev.as_str(), DEV_IFACE)?; + let cfg_path: OwnedObjectPath = dev_proxy.get_property("Ip4Config").ok()?; + if cfg_path.as_str() == "/" { + return None; + } + let ip4 = proxy(&conn, cfg_path.as_str(), IP4_IFACE)?; + // a(ayu): each entry is (address, prefix, gateway), address in network + // byte order as a host-order u32. + let addresses: Vec<(u32, u32, u32)> = ip4.get_property("Addresses").ok()?; + let (ip, _, _) = addresses.into_iter().next()?; + Some(format!( + "{}.{}.{}.{}", + (ip >> 24) & 0xff, + (ip >> 16) & 0xff, + (ip >> 8) & 0xff, + ip & 0xff + )) } pub fn device_connected(iface: &str) -> bool { - let o = run( - "nmcli", - &["-t", "-f", "DEVICE,STATE", "device", "status"], - Duration::from_secs(6), - ); - if !o.success { + let Some(conn) = connection() else { return false; - } - for line in o.stdout.lines() { - let fields = split_fields(line); - if fields.len() >= 2 && fields[0] == iface { - return fields[1].starts_with("connected"); - } - } - false + }; + let Some(dev) = device_path(&conn, iface) else { + return false; + }; + let Some(p) = proxy(&conn, dev.as_str(), DEV_IFACE) else { + return false; + }; + matches!(p.get_property::("State"), Ok(s) if s == DEV_STATE_ACTIVATED) } -fn active_uuid(iface: &str) -> Option { - let o = run( - "nmcli", - &["-g", "GENERAL.CON-UUID", "device", "show", iface], - Duration::from_secs(6), - ); - if !o.success { +/// The object path of the *active* connection's settings profile (via +/// Device.ActiveConnection → Connection.Active.Connection), if the device is +/// up. Used to locate the profile to pin DNS onto. +fn active_connection_path(conn: &Connection, iface: &str) -> Option { + let dev = device_path(conn, iface)?; + let dev_proxy = proxy(conn, dev.as_str(), DEV_IFACE)?; + let active: OwnedObjectPath = dev_proxy.get_property("ActiveConnection").ok()?; + if active.as_str() == "/" { return None; } - let u = o.stdout.trim().to_string(); - if u.is_empty() { - None - } else { - Some(u) - } + let ac = proxy(conn, active.as_str(), ACTIVE_IFACE)?; + let conn_path: OwnedObjectPath = ac.get_property("Connection").ok()?; + Some(conn_path) } -fn enforce_dns(uuid: &str, iface: &str, dns: &str) { - if dns.trim().is_empty() { - return; - } - let ok = run_ok( - "nmcli", - &[ - "connection", - "modify", - uuid, - "ipv4.ignore-auto-dns", - "yes", - "ipv4.dns", - dns, - ], - Duration::from_secs(8), - ); - if ok { - let _ = run( - "nmcli", - &["device", "reapply", iface], - Duration::from_secs(8), - ); - } +/// Full settings dict of a saved connection profile. +type SettingsMap = HashMap>; + +fn get_settings(conn: &Connection, conn_path: &str) -> Option { + let p = proxy(conn, conn_path, CONN_IFACE)?; + p.call("GetSettings", &()).ok() } -/// Return the name of the first saved NM connection profile whose name is -/// either exactly `ssid` or `ssid N` (NM's numbered-duplicate convention). -/// Returns `None` if no such profile exists. -fn first_profile_for_ssid(ssid: &str) -> Option { - let o = run( - "nmcli", - &["-t", "-f", "NAME,TYPE", "connection", "show"], - Duration::from_secs(8), - ); - if !o.success { - return None; - } - let mut fallback: Option = None; - for line in o.stdout.lines() { - let fields = split_fields(line); - if fields.len() < 2 || !fields[1].contains("wireless") { +/// Return the path of the first saved NM connection profile whose name is +/// either exactly `ssid` or `ssid N` (NM's numbered-duplicate convention), or +/// whose 802-11-wireless SSID equals `ssid`. Returns `None` if no such +/// profile exists. +fn first_profile_for_ssid(conn: &Connection, ssid: &str) -> Option { + let settings = proxy(conn, SETTINGS_PATH, SETTINGS_IFACE)?; + let conns: Vec = settings.call("ListConnections", &()).ok()?; + let mut fallback: Option = None; + for c in conns { + let Some(s) = get_settings(conn, c.as_str()) else { continue; + }; + let conn_id = s + .get("connection") + .and_then(|m| m.get("id")) + .and_then(|v| v.downcast_ref::().ok()); + let conn_ssid = s + .get("802-11-wireless") + .and_then(|m| m.get("ssid")) + .and_then(|v| value_bytes(v)) + .map(|b| String::from_utf8_lossy(&b).into_owned()); + match &conn_id { + Some(id) if id == ssid => return Some(c), + _ => {} } - let name = fields[0].clone(); - if name == ssid { - return Some(name); + if conn_ssid.as_deref() == Some(ssid) { + return Some(c); } if fallback.is_none() { - if let Some(suffix) = name.strip_prefix(ssid) { - let s = suffix.trim(); - if !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()) { - fallback = Some(name); + if let Some(id) = &conn_id { + if let Some(suffix) = id.strip_prefix(ssid) { + let s = suffix.trim(); + if !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()) { + fallback = Some(c); + } } } } @@ -356,254 +464,279 @@ fn first_profile_for_ssid(ssid: &str) -> Option { fallback } +/// Build the `a{sa{sv}}` settings dict for a new Wi-Fi connection. +fn wifi_settings(net: &NetworkDef, uuid: &str) -> SettingsMap { + let mut settings: SettingsMap = HashMap::new(); + + let mut conn: HashMap = HashMap::new(); + conn.insert("id".into(), ov(Value::from(net.ssid.clone()))); + conn.insert("type".into(), ov(Value::from("802-11-wireless"))); + conn.insert("uuid".into(), ov(Value::from(uuid.to_string()))); + settings.insert("connection".into(), conn); + + let mut wifi: HashMap = HashMap::new(); + wifi.insert("ssid".into(), ov(Value::from(net.ssid.as_bytes().to_vec()))); + wifi.insert("mode".into(), ov(Value::from("infrastructure"))); + wifi.insert("hidden".into(), ov(Value::from(net.hidden))); + settings.insert("802-11-wireless".into(), wifi); + + if net.eap.is_some() { + let mut sec: HashMap = HashMap::new(); + sec.insert("key-mgmt".into(), ov(Value::from("wpa-eap"))); + settings.insert("802-11-wireless-security".into(), sec); + + let mut x1: HashMap = HashMap::new(); + if let Some(eap) = &net.eap { + x1.insert("eap".into(), ov(Value::from(vec![eap.clone()]))); + } + if let Some(id) = &net.identity { + x1.insert("identity".into(), ov(Value::from(id.clone()))); + } + if let Some(ca) = &net.ca_cert { + // NM stores `802-1x.ca-cert` as a GBytes (`ay`). For a filesystem + // path we send the conventional `file://` URI — the same form + // `nmcli` persists when given a path — not a bare string. + x1.insert( + "ca-cert".into(), + ov(Value::from(format!("file://{ca}").into_bytes())), + ); + } + if let Some(pw) = &net.password { + x1.insert("password".into(), ov(Value::from(pw.clone()))); + } + settings.insert("802-1x".into(), x1); + } else if let Some(pw) = &net.password { + let mut sec: HashMap = HashMap::new(); + sec.insert("key-mgmt".into(), ov(Value::from("wpa-psk"))); + sec.insert("psk".into(), ov(Value::from(pw.clone()))); + settings.insert("802-11-wireless-security".into(), sec); + } + + let mut ipv4: HashMap = HashMap::new(); + ipv4.insert("method".into(), ov(Value::from("auto"))); + settings.insert("ipv4".into(), ipv4); + let mut ipv6: HashMap = HashMap::new(); + ipv6.insert("method".into(), ov(Value::from("auto"))); + settings.insert("ipv6".into(), ipv6); + + settings +} + +/// Update a saved profile's settings in place for `net`: PSK (or 802.1x +/// properties for enterprise networks) and the hidden flag. Returns the +/// updated dict. +fn updated_settings_for(net: &NetworkDef, s: &mut SettingsMap) { + let sec = s.entry("802-11-wireless-security".to_string()).or_default(); + if net.eap.is_some() { + sec.insert("key-mgmt".into(), ov(Value::from("wpa-eap"))); + } else if net.password.is_some() { + sec.insert("key-mgmt".into(), ov(Value::from("wpa-psk"))); + } + if let Some(pw) = &net.password { + if net.eap.is_some() { + let x1 = s.entry("802-1x".to_string()).or_default(); + x1.insert("password".into(), ov(Value::from(pw.clone()))); + } else { + sec.insert("psk".into(), ov(Value::from(pw.clone()))); + } + } + let wifi = s.entry("802-11-wireless".to_string()).or_default(); + wifi.insert("hidden".into(), ov(Value::from(net.hidden))); +} + +/// RFC 4122 v4 UUID from `/dev/urandom` — used for the `connection.uuid` of +/// newly created profiles. (NetworkManager would generate one itself if +/// omitted, but being explicit matches `nmcli` and keeps the fake NM simple.) +fn new_uuid() -> String { + let mut bytes = [0u8; 16]; + if let Ok(mut f) = std::fs::File::open("/dev/urandom") { + use std::io::Read; + let _ = f.read_exact(&mut bytes); + } + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10 + format!( + "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], bytes[8], + bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15] + ) +} + +/// Wait up to `wait` seconds for the device to reach ACTIVATED. Polls the +/// device State property, like `nmcli --wait` blocks for activation. +fn wait_activated(conn: &Connection, dev: &OwnedObjectPath, wait: u32) -> Result<(), String> { + let deadline = Instant::now() + Duration::from_secs(wait.max(1) as u64); + loop { + match proxy(conn, dev.as_str(), DEV_IFACE) { + Some(p) => match p.get_property::("State") { + Ok(s) if s == DEV_STATE_ACTIVATED => return Ok(()), + Ok(_) => {} + Err(e) => return Err(err_text(e)), + }, + None => return Err("device disappeared while waiting for activation".into()), + } + if Instant::now() >= deadline { + return Err(format!( + "timed out waiting for device {dev} to activate after {wait}s" + )); + } + std::thread::sleep(Duration::from_millis(100)); + } +} + +/// Pin DNS onto the active connection of `iface`: update `ipv4.ignore-auto-dns` +/// and `ipv4.dns`, persist via Update2, then reapply on the device. +fn enforce_dns(conn: &Connection, iface: &str, dns: &str) -> bool { + if dns.trim().is_empty() { + return true; + } + let Some(active) = active_connection_path(conn, iface) else { + return false; + }; + let Some(mut s) = get_settings(conn, active.as_str()) else { + return false; + }; + let ipv4 = s.entry("ipv4".to_string()).or_default(); + ipv4.insert("ignore-auto-dns".into(), ov(Value::from(true))); + ipv4.insert("dns".into(), ov(Value::from(vec![dns.to_string()]))); + + // Persist the change on the *profile* object via `Update2` — calling + // `Settings.AddConnection2` with the profile's own UUID would fail on + // real NetworkManager (NM_SETTINGS_ERROR_UUID_EXISTS; duplicates are + // rejected, not upserted). + let conn_proxy = match proxy(conn, active.as_str(), CONN_IFACE) { + Some(p) => p, + None => return false, + }; + let _: Result, _> = conn_proxy.call( + "Update2", + &(&s, FLAG_TO_DISK, &HashMap::::new()), + ); + + // Reapply the updated settings on the device so DNS takes effect without + // bouncing the link. + let Some(dev) = device_path(conn, iface) else { + return false; + }; + let Some(dev_proxy) = proxy(conn, dev.as_str(), DEV_IFACE) else { + return false; + }; + let _: Result<(), _> = dev_proxy.call("Reapply", &(&s, 0u64, 0u32)); + true +} + /// Connect to a network and pin DNS. Returns true only if associated. pub fn connect(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> bool { connect_verbose(iface, net, wait, dns).is_ok() } -/// Connect to a network and pin DNS. Returns the nmcli error on failure. +/// Connect to a network and pin DNS. Returns the D-Bus error on failure. /// /// Reuses an existing saved profile for the SSID when one exists (updating its /// PSK) so that repeated connections do not accumulate numbered duplicates in -/// NetworkManager ("NCC", "NCC 1", "NCC 2", …). Falls back to -/// `nmcli device wifi connect` — which creates a new profile — only when no -/// saved profile is found. +/// NetworkManager. Falls back to creating a new connection +/// (`AddAndActivateConnection2`, which also activates it) only when no saved +/// profile is found. /// /// `net.password` is only sent when `Some`: on the reuse path, `None` means -/// "leave the saved PSK alone" (either NetworkManager already durably owns -/// it, or the network is open); on the create path it means "no password -/// argument at all", which is also how a genuinely open (no-security) SSID -/// is connected. See the field doc on [`NetworkDef::password`] for how a -/// local secret transitions to `None` after its first successful use. +/// \"leave the saved secret alone\" (either NetworkManager already durably +/// owns it, or the network is open); on the create path it means \"no secret +/// section\", which is also how a genuinely open (no-security) SSID is +/// connected. See the field doc on [`NetworkDef::password`] for how a local +/// secret transitions to `None` after its first successful use. /// -/// KNOWN LIMITATION (credential exposure): when a password *is* sent, it's -/// passed to `nmcli` as a plain command-line argument -/// (`802-11-wireless-security.psk ` on the reuse path, `password ` -/// on the create path). For the lifetime of that `nmcli` child, the secret -/// is readable by other local users via `/proc//cmdline`. -/// `util::run_with_stdin` exists to feed secrets on stdin instead, but -/// wiring it up correctly needs either verified `nmcli --ask` piped-stdin -/// behavior or NetworkManager's D-Bus secret-agent API — neither of which -/// can be validated without a live NetworkManager connection — so this is -/// left as documented tech debt rather than a guess. In practice this -/// exposure window now only exists on a network's *first* connect: once -/// NetworkManager has the credential, breadcrumbs clears its local copy, so -/// there's nothing left to pass on argv for every subsequent connect. +/// The secret (when sent) travels inside the D-Bus `Update2`/`AddAndActivate +/// Connection2` settings payload — never on a process argv line — so it is +/// not readable by other local users via `/proc//cmdline` the way the +/// old `nmcli ... psk ` invocation was. pub fn connect_verbose(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> Result<(), String> { - let wait_s = wait.to_string(); + let conn = connection().ok_or_else(|| "cannot connect to the D-Bus system bus".to_string())?; + let dev = device_path(&conn, iface) + .ok_or_else(|| format!("no NetworkManager device named '{iface}'"))?; - if let Some(profile) = first_profile_for_ssid(&net.ssid) { + if let Some(profile) = first_profile_for_ssid(&conn, &net.ssid) { // Update the saved credentials and, for hidden networks, ensure the - // flag is set. PSK vs 802.1x (enterprise) profiles are updated with - // their own property sets. - if let Some(pw) = &net.password { - if net.eap.is_some() { - enterprise_modify(&profile, net); - } else { - let _ = run( - "nmcli", - &[ - "connection", - "modify", - &profile, - "802-11-wireless-security.psk", - pw.as_str(), - ], - Duration::from_secs(6), - ); - } - } - if net.hidden { - let _ = run( - "nmcli", - &[ - "connection", - "modify", - &profile, - "802-11-wireless.hidden", - "yes", - ], - Duration::from_secs(6), - ); - } - let o = run( - "nmcli", - &["--wait", &wait_s, "connection", "up", &profile, "ifname", iface], - Duration::from_secs(wait as u64 + 15), - ); - if !o.success { - let detail = o.stderr.trim().to_string(); - return Err(if detail.is_empty() { - o.stdout.trim().to_string() - } else { - detail - }); - } - if let Some(uuid) = active_uuid(iface) { - enforce_dns(&uuid, iface, dns); + // hidden flag is set. PSK vs 802.1x (enterprise) profiles get their + // own property sets. + if net.password.is_some() || net.hidden { + let mut s = get_settings(&conn, profile.as_str()) + .ok_or_else(|| "failed to read saved profile settings".to_string())?; + updated_settings_for(net, &mut s); + // Update the existing profile in place via `Settings.Connection + // .Update2`. `Settings.AddConnection2` with the profile's own + // UUID would be rejected by real NetworkManager + // (NM_SETTINGS_ERROR_UUID_EXISTS — duplicates are not upserted). + let conn_proxy = proxy(&conn, profile.as_str(), CONN_IFACE) + .ok_or_else(|| "NetworkManager Settings.Connection unavailable".to_string())?; + let _: HashMap = conn_proxy + .call("Update2", &(&s, FLAG_TO_DISK, &HashMap::::new())) + .map_err(err_text)?; } + let nm = nm_proxy(&conn).ok_or_else(|| "NetworkManager unavailable".to_string())?; + let specific = zbus::zvariant::ObjectPath::try_from("/").expect("root object path"); + let _: OwnedObjectPath = nm + .call("ActivateConnection", &(&profile, &dev, &specific)) + .map_err(err_text)?; + wait_activated(&conn, &dev, wait)?; + enforce_dns(&conn, iface, dns); return Ok(()); } - if net.eap.is_some() { - // Enterprise networks can't be created via `device wifi connect` - // (no 802-1x options) — create the profile explicitly, then bring - // it up. - let args = enterprise_create_args(net, &wait_s, iface); - let o = run("nmcli", &args, Duration::from_secs(wait as u64 + 15)); - if !o.success { - let detail = o.stderr.trim().to_string(); - return Err(if detail.is_empty() { - o.stdout.trim().to_string() - } else { - detail - }); - } - let o = run( - "nmcli", - &["--wait", &wait_s, "connection", "up", &net.ssid, "ifname", iface], - Duration::from_secs(wait as u64 + 15), - ); - if !o.success { - let detail = o.stderr.trim().to_string(); - return Err(if detail.is_empty() { - o.stdout.trim().to_string() - } else { - detail - }); - } - if let Some(uuid) = active_uuid(iface) { - enforce_dns(&uuid, iface, dns); - } - return Ok(()); - } - - // No saved profile — create one via device wifi connect. - let hidden = if net.hidden { "yes" } else { "no" }; - let mut args: Vec<&str> = vec![ - "--wait", - &wait_s, - "device", - "wifi", - "connect", - net.ssid.as_str(), - ]; - // Only pass `password` when we actually have one. An empty/missing PSK - // argument makes nmcli treat the network as open (no security), which is - // what we want both for genuinely open SSIDs and for a network whose - // secret NetworkManager should already hold — though the latter case - // only succeeds if a saved profile in fact exists, which is why we only - // reach this branch (no saved profile found) when that assumption held. - if let Some(pw) = &net.password { - args.push("password"); - args.push(pw.as_str()); - } - args.push("hidden"); - args.push(hidden); - args.push("ifname"); - args.push(iface); - let o = run("nmcli", &args, Duration::from_secs(wait as u64 + 15)); - if !o.success { - let detail = o.stderr.trim().to_string(); - return Err(if detail.is_empty() { - o.stdout.trim().to_string() - } else { - detail - }); - } - if let Some(uuid) = active_uuid(iface) { - enforce_dns(&uuid, iface, dns); - } + // No saved profile — create one (and activate it in one call). + let settings = wifi_settings(net, &new_uuid()); + let nm = nm_proxy(&conn).ok_or_else(|| "NetworkManager unavailable".to_string())?; + let options: HashMap = + HashMap::from([("persist".to_string(), Value::from("disk"))]); + let specific = zbus::zvariant::ObjectPath::try_from("/").expect("root object path"); + let (_path, _active): (OwnedObjectPath, OwnedObjectPath) = nm + .call( + "AddAndActivateConnection2", + &(&settings, &dev, &specific, &options), + ) + .map_err(err_text)?; + wait_activated(&conn, &dev, wait)?; + enforce_dns(&conn, iface, dns); Ok(()) } -/// `nmcli connection modify` args switching an existing profile to the -/// network's 802.1x settings (`wifi-sec.key-mgmt wpa-eap` + 802-1x props). -fn enterprise_modify(profile: &str, net: &NetworkDef) { - let mut args: Vec<&str> = vec![ - "connection", - "modify", - profile, - "wifi-sec.key-mgmt", - "wpa-eap", - ]; - enterprise_props(&mut args, net); - let _ = run("nmcli", &args, Duration::from_secs(6)); -} - -/// Append the 802.1x property pairs for `net` to `args`. -fn enterprise_props<'a>(args: &mut Vec<&'a str>, net: &'a NetworkDef) { - if let Some(eap) = &net.eap { - args.push("802-1x.eap"); - args.push(eap.as_str()); - } - if let Some(id) = &net.identity { - args.push("802-1x.identity"); - args.push(id.as_str()); - } - if let Some(ca) = &net.ca_cert { - args.push("802-1x.ca-cert"); - args.push(ca.as_str()); - } - if let Some(pw) = &net.password { - args.push("802-1x.password"); - args.push(pw.as_str()); - } -} - -/// `nmcli connection add` args for an enterprise (802.1x) network — the -/// create path, since `device wifi connect` can't express 802-1x settings. -fn enterprise_create_args<'a>(net: &'a NetworkDef, wait_s: &'a str, iface: &'a str) -> Vec<&'a str> { - let mut args: Vec<&str> = vec![ - "--wait", - wait_s, - "connection", - "add", - "type", - "wifi", - "con-name", - net.ssid.as_str(), - "ssid", - net.ssid.as_str(), - "wifi-sec.key-mgmt", - "wpa-eap", - ]; - enterprise_props(&mut args, net); - if net.hidden { - args.push("802-11-wireless.hidden"); - args.push("yes"); - } - args.push("ifname"); - args.push(iface); - args -} - /// List all wireless connection profiles as `(name, ssid)` pairs, using the /// profile's `802-11-wireless.ssid` setting when present (falling back to /// the profile name). Used by `breadcrumbs prune`. pub fn wireless_profiles() -> Vec<(String, String)> { - let list = run( - "nmcli", - &["-t", "-f", "NAME,TYPE", "connection", "show"], - Duration::from_secs(8), - ); + let Some(conn) = connection() else { + return Vec::new(); + }; + let Some(settings) = proxy(&conn, SETTINGS_PATH, SETTINGS_IFACE) else { + return Vec::new(); + }; + let conns: Vec = match settings.call("ListConnections", &()) { + Ok(v) => v, + Err(_) => return Vec::new(), + }; let mut out = Vec::new(); - if !list.success { - return out; - } - for line in list.stdout.lines() { - let fields = split_fields(line); - if fields.len() < 2 || !fields[1].contains("wireless") { + for c in conns { + let Some(s) = get_settings(&conn, c.as_str()) else { + continue; + }; + let typ = s + .get("connection") + .and_then(|m| m.get("type")) + .and_then(|v| v.downcast_ref::().ok()); + if typ.as_deref() != Some("802-11-wireless") { continue; } - let name = fields[0].clone(); - let conn_ssid = run( - "nmcli", - &["-g", "802-11-wireless.ssid", "connection", "show", &name], - Duration::from_secs(6), - ); - let conn_ssid = conn_ssid.stdout.trim().to_string(); - out.push((name.clone(), if conn_ssid.is_empty() { name } else { conn_ssid })); + let name = s + .get("connection") + .and_then(|m| m.get("id")) + .and_then(|v| v.downcast_ref::().ok()) + .unwrap_or_default(); + let conn_ssid = s + .get("802-11-wireless") + .and_then(|m| m.get("ssid")) + .and_then(|v| value_bytes(v)) + .map(|b| String::from_utf8_lossy(&b).into_owned()) + .filter(|s| !s.is_empty()); + out.push((name.clone(), conn_ssid.unwrap_or(name))); } out } @@ -611,39 +744,38 @@ pub fn wireless_profiles() -> Vec<(String, String)> { /// Delete every saved connection profile whose name or 802-11-wireless SSID /// matches `ssid` (used by `breadcrumbs forget` to purge stale entries). pub fn delete_connections_for_ssid(ssid: &str) -> bool { - let list = run( - "nmcli", - &["-t", "-f", "NAME,TYPE", "connection", "show"], - Duration::from_secs(8), - ); - if !list.success { + let Some(conn) = connection() else { return false; - } + }; + let Some(settings) = proxy(&conn, SETTINGS_PATH, SETTINGS_IFACE) else { + return false; + }; + let conns: Vec = match settings.call("ListConnections", &()) { + Ok(v) => v, + Err(_) => return false, + }; let mut removed = false; - for line in list.stdout.lines() { - let fields = split_fields(line); - if fields.len() < 2 { + for c in conns { + let Some(s) = get_settings(&conn, c.as_str()) else { continue; - } - let name = fields[0].clone(); - let typ = &fields[1]; - if !typ.contains("wireless") { - continue; - } - let conn_ssid = run( - "nmcli", - &["-g", "802-11-wireless.ssid", "connection", "show", &name], - Duration::from_secs(6), - ); - let conn_ssid = conn_ssid.stdout.trim(); - if (name == ssid || conn_ssid == ssid) - && run_ok( - "nmcli", - &["connection", "delete", "id", &name], - Duration::from_secs(8), - ) - { - removed = true; + }; + let name = s + .get("connection") + .and_then(|m| m.get("id")) + .and_then(|v| v.downcast_ref::().ok()) + .unwrap_or_default(); + let conn_ssid = s + .get("802-11-wireless") + .and_then(|m| m.get("ssid")) + .and_then(|v| value_bytes(v)) + .map(|b| String::from_utf8_lossy(&b).into_owned()) + .unwrap_or_default(); + if name == ssid || conn_ssid == ssid { + if let Some(p) = proxy(&conn, c.as_str(), CONN_IFACE) { + if p.call::<_, _, ()>("Delete", &()).is_ok() { + removed = true; + } + } } } removed @@ -654,45 +786,41 @@ mod tests { use super::*; #[test] - fn unescape_handles_nmcli_escaping() { - assert_eq!(unescape("plain"), "plain"); - assert_eq!(unescape(r"a\:b"), "a:b"); - assert_eq!(unescape(r"back\\slash"), r"back\slash"); - assert_eq!(unescape("trailing\\"), "trailing\\"); + fn security_string_derivation() { + // Open AP: no privacy bit, no WPA/RSN. + assert_eq!(security_string(0, 0, 0), "--"); + // WPA2-PSK (RSN PSK set). + assert_eq!(security_string(AP_FLAG_PRIVACY, 0, SEC_PSK), "WPA2"); + // WPA1+WPA2 (both flag sets carry PSK). + assert_eq!(security_string(AP_FLAG_PRIVACY, SEC_PSK, SEC_PSK), "WPA1 WPA2"); + // WPA3 (SAE). + assert_eq!(security_string(AP_FLAG_PRIVACY, 0, SEC_SAE), "WPA3"); + // Enterprise (802.1x key management). + assert_eq!( + security_string(AP_FLAG_PRIVACY, SEC_802_1X, SEC_802_1X), + "802.1X" + ); + // WEP: privacy bit but no WPA/RSN. + assert_eq!(security_string(AP_FLAG_PRIVACY, 0, 0), "WEP"); } #[test] - fn split_fields_splits_and_unescapes() { - // SSID:SIGNAL:SECURITY with an escaped ':' inside the SSID. - let f = split_fields(r"My\:Net:72:WPA2"); - assert_eq!(f, vec!["My:Net", "72", "WPA2"]); - - // SSID with a space (common in real network names) - let f = split_fields("My Network:88:WPA2"); - assert_eq!(f, vec!["My Network", "88", "WPA2"]); - - // Empty SSID (hidden) keeps the empty leading field. - let f = split_fields(":40:WPA3"); - assert_eq!(f, vec!["", "40", "WPA3"]); + fn signal_rank_handles_percent_suffix() { + assert_eq!(signal_rank("90 %"), 90); + assert_eq!(signal_rank("80"), 80); + assert_eq!(signal_rank("garbage"), -100); } #[test] - fn split_fields_two_column_with_colon_in_first_field() { - // A connection NAME or SSID containing a literal ':' must not be - // mis-split into TYPE — this is what a plain `splitn(2, ':')` gets - // wrong (e.g. `wifi_interface`/`first_profile_for_ssid` parsing). - let f = split_fields(r"Office\:5G:802-11-wireless"); - assert_eq!(f, vec!["Office:5G", "802-11-wireless"]); - } - - #[test] - fn split_fields_empty_line() { - assert_eq!(split_fields(""), vec![""]); - } - - #[test] - fn split_fields_trailing_backslash_in_field() { - let f = split_fields(r"trail\\:wifi"); - assert_eq!(f, vec![r"trail\", "wifi"]); + fn uuid_v4_is_shape_valid() { + let u = new_uuid(); + let bytes = u.as_bytes(); + assert_eq!(bytes.len(), 36); + assert_eq!(bytes[8], b'-'); + assert_eq!(bytes[13], b'-'); + assert_eq!(bytes[18], b'-'); + assert_eq!(bytes[23], b'-'); + // Version nibble is 4. + assert_eq!(bytes[14], b'4'); } } diff --git a/src/status.rs b/src/status.rs index 8e0fe71..84b50e3 100644 --- a/src/status.rs +++ b/src/status.rs @@ -65,23 +65,6 @@ pub fn internet_ok(cfg: &Config) -> bool { matches!(connectivity(cfg), Connectivity::Online) } -fn ipv4(iface: &str) -> Option { - let o = run( - "nmcli", - &["-g", "IP4.ADDRESS", "device", "show", iface], - Duration::from_secs(6), - ); - if !o.success { - return None; - } - let s = o.stdout.trim(); - if s.is_empty() { - None - } else { - Some(s.lines().next().unwrap_or(s).trim().to_string()) - } -} - pub struct Status { pub iface: Option, pub ssid: Option, @@ -97,7 +80,7 @@ pub struct Status { pub fn gather(cfg: &Config, profile_name: &str) -> Status { let iface = nm::wifi_interface_preferred(cfg.settings.interface.as_deref()); let ssid = iface.as_deref().and_then(nm::active_ssid); - let ip = iface.as_deref().and_then(ipv4); + let ip = iface.as_deref().and_then(nm::ipv4_address); // Skip the (potentially 4s-blocking) connectivity probe when there's no // Wi-Fi interface at all: the watch loop classifies NoAdapter and would // otherwise burn a network round-trip (curl/ping) every tick for nothing. diff --git a/src/util.rs b/src/util.rs index 2a5ed41..3e8fced 100644 --- a/src/util.rs +++ b/src/util.rs @@ -88,14 +88,14 @@ pub fn command_exists(name: &str) -> bool { } /// Run a command with a hard timeout. The child is killed if it overruns so a -/// hung nmcli/tailscale can never wedge the daemon. +/// hung subprocess can never wedge the daemon. pub fn run(prog: &str, args: &[&str], timeout: Duration) -> Output { run_with_stdin(prog, args, None, timeout) } -/// Like [`run`], but feeds `stdin` to the child's standard input. Used to hand -/// secrets (e.g. Wi-Fi PSKs) to `nmcli --ask` without exposing them in argv, -/// where any local user could read them via `ps`. +/// Like [`run`], but feeds `stdin` to the child's standard input. +/// (Wi-Fi secrets no longer go through here: `nm` sends them inside D-Bus +/// payloads, never on a command line.) pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration) -> Output { RUNNER.with(|r| r.borrow().run(prog, args, stdin, timeout)) } diff --git a/src/watch.rs b/src/watch.rs index 3d64182..6da41b7 100644 --- a/src/watch.rs +++ b/src/watch.rs @@ -1,19 +1,27 @@ -use std::io::{BufRead, BufReader}; -use std::process::{Command, Stdio}; +use std::collections::HashMap; use std::sync::mpsc::{self, Receiver}; use std::thread; use std::time::{Duration, Instant}; use bread_utils::bread_client::BreadClient; +use zbus::blocking::{Connection, Proxy}; +use zbus::zvariant::OwnedValue; use crate::bread_events; use crate::config::Config; use crate::flow; +use crate::nm; use crate::notify::{log, notify, Urgency}; use crate::state::{self, State}; use crate::status::{self}; use crate::tailscale::TsHealth; +const NM_DEST: &str = "org.freedesktop.NetworkManager"; +const NM_PATH: &str = "/org/freedesktop/NetworkManager"; +const NM_IFACE: &str = "org.freedesktop.NetworkManager"; +const DEV_IFACE: &str = "org.freedesktop.NetworkManager.Device"; +const PROPS_IFACE: &str = "org.freedesktop.DBus.Properties"; + /// Coarse health classification the watch loop reacts to each tick. `pub` /// (and so is [`classify`]) purely so integration tests can drive the real /// classification logic in-process against a faked [`crate::util::Runner`], @@ -146,58 +154,89 @@ enum Wake { SetProfile(String), } -/// Tail `nmcli monitor` and ping the channel on link-state churn so we react -/// to drops within a second instead of waiting out the poll interval. -fn spawn_nm_monitor(tx: mpsc::Sender) { +/// Whether a `PropertiesChanged` message on the NM root changes the +/// `Connectivity` property of the NetworkManager interface — the signal +/// that catches "still connected but lost the internet" (portal, DHCP +/// failure) without waiting out the poll interval. +fn props_changed_connectivity(msg: &zbus::Message) -> bool { + let Ok(body) = msg.body().deserialize::<(String, HashMap, Vec)>() else { + return false; + }; + body.0 == NM_IFACE && body.1.contains_key("Connectivity") +} + +/// Subscribe to a D-Bus signal from NetworkManager and ping the channel for +/// each matching message, reconnecting on bus/NM restarts. One thread per +/// subscription (a handful at most); each owns its own connection so a dead +/// bus can't wedge the others. +fn spawn_signal_watcher(tx: mpsc::Sender, path: String, iface: &'static str, signal: &'static str, mut on_msg: F) +where + F: FnMut(&zbus::Message) -> bool + Send + 'static, +{ thread::spawn(move || loop { - let child = Command::new("nmcli") - .arg("monitor") - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn(); - let mut child = match child { - Ok(c) => c, - Err(_) => { - thread::sleep(Duration::from_secs(10)); - continue; - } + let Ok(conn) = Connection::system() else { + thread::sleep(Duration::from_secs(10)); + continue; }; - if let Some(out) = child.stdout.take() { - let reader = BufReader::new(out); - // `None` means "haven't fired yet, so fire on the first interesting - // line". Storing an `Option` instead of seeding with - // `Instant::now() - 10s` avoids a panic: `Instant - Duration` - // underflows (and panics) when the monotonic clock is younger than - // the offset, which happens if `watch` starts within ~10s of boot — - // exactly when the systemd unit (ordered after graphical-session) - // tends to launch. - let mut last: Option = None; - for line in reader.lines().map_while(Result::ok) { - let l = line.to_lowercase(); - // `connectivity` lines catch drops that keep the device - // "connected" but lose the internet (captive portal, DHCP - // failure); `deactivating` covers teardown. Everything else - // waits out the poll interval. - let interesting = l.contains("disconnect") - || l.contains("unavailable") - || l.contains("failed") - || l.contains("deactivating") - || l.contains("connectivity"); - if interesting && debounce_ready(last, Duration::from_millis(1500)) { - last = Some(Instant::now()); - let _ = tx.send(Wake::LinkChurn); - } + let Ok(proxy) = Proxy::new(&conn, NM_DEST, path.as_str(), iface) else { + thread::sleep(Duration::from_secs(10)); + continue; + }; + let Ok(mut iter) = proxy.receive_signal(signal) else { + thread::sleep(Duration::from_secs(10)); + continue; + }; + // `None` means "haven't fired yet, so fire on the first interesting + // signal". Storing an `Option` instead of seeding with + // `Instant::now() - 10s` avoids a panic: `Instant - Duration` + // underflows (and panics) when the monotonic clock is younger than + // the offset, which happens if `watch` starts within ~10s of boot — + // exactly when the systemd unit (ordered after graphical-session) + // tends to launch. + let mut last: Option = None; + for msg in iter.by_ref() { + if on_msg(&msg) && debounce_ready(last, Duration::from_millis(1500)) { + last = Some(Instant::now()); + let _ = tx.send(Wake::LinkChurn); } } - let _ = child.wait(); - // monitor died (NM restart?) — back off and respawn. + // Subscription died (NM or bus restart) — back off and resubscribe. thread::sleep(Duration::from_secs(5)); }); } -/// Sleep up to `dur`, but wake early if `nmcli monitor` signals link churn or -/// a `set_profile` command arrives. Returns the pending action, if any. +/// Subscribe to NetworkManager D-Bus signals and ping the channel on +/// link-state churn so we react to drops within a second instead of waiting +/// out the poll interval. Replaces the old `nmcli monitor` subprocess: the +/// same events are observed, but as structured D-Bus signals. +/// +/// Watched signals: +/// - `PropertiesChanged` on the NM root object, filtered to the +/// `Connectivity` property — catches captive portals / DHCP failures that +/// keep the device "connected" while losing the internet; +/// - `DeviceAdded` / `DeviceRemoved` — hotplug; +/// - `Device.StateChanged` on every Wi-Fi device — drops and reconnects. +fn spawn_nm_monitor(tx: mpsc::Sender) { + spawn_signal_watcher( + tx.clone(), + NM_PATH.to_string(), + PROPS_IFACE, + "PropertiesChanged", + props_changed_connectivity, + ); + spawn_signal_watcher(tx.clone(), NM_PATH.to_string(), NM_IFACE, "DeviceAdded", |_| true); + spawn_signal_watcher(tx.clone(), NM_PATH.to_string(), NM_IFACE, "DeviceRemoved", |_| true); + // StateChanged on each Wi-Fi device. Devices added later (USB dongle + // hotplug) are caught by the DeviceAdded watcher waking the loop; the + // poll interval covers anything else. + for path in nm::wifi_device_paths() { + spawn_signal_watcher(tx.clone(), path, DEV_IFACE, "StateChanged", |_| true); + } +} + +/// Sleep up to `dur`, but wake early if the D-Bus signal monitor signals +/// link churn or a `set_profile` command arrives. Returns the pending +/// action, if any. fn wait_for_tick(rx: &Receiver, dur: Duration) -> Option { match rx.recv_timeout(dur) { Ok(first) => { @@ -301,10 +340,10 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { } profile = State::load(&cfg.settings.default_profile).profile; - // Suspend/resume: `nmcli monitor` sees nothing while the machine - // sleeps, so a large wall-clock gap means the network state may have - // changed underneath us — allow an immediate recovery run instead of - // waiting out any remaining flow cooldown. + // Suspend/resume: the D-Bus signal monitor sees nothing while the + // machine sleeps, so a large wall-clock gap means the network state + // may have changed underneath us — allow an immediate recovery run + // instead of waiting out any remaining flow cooldown. if last_tick_at.elapsed() > prev_wait + RESUME_SLACK { log("watch: large gap since last tick (suspend/resume?) — forcing recovery check"); last_flow_at = None; From cc709a4af9699da2d8b06433f1c30184a850f166 Mon Sep 17 00:00:00 2001 From: Breadway Date: Mon, 31 Aug 2026 15:10:42 +0800 Subject: [PATCH 5/5] Test the NM layer against an in-process D-Bus fake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tests/common/fake_nm.rs` stands up a fake `org.freedesktop.NetworkManager` on a private bus so `cli.rs` and `flow_watch.rs` exercise the real `nm` code paths without a live NetworkManager and without shelling out. Replaces the previous command-capture scaffolding in those two files; scenario coverage (captive portal, exit-node failover, 802.1x, per-network DNS, schedule triggers, Tailscale recovery, SSID verification) is preserved — 139 tests. --- tests/cli.rs | 307 ++++-------- tests/common/fake_nm.rs | 1007 +++++++++++++++++++++++++++++++++++++++ tests/common/mod.rs | 10 +- tests/flow_watch.rs | 633 +++++++++--------------- 4 files changed, 1345 insertions(+), 612 deletions(-) create mode 100644 tests/common/fake_nm.rs diff --git a/tests/cli.rs b/tests/cli.rs index c5a0109..2d1a422 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,6 +1,10 @@ //! End-to-end CLI tests. Each run is fully isolated: HOME / XDG dirs point at a -//! throwaway tempdir and PATH is emptied so no real `nmcli`/`tailscale`/`date` -//! is ever invoked and the host system is never touched. +//! throwaway tempdir, PATH is emptied so no real `tailscale`/`curl`/`date` is +//! ever invoked, and a private `dbus-daemon` (optionally hosting a fake +//! NetworkManager service — see `tests/common::fake_nm`) stands in for the +//! system bus so the binary's D-Bus calls never touch the host. + +mod common; use std::fs; use std::path::PathBuf; @@ -8,12 +12,15 @@ use std::process::Command; use std::sync::atomic::{AtomicU32, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; +use common::fake_nm::{self, Security}; + const BIN: &str = env!("CARGO_BIN_EXE_breadcrumbs"); static COUNTER: AtomicU32 = AtomicU32::new(0); struct Sandbox { root: PathBuf, + _bus: fake_nm::Daemon, } impl Sandbox { @@ -30,7 +37,16 @@ impl Sandbox { nanos )); fs::create_dir_all(root.join("bin")).unwrap(); - Sandbox { root } + Sandbox { + root, + _bus: fake_nm::launch_daemon(), + } + } + + /// Attach the fake NetworkManager service to this sandbox's bus and + /// return a handle for driving its state. + fn nm(&self) -> fake_nm::FakeNmBus { + fake_nm::serve_on(&self._bus.addr) } /// Binary invocation with an isolated, side-effect-free environment. @@ -48,7 +64,10 @@ impl Sandbox { .env("XDG_CONFIG_HOME", self.root.join("config")) .env("XDG_STATE_HOME", self.root.join("state")) // Empty bin dir => no external commands resolve. - .env("PATH", self.root.join("bin")); + .env("PATH", self.root.join("bin")) + // Point the binary's `Connection::system()` at this test's + // private bus so it never touches a real system bus. + .env("DBUS_SYSTEM_BUS_ADDRESS", &self._bus.addr); for (k, v) in extra { c.env(k, v); } @@ -66,7 +85,7 @@ impl Sandbox { } /// Write an executable shell script into the sandbox's PATH dir so a - /// test can stand in for an external command (e.g. `$EDITOR`). + /// test can stand in for an external command (e.g. `$EDITOR`, `curl`). fn write_fake_bin(&self, name: &str, script: &str) -> PathBuf { let path = self.root.join("bin").join(name); fs::write(&path, script).unwrap(); @@ -151,7 +170,7 @@ fn profile_defaults_to_away_then_persists_set() { assert!(o.status.success()); assert_eq!(stdout(&o).trim(), "away"); - // `set --no-apply` must not touch the network (no nmcli available anyway). + // `set --no-apply` must not touch the network (no NM on the bus anyway). let o = sb.cmd(&["profile", "set", "home", "--no-apply"]); assert!( o.status.success(), @@ -194,9 +213,7 @@ fn profile_list_marks_exactly_the_current_profile() { let out = stdout(&o); assert!(out.contains("* home"), "out: {out}"); assert_eq!( - out.lines() - .filter(|l| l.trim_start().starts_with('*')) - .count(), + out.lines().filter(|l| l.trim_start().starts_with('*')).count(), 1, "expected exactly one marked profile, got: {out}" ); @@ -264,10 +281,7 @@ fn forget_removes_network_from_config() { ); // ...and it should never have been in breadcrumbs.toml to begin with. let text = fs::read_to_string(sb.config_file()).unwrap(); - assert!( - !text.contains("CafeWifi"), - "network leaked into config: {text}" - ); + assert!(!text.contains("CafeWifi"), "network leaked into config: {text}"); } #[test] @@ -275,15 +289,13 @@ fn detect_without_wifi_adapter_errors() { let sb = Sandbox::new(); let o = sb.cmd(&["detect"]); assert!(!o.status.success()); - assert!( - stderr(&o).contains("could not detect"), - "stderr: {}", - stderr(&o) - ); + assert!(stderr(&o).contains("could not detect"), "stderr: {}", stderr(&o)); } #[test] -fn doctor_reports_missing_nmcli_in_sandbox() { +fn doctor_reports_missing_network_manager_on_private_bus() { + // The sandbox bus has no NetworkManager service on it, so doctor must + // report it missing rather than assuming presence. let sb = Sandbox::new(); let o = sb.cmd(&["doctor"]); assert!(o.status.success(), "stderr: {}", stderr(&o)); @@ -376,11 +388,7 @@ fn networks_are_stored_separately_from_settings_and_profiles() { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - let mode = fs::metadata(sb.networks_file()) - .unwrap() - .permissions() - .mode() - & 0o777; + let mode = fs::metadata(sb.networks_file()).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o600, "networks.toml should be owner-only"); } } @@ -390,8 +398,8 @@ fn add_with_empty_password_is_stored_as_no_password() { // An explicitly empty password (e.g. `add SSID ""`, or a blank response // at the interactive prompt) means "this is an open network" — it must // round-trip as an absent `password` key, the same as a cleared one, - // not as `password = ""` (which `nm::connect_verbose` would treat as a - // blank secret rather than an open network). + // not as `password = ""` (which `nm::connect_verbose` would send as a + // literal empty PSK and fail against a real open SSID). let sb = Sandbox::new(); let o = sb.cmd(&["add", "OpenCafe", ""]); assert!(o.status.success(), "stderr: {}", stderr(&o)); @@ -408,77 +416,25 @@ fn add_with_empty_password_is_stored_as_no_password() { // NM-owned credentials (item 4): a password is only ever needed once. // ----------------------------------------------------------------------- -/// A fake `nmcli` that behaves statefully enough to exercise the "first -/// connect creates a profile with a password, second connect reuses it -/// without one" path: it records every invocation's argv (one per line) to -/// `$HOME/.nmcli-calls`, and remembers — via a marker file, also under -/// `$HOME` — that "device wifi connect TestNet" has already run, so a -/// following `connection show` reports a saved profile exists. -const FAKE_NMCLI_STATEFUL: &str = r#"#!/bin/sh -record="$HOME/.nmcli-calls" -marker="$HOME/.nmcli-profile-created" -echo "$@" >> "$record" -args="$*" -case "$args" in - "-t -f DEVICE,TYPE device status") - echo "wlan0:wifi" ;; - "radio wifi on") ;; - "device wifi rescan"*) ;; - "-t -f SSID device wifi list ifname wlan0") - echo "TestNet" ;; - "-t -f SSID,SIGNAL device wifi list ifname wlan0") - echo "TestNet:80" ;; - "-t -f ACTIVE,SSID device wifi list ifname wlan0") - echo "yes:TestNet" ;; - "-t -f NAME,TYPE connection show") - if [ -f "$marker" ]; then - echo "TestNet:802-11-wireless" - fi - ;; - *"device wifi connect TestNet"*) - # `: > file` (truncate-or-create via a shell builtin + redirection) — - # not `touch`, which is an external binary and the sandbox's PATH - # deliberately contains nothing but this fake nmcli itself. - : > "$marker" ;; - *"connection up TestNet"*) ;; - *"802-11-wireless-security.psk"*) ;; - "-g GENERAL.CON-UUID device show wlan0") - echo "uuid-1" ;; - *"ipv4.ignore-auto-dns"*) ;; - "device reapply wlan0") ;; - "-t -f DEVICE,STATE device status") - echo "wlan0:connected" ;; - *) ;; -esac -exit 0 -"#; - #[test] fn password_is_cleared_after_first_connect_and_never_sent_again() { + // The first connect creates an NM profile carrying the PSK (over D-Bus, + // never in argv); the second connect reuses that profile and must not + // create a duplicate or resend the password. let sb = Sandbox::new(); - sb.write_fake_bin("nmcli", FAKE_NMCLI_STATEFUL); + let nm = sb.nm(); + let dev = nm.add_wifi_device("wlan0", 100); + nm.add_ap(&dev, "TestNet", 80, Security::Wpa2); let add = sb.cmd(&["add", "TestNet", "hunter2"]); assert!(add.status.success(), "stderr: {}", stderr(&add)); // "away" is the default profile and defaults to include_all_known, so // TestNet is already a connect candidate with no `--to` needed. - let record = sb.root.join(".nmcli-calls"); - - // First connect: no saved NM profile yet, so breadcrumbs creates one via - // `nmcli --ask device wifi connect ...` with the PSK on stdin, not argv. + // First connect: no saved NM profile yet, so breadcrumbs creates one + // with the password. let first = sb.cmd(&["init"]); assert!(first.status.success(), "stderr: {}", stderr(&first)); - let first_calls = fs::read_to_string(&record).unwrap_or_default(); - assert!( - first_calls.contains("device wifi connect TestNet") && first_calls.contains("--ask"), - "first connect should create a new NM profile via --ask: {first_calls}" - ); - assert!( - !first_calls.contains("hunter2"), - "PSK must not appear on nmcli argv: {first_calls}" - ); - // stdin payload is asserted in-process via FakeRunner (see flow_watch). // The local copy is gone from disk immediately after. let networks = fs::read_to_string(sb.networks_file()).unwrap(); @@ -486,54 +442,43 @@ fn password_is_cleared_after_first_connect_and_never_sent_again() { !networks.contains("hunter2"), "password should have been cleared from networks.toml: {networks}" ); - assert!( - networks.contains("TestNet"), - "network entry itself should remain" - ); + assert!(networks.contains("TestNet"), "network entry itself should remain"); - // Reset the recording so the second run's argv can be checked in isolation. - fs::write(&record, "").unwrap(); + // The NM profile durably holds the secret. + let psk = { + let st = nm.state.lock().unwrap(); + st.connections + .values() + .filter_map(|s| { + let sec = s.get("802-11-wireless-security")?; + sec.get("psk").and_then(|v| v.downcast_ref::().ok()) + }) + .next() + }; + assert_eq!(psk.as_deref(), Some("hunter2"), "NM profile must hold the PSK"); - // Second connect: a saved profile now exists (per the fake nmcli's own - // bookkeeping) and breadcrumbs has no local password anymore, so it must - // reuse the profile via `connection up` and never send a PSK argument. + // Second connect: breadcrumbs has no local password anymore, so it must + // reuse the existing profile without creating a duplicate. let second = sb.cmd(&["init"]); assert!(second.status.success(), "stderr: {}", stderr(&second)); - let second_calls = fs::read_to_string(&record).unwrap_or_default(); - assert!( - second_calls.contains("connection up TestNet"), - "second connect should reuse the existing NM profile: {second_calls}" - ); - assert!( - !second_calls.to_lowercase().contains("hunter2") - && !second_calls.contains("psk") - && !second_calls.contains("password"), - "second connect must never send a password argument: {second_calls}" + assert_eq!( + nm.connection_count(), + 1, + "reuse must not accumulate duplicate NM profiles" ); } // ----------------------------------------------------------------------- -// CLI-level coverage through fake nmcli/tailscale (item 3) +// CLI-level coverage through fake NM + tailscale (item 3) // ----------------------------------------------------------------------- -const FAKE_NMCLI_HEALTHY: &str = r#"#!/bin/sh -args="$*" -case "$args" in - "-t -f DEVICE,TYPE device status") - echo "wlan0:wifi" ;; - "-t -f ACTIVE,SSID device wifi list ifname wlan0") - echo "yes:HomeWifi" ;; - "-g IP4.ADDRESS device show wlan0") - echo "192.168.1.50/24" ;; - *) ;; -esac -exit 0 -"#; - #[test] -fn status_reports_healthy_through_fake_nmcli_and_curl() { +fn status_reports_healthy_through_fake_nm_and_curl() { let sb = Sandbox::new(); - sb.write_fake_bin("nmcli", FAKE_NMCLI_HEALTHY); + let nm = sb.nm(); + let dev = nm.add_wifi_device("wlan0", 100); + let ap = nm.add_ap(&dev, "HomeWifi", 80, Security::Wpa2); + nm.set_active_ap(&dev, &ap); sb.write_fake_bin("curl", "#!/bin/sh\necho -n 204\nexit 0\n"); let o = sb.cmd(&["status"]); @@ -544,41 +489,27 @@ fn status_reports_healthy_through_fake_nmcli_and_curl() { } #[test] -fn doctor_reports_present_when_nmcli_and_tailscale_are_on_path() { +fn doctor_reports_present_when_nm_and_tailscale_are_available() { let sb = Sandbox::new(); - sb.write_fake_bin("nmcli", FAKE_NMCLI_HEALTHY); + let _nm = sb.nm(); // attach the fake NM; keep the handle alive for the run sb.write_fake_bin("tailscale", "#!/bin/sh\nexit 0\n"); let o = sb.cmd(&["doctor"]); assert!(o.status.success(), "stderr: {}", stderr(&o)); let out = stdout(&o); assert!( - out.contains("nmcli") && out.contains("present"), + out.contains("network-manager") && out.contains("present"), "out: {out}" ); assert!(!out.contains("MISSING"), "out: {out}"); } -const FAKE_NMCLI_DETECT: &str = r#"#!/bin/sh -args="$*" -case "$args" in - "-t -f DEVICE,TYPE device status") - echo "wlan0:wifi" ;; - "radio wifi on") ;; - "device wifi rescan"*) ;; - "-t -f SSID device wifi list ifname wlan0") - echo "CorpWifi" ;; - "-t -f SSID,SIGNAL device wifi list ifname wlan0") - echo "CorpWifi:80" ;; - *) ;; -esac -exit 0 -"#; - #[test] fn detect_picks_profile_whose_detect_ssids_are_visible() { let sb = Sandbox::new(); - sb.write_fake_bin("nmcli", FAKE_NMCLI_DETECT); + let nm = sb.nm(); + let dev = nm.add_wifi_device("wlan0", 100); + nm.add_ap(&dev, "CorpWifi", 80, Security::Wpa2); sb.cmd(&["list"]); // bootstrap the default config (home/work/away) // Attach a marker SSID to "work" so detection has something to match — @@ -789,7 +720,9 @@ fn status_json_emits_machine_readable_output() { #[test] fn detect_json_emits_machine_readable_output() { let sb = Sandbox::new(); - sb.write_fake_bin("nmcli", FAKE_NMCLI_DETECT); + let nm = sb.nm(); + let dev = nm.add_wifi_device("wlan0", 100); + nm.add_ap(&dev, "CorpWifi", 80, Security::Wpa2); sb.cmd(&["list"]); // bootstrap the default config let text = fs::read_to_string(sb.config_file()).unwrap(); @@ -806,25 +739,13 @@ fn detect_json_emits_machine_readable_output() { assert_eq!(v["profile"].as_str(), Some("work")); } -const FAKE_NMCLI_DETECT_TWO: &str = r#"#!/bin/sh -args="$*" -case "$args" in - "-t -f DEVICE,TYPE device status") - echo "wlan0:wifi" ;; - "radio wifi on") ;; - "device wifi rescan"*) ;; - "-t -f SSID,SIGNAL device wifi list ifname wlan0") - echo "CorpWifi:80" - echo "CafeWifi:70" ;; - *) ;; -esac -exit 0 -"#; - #[test] fn detect_prefers_profile_with_more_matching_markers() { let sb = Sandbox::new(); - sb.write_fake_bin("nmcli", FAKE_NMCLI_DETECT_TWO); + let nm = sb.nm(); + let dev = nm.add_wifi_device("wlan0", 100); + nm.add_ap(&dev, "CorpWifi", 80, Security::Wpa2); + nm.add_ap(&dev, "CafeWifi", 70, Security::Wpa2); sb.cmd(&["list"]); // bootstrap // home matches 1 marker (CorpWifi); work matches 2 (CorpWifi + CafeWifi). @@ -849,23 +770,11 @@ fn detect_prefers_profile_with_more_matching_markers() { ); } -const FAKE_NMCLI_PRUNE: &str = r#"#!/bin/sh -args="$*" -case "$args" in - "-t -f NAME,TYPE connection show") - echo "OldCafe:802-11-wireless" ;; - "-g 802-11-wireless.ssid connection show OldCafe") - echo "OldCafe" ;; - "connection delete id OldCafe") ;; - *) ;; -esac -exit 0 -"#; - #[test] fn prune_dry_run_lists_stale_nm_profiles() { let sb = Sandbox::new(); - sb.write_fake_bin("nmcli", FAKE_NMCLI_PRUNE); + let nm = sb.nm(); + nm.save_connection("OldCafe", None); sb.cmd(&["list"]); // bootstrap (no saved networks → everything is stale) let o = sb.cmd(&["prune", "--dry-run"]); @@ -880,7 +789,8 @@ fn prune_dry_run_lists_stale_nm_profiles() { #[test] fn prune_removes_stale_nm_profiles() { let sb = Sandbox::new(); - sb.write_fake_bin("nmcli", FAKE_NMCLI_PRUNE); + let nm = sb.nm(); + nm.save_connection("OldCafe", None); sb.cmd(&["list"]); let o = sb.cmd(&["prune"]); @@ -890,53 +800,26 @@ fn prune_removes_stale_nm_profiles() { out.contains("removed") && out.contains("OldCafe"), "out: {out}" ); + assert_eq!( + nm.connection_count(), + 0, + "prune must actually delete the stale profile" + ); } -const FAKE_NMCLI_RETRY: &str = r#"#!/bin/sh -marker="$HOME/.nmcli-connect-ok" -args="$*" -case "$args" in - "-t -f DEVICE,TYPE device status") - echo "wlan0:wifi" ;; - "radio wifi on") ;; - "device wifi rescan"*) ;; - "-t -f SSID device wifi list ifname wlan0") - echo "HomeWifi" ;; - "-t -f SSID,SIGNAL device wifi list ifname wlan0") - echo "HomeWifi:80" ;; - "-t -f ACTIVE,SSID device wifi list ifname wlan0") - echo "yes:HomeWifi" ;; - "-t -f NAME,TYPE connection show") ;; - *"device wifi connect HomeWifi"*) - if [ -f "$marker" ]; then - exit 0 - else - : > "$marker" - exit 1 - fi ;; - *"connection up HomeWifi"*) - exit 0 ;; - "-g GENERAL.CON-UUID device show wlan0") - echo "uuid-1" ;; - *"ipv4.ignore-auto-dns"*) ;; - "device reapply wlan0") ;; - "-t -f DEVICE,STATE device status") - echo "wlan0:connected" ;; - *) ;; -esac -exit 0 -"#; - #[test] fn init_wait_retries_until_connect_succeeds() { let sb = Sandbox::new(); - sb.write_fake_bin("nmcli", FAKE_NMCLI_RETRY); + let nm = sb.nm(); + let dev = nm.add_wifi_device("wlan0", 100); + nm.add_ap(&dev, "HomeWifi", 80, Security::Wpa2); // "away" defaults to include_all_known, so HomeWifi is a candidate. let add = sb.cmd(&["add", "HomeWifi", "hunter2"]); assert!(add.status.success(), "stderr: {}", stderr(&add)); - // The fake's first `device wifi connect` fails; the retry succeeds. - // `--wait` must keep going past the first failure rather than bailing. + // The fake's first activation fails; the retry succeeds. `--wait` must + // keep going past the first failure rather than bailing. + nm.fail_next_activations(1); let o = sb.cmd(&["init", "--wait", "5"]); assert!(o.status.success(), "stderr: {}", stderr(&o)); assert!(stdout(&o).contains("connected"), "out: {}", stdout(&o)); diff --git a/tests/common/fake_nm.rs b/tests/common/fake_nm.rs new file mode 100644 index 0000000..bc2ee96 --- /dev/null +++ b/tests/common/fake_nm.rs @@ -0,0 +1,1007 @@ +//! A faithful fake NetworkManager served as a real D-Bus service on a +//! private `dbus-daemon`, so the production `nm` module (a pure zbus client +//! against `org.freedesktop.NetworkManager` on the system bus) can be +//! exercised end-to-end — real D-Bus marshalling, real property reads, real +//! method calls — with zero test-only code paths in `src/`. +//! +//! Two ways to use it: +//! +//! - [`launch_private`] — starts a fresh private daemon + fake NM and +//! returns a [`FakeNmBus`] handle. The caller passes +//! `DBUS_SYSTEM_BUS_ADDRESS=` to any subprocess (the CLI sandbox) so +//! the binary's `Connection::system()` lands on this bus. Independent per +//! test → safe to run in parallel. +//! - [`shared`] — one process-wide fake NM bus pointed at by the process +//! env var (in-process tests can't set per-test env safely). Tests using +//! it must serialize against each other, which the returned guard does. +//! +//! The fake implements the subset of the NetworkManager D-Bus API the +//! client uses: devices, access points (SSID/strength/security flags), +//! connection profiles (list/add/get/delete), activation (which lands the +//! device on the matching AP and marks it ACTIVATED), per-device IP4Config, +//! and the `WirelessEnabled`/`Connectivity` root properties. It also +//! records every call for assertions. + +use std::collections::{BTreeMap, HashMap}; +use std::io::BufRead; +use std::ops::Deref; +use std::process::{Child, Command, Stdio}; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; + +use zbus::fdo; +use zbus::interface; +use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value}; + +pub const NM_DEST: &str = "org.freedesktop.NetworkManager"; +pub const NM_PATH: &str = "/org/freedesktop/NetworkManager"; +pub const SETTINGS_PATH: &str = "/org/freedesktop/NetworkManager/Settings"; +const DEVICES_PREFIX: &str = "/org/freedesktop/NetworkManager/Devices"; +const APS_PREFIX: &str = "/org/freedesktop/NetworkManager/AccessPoints"; +const ACTIVE_PREFIX: &str = "/org/freedesktop/NetworkManager/ActiveConnection"; +const IP4_PREFIX: &str = "/org/freedesktop/NetworkManager/IP4Config"; +const CONNS_PREFIX: &str = "/org/freedesktop/NetworkManager/Settings"; + +// Security flag constants (mirrors src/nm.rs). +pub const DEV_TYPE_WIFI: u32 = 2; +pub const DEV_STATE_ACTIVATED: u32 = 100; +const AP_FLAG_PRIVACY: u32 = 0x1; +const SEC_PSK: u32 = 0x100; +const SEC_802_1X: u32 = 0x200; +const SEC_SAE: u32 = 0x400; + +/// Wi-Fi security flavors the fake can advertise for an AP. +#[derive(Debug, Clone, Copy)] +pub enum Security { + Open, + Wpa2, + Wpa3, + Wpa1Wpa2, + Enterprise, + Wep, +} + +impl Security { + fn to_flags(self) -> (u32, u32, u32) { + match self { + Security::Open => (0, 0, 0), + Security::Wpa2 => (AP_FLAG_PRIVACY, 0, SEC_PSK), + Security::Wpa3 => (AP_FLAG_PRIVACY, 0, SEC_SAE), + Security::Wpa1Wpa2 => (AP_FLAG_PRIVACY, SEC_PSK, SEC_PSK), + Security::Enterprise => (AP_FLAG_PRIVACY, SEC_802_1X, SEC_802_1X), + Security::Wep => (AP_FLAG_PRIVACY, 0, 0), + } + } +} + +pub type SettingsMap = HashMap>; + +#[derive(Debug, Clone)] +pub struct FakeDeviceData { + pub iface: String, + pub dev_type: u32, + pub state: u32, + pub active_ap: Option, + pub ip4: String, +} + +#[derive(Debug, Clone)] +pub struct FakeApData { + pub ssid: Vec, + pub strength: u8, + pub flags: u32, + pub wpa: u32, + pub rsn: u32, +} + +#[derive(Debug, Default)] +pub struct FakeState { + pub devices: BTreeMap, + pub aps: BTreeMap, + pub dev_aps: HashMap>, + pub connections: BTreeMap, + /// active-conn path -> settings-conn path + pub active_conns: BTreeMap, + /// device path -> active-conn path + pub dev_active: HashMap, + pub connectivity: u32, + pub wireless_enabled: bool, + /// When set, activation lands the device on the AP with this SSID + /// (simulates an NM autoconnect race landing elsewhere). + pub land_on: Option, + /// When set, activation creates a matching AP on the fly if none exists + /// (hidden networks appear in the scan only after connecting). + pub connect_any: bool, + /// When > 0, the next activations fail (simulating a transient NM + /// failure); each attempted activation decrements the counter. + pub fail_next_activations: u32, + pub calls: Vec, + pub next_dev: u32, + pub next_ap: u32, + pub next_conn: u32, + pub next_active: u32, +} + +#[derive(Clone)] +struct Shared { + state: Arc>, +} + +pub fn value_bytes(v: &OwnedValue) -> Option> { + match v.deref() { + Value::Array(a) => { + let mut out = Vec::new(); + for item in a.inner() { + if let Value::U8(b) = item { + out.push(*b); + } else { + return None; + } + } + Some(out) + } + _ => None, + } +} + +/// Extract an array-of-strings (e.g. `ipv4.dns`, `802-1x.eap`) from a +/// settings dict value. zvariant has no `TryFrom<&Value>` for `Vec`, +/// so we peel the `Array` ourselves. +pub fn value_str_list(v: &OwnedValue) -> Option> { + match v.deref() { + Value::Array(a) => { + let mut out = Vec::new(); + for item in a.inner() { + match item { + Value::Str(s) => out.push(s.as_str().to_string()), + _ => return None, + } + } + Some(out) + } + _ => None, + } +} + +fn ov(v: Value<'_>) -> OwnedValue { + OwnedValue::try_from(v).expect("settings value is ownable") +} + +/// OwnedValue isn't `Clone` (only `try_clone`), so settings dicts must be +/// copied field-by-field when the fake hands one out. +fn clone_settings(s: &SettingsMap) -> SettingsMap { + s.iter() + .map(|(section, vals)| { + let cloned = vals + .iter() + .map(|(k, v)| v.try_clone().map(|c| (k.clone(), c))) + .collect::, _>>() + .expect("settings values are ownable"); + (section.clone(), cloned) + }) + .collect() +} + +fn conn_ssid(s: &SettingsMap) -> Option { + s.get("802-11-wireless") + .and_then(|m| m.get("ssid")) + .and_then(value_bytes) + .map(|b| String::from_utf8_lossy(&b).into_owned()) +} + +fn conn_id(s: &SettingsMap) -> Option { + s.get("connection") + .and_then(|m| m.get("id")) + .and_then(|v| v.downcast_ref::().ok()) +} + +fn obj(path: &str) -> OwnedObjectPath { + OwnedObjectPath::try_from(path).expect("valid object path") +} + +// --------------------------------------------------------------------- +// Root interface: org.freedesktop.NetworkManager +// --------------------------------------------------------------------- + +struct FakeNm { + shared: Shared, +} + +#[interface(name = "org.freedesktop.NetworkManager")] +impl FakeNm { + async fn get_devices(&self) -> fdo::Result> { + let st = self.shared.state.lock().unwrap(); + Ok(st.devices.keys().map(|p| obj(p)).collect()) + } + + #[zbus(property)] + fn wireless_enabled(&self) -> fdo::Result { + Ok(self.shared.state.lock().unwrap().wireless_enabled) + } + + // Setters must return `zbus::Error` (not `fdo::Error`): the macro's + // generated setter keeps the fallible arm's error type verbatim, and the + // dispatch future is typed `Result<(), zbus::Error>`. + #[zbus(property)] + fn set_wireless_enabled(&self, v: bool) -> zbus::Result<()> { + self.shared.state.lock().unwrap().wireless_enabled = v; + Ok(()) + } + + #[zbus(property)] + fn connectivity(&self) -> fdo::Result { + Ok(self.shared.state.lock().unwrap().connectivity) + } + + async fn activate_connection( + &self, + conn: OwnedObjectPath, + dev: OwnedObjectPath, + _specific: OwnedObjectPath, + #[zbus(connection)] c: &zbus::Connection, + ) -> fdo::Result { + let active = self.register_active(c, conn.as_str(), dev.as_str()).await?; + Ok(active) + } + + async fn add_and_activate_connection2( + &self, + settings: SettingsMap, + dev: OwnedObjectPath, + _specific: OwnedObjectPath, + _options: HashMap, + #[zbus(connection)] c: &zbus::Connection, + ) -> fdo::Result<(OwnedObjectPath, OwnedObjectPath)> { + let conn_path = self.save_connection(settings, c).await?; + let active = self.register_active(c, conn_path.as_str(), dev.as_str()).await?; + Ok((conn_path, active)) + } +} + +impl FakeNm { + async fn save_connection(&self, settings: SettingsMap, c: &zbus::Connection) -> fdo::Result { + let path = { + let mut st = self.shared.state.lock().unwrap(); + st.calls.push("AddAndActivateConnection2".into()); + // Same UUID-exists semantics as real NetworkManager: this call + // creates a *new* profile, so a duplicate UUID is an error, not + // an upsert (updates go through Settings.Connection.Update2). + if st.connections.values().any(|s| conn_id(s) == conn_id(&settings)) { + return Err(fdo::Error::Failed( + "A connection with this UUID already exists.".into(), + )); + } + let p = format!("{CONNS_PREFIX}/{}", st.next_conn); + st.next_conn += 1; + st.connections.insert(p.clone(), settings); + p + }; + c.object_server() + .at(path.as_str(), FakeConn { + shared: self.shared.clone(), + path: path.clone(), + }) + .await?; + Ok(obj(&path)) + } + + async fn register_active( + &self, + c: &zbus::Connection, + conn_path: &str, + dev: &str, + ) -> fdo::Result { + let (active_path, new_ap) = { + let mut st = self.shared.state.lock().unwrap(); + st.calls.push(format!("activate {conn_path} -> {dev}")); + if st.fail_next_activations > 0 { + st.fail_next_activations -= 1; + return Err(fdo::Error::Failed("transient activation failure".into())); + } + let ssid = st.connections.get(conn_path).and_then(conn_ssid); + let target = st.land_on.clone().or(ssid); + let mut dev_ap = target.as_ref().and_then(|t| { + let aps = st.dev_aps.get(dev).cloned().unwrap_or_default(); + aps.into_iter().find(|ap| { + st.aps + .get(ap) + .map(|a| String::from_utf8_lossy(&a.ssid).into_owned() == *t) + .unwrap_or(false) + }) + }); + // Hidden networks don't appear in a scan until after they're + // associated; create the AP on the fly in that case. + let mut new_ap = None; + if dev_ap.is_none() && st.connect_any { + if let Some(t) = &target { + let id = st.next_ap; + st.next_ap += 1; + let ap_path = format!("{APS_PREFIX}/{id}"); + st.aps.insert( + ap_path.clone(), + FakeApData { + ssid: t.as_bytes().to_vec(), + strength: 80, + flags: AP_FLAG_PRIVACY, + wpa: 0, + rsn: SEC_PSK, + }, + ); + st.dev_aps.entry(dev.to_string()).or_default().push(ap_path.clone()); + dev_ap = Some(ap_path.clone()); + new_ap = Some(ap_path); + } + } + if let Some(d) = st.devices.get_mut(dev) { + if let Some(ap) = &dev_ap { + d.active_ap = Some(ap.clone()); + } + d.state = DEV_STATE_ACTIVATED; + } + let p = format!("{ACTIVE_PREFIX}/{}", st.next_active); + st.next_active += 1; + st.active_conns.insert(p.clone(), conn_path.to_string()); + st.dev_active.insert(dev.to_string(), p.clone()); + (p, new_ap) + }; + if let Some(ap) = &new_ap { + c.object_server() + .at(ap.as_str(), FakeAp { + shared: self.shared.clone(), + path: ap.clone(), + }) + .await?; + } + c.object_server() + .at(active_path.as_str(), FakeActive { + shared: self.shared.clone(), + path: active_path.clone(), + }) + .await?; + Ok(obj(&active_path)) + } +} + +// --------------------------------------------------------------------- +// Settings: org.freedesktop.NetworkManager.Settings +// --------------------------------------------------------------------- + +struct FakeSettings { + shared: Shared, +} + +#[interface(name = "org.freedesktop.NetworkManager.Settings")] +impl FakeSettings { + async fn list_connections(&self) -> fdo::Result> { + let st = self.shared.state.lock().unwrap(); + Ok(st.connections.keys().map(|p| obj(p)).collect()) + } + + async fn add_connection2( + &self, + settings: SettingsMap, + _flags: u32, + _args: HashMap, + #[zbus(connection)] c: &zbus::Connection, + ) -> fdo::Result<(OwnedObjectPath, HashMap)> { + let path = { + let mut st = self.shared.state.lock().unwrap(); + st.calls.push("AddConnection2".into()); + // Real NetworkManager rejects a duplicate UUID with + // NM_SETTINGS_ERROR_UUID_EXISTS — it does NOT upsert. Existing + // profiles must be edited via Settings.Connection.Update2; model + // that here so a client regression fails loudly. + if st.connections.values().any(|s| conn_id(s) == conn_id(&settings)) { + return Err(fdo::Error::Failed( + "A connection with this UUID already exists.".into(), + )); + } + let p = format!("{CONNS_PREFIX}/{}", st.next_conn); + st.next_conn += 1; + st.connections.insert(p.clone(), settings); + p + }; + c.object_server() + .at(path.as_str(), FakeConn { + shared: self.shared.clone(), + path: path.clone(), + }) + .await?; + Ok((obj(&path), HashMap::new())) + } +} + +// --------------------------------------------------------------------- +// Settings.Connection +// --------------------------------------------------------------------- + +struct FakeConn { + shared: Shared, + path: String, +} + +#[interface(name = "org.freedesktop.NetworkManager.Settings.Connection")] +impl FakeConn { + async fn get_settings(&self) -> fdo::Result { + let st = self.shared.state.lock().unwrap(); + let settings = st + .connections + .get(&self.path) + .ok_or_else(|| fdo::Error::UnknownObject(self.path.clone()))?; + Ok(clone_settings(settings)) + } + + async fn delete(&self) -> fdo::Result<()> { + let mut st = self.shared.state.lock().unwrap(); + st.calls.push(format!("delete {}", self.path)); + st.connections.remove(&self.path); + Ok(()) + } + + async fn update2( + &self, + settings: SettingsMap, + _flags: u32, + _args: HashMap, + ) -> fdo::Result> { + let mut st = self.shared.state.lock().unwrap(); + st.calls.push("Update2".into()); + st.connections.insert(self.path.clone(), settings); + Ok(HashMap::new()) + } +} + +// --------------------------------------------------------------------- +// Access point +// --------------------------------------------------------------------- + +struct FakeAp { + shared: Shared, + path: String, +} + +#[interface(name = "org.freedesktop.NetworkManager.AccessPoint")] +impl FakeAp { + #[zbus(property)] + fn ssid(&self) -> fdo::Result> { + Ok(self.shared.state.lock().unwrap().aps[&self.path].ssid.clone()) + } + + #[zbus(property)] + fn strength(&self) -> fdo::Result { + Ok(self.shared.state.lock().unwrap().aps[&self.path].strength) + } + + #[zbus(property)] + fn flags(&self) -> fdo::Result { + Ok(self.shared.state.lock().unwrap().aps[&self.path].flags) + } + + #[zbus(property)] + fn wpa_flags(&self) -> fdo::Result { + Ok(self.shared.state.lock().unwrap().aps[&self.path].wpa) + } + + #[zbus(property)] + fn rsn_flags(&self) -> fdo::Result { + Ok(self.shared.state.lock().unwrap().aps[&self.path].rsn) + } +} + +// --------------------------------------------------------------------- +// Device + Wireless + IP4Config +// --------------------------------------------------------------------- + +struct FakeDevice { + shared: Shared, + path: String, +} + +#[interface(name = "org.freedesktop.NetworkManager.Device")] +impl FakeDevice { + #[zbus(property)] + fn interface(&self) -> fdo::Result { + Ok(self.shared.state.lock().unwrap().devices[&self.path].iface.clone()) + } + + #[zbus(property)] + fn device_type(&self) -> fdo::Result { + Ok(self.shared.state.lock().unwrap().devices[&self.path].dev_type) + } + + #[zbus(property)] + fn state(&self) -> fdo::Result { + Ok(self.shared.state.lock().unwrap().devices[&self.path].state) + } + + #[zbus(property)] + fn active_connection(&self) -> fdo::Result { + let st = self.shared.state.lock().unwrap(); + Ok(st + .dev_active + .get(&self.path) + .map(|p| obj(p)) + .unwrap_or_else(|| obj("/"))) + } + + #[zbus(property)] + fn ip4_config(&self) -> fdo::Result { + let st = self.shared.state.lock().unwrap(); + Ok(obj(&st.devices[&self.path].ip4)) + } + + async fn reapply( + &self, + _settings: SettingsMap, + _version: u64, + _flags: u32, + ) -> fdo::Result<()> { + let mut st = self.shared.state.lock().unwrap(); + st.calls.push("Reapply".into()); + Ok(()) + } +} + +struct FakeWireless { + shared: Shared, + path: String, +} + +#[interface(name = "org.freedesktop.NetworkManager.Device.Wireless")] +impl FakeWireless { + #[zbus(property)] + fn active_access_point(&self) -> fdo::Result { + let st = self.shared.state.lock().unwrap(); + Ok(st.devices[&self.path] + .active_ap + .as_ref() + .map(|p| obj(p)) + .unwrap_or_else(|| obj("/"))) + } + + async fn get_all_access_points(&self) -> fdo::Result> { + let st = self.shared.state.lock().unwrap(); + Ok(st + .dev_aps + .get(&self.path) + .map(|aps| aps.iter().map(|p| obj(p)).collect()) + .unwrap_or_default()) + } + + async fn request_scan(&self, _options: HashMap) -> fdo::Result<()> { + let mut st = self.shared.state.lock().unwrap(); + st.calls.push("RequestScan".into()); + Ok(()) + } +} + +struct FakeIp4 { + shared: Shared, + /// Device path (so the fake can find the device's IP). + dev: String, +} + +#[interface(name = "org.freedesktop.NetworkManager.IP4Config")] +impl FakeIp4 { + #[zbus(property)] + fn addresses(&self) -> fdo::Result> { + let st = self.shared.state.lock().unwrap(); + // A fixed, recognizable address: 192.168.1.42/24, gw .1. + let _ = &st.devices[&self.dev]; + Ok(vec![(0xC0A8012A, 24, 0xC0A80101)]) + } +} + +// --------------------------------------------------------------------- +// Connection.Active +// --------------------------------------------------------------------- + +struct FakeActive { + shared: Shared, + path: String, +} + +#[interface(name = "org.freedesktop.NetworkManager.Connection.Active")] +impl FakeActive { + #[zbus(property)] + fn connection(&self) -> fdo::Result { + let st = self.shared.state.lock().unwrap(); + Ok(obj(&st.active_conns[&self.path])) + } +} + +// --------------------------------------------------------------------- +// Harness +// --------------------------------------------------------------------- + +/// A private `dbus-daemon`. Dropping it kills the daemon. +pub struct Daemon { + pub addr: String, + child: Child, +} + +impl Drop for Daemon { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +static DBUS_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + +/// Start a private `dbus-daemon` with permissive policies. Nothing is +/// registered on it — attach the fake NM with [`serve_on`] if the test +/// needs NetworkManager to be present. +pub fn launch_daemon() -> Daemon { + let n = DBUS_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let dir = std::env::temp_dir().join(format!( + "breadcrumbs-dbus-{}-{}", + std::process::id(), + n + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create dbus dir"); + let config = dir.join("bus.conf"); + std::fs::write(&config, BUS_CONFIG).expect("write bus config"); + + let mut child = Command::new("dbus-daemon") + .arg("--nofork") + .arg("--nopidfile") + .arg(format!("--config-file={}", config.display())) + .arg("--print-address=1") + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("dbus-daemon must be installed to run the NetworkManager fake tests"); + let mut line = String::new(); + std::io::BufReader::new(child.stdout.take().expect("dbus stdout")) + .read_line(&mut line) + .expect("read dbus-daemon address"); + let addr = line + .trim() + .split(';') + .next() + .expect("address") + .to_string(); + Daemon { addr, child } +} + +/// A fake NM service served on an existing (usually private) bus. +pub struct FakeNmBus { + pub addr: String, + pub state: Arc>, + server: zbus::blocking::Connection, +} + +const BUS_CONFIG: &str = r#" + + session + unix:tmpdir=/tmp + + + + + + +"#; + +/// Serve the fake NetworkManager on the bus at `addr` and claim its name. +/// Subprocesses pointed at the same address via `DBUS_SYSTEM_BUS_ADDRESS` +/// will see this as their system NetworkManager. +pub fn serve_on(addr: &str) -> FakeNmBus { + let conn = zbus::blocking::connection::Builder::address(addr) + .expect("connect to private bus") + .build() + .expect("build blocking connection"); + let state = Arc::new(Mutex::new(FakeState { + connectivity: 4, + wireless_enabled: true, + next_dev: 1, + next_ap: 1, + next_conn: 1, + next_active: 1, + ..Default::default() + })); + let shared = Shared { + state: state.clone(), + }; + + conn.object_server() + .at(NM_PATH, FakeNm { + shared: shared.clone(), + }) + .expect("register fake NM"); + conn.object_server() + .at(SETTINGS_PATH, FakeSettings { + shared: shared.clone(), + }) + .expect("register fake settings"); + conn.request_name(NM_DEST).expect("claim NM name"); + + FakeNmBus { + addr: addr.to_string(), + state, + server: conn, + } +} + +impl FakeNmBus { + pub fn addr(&self) -> &str { + &self.addr + } + + pub fn reset(&self) { + let mut st = self.state.lock().unwrap(); + st.devices.clear(); + st.aps.clear(); + st.dev_aps.clear(); + st.connections.clear(); + st.active_conns.clear(); + st.dev_active.clear(); + st.connectivity = 4; + st.wireless_enabled = true; + st.land_on = None; + st.connect_any = false; + st.fail_next_activations = 0; + st.calls.clear(); + st.next_dev = 1; + st.next_ap = 1; + st.next_conn = 1; + st.next_active = 1; + } + + /// Add a Wi-Fi device; returns its object path. `state` is the device + /// state (e.g. 100 = ACTIVATED). + pub fn add_wifi_device(&self, iface: &str, state: u32) -> String { + let (dev_path, ip4_path) = { + let mut st = self.state.lock().unwrap(); + let id = st.next_dev; + st.next_dev += 1; + let dev_path = format!("{DEVICES_PREFIX}/{id}"); + let ip4_path = format!("{IP4_PREFIX}/{id}"); + st.devices.insert( + dev_path.clone(), + FakeDeviceData { + iface: iface.to_string(), + dev_type: DEV_TYPE_WIFI, + state, + active_ap: None, + ip4: ip4_path.clone(), + }, + ); + (dev_path, ip4_path) + }; + self.server + .object_server() + .at( + dev_path.as_str(), + FakeDevice { + shared: self.shared(), + path: dev_path.clone(), + }, + ) + .expect("register device"); + self.server + .object_server() + .at( + dev_path.as_str(), + FakeWireless { + shared: self.shared(), + path: dev_path.clone(), + }, + ) + .expect("register wireless"); + self.server + .object_server() + .at( + ip4_path.as_str(), + FakeIp4 { + shared: self.shared(), + dev: dev_path.clone(), + }, + ) + .expect("register ip4"); + dev_path + } + + fn shared(&self) -> Shared { + Shared { + state: self.state.clone(), + } + } + + /// Add an access point to a device; returns its object path. + pub fn add_ap(&self, dev: &str, ssid: &str, strength: u8, sec: Security) -> String { + let (ap_path, flags, wpa, rsn) = { + let mut st = self.state.lock().unwrap(); + let id = st.next_ap; + st.next_ap += 1; + let ap_path = format!("{APS_PREFIX}/{id}"); + let (flags, wpa, rsn) = sec.to_flags(); + st.aps.insert( + ap_path.clone(), + FakeApData { + ssid: ssid.as_bytes().to_vec(), + strength, + flags, + wpa, + rsn, + }, + ); + st.dev_aps.entry(dev.to_string()).or_default().push(ap_path.clone()); + (ap_path, flags, wpa, rsn) + }; + let _ = (flags, wpa, rsn); + self.server + .object_server() + .at( + ap_path.as_str(), + FakeAp { + shared: self.shared(), + path: ap_path.clone(), + }, + ) + .expect("register AP"); + ap_path + } + + pub fn set_active_ap(&self, dev: &str, ap: &str) { + let mut st = self.state.lock().unwrap(); + if let Some(d) = st.devices.get_mut(dev) { + d.active_ap = Some(ap.to_string()); + d.state = DEV_STATE_ACTIVATED; + } + } + + /// When set, any activation lands the device on the AP with this SSID + /// (simulating an NM autoconnect race). + pub fn set_land_on(&self, ssid: Option<&str>) { + self.state.lock().unwrap().land_on = ssid.map(str::to_string); + } + + /// When enabled, connecting to a network with no visible AP creates one + /// (hidden-network semantics). + pub fn set_connect_any(&self, on: bool) { + self.state.lock().unwrap().connect_any = on; + } + + /// Make the next `n` activation attempts fail (transient-failure + /// simulation, e.g. for `init --wait` retries). + pub fn fail_next_activations(&self, n: u32) { + self.state.lock().unwrap().fail_next_activations = n; + } + + pub fn set_connectivity(&self, c: u32) { + self.state.lock().unwrap().connectivity = c; + } + + pub fn set_device_state(&self, dev: &str, state: u32) { + let mut st = self.state.lock().unwrap(); + if let Some(d) = st.devices.get_mut(dev) { + d.state = state; + } + } + + /// Save a wireless connection profile (as `nm` would create one) and + /// return its path. + pub fn save_connection(&self, ssid: &str, password: Option<&str>) -> String { + let mut settings: SettingsMap = HashMap::new(); + let mut conn: HashMap = HashMap::new(); + conn.insert("id".into(), ov(Value::from(ssid.to_string()))); + conn.insert("type".into(), ov(Value::from("802-11-wireless"))); + conn.insert( + "uuid".into(), + ov(Value::from("00000000-0000-4000-8000-000000000001")), + ); + settings.insert("connection".into(), conn); + + let mut wifi: HashMap = HashMap::new(); + wifi.insert("ssid".into(), ov(Value::from(ssid.as_bytes().to_vec()))); + wifi.insert("mode".into(), ov(Value::from("infrastructure"))); + settings.insert("802-11-wireless".into(), wifi); + + if let Some(pw) = password { + let mut sec: HashMap = HashMap::new(); + sec.insert("key-mgmt".into(), ov(Value::from("wpa-psk"))); + sec.insert("psk".into(), ov(Value::from(pw.to_string()))); + settings.insert("802-11-wireless-security".into(), sec); + } + + let mut ipv4: HashMap = HashMap::new(); + ipv4.insert("method".into(), ov(Value::from("auto"))); + settings.insert("ipv4".into(), ipv4); + + let path = { + let mut st = self.state.lock().unwrap(); + st.calls.push(format!("save {ssid}")); + let path = format!("{CONNS_PREFIX}/{}", st.next_conn); + st.next_conn += 1; + st.connections.insert(path.clone(), settings); + path + }; + self.server + .object_server() + .at( + path.as_str(), + FakeConn { + shared: self.shared(), + path: path.clone(), + }, + ) + .expect("register saved connection"); + path + } + + pub fn calls(&self) -> Vec { + self.state.lock().unwrap().calls.clone() + } + + pub fn connection_count(&self) -> usize { + self.state.lock().unwrap().connections.len() + } + + pub fn device_state(&self, dev: &str) -> u32 { + self.state.lock().unwrap().devices.get(dev).map(|d| d.state).unwrap_or(0) + } + + pub fn active_ssid(&self, dev: &str) -> Option { + let st = self.state.lock().unwrap(); + let ap = st.devices.get(dev)?.active_ap.clone()?; + st.aps.get(&ap).map(|a| String::from_utf8_lossy(&a.ssid).into_owned()) + } + + /// SSIDs of every connection activated so far, in activation order. + pub fn activated_ssids(&self) -> Vec { + let st = self.state.lock().unwrap(); + st.active_conns + .values() + .filter_map(|p| st.connections.get(p).and_then(conn_ssid)) + .collect() + } +} + +// --------------------------------------------------------------------- +// Shared in-process bus (flow_watch tests) +// --------------------------------------------------------------------- + +struct SharedBus { + _daemon: Daemon, + bus: FakeNmBus, +} + +static SHARED: OnceLock> = OnceLock::new(); + +/// The process-wide fake NM bus for in-process tests. The env var +/// `DBUS_SYSTEM_BUS_ADDRESS` is pointed at it once, so the production +/// `nm` module (which uses `Connection::system()`) reaches it with zero +/// test seams. Tests using this must serialize against each other — the +/// returned guard holds the bus's lock for its whole lifetime. +pub struct SharedNm { + guard: MutexGuard<'static, SharedBus>, +} + +impl std::ops::Deref for SharedNm { + type Target = FakeNmBus; + fn deref(&self) -> &FakeNmBus { + &self.guard.bus + } +} + +pub fn shared() -> SharedNm { + let bus = SHARED.get_or_init(|| { + let daemon = launch_daemon(); + let bus = serve_on(&daemon.addr); + std::env::set_var("DBUS_SYSTEM_BUS_ADDRESS", &daemon.addr); + Mutex::new(SharedBus { _daemon: daemon, bus }) + }); + let guard = bus.lock().unwrap_or_else(|e| e.into_inner()); + SharedNm { guard } +} + +/// Convenience guard used by tests that only need the bus available (no +/// state control) — e.g. classify tests that merely observe "no adapter". +/// Ensures the shared bus is up (and the env var set) before any `nm` +/// call happens. +pub fn ensure_shared() -> SharedNm { + shared() +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 67f5f2c..704a989 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -9,7 +9,7 @@ //! rules ("if the program+args match this predicate, return this canned //! `Output`"), which also records every invocation so a test can assert //! exactly what was — or, just as importantly, was *not* — passed (e.g. -//! that a password argument never reaches a fake `nmcli`). +//! that a password argument never reaches a fake subprocess). //! - [`EnvSandbox`]: real logic (`flow::run`, `watch::classify`) still does //! its own best-effort file logging via `notify::log`, which resolves a //! path from `$HOME`/`$XDG_STATE_HOME`. `EnvSandbox` points those at a @@ -18,9 +18,15 @@ //! inherently cross-test-within-this-binary racy, so it's guarded by a //! process-wide mutex — tests using it serialize against each other but //! not against unrelated tests (each `tests/*.rs` file is its own binary). +//! - [`fake_nm`]: a real fake NetworkManager D-Bus service on a private +//! `dbus-daemon`. The production `nm` module talks to it over real D-Bus +//! marshalling (`Connection::system()` honors `DBUS_SYSTEM_BUS_ADDRESS`), +//! replacing the old fake-`nmcli`-argv rules. #![allow(dead_code)] // not every test file uses every helper here +pub mod fake_nm; + use std::cell::RefCell; use std::collections::HashSet; use std::path::{Path, PathBuf}; @@ -94,7 +100,7 @@ impl FakeRunner { } /// Shorthand for matching on `prog` plus a whitespace-joined view of - /// `args` containing `substr` (handy for `nmcli`/`tailscale` calls, whose + /// `args` containing `substr` (handy for `tailscale`/`curl` calls, whose /// interesting bit is usually a subcommand somewhere in the middle). pub fn on_contains(self, prog: &'static str, substr: &'static str, output: Output) -> Self { self.on( diff --git a/tests/flow_watch.rs b/tests/flow_watch.rs index 9fc207f..eadeb0e 100644 --- a/tests/flow_watch.rs +++ b/tests/flow_watch.rs @@ -1,9 +1,11 @@ //! In-process tests for the actual state machine (`flow::run`) and the watch -//! loop's health classification (`watch::classify`), driven entirely through -//! a faked `breadcrumbs::util::Runner` (see `tests/common`) — no subprocess -//! is ever spawned. This complements `tests/cli.rs`'s black-box coverage -//! (which spawns the real binary against fake-bin shell scripts) with fast, -//! precise coverage of the logic itself: candidate priority order, the +//! loop's health classification (`watch::classify`). NetworkManager is a real +//! fake NM D-Bus service on a private bus (see `tests/common::fake_nm`), so +//! every `nm` call is exercised over genuine D-Bus marshalling. Everything +//! else (tailscale, curl/ping, notify) is faked through a +//! `breadcrumbs::util::Runner` (see `tests/common`). This complements +//! `tests/cli.rs`'s black-box coverage (which spawns the real binary) with +//! fast, precise coverage of the logic itself: candidate priority order, the //! bootstrap+Tailscale gate, and every `watch::Health` transition. mod common; @@ -18,6 +20,7 @@ use breadcrumbs::state::{self, State}; use breadcrumbs::util::with_runner; use breadcrumbs::watch::{classify, Health}; +use common::fake_nm::{self, Security, SharedNm}; use common::{fail, ok, EnvSandbox, FakeRunner}; fn net(ssid: &str, password: Option<&str>) -> NetworkDef { @@ -52,82 +55,52 @@ fn base_config() -> Config { } } -/// Wires up the nmcli plumbing every `flow::run` call needs regardless of -/// scenario: a Wi-Fi interface exists, radio/rescan calls are no-ops, no -/// saved NM connection profiles exist yet (so every connect takes the -/// "create via `device wifi connect`" path), DNS enforcement succeeds, and -/// the device reports connected after any successful connect attempt. -fn base_nm(visible_ssids: &[&str]) -> FakeRunner { - let visible = visible_ssids.join("\n"); - // `-f SSID,SIGNAL` lines: all SSIDs at the same (strong) signal, so - // priority order — not signal — decides between them. - let with_signal = visible_ssids - .iter() - .map(|s| format!("{s}:80")) - .collect::>() - .join("\n"); - let runner = FakeRunner::new() - .on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi")) - .on_contains("nmcli", "radio wifi on", ok("")) - .on_contains("nmcli", "wifi rescan", ok("")) - // Exact matches: `-f ACTIVE,SSID` queries (which contain the - // substring "SSID device wifi list") must NOT be answered with the - // visible list — they go to the stateful rule below. - .on( - move |_prog, args| args.join(" ") == "-t -f SSID device wifi list ifname wlan0", - ok(&visible), - ) - .on( - move |_prog, args| args.join(" ") == "-t -f SSID,SIGNAL device wifi list ifname wlan0", - ok(&with_signal), - ) - .on_contains("nmcli", "NAME,TYPE", ok("")) // no saved profiles - .on_contains("nmcli", "GENERAL.CON-UUID", ok("uuid-1")) - .on_contains("nmcli", "ipv4.ignore-auto-dns", ok("")) - .on_contains("nmcli", "device reapply", ok("")) - .on_contains("nmcli", "DEVICE,STATE", ok("wlan0:connected")); - let calls = runner.calls_handle(); - runner.on_dynamic( - move |_prog, args| args.join(" ") == "-t -f ACTIVE,SSID device wifi list ifname wlan0", - move |_prog, _args| { - // Stateful: answer with the SSID of the most recently dialed - // connection, so connect_and_verify's post-connect SSID check - // sees the network that was just activated (bootstrap first, - // then the target). - let rec = calls.borrow(); - let ssid = rec.iter().rev().find_map(|call| { - let j = call.args.join(" "); - if j.contains("connect") { - call.args - .iter() - .position(|a| a == "connect") - .map(|i| call.args[i + 1].clone()) - } else if j.contains("connection up") { - call.args - .iter() - .position(|a| a == "up") - .map(|i| call.args[i + 1].clone()) - } else { - None - } - }); - match ssid { - Some(s) => ok(&format!("yes:{s}")), - None => ok(""), - } - }, +/// Reset the shared fake-NM bus and put a Wi-Fi device on it with one AP per +/// SSID at the given signal strength. Returns the bus guard (held for the +/// whole test so tests serialize) and the device path. +fn setup_wifi(ssids: &[(&str, u8)]) -> (SharedNm, String) { + let nm = fake_nm::shared(); + nm.reset(); + let dev = nm.add_wifi_device("wlan0", 100); + for (ssid, strength) in ssids { + nm.add_ap(&dev, ssid, *strength, Security::Wpa2); + } + (nm, dev) +} + +/// A runner that fakes the non-NM subprocesses a successful `flow::run` +/// needs: curl (internet check) and nothing else. +fn healthy_runner() -> FakeRunner { + FakeRunner::new() + .with_command("curl") + .on(|prog, _| prog == "curl", ok("204")) +} + +/// The runner used by `classify` tests: internet check + optional tailscale. +fn classify_runner(curl: &str, tailscale_status: Option<&str>) -> FakeRunner { + let mut r = FakeRunner::new().with_command("curl").on(|p, _| p == "curl", ok(curl)); + if let Some(json) = tailscale_status { + r = r + .with_command("tailscale") + .on(|p, args| p == "tailscale" && args.contains(&"status"), ok(json)); + } + r +} + +/// Make the device report being associated with `ssid` (for classify tests). +fn associate(nm: &SharedNm, dev: &str, ssid: &str) { + let ap = nm.add_ap(dev, ssid, 80, Security::Wpa2); + nm.set_active_ap(dev, &ap); +} + +fn tailscale_json_ok(exit_node: &str) -> String { + format!( + r#"{{"BackendState":"Running","Peer":{{"k1":{{"HostName":"{exit_node}","DNSName":"{exit_node}.ts.net.","Online":true,"ExitNode":true,"ExitNodeOption":true}}}}}}"# ) } -/// A successful `device wifi connect ...` for every ssid in `ssids`. -fn allow_connects(runner: FakeRunner, ssids: &[&'static str]) -> FakeRunner { - ssids.iter().fold(runner, |r, ssid| { - let ssid: &'static str = ssid; - r.on( - move |prog, args| prog == "nmcli" && args.contains(&"connect") && args.contains(&ssid), - ok(""), - ) - }) +fn tailscale_json_missing() -> &'static str { + r#"{"BackendState":"Running","Peer":{}}"# } // --------------------------------------------------------------------- @@ -137,12 +110,10 @@ fn allow_connects(runner: FakeRunner, ssids: &[&'static str]) -> FakeRunner { #[test] fn flow_run_connects_to_first_visible_candidate_in_priority_order() { let _env = EnvSandbox::new(); + let (nm, _dev) = setup_wifi(&[("First", 80), ("Second", 80)]); let mut cfg = base_config(); - cfg.networks = vec![ - net("First", Some("pw1")), - net("Second", Some("pw2")), - ]; + cfg.networks = vec![net("First", Some("pw1")), net("Second", Some("pw2"))]; cfg.profiles.insert( "home".into(), Profile { @@ -151,12 +122,7 @@ fn flow_run_connects_to_first_visible_candidate_in_priority_order() { }, ); - let runner = allow_connects(base_nm(&["First", "Second"]), &["First", "Second"]) - .on(|prog, _| prog == "curl", ok("204")) - .with_command("curl"); - let calls = runner.calls_handle(); - - let outcome = with_runner(runner, || flow::run(&mut cfg, "home")); + let outcome = with_runner(healthy_runner(), || flow::run(&mut cfg, "home")); match outcome { flow::Outcome::Connected { ssid, note } => { @@ -166,13 +132,13 @@ fn flow_run_connects_to_first_visible_candidate_in_priority_order() { other => panic!("expected Connected, got {other:?}"), } - // Priority order actually mattered: "Second" was never dialed even + // Priority order actually mattered: "Second" was never activated even // though it was visible and would have succeeded too. - let dialed_second = calls - .borrow() - .iter() - .any(|c| c.prog == "nmcli" && c.args.contains(&"connect".to_string()) && c.args.iter().any(|a| a == "Second")); - assert!(!dialed_second, "connected to Second when First should win"); + assert_eq!( + nm.activated_ssids(), + vec!["First".to_string()], + "Second must not be dialed when First wins" + ); // The password used for the winning connect is now NM's problem, not // breadcrumbs' — cleared and (via clear_password_if_used) persisted. @@ -187,14 +153,15 @@ fn flow_run_connects_to_first_visible_candidate_in_priority_order() { #[test] fn flow_run_pass2_falls_back_to_hidden_candidate_not_in_scan() { let _env = EnvSandbox::new(); + // Nothing is visible; connecting creates the AP on the fly (hidden + // networks appear only after association). + let (nm, _dev) = setup_wifi(&[]); + nm.set_connect_any(true); let mut cfg = base_config(); // "Ghost" is neither visible nor hidden, so pass 1 *and* pass 2 both // skip it outright — it should never be dialed. - cfg.networks = vec![ - net("Ghost", Some("pw-ghost")), - hidden_net("Shadow", Some("pw-shadow")), - ]; + cfg.networks = vec![net("Ghost", Some("pw-ghost")), hidden_net("Shadow", Some("pw-shadow"))]; cfg.profiles.insert( "away".into(), Profile { @@ -203,44 +170,37 @@ fn flow_run_pass2_falls_back_to_hidden_candidate_not_in_scan() { }, ); - // Neither SSID shows up in the scan — "Shadow" is only reachable via the - // pass-2 "hidden and unseen" path. - let runner = allow_connects(base_nm(&[]), &["Shadow"]) - .on(|prog, _| prog == "curl", ok("204")) - .with_command("curl"); - let calls = runner.calls_handle(); - - let outcome = with_runner(runner, || flow::run(&mut cfg, "away")); + let outcome = with_runner(healthy_runner(), || flow::run(&mut cfg, "away")); match outcome { flow::Outcome::Connected { ssid, .. } => assert_eq!(ssid, "Shadow"), other => panic!("expected Connected to Shadow, got {other:?}"), } - let dialed_ghost = calls - .borrow() - .iter() - .any(|c| c.args.iter().any(|a| a == "Ghost") && c.args.contains(&"connect".to_string())); - assert!(!dialed_ghost, "Ghost should never have been dialed"); + assert_eq!( + nm.activated_ssids(), + vec!["Shadow".to_string()], + "Ghost must never have been dialed" + ); } #[test] fn flow_run_unknown_profile_short_circuits_before_touching_nm() { let _env = EnvSandbox::new(); + let nm = fake_nm::shared(); + nm.reset(); let mut cfg = base_config(); let runner = FakeRunner::new(); // no rules at all - let calls = runner.calls_handle(); let outcome = with_runner(runner, || flow::run(&mut cfg, "does-not-exist")); assert!(matches!(outcome, flow::Outcome::UnknownProfile(p) if p == "does-not-exist")); - // The only `Runner::run` call on this path is `notify`/`log`'s own - // `date` timestamp lookup — nmcli (or anything network-related) is - // never touched for a profile that doesn't exist. + // The fake NetworkManager must never be touched for a profile that + // doesn't exist (no devices, no scans, no activations). assert!( - calls.borrow().iter().all(|c| c.prog != "nmcli"), - "unknown-profile path should never shell out to nmcli: {:?}", - calls.borrow() + nm.calls().is_empty(), + "unknown-profile path should never call NetworkManager: {:?}", + nm.calls() ); } @@ -248,19 +208,10 @@ fn flow_run_unknown_profile_short_circuits_before_touching_nm() { // flow::run — bootstrap + Tailscale gating // --------------------------------------------------------------------- -fn tailscale_json_ok(exit_node: &str) -> String { - format!( - r#"{{"BackendState":"Running","Peer":{{"k1":{{"HostName":"{exit_node}","DNSName":"{exit_node}.ts.net.","Online":true,"ExitNode":true,"ExitNodeOption":true}}}}}}"# - ) -} - -fn tailscale_json_missing() -> &'static str { - r#"{"BackendState":"Running","Peer":{}}"# -} - #[test] fn flow_run_moves_past_bootstrap_once_tailscale_is_healthy() { let _env = EnvSandbox::new(); + let (nm, _dev) = setup_wifi(&[("Guest", 80), ("Corp", 80)]); let mut cfg = base_config(); cfg.settings.exit_node = "exitnode".into(); @@ -275,13 +226,10 @@ fn flow_run_moves_past_bootstrap_once_tailscale_is_healthy() { }, ); - let runner = allow_connects(base_nm(&["Guest", "Corp"]), &["Guest", "Corp"]) - .with_command("curl") + let runner = healthy_runner() .with_command("tailscale") - .on(|prog, _| prog == "curl", ok("204")) .on_contains("tailscale", "status", ok(&tailscale_json_ok("exitnode"))) .on_contains("tailscale", "set", ok("")); - let calls = runner.calls_handle(); let outcome = with_runner(runner, || flow::run(&mut cfg, "work")); @@ -294,16 +242,17 @@ fn flow_run_moves_past_bootstrap_once_tailscale_is_healthy() { assert_eq!(cfg.network("Guest").unwrap().password, None); assert_eq!(cfg.network("Corp").unwrap().password, None); - let dialed_guest = calls - .borrow() - .iter() - .any(|c| c.args.iter().any(|a| a == "Guest") && c.args.contains(&"connect".to_string())); - assert!(dialed_guest, "bootstrap should have been dialed first"); + assert_eq!( + nm.activated_ssids(), + vec!["Guest".to_string(), "Corp".to_string()], + "bootstrap must be dialed before the target" + ); } #[test] fn flow_run_stays_on_bootstrap_and_never_dials_target_when_tailscale_unhealthy() { let _env = EnvSandbox::new(); + let (nm, _dev) = setup_wifi(&[("Guest", 80), ("Corp", 80)]); let mut cfg = base_config(); cfg.settings.exit_node = "exitnode".into(); @@ -318,19 +267,10 @@ fn flow_run_stays_on_bootstrap_and_never_dials_target_when_tailscale_unhealthy() }, ); - let runner = allow_connects(base_nm(&["Guest", "Corp"]), &["Guest", "Corp"]) - .with_command("curl") + let runner = healthy_runner() .with_command("tailscale") - .on(|prog, _| prog == "curl", ok("204")) - .on( - |prog, args| prog == "tailscale" && args.contains(&"status"), - ok(tailscale_json_missing()), - ) - .on( - |prog, args| prog == "tailscale" && args.contains(&"set"), - ok(""), - ); - let calls = runner.calls_handle(); + .on(|p, args| p == "tailscale" && args.contains(&"status"), ok(tailscale_json_missing())) + .on(|p, args| p == "tailscale" && args.contains(&"set"), ok("")); let outcome = with_runner(runner, || flow::run(&mut cfg, "work")); @@ -342,12 +282,9 @@ fn flow_run_stays_on_bootstrap_and_never_dials_target_when_tailscale_unhealthy() other => panic!("expected TailscaleError, got {other:?}"), } - let dialed_corp = calls - .borrow() - .iter() - .any(|c| c.args.iter().any(|a| a == "Corp") && c.args.contains(&"connect".to_string())); - assert!( - !dialed_corp, + assert_eq!( + nm.activated_ssids(), + vec!["Guest".to_string()], "target network must never be dialed while Tailscale is unhealthy" ); // The bootstrap connect *did* use a password and succeeded, so it's @@ -362,75 +299,69 @@ fn flow_run_stays_on_bootstrap_and_never_dials_target_when_tailscale_unhealthy() #[test] fn classify_reports_unknown_profile_without_touching_nm() { let _env = EnvSandbox::new(); + let nm = fake_nm::shared(); + nm.reset(); let cfg = base_config(); // no profiles at all let runner = FakeRunner::new(); let calls = runner.calls_handle(); let class = with_runner(runner, || classify(&cfg, "ghost")); - let health = class.health; - let ssid = class.ssid; - assert_eq!(health, Health::UnknownProfile); - assert_eq!(ssid, None); + assert_eq!(class.health, Health::UnknownProfile); + assert_eq!(class.ssid, None); assert!(calls.borrow().is_empty()); + assert!( + nm.calls().is_empty(), + "unknown-profile classify must not touch NetworkManager" + ); } #[test] fn classify_reports_no_adapter_when_wifi_interface_absent() { let _env = EnvSandbox::new(); + let nm = fake_nm::shared(); + nm.reset(); // no devices at all let mut cfg = base_config(); cfg.profiles.insert("away".into(), Profile::default()); - // `device status` succeeds but lists no wifi-type device. - let runner = FakeRunner::new().on_contains("nmcli", "DEVICE,TYPE", ok("eth0:ethernet")); - let class = with_runner(runner, || classify(&cfg, "away")); - let health = class.health; - - assert_eq!(health, Health::NoAdapter); + let class = with_runner(FakeRunner::new(), || classify(&cfg, "away")); + assert_eq!(class.health, Health::NoAdapter); } #[test] fn classify_reports_down_no_net_when_internet_check_fails() { let _env = EnvSandbox::new(); + let (nm, dev) = setup_wifi(&[]); + associate(&nm, &dev, "HomeWifi"); let mut cfg = base_config(); cfg.profiles.insert("away".into(), Profile::default()); - let runner = FakeRunner::new() - .on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi")) - .on_contains("nmcli", "ACTIVE,SSID", ok("yes:HomeWifi")) - .on_contains("nmcli", "IP4.ADDRESS", ok("192.168.1.50/24")) - .on(|prog, _| prog == "curl" || prog == "ping", fail("")); + let runner = FakeRunner::new().on(|prog, _| prog == "curl" || prog == "ping", fail("")); let class = with_runner(runner, || classify(&cfg, "away")); - let health = class.health; - let ssid = class.ssid; - assert_eq!(health, Health::DownNoNet); - assert_eq!(ssid, Some("HomeWifi".to_string())); + assert_eq!(class.health, Health::DownNoNet); + assert_eq!(class.ssid, Some("HomeWifi".to_string())); } #[test] fn classify_reports_up_when_healthy_and_tailscale_not_required() { let _env = EnvSandbox::new(); + let (nm, dev) = setup_wifi(&[]); + associate(&nm, &dev, "HomeWifi"); let mut cfg = base_config(); cfg.profiles.insert("home".into(), Profile::default()); // tailscale: false - let runner = FakeRunner::new() - .on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi")) - .on_contains("nmcli", "ACTIVE,SSID", ok("yes:HomeWifi")) - .on_contains("nmcli", "IP4.ADDRESS", ok("192.168.1.50/24")) - .with_command("curl") - .on(|prog, _| prog == "curl", ok("204")); - let class = with_runner(runner, || classify(&cfg, "home")); - let health = class.health; - let ssid = class.ssid; + let class = with_runner(healthy_runner(), || classify(&cfg, "home")); - assert_eq!(health, Health::Up); - assert_eq!(ssid, Some("HomeWifi".to_string())); + assert_eq!(class.health, Health::Up); + assert_eq!(class.ssid, Some("HomeWifi".to_string())); } #[test] fn classify_reports_down_tailscale_manual_when_not_installed() { let _env = EnvSandbox::new(); + let (nm, dev) = setup_wifi(&[]); + associate(&nm, &dev, "CorpWifi"); let mut cfg = base_config(); cfg.profiles.insert( "work".into(), @@ -440,23 +371,17 @@ fn classify_reports_down_tailscale_manual_when_not_installed() { }, ); - // No `with_command("tailscale")`, so `tailscale::installed()` is false — - // `status::gather` never even tries to run the `tailscale` binary. - let runner = FakeRunner::new() - .on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi")) - .on_contains("nmcli", "ACTIVE,SSID", ok("yes:CorpWifi")) - .on_contains("nmcli", "IP4.ADDRESS", ok("10.0.0.5/24")) - .with_command("curl") - .on(|prog, _| prog == "curl", ok("204")); - let class = with_runner(runner, || classify(&cfg, "work")); - let health = class.health; + // No `with_command("tailscale")`, so `tailscale::installed()` is false. + let class = with_runner(healthy_runner(), || classify(&cfg, "work")); - assert_eq!(health, Health::DownTailscaleManual); + assert_eq!(class.health, Health::DownTailscaleManual); } #[test] fn classify_reports_down_tailscale_manual_when_needs_login() { let _env = EnvSandbox::new(); + let (nm, dev) = setup_wifi(&[]); + associate(&nm, &dev, "CorpWifi"); let mut cfg = base_config(); cfg.profiles.insert( "work".into(), @@ -466,26 +391,17 @@ fn classify_reports_down_tailscale_manual_when_needs_login() { }, ); - let runner = FakeRunner::new() - .on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi")) - .on_contains("nmcli", "ACTIVE,SSID", ok("yes:CorpWifi")) - .on_contains("nmcli", "IP4.ADDRESS", ok("10.0.0.5/24")) - .with_command("curl") - .with_command("tailscale") - .on(|prog, _| prog == "curl", ok("204")) - .on( - |prog, args| prog == "tailscale" && args.contains(&"status"), - ok(r#"{"BackendState":"NeedsLogin"}"#), - ); + let runner = classify_runner("204", Some(r#"{"BackendState":"NeedsLogin"}"#)); let class = with_runner(runner, || classify(&cfg, "work")); - let health = class.health; - assert_eq!(health, Health::DownTailscaleManual); + assert_eq!(class.health, Health::DownTailscaleManual); } #[test] fn classify_reports_down_tailscale_other_when_exit_node_offline() { let _env = EnvSandbox::new(); + let (nm, dev) = setup_wifi(&[]); + associate(&nm, &dev, "CorpWifi"); let mut cfg = base_config(); cfg.settings.exit_node = "exitnode".into(); cfg.profiles.insert( @@ -497,26 +413,17 @@ fn classify_reports_down_tailscale_other_when_exit_node_offline() { ); let json = r#"{"BackendState":"Running","Peer":{"k1":{"HostName":"exitnode","Online":false,"ExitNode":false,"ExitNodeOption":true}}}"#; - let runner = FakeRunner::new() - .on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi")) - .on_contains("nmcli", "ACTIVE,SSID", ok("yes:CorpWifi")) - .on_contains("nmcli", "IP4.ADDRESS", ok("10.0.0.5/24")) - .with_command("curl") - .with_command("tailscale") - .on(|prog, _| prog == "curl", ok("204")) - .on( - |prog, args| prog == "tailscale" && args.contains(&"status"), - ok(json), - ); + let runner = classify_runner("204", Some(json)); let class = with_runner(runner, || classify(&cfg, "work")); - let health = class.health; - assert_eq!(health, Health::DownTailscaleOther); + assert_eq!(class.health, Health::DownTailscaleOther); } #[test] fn classify_reports_up_when_tailscale_healthy() { let _env = EnvSandbox::new(); + let (nm, dev) = setup_wifi(&[]); + associate(&nm, &dev, "CorpWifi"); let mut cfg = base_config(); cfg.settings.exit_node = "exitnode".into(); cfg.profiles.insert( @@ -527,18 +434,10 @@ fn classify_reports_up_when_tailscale_healthy() { }, ); - let runner = FakeRunner::new() - .on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi")) - .on_contains("nmcli", "ACTIVE,SSID", ok("yes:CorpWifi")) - .on_contains("nmcli", "IP4.ADDRESS", ok("10.0.0.5/24")) - .with_command("curl") - .with_command("tailscale") - .on(|prog, _| prog == "curl", ok("204")) - .on_contains("tailscale", "status", ok(&tailscale_json_ok("exitnode"))); + let runner = classify_runner("204", Some(&tailscale_json_ok("exitnode"))); let class = with_runner(runner, || classify(&cfg, "work")); - let health = class.health; - assert_eq!(health, Health::Up); + assert_eq!(class.health, Health::Up); } // --------------------------------------------------------------------- @@ -662,6 +561,8 @@ fn flow_run_reports_no_exit_node_and_never_clears_selection() { // TsHealth::NoExitNode — and must never run `tailscale set --exit-node=` // with an empty value, which would clear the user's current selection. let _env = EnvSandbox::new(); + let (nm, _dev) = setup_wifi(&[("Corp", 80)]); + let mut cfg = base_config(); cfg.networks = vec![net("Corp", Some("corp-pw"))]; cfg.profiles.insert( @@ -673,7 +574,7 @@ fn flow_run_reports_no_exit_node_and_never_clears_selection() { }, ); - let runner = base_nm(&["Corp"]).with_command("tailscale"); + let runner = FakeRunner::new().with_command("tailscale"); let calls = runner.calls_handle(); let outcome = with_runner(runner, || flow::run(&mut cfg, "work")); @@ -688,6 +589,11 @@ fn flow_run_reports_no_exit_node_and_never_clears_selection() { "with no exit node configured, tailscale must not be touched: {:?}", calls.borrow() ); + assert!( + nm.activated_ssids().is_empty(), + "with no exit node configured, no network must be dialed: {:?}", + nm.activated_ssids() + ); } #[test] @@ -696,6 +602,8 @@ fn classify_reports_down_tailscale_manual_when_no_exit_node_configured() { // classify as DownTailscaleManual — not DownTailscaleOther, which would // make the watcher spin auto-recovery forever. let _env = EnvSandbox::new(); + let (nm, dev) = setup_wifi(&[]); + associate(&nm, &dev, "CorpWifi"); let mut cfg = base_config(); cfg.profiles.insert( "work".into(), @@ -705,19 +613,11 @@ fn classify_reports_down_tailscale_manual_when_no_exit_node_configured() { }, ); - let runner = FakeRunner::new() - .on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi")) - .on_contains("nmcli", "ACTIVE,SSID", ok("yes:CorpWifi")) - .on_contains("nmcli", "IP4.ADDRESS", ok("10.0.0.5/24")) - .with_command("curl") - .with_command("tailscale") - .on(|prog, _| prog == "curl", ok("204")); + let runner = classify_runner("204", None).with_command("tailscale"); let class = with_runner(runner, || classify(&cfg, "work")); - let health = class.health; - let ssid = class.ssid; - assert_eq!(health, Health::DownTailscaleManual); - assert_eq!(ssid, Some("CorpWifi".to_string())); + assert_eq!(class.health, Health::DownTailscaleManual); + assert_eq!(class.ssid, Some("CorpWifi".to_string())); } #[test] @@ -733,10 +633,9 @@ fn ensure_exit_node_attempts_to_start_unreachable_daemon() { .on(|p, args| p == "tailscale" && args.contains(&"status"), ok("")) .on(|p, args| p == "tailscale" && args.contains(&"up"), ok("")); let calls = runner.calls_handle(); - let health = - with_runner(runner, || { - breadcrumbs::tailscale::ensure_exit_node(&["exitnode".to_string()]) - }); + let health = with_runner(runner, || { + breadcrumbs::tailscale::ensure_exit_node(&["exitnode".to_string()]) + }); assert!( matches!(health, breadcrumbs::tailscale::TsHealth::Error(_)), "daemon still unreachable after `up` → Error, got {health:?}" @@ -759,6 +658,9 @@ fn flow_run_fails_when_device_lands_on_wrong_ssid() { // *different* network than requested. flow must not report Connected to // the requested SSID, and must not clear its password. let _env = EnvSandbox::new(); + let (nm, _dev) = setup_wifi(&[("First", 80), ("OtherNet", 90)]); + nm.set_land_on(Some("OtherNet")); + let mut cfg = base_config(); cfg.networks = vec![net("First", Some("pw1"))]; cfg.profiles.insert( @@ -769,31 +671,7 @@ fn flow_run_fails_when_device_lands_on_wrong_ssid() { }, ); - let runner = FakeRunner::new() - .on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi")) - .on_contains("nmcli", "radio wifi on", ok("")) - .on_contains("nmcli", "wifi rescan", ok("")) - .on( - |_p, args| args.join(" ") == "-t -f SSID device wifi list ifname wlan0", - ok("First"), - ) - .on( - |_p, args| args.join(" ") == "-t -f SSID,SIGNAL device wifi list ifname wlan0", - ok("First:80"), - ) - .on_contains("nmcli", "NAME,TYPE", ok("")) - .on_contains("nmcli", "GENERAL.CON-UUID", ok("uuid-1")) - .on_contains("nmcli", "ipv4.ignore-auto-dns", ok("")) - .on_contains("nmcli", "device reapply", ok("")) - .on_contains("nmcli", "DEVICE,STATE", ok("wlan0:connected")) - // The connect itself succeeds... - .on_contains("nmcli", "device wifi connect First", ok("")) - // ...but the device reports being on a different network. - .on( - |_p, args| args.join(" ") == "-t -f ACTIVE,SSID device wifi list ifname wlan0", - ok("yes:OtherNet"), - ); - let outcome = with_runner(runner, || flow::run(&mut cfg, "home")); + let outcome = with_runner(healthy_runner(), || flow::run(&mut cfg, "home")); assert!( !matches!(outcome, flow::Outcome::Connected { .. }), @@ -811,6 +689,8 @@ fn run_quiet_suppresses_notifications_that_run_emits() { // The watch loop calls flow::run_quiet so a persistent failure doesn't // re-notify on every retry; the CLI keeps flow::run's notifications. let _env = EnvSandbox::new(); + let nm = fake_nm::shared(); + nm.reset(); let mut cfg = base_config(); // no profiles → UnknownProfile path notifies let runner = FakeRunner::new().with_command("notify-send"); @@ -860,21 +740,22 @@ fn internet_ok_requires_204_and_falls_back_to_ping() { #[test] fn scan_list_dedups_by_ssid_keeping_strongest_signal() { - // One line per BSSID: the same SSID broadcast by several APs must show - // once, at its strongest signal (not the first, possibly weak, listing). + // One entry per SSID, at its strongest signal (not the first, possibly + // weak, listing). Hidden (empty-SSID) APs are skipped. let _env = EnvSandbox::new(); - let runner = FakeRunner::new().on_contains( - "nmcli", - "SSID,SIGNAL,SECURITY", - ok("Cafe:40:WPA2\nCafe:80:WPA2\nOffice:60:WPA3\nCafe:90 %:WPA2\n:70:WPA2"), - ); - let list = with_runner(runner, || breadcrumbs::nm::scan_list("wlan0")); + let (nm, dev) = setup_wifi(&[]); + nm.add_ap(&dev, "Cafe", 40, Security::Wpa2); + nm.add_ap(&dev, "Cafe", 80, Security::Wpa2); + nm.add_ap(&dev, "Office", 60, Security::Wpa3); + nm.add_ap(&dev, "Cafe", 90, Security::Wpa2); - assert_eq!(list.len(), 2, "dedup by SSID, hidden (empty SSID) skipped: {list:?}"); + let list = breadcrumbs::nm::scan_list("wlan0"); + assert_eq!(list.len(), 2, "dedup by SSID: {list:?}"); let cafe = list.iter().find(|e| e.ssid == "Cafe").unwrap(); - assert_eq!(cafe.signal, "90 %", "strongest signal wins"); + assert_eq!(cafe.signal, "90", "strongest signal wins"); let office = list.iter().find(|e| e.ssid == "Office").unwrap(); assert_eq!(office.signal, "60"); + assert_eq!(office.security, "WPA3"); } // --------------------------------------------------------------------- @@ -886,6 +767,10 @@ fn scan_list_dedups_by_ssid_keeping_strongest_signal() { #[test] fn flow_run_prefers_strongest_visible_signal_over_priority_order() { let _env = EnvSandbox::new(); + // "Weak" is listed first (higher priority), but "Strong" has the better + // signal — signal-aware selection must dial Strong first. + let (nm, _dev) = setup_wifi(&[("Weak", 40), ("Strong", 90)]); + let mut cfg = base_config(); cfg.networks = vec![net("Weak", Some("pw1")), net("Strong", Some("pw2"))]; cfg.profiles.insert( @@ -896,58 +781,24 @@ fn flow_run_prefers_strongest_visible_signal_over_priority_order() { }, ); - // "Weak" is listed first (higher priority), but "Strong" has the better - // signal — signal-aware selection must dial Strong first. - let runner = FakeRunner::new() - .on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi")) - .on_contains("nmcli", "radio wifi on", ok("")) - .on_contains("nmcli", "wifi rescan", ok("")) - .on( - |_p, args| args.join(" ") == "-t -f SSID device wifi list ifname wlan0", - ok("Weak\nStrong"), - ) - .on( - |_p, args| args.join(" ") == "-t -f SSID,SIGNAL device wifi list ifname wlan0", - ok("Weak:40\nStrong:90"), - ) - .on_contains("nmcli", "NAME,TYPE", ok("")) - .on_contains("nmcli", "GENERAL.CON-UUID", ok("uuid-1")) - .on_contains("nmcli", "ipv4.ignore-auto-dns", ok("")) - .on_contains("nmcli", "device reapply", ok("")) - .on_contains("nmcli", "DEVICE,STATE", ok("wlan0:connected")) - .on( - |_p, args| args.join(" ") == "-t -f ACTIVE,SSID device wifi list ifname wlan0", - ok("yes:Strong"), - ) - .on( - |p, args| p == "nmcli" && args.contains(&"connect") && args.contains(&"Strong"), - ok(""), - ) - .on(|p, _| p == "curl", ok("204")) - .with_command("curl"); - let calls = runner.calls_handle(); - - let outcome = with_runner(runner, || flow::run(&mut cfg, "home")); + let outcome = with_runner(healthy_runner(), || flow::run(&mut cfg, "home")); match &outcome { flow::Outcome::Connected { ssid, .. } => assert_eq!(ssid, "Strong"), other => panic!("expected Connected to Strong, got {other:?}"), } - let dialed = |s: &str| { - calls.borrow().iter().any(|c| { - c.args.contains(&"connect".to_string()) && c.args.iter().any(|a| a == s) - }) - }; - assert!(dialed("Strong"), "the stronger network must be dialed"); - assert!( - !dialed("Weak"), - "the weaker network must not be dialed despite higher priority" + assert_eq!( + nm.activated_ssids(), + vec!["Strong".to_string()], + "the stronger network must be dialed, and only it" ); } #[test] fn flow_run_pins_per_network_dns_override() { let _env = EnvSandbox::new(); + let (nm, _dev) = setup_wifi(&[("Home", 80)]); + let mut cfg = base_config(); cfg.settings.dns = "1.1.1.1".into(); let mut def = net("Home", Some("pw")); @@ -961,24 +812,25 @@ fn flow_run_pins_per_network_dns_override() { }, ); - let runner = allow_connects(base_nm(&["Home"]), &["Home"]) - .on(|p, _| p == "curl", ok("204")) - .with_command("curl"); - let calls = runner.calls_handle(); - let outcome = with_runner(runner, || flow::run(&mut cfg, "home")); + let outcome = with_runner(healthy_runner(), || flow::run(&mut cfg, "home")); assert!(matches!(outcome, flow::Outcome::Connected { .. })); - // The DNS-pinning `connection modify` must carry the per-network override, - // not the global 1.1.1.1. - let dns_arg = calls.borrow().iter().any(|c| { - c.args.join(" ").contains("ipv4.dns") && c.args.iter().any(|a| a == "9.9.9.9") - }); - assert!(dns_arg, "per-network DNS override must reach nmcli"); + // The DNS-pinned profile must carry the per-network override, not the + // global 1.1.1.1. + let st = nm.state.lock().unwrap(); + let conn = st.connections.values().next().expect("a profile was saved"); + let dns = conn + .get("ipv4") + .and_then(|m| m.get("dns")) + .and_then(fake_nm::value_str_list); + assert_eq!(dns, Some(vec!["9.9.9.9".to_string()])); } #[test] fn flow_run_appends_learned_ssid_to_detect_ssids() { let _env = EnvSandbox::new(); + let (_nm, _dev) = setup_wifi(&[("Home", 80)]); + let mut cfg = base_config(); cfg.networks = vec![net("Home", Some("pw"))]; cfg.profiles.insert( @@ -990,10 +842,7 @@ fn flow_run_appends_learned_ssid_to_detect_ssids() { }, ); - let runner = allow_connects(base_nm(&["Home"]), &["Home"]) - .on(|p, _| p == "curl", ok("204")) - .with_command("curl"); - let outcome = with_runner(runner, || flow::run(&mut cfg, "home")); + let outcome = with_runner(healthy_runner(), || flow::run(&mut cfg, "home")); assert!(matches!(outcome, flow::Outcome::Connected { .. })); assert_eq!( @@ -1006,15 +855,12 @@ fn flow_run_appends_learned_ssid_to_detect_ssids() { #[test] fn classify_reports_captive_portal_when_connectivity_returns_200() { let _env = EnvSandbox::new(); + let (nm, dev) = setup_wifi(&[]); + associate(&nm, &dev, "HomeWifi"); let mut cfg = base_config(); cfg.profiles.insert("home".into(), Profile::default()); - let runner = FakeRunner::new() - .on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi")) - .on_contains("nmcli", "ACTIVE,SSID", ok("yes:HomeWifi")) - .on_contains("nmcli", "IP4.ADDRESS", ok("192.168.1.50/24")) - .with_command("curl") - .on(|p, _| p == "curl", ok("200")); + let runner = classify_runner("200", None); let class = with_runner(runner, || classify(&cfg, "home")); assert_eq!(class.health, Health::CaptivePortal); @@ -1023,10 +869,10 @@ fn classify_reports_captive_portal_when_connectivity_returns_200() { #[test] fn ensure_exit_node_failover_tries_nodes_in_priority_order() { - let _env = EnvSandbox::new(); // The status never shows nodeA; it always shows nodeB selected + online. // ensure_exit_node must therefore try nodeA (fail), then nodeB (succeed), // in that exact priority order. + let _env = EnvSandbox::new(); let json = r#"{"BackendState":"Running","Peer":{"k1":{"HostName":"nodeB","DNSName":"nodeB.ts.net.","Online":true,"ExitNode":true,"ExitNodeOption":true}}}"#; let runner = FakeRunner::new() .with_command("tailscale") @@ -1054,78 +900,69 @@ fn ensure_exit_node_failover_tries_nodes_in_priority_order() { #[test] fn wifi_interface_preferred_picks_named_device_over_first_wifi() { - let runner = FakeRunner::new().on_contains( - "nmcli", - "DEVICE,TYPE", - ok("wlan0:wifi\nwlan1:wifi"), - ); - let iface = with_runner(runner, || { - breadcrumbs::nm::wifi_interface_preferred(Some("wlan1")) - }); - assert_eq!(iface.as_deref(), Some("wlan1")); + let _env = EnvSandbox::new(); + let nm = fake_nm::shared(); + nm.reset(); + nm.add_wifi_device("wlan0", 100); + nm.add_wifi_device("wlan1", 100); + + assert_eq!(breadcrumbs::nm::wifi_interface_preferred(Some("wlan1")).as_deref(), Some("wlan1")); } #[test] fn wifi_interface_preferred_falls_back_to_first_wifi_when_pref_missing() { - let runner = FakeRunner::new().on_contains( - "nmcli", - "DEVICE,TYPE", - ok("wlan0:wifi\nwlan1:wifi"), - ); - let iface = with_runner(runner, || { - breadcrumbs::nm::wifi_interface_preferred(Some("wlan9")) - }); - assert_eq!(iface.as_deref(), Some("wlan0")); + let _env = EnvSandbox::new(); + let nm = fake_nm::shared(); + nm.reset(); + nm.add_wifi_device("wlan0", 100); + nm.add_wifi_device("wlan1", 100); + + assert_eq!(breadcrumbs::nm::wifi_interface_preferred(Some("wlan9")).as_deref(), Some("wlan0")); } #[test] fn visible_signals_dedups_by_strongest_signal() { - let runner = FakeRunner::new().on_contains( - "nmcli", - "SSID,SIGNAL", - ok("Cafe:40\nCafe:85\nOffice:60\n:90"), - ); - let map = with_runner(runner, || breadcrumbs::nm::visible_signals("wlan0")); + let _env = EnvSandbox::new(); + let (nm, dev) = setup_wifi(&[]); + nm.add_ap(&dev, "Cafe", 40, Security::Wpa2); + nm.add_ap(&dev, "Cafe", 85, Security::Wpa2); + nm.add_ap(&dev, "Office", 60, Security::Wpa2); + + let map = breadcrumbs::nm::visible_signals("wlan0"); assert_eq!(map.get("Cafe"), Some(&85)); assert_eq!(map.get("Office"), Some(&60)); - assert!(!map.contains_key(""), "hidden/empty SSID must be skipped"); } #[test] fn connect_verbose_enterprise_creates_8021x_profile() { let _env = EnvSandbox::new(); + let (nm, dev) = setup_wifi(&[]); + nm.add_ap(&dev, "Corp", 80, Security::Enterprise); + let mut def = net("Corp", Some("pw")); def.eap = Some("peap".into()); def.identity = Some("user@corp".into()); def.ca_cert = Some("/etc/ca.pem".into()); - // No saved profile (NAME,TYPE empty), so the enterprise create path runs. - let runner = FakeRunner::new() - .on_contains("nmcli", "NAME,TYPE", ok("")) - .on( - |p, args| p == "nmcli" && args.contains(&"add") && args.contains(&"connection"), - ok(""), - ) - .on(|p, args| p == "nmcli" && args.contains(&"up"), ok("")) - .on_contains("nmcli", "GENERAL.CON-UUID", ok("uuid-1")) - .on_contains("nmcli", "ipv4.ignore-auto-dns", ok("")) - .on_contains("nmcli", "device reapply", ok("")); - let calls = runner.calls_handle(); - - let res = with_runner(runner, || { - breadcrumbs::nm::connect_verbose("wlan0", &def, 8, "1.1.1.1") - }); + let res = breadcrumbs::nm::connect_verbose("wlan0", &def, 8, "1.1.1.1"); assert!(res.is_ok(), "enterprise connect should succeed: {res:?}"); - let calls_ref = calls.borrow(); - let add = calls_ref - .iter() - .find(|c| c.args.contains(&"add".to_string()) && c.args.contains(&"connection".to_string())) - .expect("enterprise path must create a profile via `connection add`"); - let joined = add.args.join(" "); - assert!(joined.contains("wpa-eap")); - assert!(joined.contains("peap")); - assert!(joined.contains("user@corp")); - assert!(joined.contains("/etc/ca.pem")); - assert!(joined.contains("802-1x.password")); + let st = nm.state.lock().unwrap(); + let (_, settings) = st.connections.iter().next().expect("a profile was saved"); + let x1 = settings.get("802-1x").expect("802-1x section"); + assert_eq!( + x1.get("identity").and_then(|v| v.downcast_ref::().ok()).as_deref(), + Some("user@corp") + ); + let eap = x1.get("eap").and_then(fake_nm::value_str_list); + assert_eq!(eap.as_deref(), Some(&["peap".to_string()][..])); + // ca-cert is a GBytes (`ay`) holding the conventional `file://` URI for + // a filesystem path — never a bare string. + let ca = x1.get("ca-cert").and_then(fake_nm::value_bytes); + assert_eq!(ca.as_deref(), Some(b"file:///etc/ca.pem".as_slice())); + let sec = settings.get("802-11-wireless-security").expect("security section"); + assert_eq!( + sec.get("key-mgmt").and_then(|v| v.downcast_ref::().ok()).as_deref(), + Some("wpa-eap") + ); }