From d3c1e19ba31e6c251dfd6fad723634b98b7b172b Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 23 Jun 2026 12:13:34 +0800 Subject: [PATCH 1/2] Release v2.1.0: backend test seam, captive-portal detection, JSON status, robustness Features: - Introduce a Backend trait + System impl so flow/status/watch can be unit tested against a fake; add 11 connect-state-machine tests. - Captive-portal detection: status::connectivity returns Online/Portal/Offline; surfaced in status, JSON, connect notes, and a dedicated watch state. - `status --json` for bars/scripts; `profile add`/`profile remove`; detect now scores by number of in-range markers. Robustness: - Pin LC_ALL=C/LANG=C on child processes for locale-independent parsing. - Atomic config/state writes (temp + rename); 0600 config never world-readable. - Transient PSK file written to $XDG_RUNTIME_DIR when available. Fixes (from prior audit): - Feed Wi-Fi PSK to nmcli via stdin/passwd-file, never argv. - mask() no longer panics on multi-byte passwords. - Connectivity check requires HTTP 204 (no captive-portal false positives). - nmcli NAME,TYPE parsing handles escaped colons. - Strip CIDR suffix from displayed IP; PKGBUILD/Cargo version aligned (2.1.0). --- .github/workflows/release.yml | 3 - Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 12 +- breadcrumbs.example.toml | 2 + packaging/arch/PKGBUILD | 2 +- src/backend.rs | 80 ++++++ src/config.rs | 131 ++++++++- src/flow.rs | 499 ++++++++++++++++++++++++++++++---- src/main.rs | 178 +++++++++--- src/nm.rs | 247 +++++++++++++---- src/state.rs | 3 +- src/status.rs | 86 ++++-- src/tailscale.rs | 110 ++++++++ src/util.rs | 115 +++++++- src/watch.rs | 60 ++-- tests/cli.rs | 347 +++++++++++++++++++++++ 17 files changed, 1662 insertions(+), 217 deletions(-) create mode 100644 src/backend.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 78c9730..2def9df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,9 +17,6 @@ jobs: steps: - uses: actions/checkout@v4 - - name: install build deps - run: sudo apt-get install -y libnm-dev libdbus-1-dev pkg-config 2>/dev/null || true - - name: build run: cargo build --release --locked diff --git a/Cargo.lock b/Cargo.lock index 4751a84..afa4c8a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -54,7 +54,7 @@ dependencies = [ [[package]] name = "breadcrumbs" -version = "2.0.1" +version = "2.1.0" dependencies = [ "clap", "serde", diff --git a/Cargo.toml b/Cargo.toml index 4b2ec2f..8b52159 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadcrumbs" -version = "2.0.1" +version = "2.1.0" edition = "2021" description = "Profile-aware Wi-Fi state machine with Tailscale handling and self-healing watch daemon" license = "MIT" diff --git a/README.md b/README.md index 8f55369..be1bd9d 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,10 @@ breadcrumbs sits on top of NetworkManager (`nmcli`) and manages your Wi-Fi based - **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` -- **Auto-detection** — scans visible SSIDs and guesses your location from config-defined markers -- **Secure credential handling** — passwords fed to `nmcli` via stdin (never in argv/`ps`), config stored at 0600 +- **Auto-detection** — scans visible SSIDs and guesses your location from config-defined markers (picks the profile with the most markers in range) +- **Captive-portal detection** — distinguishes a real connection from a sign-in page and surfaces the portal URL instead of falsely reporting "online" +- **Secure credential handling** — passwords fed to `nmcli` out-of-band (via stdin with `--ask`, or a 0600 `passwd-file`), never in argv/`ps`; config stored at 0600 +- **Machine-readable status** — `breadcrumbs status --json` for bars/scripts - **Desktop notifications** via `notify-send` (optional) - **systemd user service** generation via `breadcrumbs install-service` @@ -25,7 +27,7 @@ breadcrumbs sits on top of NetworkManager (`nmcli`) and manages your Wi-Fi based ## Installation ```bash -git clone https://github.com/breadway/breadcrumbs +git clone https://github.com/Breadway/breadcrumbs cd breadcrumbs cargo build --release # Copy to somewhere on your PATH: @@ -95,12 +97,14 @@ breadcrumbs [--profile ] | Command | Description | |---------|-------------| -| `status` | Show current Wi-Fi / Tailscale health (default) | +| `status [--json]` | Show current Wi-Fi / Tailscale health (default); `--json` for scripts | | `init` | Run the full connect sequence for the active profile | | `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 | +| `profile add [--detect ]…` | Create a new (empty) profile, optionally with detection markers | +| `profile remove ` | Delete a profile (core `home`/`work`/`away` are protected) | | `detect [--apply]` | Guess profile from visible networks; optionally apply it | | `add [password]` | Add or update a saved network | | `forget ` | Remove a network from config and NetworkManager | diff --git a/breadcrumbs.example.toml b/breadcrumbs.example.toml index ead73f0..6d42e44 100644 --- a/breadcrumbs.example.toml +++ b/breadcrumbs.example.toml @@ -11,6 +11,8 @@ nmcli_wait = 8 exit_node = "my-exit-node" # Tailscale hostname of your preferred exit node default_profile = "away" watch_interval = 12 +# Must be a "generate_204"-style endpoint: only an empty HTTP 204 counts as +# online, so a captive portal (200 login page / 30x redirect) is detected. connectivity_url = "http://connectivitycheck.gstatic.com/generate_204" ping_host = "1.1.1.1" diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 041d6c5..8b84450 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: Breadway pkgname=breadcrumbs -pkgver=0.1.0 +pkgver=2.1.0 pkgrel=1 pkgdesc="Profile-aware Wi-Fi state machine with Tailscale integration" arch=('x86_64') diff --git a/src/backend.rs b/src/backend.rs new file mode 100644 index 0000000..48511cb --- /dev/null +++ b/src/backend.rs @@ -0,0 +1,80 @@ +//! The seam between breadcrumbs' decision logic and the outside world. +//! +//! Every interaction with NetworkManager, Tailscale, connectivity probes, +//! notifications and logging goes through the [`Backend`] trait. Production code +//! uses [`System`], which delegates to the `nm`/`tailscale`/`status`/`notify` +//! modules that shell out. Tests inject a fake so the connect state machine +//! (`flow`) and watch classifier can be exercised without touching the host. + +use std::collections::HashSet; + +use crate::config::{Config, NetworkDef}; +use crate::nm; +use crate::notify::{self, Urgency}; +use crate::status::{self, Connectivity}; +use crate::tailscale::{self, TsHealth}; + +pub trait Backend { + fn wifi_interface(&self) -> Option; + fn radio_on(&self); + fn rescan(&self, iface: &str, ssids: &[String]); + fn visible_ssids(&self, iface: &str) -> HashSet; + fn active_ssid(&self, iface: &str) -> Option; + fn ipv4(&self, iface: &str) -> Option; + fn device_connected(&self, iface: &str) -> bool; + fn connect(&self, iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> Result<(), String>; + fn tailscale_installed(&self) -> bool; + fn ensure_exit_node(&self, node: &str) -> TsHealth; + fn tailscale_check(&self, node: &str) -> TsHealth; + fn connectivity(&self, cfg: &Config) -> Connectivity; + fn notify(&self, summary: &str, body: &str, urgency: Urgency); + fn log(&self, line: &str); +} + +/// The real backend: every method delegates to the system-facing modules. +pub struct System; + +impl Backend for System { + fn wifi_interface(&self) -> Option { + nm::wifi_interface() + } + fn radio_on(&self) { + nm::radio_on() + } + fn rescan(&self, iface: &str, ssids: &[String]) { + nm::rescan(iface, ssids) + } + fn visible_ssids(&self, iface: &str) -> HashSet { + nm::visible_ssids(iface) + } + fn active_ssid(&self, iface: &str) -> Option { + nm::active_ssid(iface) + } + fn ipv4(&self, iface: &str) -> Option { + status::ipv4(iface) + } + fn device_connected(&self, iface: &str) -> bool { + nm::device_connected(iface) + } + fn connect(&self, iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> Result<(), String> { + nm::connect_verbose(iface, net, wait, dns) + } + fn tailscale_installed(&self) -> bool { + tailscale::installed() + } + fn ensure_exit_node(&self, node: &str) -> TsHealth { + tailscale::ensure_exit_node(node) + } + fn tailscale_check(&self, node: &str) -> TsHealth { + tailscale::check(node) + } + fn connectivity(&self, cfg: &Config) -> Connectivity { + status::connectivity(cfg) + } + fn notify(&self, summary: &str, body: &str, urgency: Urgency) { + notify::notify(summary, body, urgency) + } + fn log(&self, line: &str) { + notify::log(line) + } +} diff --git a/src/config.rs b/src/config.rs index c258d31..da094aa 100644 --- a/src/config.rs +++ b/src/config.rs @@ -158,13 +158,10 @@ impl Config { fs::create_dir_all(&dir).map_err(|e| format!("creating {}: {e}", dir.display()))?; let text = toml::to_string_pretty(self).map_err(|e| format!("serializing config: {e}"))?; let path = config_path(); - fs::write(&path, text).map_err(|e| format!("writing {}: {e}", path.display()))?; - // Plaintext Wi-Fi passwords live here — keep it owner-only. - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600)); - } + // Plaintext Wi-Fi passwords live here: write atomically and owner-only, + // so there's no torn read and no world-readable window. + crate::util::write_atomic(&path, &text, 0o600) + .map_err(|e| format!("writing {}: {e}", path.display()))?; Ok(()) } } @@ -274,4 +271,124 @@ mod tests { assert!(cfg.profile("work").is_some()); assert!(cfg.profile("away").is_some()); } + + #[test] + fn ensure_core_profiles_does_not_overwrite_existing() { + let mut cfg = Config { + settings: Settings::default(), + networks: vec![], + profiles: BTreeMap::new(), + }; + cfg.profiles.insert( + "home".to_string(), + Profile { + tailscale: true, + exit_node: Some("mynode".into()), + ..Default::default() + }, + ); + ensure_core_profiles(&mut cfg); + let home = cfg.profile("home").unwrap(); + assert!(home.tailscale, "existing field should be preserved"); + assert_eq!(home.exit_node.as_deref(), Some("mynode")); + } + + #[test] + fn network_lookup_found_and_not_found() { + let mut cfg = build_initial_config(); + cfg.networks.push(NetworkDef { + ssid: "TestNet".into(), + password: "secret".into(), + hidden: false, + }); + let found = cfg.network("TestNet"); + assert!(found.is_some()); + assert_eq!(found.unwrap().password, "secret"); + assert!(cfg.network("NoSuchSSID").is_none()); + } + + #[test] + fn profile_lookup_found_and_not_found() { + let cfg = build_initial_config(); + assert!(cfg.profile("home").is_some()); + assert!(cfg.profile("nonexistent").is_none()); + } + + #[test] + fn settings_default_values() { + let s = Settings::default(); + assert_eq!(s.dns, "1.1.1.1"); + assert_eq!(s.nmcli_wait, 8); + assert!(s.exit_node.is_empty()); + assert_eq!(s.default_profile, "away"); + assert_eq!(s.watch_interval, 12); + assert!(!s.connectivity_url.is_empty()); + assert!(!s.ping_host.is_empty()); + } + + #[test] + fn config_toml_roundtrip_with_hidden_network() { + let mut cfg = build_initial_config(); + cfg.networks.push(NetworkDef { + ssid: "HiddenNet".into(), + password: "pw".into(), + hidden: true, + }); + cfg.networks.push(NetworkDef { + ssid: "VisibleNet".into(), + password: "pw2".into(), + hidden: false, + }); + let text = toml::to_string_pretty(&cfg).unwrap(); + let back: Config = toml::from_str(&text).unwrap(); + assert_eq!(back.networks.len(), 2); + let hidden = back.network("HiddenNet").unwrap(); + assert!(hidden.hidden); + let visible = back.network("VisibleNet").unwrap(); + assert!(!visible.hidden); + } + + #[test] + fn config_toml_roundtrip_with_full_profile_fields() { + let mut cfg = build_initial_config(); + let work = cfg.profiles.get_mut("work").unwrap(); + work.tailscale = true; + work.exit_node = Some("myexit".into()); + work.bootstrap = Some("BootstrapSSID".into()); + work.detect_ssids = vec!["WorkWifi".into(), "CorpGuest".into()]; + work.networks = vec!["WorkWifi".into()]; + let text = toml::to_string_pretty(&cfg).unwrap(); + let back: Config = toml::from_str(&text).unwrap(); + let w = back.profile("work").unwrap(); + assert!(w.tailscale); + assert_eq!(w.exit_node.as_deref(), Some("myexit")); + assert_eq!(w.bootstrap.as_deref(), Some("BootstrapSSID")); + assert_eq!(w.detect_ssids, vec!["WorkWifi", "CorpGuest"]); + assert_eq!(w.networks, vec!["WorkWifi"]); + } + + #[test] + fn config_deserialization_applies_settings_defaults_for_missing_fields() { + let toml_str = r#" +[settings] +dns = "8.8.8.8" +"#; + let cfg: Config = toml::from_str(toml_str).unwrap(); + assert_eq!(cfg.settings.dns, "8.8.8.8"); + // Fields not specified should get their defaults. + assert_eq!(cfg.settings.nmcli_wait, 8); + assert_eq!(cfg.settings.default_profile, "away"); + assert_eq!(cfg.settings.watch_interval, 12); + } + + #[test] + fn network_def_hidden_defaults_to_false() { + let toml_str = r#" +[[networks]] +ssid = "MyNet" +password = "pass" +"#; + let cfg: Config = toml::from_str(toml_str).unwrap(); + assert!(!cfg.networks[0].hidden); + } } diff --git a/src/flow.rs b/src/flow.rs index 53a85dd..0261496 100644 --- a/src/flow.rs +++ b/src/flow.rs @@ -1,8 +1,8 @@ +use crate::backend::Backend; use crate::config::{Config, NetworkDef}; -use crate::nm; -use crate::notify::{log, notify, Urgency}; -use crate::status::internet_ok; -use crate::tailscale::{self, TsHealth}; +use crate::notify::Urgency; +use crate::status::Connectivity; +use crate::tailscale::TsHealth; #[derive(Debug)] pub enum Outcome { @@ -48,20 +48,35 @@ fn resolve_candidates<'a>(cfg: &'a Config, p: &crate::config::Profile) -> Vec<&' /// Try to connect + confirm it actually carries traffic. /// 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) { +fn connect_and_verify( + be: &dyn Backend, + iface: &str, + def: &NetworkDef, + cfg: &Config, +) -> Result<(), String> { + be.connect(iface, def, cfg.settings.nmcli_wait, &cfg.settings.dns)?; + if !be.device_connected(iface) { return Err("device not connected after nmcli success".into()); } Ok(()) } +/// Describe post-association connectivity as an optional caveat note. +fn connectivity_note(be: &dyn Backend, cfg: &Config) -> Option { + match be.connectivity(cfg) { + Connectivity::Online => None, + Connectivity::Portal(Some(url)) => Some(format!("captive portal — sign in at {url}")), + Connectivity::Portal(None) => Some("captive portal — sign in required".into()), + Connectivity::Offline => Some("associated but no internet yet".into()), + } +} + /// Run the connection state machine for `profile_name`. -pub fn run(cfg: &Config, profile_name: &str) -> Outcome { +pub fn run(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Outcome { let profile = match cfg.profile(profile_name) { Some(p) => p.clone(), None => { - notify( + be.notify( "breadcrumbs: unknown profile", &format!("'{profile_name}' is not defined in breadcrumbs.toml"), Urgency::Critical, @@ -70,10 +85,10 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome { } }; - let iface = match nm::wifi_interface() { + let iface = match be.wifi_interface() { Some(i) => i, None => { - notify( + be.notify( "breadcrumbs: no Wi-Fi adapter", "Hardware issue — Wi-Fi device not found. Manual check needed.", Urgency::Critical, @@ -81,7 +96,7 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome { return Outcome::NoInterface; } }; - nm::radio_on(); + be.radio_on(); let exit_node = profile .exit_node @@ -89,7 +104,7 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome { .unwrap_or_else(|| cfg.settings.exit_node.clone()); let candidates = resolve_candidates(cfg, &profile); - log(&format!( + be.log(&format!( "flow start: profile={profile_name} iface={iface} tailscale={} candidates=[{}]", profile.tailscale, candidates @@ -104,8 +119,8 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome { if let Some(bs) = &profile.bootstrap { scan_targets.push(bs.clone()); } - nm::rescan(&iface, &scan_targets); - let visible = nm::visible_ssids(&iface); + be.rescan(&iface, &scan_targets); + let visible = be.visible_ssids(&iface); // ---- Tailscale-gated profiles (e.g. school) ------------------------- let mut on_bootstrap = false; @@ -114,29 +129,29 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome { match cfg.network(&bs_ssid) { Some(bdef) => { if visible.contains(&bdef.ssid) || bdef.hidden { - match connect_and_verify(&iface, bdef, cfg) { + match connect_and_verify(be, &iface, bdef, cfg) { Ok(()) => { on_bootstrap = true; - log(&format!("bootstrap connected: {}", bdef.ssid)); + be.log(&format!("bootstrap connected: {}", bdef.ssid)); } Err(e) => { - log(&format!("bootstrap connect failed: {} — {e}", bdef.ssid)) + be.log(&format!("bootstrap connect failed: {} — {e}", bdef.ssid)) } } } else { - log(&format!("bootstrap not in range: {}", bdef.ssid)); + be.log(&format!("bootstrap not in range: {}", bdef.ssid)); } } - None => log(&format!( + None => be.log(&format!( "bootstrap SSID '{bs_ssid}' has no credentials in config" )), } } - let ts = tailscale::ensure_exit_node(&exit_node); + let ts = be.ensure_exit_node(&exit_node); if !ts.is_ok() { - let ssid = nm::active_ssid(&iface).or_else(|| profile.bootstrap.clone()); - notify( + let ssid = be.active_ssid(&iface).or_else(|| profile.bootstrap.clone()); + be.notify( "Tailscale Error", &format!( "{} — staying on {}", @@ -147,12 +162,12 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome { ); return Outcome::TailscaleError { ssid, health: ts }; } - log(&format!("tailscale healthy via exit node {exit_node}")); + be.log(&format!("tailscale healthy via exit node {exit_node}")); // Refresh visibility before moving to the target network. - nm::rescan(&iface, &scan_targets); + be.rescan(&iface, &scan_targets); } - let visible = nm::visible_ssids(&iface); + let visible = be.visible_ssids(&iface); // ---- Connect to the priority list ---------------------------------- // Pass 1: visible networks in priority order. @@ -160,20 +175,16 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome { for def in &candidates { if visible.contains(&def.ssid) { any_attempted = true; - match connect_and_verify(&iface, def, cfg) { + match connect_and_verify(be, &iface, def, cfg) { Ok(()) => { - let note = if internet_ok(cfg) { - None - } else { - Some("associated but no internet yet".to_string()) - }; - finish_connected(&def.ssid, profile_name, ¬e); + let note = connectivity_note(be, cfg); + finish_connected(be, &def.ssid, profile_name, ¬e); return Outcome::Connected { ssid: def.ssid.clone(), note, }; } - Err(e) => log(&format!("connect failed (visible): {} — {e}", def.ssid)), + Err(e) => be.log(&format!("connect failed (visible): {} — {e}", def.ssid)), } } } @@ -181,20 +192,16 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome { for def in &candidates { if def.hidden && !visible.contains(&def.ssid) { any_attempted = true; - match connect_and_verify(&iface, def, cfg) { + match connect_and_verify(be, &iface, def, cfg) { Ok(()) => { - let note = if internet_ok(cfg) { - None - } else { - Some("associated but no internet yet".to_string()) - }; - finish_connected(&def.ssid, profile_name, ¬e); + let note = connectivity_note(be, cfg); + finish_connected(be, &def.ssid, profile_name, ¬e); return Outcome::Connected { ssid: def.ssid.clone(), note, }; } - Err(e) => log(&format!("connect failed (hidden): {} — {e}", def.ssid)), + Err(e) => be.log(&format!("connect failed (hidden): {} — {e}", def.ssid)), } } } @@ -207,12 +214,12 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome { .bootstrap .clone() .unwrap_or_else(|| "bootstrap".into()); - if !nm::device_connected(&iface) { + if !be.device_connected(&iface) { if let Some(bdef) = profile.bootstrap.as_deref().and_then(|s| cfg.network(s)) { - match connect_and_verify(&iface, bdef, cfg) { - Ok(()) => log(&format!("bootstrap reconnected: {}", bdef.ssid)), + match connect_and_verify(be, &iface, bdef, cfg) { + Ok(()) => be.log(&format!("bootstrap reconnected: {}", bdef.ssid)), Err(e) => { - log(&format!("bootstrap reconnect failed: {} — {e}", bdef.ssid)); + be.log(&format!("bootstrap reconnect failed: {} — {e}", bdef.ssid)); on_bootstrap = false; } } @@ -224,8 +231,8 @@ pub fn run(cfg: &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); - log(&format!("flow end: on bootstrap {bs_ssid}; {reason}")); + be.notify("breadcrumbs: using bootstrap", &reason, Urgency::Normal); + be.log(&format!("flow end: on bootstrap {bs_ssid}; {reason}")); return Outcome::Connected { ssid: bs_ssid, note: Some(reason), @@ -238,34 +245,34 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome { .map(|c| c.ssid.as_str()) .collect::>() .join(", "); - notify( + be.notify( "breadcrumbs: no known networks", &format!("profile '{profile_name}': none of [{names}] are in range"), Urgency::Critical, ); - log(&format!( + be.log(&format!( "flow end: no networks connected (profile={profile_name})" )); Outcome::NoNetworks } -fn finish_connected(ssid: &str, profile: &str, note: &Option) { +fn finish_connected(be: &dyn Backend, ssid: &str, profile: &str, note: &Option) { match note { None => { - notify( + be.notify( "breadcrumbs: connected", &format!("{ssid} ({profile})"), Urgency::Low, ); - log(&format!("flow end: connected {ssid} (profile={profile})")); + be.log(&format!("flow end: connected {ssid} (profile={profile})")); } Some(n) => { - notify( + be.notify( "breadcrumbs: connected (degraded)", &format!("{ssid} ({profile}) — {n}"), Urgency::Normal, ); - log(&format!( + be.log(&format!( "flow end: connected {ssid} (profile={profile}) note={n}" )); } @@ -276,6 +283,7 @@ fn finish_connected(ssid: &str, profile: &str, note: &Option) { mod tests { use super::*; use crate::config::{Profile, Settings}; + use crate::tailscale::TsHealth; use std::collections::BTreeMap; fn net(ssid: &str) -> NetworkDef { @@ -299,6 +307,341 @@ mod tests { } } + // --- a scriptable in-memory Backend for testing the state machine --- + + use std::cell::RefCell; + use std::collections::HashSet; + + struct Fake { + iface: Option, + visible: HashSet, + connectable: HashSet, + connected: RefCell>, + ts: TsHealth, + conn: Connectivity, + notes: RefCell>, + } + + impl Fake { + fn new() -> Fake { + Fake { + iface: Some("wlan0".into()), + visible: HashSet::new(), + connectable: HashSet::new(), + connected: RefCell::new(None), + ts: TsHealth::Ok, + conn: Connectivity::Online, + notes: RefCell::new(Vec::new()), + } + } + fn set(ssids: &[&str]) -> HashSet { + ssids.iter().map(|s| s.to_string()).collect() + } + fn visible(mut self, ssids: &[&str]) -> Self { + self.visible = Fake::set(ssids); + self + } + fn connectable(mut self, ssids: &[&str]) -> Self { + self.connectable = Fake::set(ssids); + self + } + fn ts(mut self, h: TsHealth) -> Self { + self.ts = h; + self + } + fn conn(mut self, c: Connectivity) -> Self { + self.conn = c; + self + } + fn no_iface(mut self) -> Self { + self.iface = None; + self + } + fn notified(&self, needle: &str) -> bool { + self.notes.borrow().iter().any(|n| n.contains(needle)) + } + } + + impl Backend for Fake { + fn wifi_interface(&self) -> Option { + self.iface.clone() + } + fn radio_on(&self) {} + fn rescan(&self, _: &str, _: &[String]) {} + fn visible_ssids(&self, _: &str) -> HashSet { + self.visible.clone() + } + fn active_ssid(&self, _: &str) -> Option { + self.connected.borrow().clone() + } + fn ipv4(&self, _: &str) -> Option { + self.connected.borrow().as_ref().map(|_| "10.0.0.2".into()) + } + fn device_connected(&self, _: &str) -> bool { + self.connected.borrow().is_some() + } + fn connect(&self, _: &str, net: &NetworkDef, _: u32, _: &str) -> Result<(), String> { + if self.connectable.contains(&net.ssid) { + *self.connected.borrow_mut() = Some(net.ssid.clone()); + Ok(()) + } else { + // A failed association drops the current link, like nmcli. + *self.connected.borrow_mut() = None; + Err(format!("cannot connect to {}", net.ssid)) + } + } + fn tailscale_installed(&self) -> bool { + true + } + fn ensure_exit_node(&self, _: &str) -> TsHealth { + self.ts.clone() + } + fn tailscale_check(&self, _: &str) -> TsHealth { + self.ts.clone() + } + fn connectivity(&self, _: &Config) -> Connectivity { + self.conn.clone() + } + fn notify(&self, summary: &str, _: &str, _: Urgency) { + self.notes.borrow_mut().push(summary.to_string()); + } + fn log(&self, _: &str) {} + } + + fn with_profile(name: &str, p: Profile) -> Config { + let mut c = cfg(); + c.profiles.insert(name.to_string(), p); + c + } + + fn connected(o: &Outcome) -> (&str, &Option) { + match o { + Outcome::Connected { ssid, note } => (ssid.as_str(), note), + other => panic!("expected Connected, got {other:?}"), + } + } + + // --- flow::run state machine --- + + #[test] + fn run_unknown_profile_returns_unknown_and_notifies() { + let be = Fake::new(); + let o = run(&be, &cfg(), "ghost"); + assert!(matches!(o, Outcome::UnknownProfile(p) if p == "ghost")); + assert!(be.notified("unknown profile")); + } + + #[test] + fn run_no_interface_returns_no_interface() { + let be = Fake::new().no_iface(); + let c = with_profile( + "home", + Profile { + networks: vec!["HomeWifi".into()], + ..Default::default() + }, + ); + assert!(matches!(run(&be, &c, "home"), Outcome::NoInterface)); + } + + #[test] + fn run_connects_to_visible_priority_network() { + let c = with_profile( + "home", + Profile { + networks: vec!["HomeWifi".into()], + ..Default::default() + }, + ); + let be = Fake::new() + .visible(&["HomeWifi", "CafeWifi"]) + .connectable(&["HomeWifi"]); + let o = run(&be, &c, "home"); + let (ssid, note) = connected(&o); + assert_eq!(ssid, "HomeWifi"); + assert!(note.is_none()); + } + + #[test] + fn run_follows_priority_order() { + let c = with_profile( + "home", + Profile { + networks: vec!["WorkNet".into(), "HomeWifi".into()], + ..Default::default() + }, + ); + let be = Fake::new() + .visible(&["WorkNet", "HomeWifi"]) + .connectable(&["WorkNet", "HomeWifi"]); + assert_eq!(connected(&run(&be, &c, "home")).0, "WorkNet"); + } + + #[test] + fn run_skips_failing_network_and_tries_next() { + let c = with_profile( + "home", + Profile { + networks: vec!["WorkNet".into(), "HomeWifi".into()], + ..Default::default() + }, + ); + let be = Fake::new() + .visible(&["WorkNet", "HomeWifi"]) + .connectable(&["HomeWifi"]); // WorkNet fails + assert_eq!(connected(&run(&be, &c, "home")).0, "HomeWifi"); + } + + #[test] + fn run_no_networks_in_range_returns_no_networks() { + let c = with_profile( + "home", + Profile { + networks: vec!["HomeWifi".into()], + ..Default::default() + }, + ); + let be = Fake::new().visible(&["SomeoneElse"]); + assert!(matches!(run(&be, &c, "home"), Outcome::NoNetworks)); + assert!(be.notified("no known networks")); + } + + #[test] + fn run_captive_portal_surfaces_as_note() { + let c = with_profile( + "home", + Profile { + networks: vec!["HomeWifi".into()], + ..Default::default() + }, + ); + let be = Fake::new() + .visible(&["HomeWifi"]) + .connectable(&["HomeWifi"]) + .conn(Connectivity::Portal(Some("http://login.test".into()))); + let o = run(&be, &c, "home"); + let (_, note) = connected(&o); + assert!(note.as_ref().unwrap().contains("captive portal")); + assert!(note.as_ref().unwrap().contains("http://login.test")); + } + + #[test] + fn run_offline_after_associate_is_degraded_note() { + let c = with_profile( + "home", + Profile { + networks: vec!["HomeWifi".into()], + ..Default::default() + }, + ); + let be = Fake::new() + .visible(&["HomeWifi"]) + .connectable(&["HomeWifi"]) + .conn(Connectivity::Offline); + let o = run(&be, &c, "home"); + let (_, note) = connected(&o); + assert!(note.as_ref().unwrap().contains("no internet")); + } + + #[test] + fn run_tailscale_gated_moves_to_target_when_healthy() { + let c = with_profile( + "work", + Profile { + bootstrap: Some("CafeWifi".into()), + networks: vec!["WorkNet".into()], + tailscale: true, + ..Default::default() + }, + ); + let be = Fake::new() + .visible(&["CafeWifi", "WorkNet"]) + .connectable(&["CafeWifi", "WorkNet"]) + .ts(TsHealth::Ok); + assert_eq!(connected(&run(&be, &c, "work")).0, "WorkNet"); + } + + #[test] + fn run_tailscale_unhealthy_stays_on_bootstrap() { + let c = with_profile( + "work", + Profile { + bootstrap: Some("CafeWifi".into()), + networks: vec!["WorkNet".into()], + tailscale: true, + ..Default::default() + }, + ); + let be = Fake::new() + .visible(&["CafeWifi", "WorkNet"]) + .connectable(&["CafeWifi", "WorkNet"]) + .ts(TsHealth::NeedsLogin); + match run(&be, &c, "work") { + Outcome::TailscaleError { ssid, health } => { + assert_eq!(ssid.as_deref(), Some("CafeWifi")); + assert_eq!(health, TsHealth::NeedsLogin); + } + other => panic!("expected TailscaleError, got {other:?}"), + } + assert!(be.notified("Tailscale")); + } + + #[test] + fn run_tailscale_ok_but_target_out_of_range_keeps_bootstrap() { + let c = with_profile( + "work", + Profile { + bootstrap: Some("CafeWifi".into()), + networks: vec!["WorkNet".into()], + tailscale: true, + ..Default::default() + }, + ); + let be = Fake::new() + .visible(&["CafeWifi"]) // WorkNet not in range + .connectable(&["CafeWifi"]) + .ts(TsHealth::Ok); + let o = run(&be, &c, "work"); + let (ssid, note) = connected(&o); + assert_eq!(ssid, "CafeWifi"); + assert!(note.as_ref().unwrap().contains("not in range")); + } + + // --- Outcome::ok --- + + #[test] + fn outcome_ok_true_for_connected_with_and_without_note() { + assert!(Outcome::Connected { + ssid: "x".into(), + note: None + } + .ok()); + assert!(Outcome::Connected { + ssid: "x".into(), + note: Some("associated but no internet yet".into()), + } + .ok()); + } + + #[test] + fn outcome_ok_false_for_all_error_variants() { + assert!(!Outcome::NoInterface.ok()); + assert!(!Outcome::NoNetworks.ok()); + assert!(!Outcome::UnknownProfile("p".into()).ok()); + assert!(!Outcome::TailscaleError { + ssid: None, + health: TsHealth::NeedsLogin, + } + .ok()); + assert!(!Outcome::TailscaleError { + ssid: Some("boot".into()), + health: TsHealth::ExitNodeOffline, + } + .ok()); + } + + // --- resolve_candidates --- + #[test] fn candidates_follow_priority_order() { let c = cfg(); @@ -345,4 +688,50 @@ mod tests { .collect(); assert_eq!(got, vec!["WorkNet"]); } + + #[test] + fn candidates_empty_when_profile_has_no_networks() { + let c = cfg(); + let p = Profile::default(); + assert!(resolve_candidates(&c, &p).is_empty()); + } + + #[test] + fn candidates_include_all_known_only_no_explicit_networks() { + let c = cfg(); + let p = Profile { + include_all_known: true, + ..Default::default() + }; + let got: Vec<&str> = resolve_candidates(&c, &p) + .iter() + .map(|n| n.ssid.as_str()) + .collect(); + assert_eq!(got.len(), 4); + assert_eq!(got[0], "HomeWifi"); + } + + #[test] + fn candidates_deduplicates_repeated_ssid_in_explicit_list() { + let c = cfg(); + let p = Profile { + networks: vec!["HomeWifi".into(), "HomeWifi".into(), "WorkNet".into()], + ..Default::default() + }; + let got: Vec<&str> = resolve_candidates(&c, &p) + .iter() + .map(|n| n.ssid.as_str()) + .collect(); + assert_eq!(got, vec!["HomeWifi", "WorkNet"]); + } + + #[test] + fn candidates_all_unknown_ssids_returns_empty() { + let c = cfg(); + let p = Profile { + networks: vec!["Ghost1".into(), "Ghost2".into()], + ..Default::default() + }; + assert!(resolve_candidates(&c, &p).is_empty()); + } } diff --git a/src/main.rs b/src/main.rs index 548da4b..86cf93d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod backend; mod config; mod flow; mod nm; @@ -14,6 +15,7 @@ use std::time::Duration; use clap::{Parser, Subcommand}; +use backend::Backend; use config::{Config, NetworkDef}; use state::State; use util::{command_exists, home_dir, run}; @@ -44,7 +46,11 @@ struct Cli { #[derive(Subcommand)] enum Cmd { /// Show current Wi-Fi / profile / Tailscale status (default) - Status, + Status { + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, /// Run the full connect sequence for the active profile #[command(visible_aliases = ["up", "connect", "i"])] Init, @@ -126,6 +132,15 @@ enum ProfileCmd { }, /// List available profiles List, + /// Create a new (empty) profile + Add { + name: String, + /// SSID whose presence marks this location (repeatable, for `detect`) + #[arg(long = "detect")] + detect: Vec, + }, + /// Delete a profile (core profiles home/work/away cannot be removed) + Remove { name: String }, } fn main() { @@ -148,7 +163,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 { @@ -156,18 +171,19 @@ fn real_main(cli: Cli) -> Result { } let mut cfg = Config::load()?; + let be = backend::System; match cmd { - Cmd::Status => cmd_status(&cfg, &cli.profile), + Cmd::Status { json } => cmd_status(&be, &cfg, &cli.profile, json), Cmd::Init => { let p = active_profile(&cfg, &cli.profile); - let outcome = flow::run(&cfg, &p); + let outcome = flow::run(&be, &cfg, &p); print_outcome(&p, &outcome); Ok(if outcome.ok() { 0 } else { 1 }) } Cmd::Watch { no_initial } => Ok(watch::run(cfg, !no_initial)), - Cmd::Profile { action } => cmd_profile(&cfg, action), - Cmd::Detect { apply } => cmd_detect(&cfg, apply), + Cmd::Profile { action } => cmd_profile(&be, &mut cfg, action), + Cmd::Detect { apply } => cmd_detect(&be, &cfg, apply), Cmd::Add { ssid, password, @@ -179,7 +195,7 @@ fn real_main(cli: Cli) -> Result { Cmd::Scan { to } => cmd_scan(&mut cfg, to), Cmd::List { show_passwords } => cmd_list(&cfg, show_passwords), Cmd::Edit => cmd_edit(), - Cmd::Doctor { full } => cmd_doctor(&cfg, &cli.profile, full), + Cmd::Doctor { full } => cmd_doctor(&be, &cfg, &cli.profile, full), Cmd::InstallService { no_enable } => cmd_install_service(!no_enable), Cmd::Cd { .. } => unreachable!(), } @@ -213,9 +229,45 @@ fn print_outcome(profile: &str, o: &flow::Outcome) { } } -fn cmd_status(cfg: &Config, override_p: &Option) -> Result { +fn status_healthy(s: &status::Status) -> bool { + s.internet + && s.iface.is_some() + && (!s.tailscale_required || s.tailscale.as_ref().map(|h| h.is_ok()).unwrap_or(false)) +} + +fn cmd_status( + be: &dyn Backend, + cfg: &Config, + override_p: &Option, + json: bool, +) -> Result { let p = active_profile(cfg, override_p); - let s = status::gather(cfg, &p); + let s = status::gather(be, cfg, &p); + let healthy = status_healthy(&s); + + if json { + let v = serde_json::json!({ + "profile": p, + "adapter": s.iface, + "ssid": s.ssid, + "ip": s.ip, + "internet": s.internet, + "captive_portal": s.portal, + "tailscale": { + "required": s.tailscale_required, + "installed": s.tailscale.is_some(), + "ok": s.tailscale.as_ref().map(|h| h.is_ok()), + "health": s.tailscale.as_ref().map(|h| h.describe()), + "exit_node": s.exit_node, + }, + "healthy": healthy, + }); + println!( + "{}", + serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".into()) + ); + return Ok(if healthy { 0 } else { 1 }); + } let dot = |ok: bool| { if ok { @@ -248,6 +300,14 @@ fn cmd_status(cfg: &Config, override_p: &Option) -> Result dot(s.internet), if s.internet { "ok" } else { "down" } ); + if let Some(portal) = &s.portal { + let detail = if portal.is_empty() { + "sign-in required".to_string() + } else { + portal.clone() + }; + println!(" portal {C_YELLOW}captive portal{C_RESET} {C_DIM}{detail}{C_RESET}"); + } match (&s.tailscale, s.tailscale_required) { (Some(h), req) => { @@ -263,9 +323,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 { @@ -277,7 +334,13 @@ fn cmd_status(cfg: &Config, override_p: &Option) -> Result Ok(if healthy { 0 } else { 1 }) } -fn cmd_profile(cfg: &Config, action: Option) -> Result { +const CORE_PROFILES: [&str; 3] = ["home", "work", "away"]; + +fn cmd_profile( + be: &dyn Backend, + cfg: &mut Config, + action: Option, +) -> Result { match action.unwrap_or(ProfileCmd::Get) { ProfileCmd::Get => { println!("{}", State::load(&cfg.settings.default_profile).profile); @@ -291,6 +354,34 @@ fn cmd_profile(cfg: &Config, action: Option) -> Result } Ok(0) } + ProfileCmd::Add { name, detect } => { + if cfg.profiles.contains_key(&name) { + return Err(format!("profile '{name}' already exists")); + } + cfg.profiles.insert( + name.clone(), + config::Profile { + detect_ssids: detect, + ..Default::default() + }, + ); + cfg.save()?; + println!("{C_GREEN}added{C_RESET} profile {name}"); + Ok(0) + } + ProfileCmd::Remove { name } => { + if CORE_PROFILES.contains(&name.as_str()) { + return Err(format!( + "'{name}' is a core profile and is always recreated; clear its networks instead" + )); + } + if cfg.profiles.remove(&name).is_none() { + return Err(format!("unknown profile '{name}'")); + } + cfg.save()?; + println!("{C_GREEN}removed{C_RESET} profile {name}"); + Ok(0) + } ProfileCmd::Set { name, no_apply } => { if !cfg.profiles.contains_key(&name) { let avail: Vec<&String> = cfg.profiles.keys().collect(); @@ -306,40 +397,46 @@ fn cmd_profile(cfg: &Config, action: Option) -> Result if no_apply { return Ok(0); } - let outcome = flow::run(cfg, &name); + let outcome = flow::run(be, cfg, &name); print_outcome(&name, &outcome); Ok(if outcome.ok() { 0 } else { 1 }) } } } -fn detect_profile(cfg: &Config) -> Option { - let iface = nm::wifi_interface()?; - nm::radio_on(); - nm::rescan(&iface, &[]); - let visible = nm::visible_ssids(&iface); +fn detect_profile(be: &dyn Backend, cfg: &Config) -> Option { + let iface = be.wifi_interface()?; + be.radio_on(); + be.rescan(&iface, &[]); + let visible = be.visible_ssids(&iface); - // Profiles are stored in a BTreeMap so iteration order is deterministic - // (alphabetical). The caller can rely on that for tie-breaking. + // Pick the profile with the most marker SSIDs in range, so overlapping + // locations disambiguate by strength of evidence. Profiles iterate in + // BTreeMap (alphabetical) order, which deterministically breaks ties. + let mut best: Option<(usize, String)> = None; for (name, profile) in &cfg.profiles { - if profile.detect_ssids.is_empty() { - continue; - } - if profile + let score = profile .detect_ssids .iter() - .any(|s| visible.contains(s.as_str())) - { - return Some(name.clone()); + .filter(|s| visible.contains(s.as_str())) + .count(); + if score == 0 { + continue; + } + if best.as_ref().map(|(s, _)| score > *s).unwrap_or(true) { + best = Some((score, name.clone())); } } // Fall back to the default profile if no markers matched. - Some(cfg.settings.default_profile.clone()) + Some( + best.map(|(_, name)| name) + .unwrap_or_else(|| cfg.settings.default_profile.clone()), + ) } -fn cmd_detect(cfg: &Config, apply: bool) -> Result { - match detect_profile(cfg) { +fn cmd_detect(be: &dyn Backend, cfg: &Config, apply: bool) -> Result { + match detect_profile(be, cfg) { Some(p) => { println!("{p}"); if apply { @@ -348,7 +445,7 @@ fn cmd_detect(cfg: &Config, apply: bool) -> Result { updated: util::timestamp(), } .save()?; - let outcome = flow::run(cfg, &p); + let outcome = flow::run(be, cfg, &p); print_outcome(&p, &outcome); return Ok(if outcome.ok() { 0 } else { 1 }); } @@ -497,10 +594,14 @@ fn cmd_scan(cfg: &mut Config, to: Option) -> Result { } fn mask(p: &str) -> String { - if p.len() <= 2 { + // Count by characters, not bytes: slicing &p[..1] would panic on a + // multi-byte first character (valid in WPA passphrases). + let count = p.chars().count(); + if count <= 2 { "••".into() } else { - format!("{}{}", &p[..1], "•".repeat(p.len().saturating_sub(1))) + let first: String = p.chars().take(1).collect(); + format!("{}{}", first, "•".repeat(count - 1)) } } @@ -573,7 +674,12 @@ fn cmd_edit() -> Result { } } -fn cmd_doctor(cfg: &Config, override_p: &Option, full: bool) -> Result { +fn cmd_doctor( + be: &dyn Backend, + cfg: &Config, + override_p: &Option, + full: bool, +) -> Result { if full { let script = config::config_dir().join("diag.sh"); if !script.exists() { @@ -590,7 +696,7 @@ fn cmd_doctor(cfg: &Config, override_p: &Option, full: bool) -> Result String { @@ -240,6 +241,19 @@ fn enforce_dns(uuid: &str, iface: &str, dns: &str) { } } +/// Return true if `name` is NetworkManager's numbered-duplicate convention for +/// `ssid`: exactly `ssid` followed by a space and one or more decimal digits +/// (e.g. "MyNet 1", "MyNet 2"). A name like "MyNet1" (no space) is a distinct +/// SSID and must not match. +fn is_numbered_nm_duplicate(name: &str, ssid: &str) -> bool { + if let Some(suffix) = name.strip_prefix(ssid) { + if let Some(digits) = suffix.strip_prefix(' ') { + return !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit()); + } + } + false +} + /// 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. @@ -254,26 +268,58 @@ fn first_profile_for_ssid(ssid: &str) -> Option { } let mut fallback: Option = None; for line in o.stdout.lines() { - let parts: Vec<&str> = line.splitn(2, ':').collect(); - if parts.len() < 2 || !parts[1].contains("wireless") { + // NAME may itself contain an (escaped) ':', so a plain splitn would + // mis-split it — parse the line the same way nmcli escapes it. + let fields = parse_scan_line(line); + if fields.len() < 2 || !fields[1].contains("wireless") { continue; } - let name = unescape(parts[0]); + let name = fields[0].clone(); if name == ssid { return Some(name); } - 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 fallback.is_none() && is_numbered_nm_duplicate(&name, ssid) { + fallback = Some(name); } } fallback } +/// Write the PSK to a 0600 file in the `setting.property:value` format nmcli's +/// `passwd-file` expects, so the secret reaches NetworkManager without ever +/// appearing in argv (where any local user could read it via `ps`). Returns the +/// path; the caller is responsible for removing it. +fn write_psk_file(password: &str) -> Option { + use std::io::Write; + // Prefer $XDG_RUNTIME_DIR (per-user tmpfs, mode 0700, wiped on logout) for a + // transient secret; fall back to the on-disk state dir only if it's unset. + let dir = std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .unwrap_or_else(state_dir); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join(format!("breadcrumbs.psk.{}", std::process::id())); + let mut f = { + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&path) + .ok()? + } + #[cfg(not(unix))] + { + std::fs::File::create(&path).ok()? + } + }; + f.write_all(format!("802-11-wireless-security.psk:{password}\n").as_bytes()) + .ok()?; + Some(path) +} + /// 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() @@ -290,20 +336,8 @@ pub fn connect_verbose(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> R let wait_s = wait.to_string(); if let Some(profile) = first_profile_for_ssid(&net.ssid) { - // Update the saved PSK and, for hidden networks, ensure the flag is set. - if !net.password.is_empty() { - let _ = run( - "nmcli", - &[ - "connection", - "modify", - &profile, - "802-11-wireless-security.psk", - net.password.as_str(), - ], - Duration::from_secs(6), - ); - } + // Ensure the hidden flag is set — this carries no secret, so passing it + // in argv is safe. if net.hidden { let _ = run( "nmcli", @@ -317,50 +351,64 @@ 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), - ); - if !o.success { - let detail = o.stderr.trim().to_string(); - return Err(if detail.is_empty() { - o.stdout.trim().to_string() - } else { - detail - }); + // Supply the PSK to the activation via a 0600 passwd-file rather than + // argv. Harmless if NM already has a matching secret stored. + let psk_file = if net.password.is_empty() { + None + } else { + write_psk_file(&net.password) + }; + let psk_path = psk_file.as_ref().map(|p| p.display().to_string()); + let mut args: Vec<&str> = vec![ + "--wait", + &wait_s, + "connection", + "up", + &profile, + "ifname", + iface, + ]; + if let Some(ref p) = psk_path { + args.push("passwd-file"); + args.push(p.as_str()); } - if let Some(uuid) = active_uuid(iface) { - enforce_dns(&uuid, iface, dns); + let o = run("nmcli", &args, Duration::from_secs(wait as u64 + 15)); + if let Some(p) = psk_file { + let _ = std::fs::remove_file(p); } - return Ok(()); + if o.success { + if let Some(uuid) = active_uuid(iface) { + enforce_dns(&uuid, iface, dns); + } + return Ok(()); + } + // Activation failed (e.g. the saved secret is stale). Fall through to a + // fresh connect below, which re-supplies the PSK over stdin. } - // No saved profile — create one via device wifi connect. + // No saved profile (or reuse failed) — create/refresh via device wifi + // connect. `--ask` makes nmcli read the PSK from stdin instead of argv. let hidden = if net.hidden { "yes" } else { "no" }; let args = [ + "--ask", "--wait", &wait_s, "device", "wifi", "connect", net.ssid.as_str(), - "password", - net.password.as_str(), "hidden", hidden, "ifname", iface, ]; - let o = run("nmcli", &args, Duration::from_secs(wait as u64 + 15)); + let stdin = format!("{}\n", net.password); + let o = run_with_stdin( + "nmcli", + &args, + Some(&stdin), + Duration::from_secs(wait as u64 + 15), + ); if !o.success { let detail = o.stderr.trim().to_string(); return Err(if detail.is_empty() { @@ -388,15 +436,12 @@ pub fn delete_connections_for_ssid(ssid: &str) -> bool { } let mut removed = false; for line in list.stdout.lines() { - let parts: Vec<&str> = line.splitn(2, ':').collect(); - if parts.len() < 2 { - continue; - } - let name = unescape(parts[0]); - let typ = parts[1]; - if !typ.contains("wireless") { + // NAME may contain an escaped ':' — parse rather than naive-split. + let fields = parse_scan_line(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], @@ -428,6 +473,21 @@ mod tests { assert_eq!(unescape("trailing\\"), "trailing\\"); } + #[test] + fn unescape_empty_string() { + assert_eq!(unescape(""), ""); + } + + #[test] + fn unescape_multiple_consecutive_escapes() { + assert_eq!(unescape(r"a\:b\:c"), "a:b:c"); + } + + #[test] + fn unescape_double_backslash_produces_single() { + assert_eq!(unescape(r"a\\b"), r"a\b"); + } + #[test] fn parse_scan_line_splits_and_unescapes() { // SSID:SIGNAL:SECURITY with an escaped ':' inside the SSID. @@ -442,4 +502,71 @@ mod tests { let f = parse_scan_line(":40:WPA3"); assert_eq!(f, vec!["", "40", "WPA3"]); } + + #[test] + fn parse_scan_line_single_field_no_separators() { + let f = parse_scan_line("OnlySSID"); + assert_eq!(f, vec!["OnlySSID"]); + } + + #[test] + fn parse_scan_line_empty_input_yields_one_empty_field() { + let f = parse_scan_line(""); + assert_eq!(f, vec![""]); + } + + #[test] + fn parse_scan_line_all_empty_fields() { + // Three colons → four empty fields. + let f = parse_scan_line(":::"); + assert_eq!(f, vec!["", "", "", ""]); + } + + #[test] + fn parse_scan_line_multiple_escaped_colons_in_ssid() { + let f = parse_scan_line(r"a\:b\:c:80:WPA3"); + assert_eq!(f, vec!["a:b:c", "80", "WPA3"]); + } + + #[test] + fn parse_scan_line_backslash_escape_then_colon_separator() { + // "abc\:60:WPA2" — \: is an escaped colon inside the SSID, not a separator. + let f = parse_scan_line(r"abc\:60:WPA2"); + assert_eq!(f, vec!["abc:60", "WPA2"]); + } + + #[test] + fn is_numbered_nm_duplicate_exact_match_is_not_duplicate() { + assert!(!is_numbered_nm_duplicate("Net", "Net")); + } + + #[test] + fn is_numbered_nm_duplicate_space_digits_matches() { + assert!(is_numbered_nm_duplicate("Net 1", "Net")); + assert!(is_numbered_nm_duplicate("My Network 12", "My Network")); + } + + #[test] + fn is_numbered_nm_duplicate_no_space_does_not_match() { + // "Net1" is a distinct SSID, not a numbered duplicate of "Net". + assert!(!is_numbered_nm_duplicate("Net1", "Net")); + assert!(!is_numbered_nm_duplicate("HomeWifi2", "HomeWifi")); + } + + #[test] + fn is_numbered_nm_duplicate_non_numeric_suffix_does_not_match() { + assert!(!is_numbered_nm_duplicate("Net foo", "Net")); + assert!(!is_numbered_nm_duplicate("Net 1x", "Net")); + } + + #[test] + fn is_numbered_nm_duplicate_empty_digits_does_not_match() { + // "Net " (trailing space only, no digits) must not match. + assert!(!is_numbered_nm_duplicate("Net ", "Net")); + } + + #[test] + fn is_numbered_nm_duplicate_unrelated_name_does_not_match() { + assert!(!is_numbered_nm_duplicate("OtherNet 1", "Net")); + } } diff --git a/src/state.rs b/src/state.rs index 0366959..8565e0d 100644 --- a/src/state.rs +++ b/src/state.rs @@ -29,6 +29,7 @@ impl State { pub fn save(&self) -> Result<(), String> { fs::create_dir_all(state_dir()).map_err(|e| format!("creating state dir: {e}"))?; let text = toml::to_string_pretty(self).map_err(|e| format!("serializing state: {e}"))?; - fs::write(state_path(), text).map_err(|e| format!("writing state: {e}")) + crate::util::write_atomic(&state_path(), &text, 0o644) + .map_err(|e| format!("writing state: {e}")) } } diff --git a/src/status.rs b/src/status.rs index b06dea9..0deaa83 100644 --- a/src/status.rs +++ b/src/status.rs @@ -1,11 +1,31 @@ use std::time::Duration; +use crate::backend::Backend; use crate::config::Config; -use crate::nm; -use crate::tailscale::{self, TsHealth}; +use crate::tailscale::TsHealth; use crate::util::{command_exists, run}; -pub fn internet_ok(cfg: &Config) -> bool { +/// Result of a connectivity probe. `Portal` distinguishes a captive portal +/// (associated, but traffic is being intercepted) from real internet or a hard +/// outage — the optional string is the portal's sign-in URL when known. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Connectivity { + Online, + Portal(Option), + Offline, +} + +impl Connectivity { + pub fn online(&self) -> bool { + matches!(self, Connectivity::Online) + } +} + +/// Probe connectivity. Only an empty HTTP 204 from the generate_204-style +/// endpoint counts as online; a 200/redirect means a captive portal is +/// intercepting traffic. If the HTTP probe is inconclusive (timeout/5xx) we fall +/// back to ICMP, which reaching the host treats as online. +pub fn connectivity(cfg: &Config) -> Connectivity { if command_exists("curl") { let o = run( "curl", @@ -14,28 +34,45 @@ pub fn internet_ok(cfg: &Config) -> bool { "-o", "/dev/null", "-w", - "%{http_code}", + "%{http_code} %{redirect_url}", "--max-time", "4", &cfg.settings.connectivity_url, ], Duration::from_secs(6), ); - let code = o.stdout.trim(); - if code == "204" || code == "200" || code == "301" || code == "302" { - return true; + let mut parts = o.stdout.split_whitespace(); + let code = parts.next().unwrap_or(""); + let redirect = parts.next().unwrap_or("").trim(); + match code { + "204" => return Connectivity::Online, + "200" | "301" | "302" | "303" | "307" | "308" => { + let url = if redirect.is_empty() { + None + } else { + Some(redirect.to_string()) + }; + return Connectivity::Portal(url); + } + // 000/timeout/5xx → inconclusive, try ICMP below. + _ => {} } } - // Fallback: ICMP to the configured host. - run( + let ping = run( "ping", &["-c", "1", "-W", "2", &cfg.settings.ping_host], Duration::from_secs(4), ) - .success + .success; + if ping { + Connectivity::Online + } else { + Connectivity::Offline + } } -fn ipv4(iface: &str) -> Option { +/// Best-effort IPv4 address of `iface` via nmcli, with the CIDR prefix stripped. +pub fn ipv4(iface: &str) -> Option { let o = run( "nmcli", &["-g", "IP4.ADDRESS", "device", "show", iface], @@ -48,7 +85,9 @@ fn ipv4(iface: &str) -> Option { if s.is_empty() { None } else { - Some(s.lines().next().unwrap_or(s).trim().to_string()) + // nmcli reports "192.168.1.5/24"; drop the prefix length for display. + let first = s.lines().next().unwrap_or(s).trim(); + Some(first.split('/').next().unwrap_or(first).to_string()) } } @@ -57,16 +96,24 @@ pub struct Status { pub ssid: Option, pub ip: Option, pub internet: bool, + /// Set when a captive portal was detected; inner string is its URL if known. + pub portal: Option, 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 ssid = iface.as_deref().and_then(nm::active_ssid); - let ip = iface.as_deref().and_then(ipv4); - let internet = internet_ok(cfg); +pub fn gather(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Status { + let iface = be.wifi_interface(); + let ssid = iface.as_deref().and_then(|i| be.active_ssid(i)); + let ip = iface.as_deref().and_then(|i| be.ipv4(i)); + + let conn = be.connectivity(cfg); + let internet = conn.online(); + let portal = match conn { + Connectivity::Portal(url) => Some(url.unwrap_or_default()), + _ => None, + }; let prof = cfg.profile(profile_name); let ts_required = prof.map(|p| p.tailscale).unwrap_or(false); @@ -74,8 +121,8 @@ pub fn gather(cfg: &Config, profile_name: &str) -> Status { .and_then(|p| p.exit_node.clone()) .unwrap_or_else(|| cfg.settings.exit_node.clone()); - let tailscale = if tailscale::installed() { - Some(tailscale::check(&exit_node)) + let tailscale = if be.tailscale_installed() { + Some(be.tailscale_check(&exit_node)) } else { None }; @@ -85,6 +132,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 a5aa49a..79aaa34 100644 --- a/src/tailscale.rs +++ b/src/tailscale.rs @@ -313,6 +313,54 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn ts_health_is_ok_only_for_ok_variant() { + assert!(TsHealth::Ok.is_ok()); + assert!(!TsHealth::NotInstalled.is_ok()); + assert!(!TsHealth::NeedsLogin.is_ok()); + assert!(!TsHealth::Stopped.is_ok()); + assert!(!TsHealth::ExitNodeMissing.is_ok()); + assert!(!TsHealth::ExitNodeOffline.is_ok()); + assert!(!TsHealth::Error("x".into()).is_ok()); + } + + #[test] + fn ts_health_describe_covers_all_variants() { + assert_eq!(TsHealth::Ok.describe(), "ok"); + assert!(TsHealth::NotInstalled.describe().contains("not installed")); + assert!(TsHealth::NeedsLogin.describe().contains("not logged in")); + assert!(TsHealth::Stopped.describe().contains("stopped")); + assert!(TsHealth::ExitNodeMissing.describe().contains("not found")); + assert!(TsHealth::ExitNodeOffline.describe().contains("offline")); + let msg = TsHealth::Error("boom".into()).describe(); + assert!(msg.contains("boom")); + } + + #[test] + fn extract_url_finds_https_url() { + assert_eq!( + extract_url("To authenticate, visit https://login.tailscale.com/a/xxx"), + Some("https://login.tailscale.com/a/xxx".into()) + ); + } + + #[test] + fn extract_url_returns_none_when_no_url() { + assert_eq!(extract_url("Waiting for login..."), None); + assert_eq!(extract_url(""), None); + } + + #[test] + fn extract_url_picks_first_https_token() { + let line = "Try https://first.example.com https://second.example.com"; + assert_eq!(extract_url(line), Some("https://first.example.com".into())); + } + + #[test] + fn extract_url_does_not_match_plain_http() { + assert_eq!(extract_url("see http://example.com for info"), None); + } + #[test] fn backend_state_extraction() { assert_eq!( @@ -322,6 +370,13 @@ mod tests { assert_eq!(backend_state(&json!({})), ""); } + #[test] + fn backend_state_all_known_values() { + for state in ["Running", "NeedsLogin", "NoState", "Stopped"] { + assert_eq!(backend_state(&json!({"BackendState": state})), state); + } + } + #[test] fn exit_node_healthy_and_selected() { let v = json!({ @@ -381,4 +436,59 @@ mod tests { }); assert_eq!(exit_node_state(&v, "exitnode"), (true, true, false)); } + + #[test] + fn exit_node_state_empty_peer_map() { + let v = json!({ "BackendState": "Running", "Peer": {} }); + assert_eq!(exit_node_state(&v, "anynode"), (false, false, false)); + } + + #[test] + fn exit_node_state_no_peer_field() { + let v = json!({ "BackendState": "Running" }); + assert_eq!(exit_node_state(&v, "anynode"), (false, false, false)); + } + + #[test] + fn exit_node_state_case_insensitive_hostname() { + let v = json!({ + "Peer": { + "k1": { "HostName": "MYNODE", "DNSName": "mynode.ts.net.", + "Online": true, "ExitNode": true, "ExitNodeOption": true } + } + }); + let (exists, online, selected) = exit_node_state(&v, "mynode"); + assert!(exists && online && selected); + } + + #[test] + fn exit_node_status_overrides_peer_online_when_selected() { + // ExitNodeStatus.Online=false should override the peer's Online=true + // when the peer is the currently-selected exit node. + let v = json!({ + "ExitNodeStatus": { "Online": false }, + "Peer": { + "k1": { "HostName": "exitnode", "DNSName": "exitnode.ts.net.", + "Online": true, "ExitNode": true, "ExitNodeOption": true } + } + }); + let (exists, online, selected) = exit_node_state(&v, "exitnode"); + assert!(exists); + assert!( + !online, + "ExitNodeStatus.Online=false should override peer Online" + ); + assert!(selected); + } + + #[test] + fn exit_node_state_wrong_node_name_not_matched() { + let v = json!({ + "Peer": { + "k1": { "HostName": "othernode", "DNSName": "othernode.ts.net.", + "Online": true, "ExitNode": true, "ExitNodeOption": true } + } + }); + assert_eq!(exit_node_state(&v, "exitnode"), (false, false, false)); + } } diff --git a/src/util.rs b/src/util.rs index 04f3740..db9bc8d 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,5 +1,6 @@ +use std::fs; use std::io::{Read, Write}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -10,6 +11,42 @@ pub fn home_dir() -> PathBuf { .unwrap_or_else(|| PathBuf::from("/root")) } +/// Atomically replace `path` with `contents`: write a sibling temp file (created +/// with `mode` on unix) and `rename` it over the target. Avoids torn reads by a +/// concurrent reader (the watch daemon reloads config every tick) and never +/// leaves a half-written file behind on crash. Because the temp file is created +/// with `mode` up front, secrets never exist world-readable even briefly. +pub fn write_atomic(path: &Path, contents: &str, mode: u32) -> std::io::Result<()> { + let dir = path.parent().unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(dir)?; + let stem = path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("breadcrumbs"); + let tmp = dir.join(format!(".{stem}.tmp.{}", std::process::id())); + + let mut open = fs::OpenOptions::new(); + open.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + open.mode(mode); + } + #[cfg(not(unix))] + let _ = mode; + + let res = (|| { + let mut f = open.open(&tmp)?; + f.write_all(contents.as_bytes())?; + f.sync_all()?; + fs::rename(&tmp, path) + })(); + if res.is_err() { + let _ = fs::remove_file(&tmp); + } + res +} + pub fn command_exists(name: &str) -> bool { if let Some(paths) = std::env::var_os("PATH") { for dir in std::env::split_paths(&paths) { @@ -55,6 +92,11 @@ pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: D }; let mut child = match Command::new(prog) .args(args) + // Pin the C locale so message text we parse (nmcli states, monitor + // lines) is stable English regardless of the user's LANG. SSID/value + // bytes are unaffected. + .env("LC_ALL", "C") + .env("LANG", "C") .stdin(stdin_cfg) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -64,13 +106,6 @@ pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: D 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(); @@ -89,6 +124,16 @@ pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: D buf }); + // Feed stdin only after the reader threads are draining stdout/stderr, so a + // child that writes more than a pipe buffer before consuming stdin can't + // deadlock against our blocking write. + 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() { @@ -176,4 +221,58 @@ mod tests { // Leap day 2024-02-29 12:00:00 UTC assert_eq!(fmt_epoch(1_709_208_000), "2024-02-29 12:00:00"); } + + #[test] + fn fmt_epoch_year_2000_century_divisible_by_400_leap() { + // 2000-01-01 00:00:00 UTC — divisible by 400, so it IS a leap year. + assert_eq!(fmt_epoch(946_684_800), "2000-01-01 00:00:00"); + } + + #[test] + fn fmt_epoch_end_of_year_boundary() { + // 2023-12-31 23:59:59 UTC + assert_eq!(fmt_epoch(1_704_067_199), "2023-12-31 23:59:59"); + } + + #[test] + fn fmt_epoch_negative_before_unix_epoch() { + // 1969-12-31 23:59:59 UTC + assert_eq!(fmt_epoch(-1), "1969-12-31 23:59:59"); + // 1969-12-31 00:00:00 UTC + assert_eq!(fmt_epoch(-86_400), "1969-12-31 00:00:00"); + } + + #[test] + fn fmt_epoch_february_non_leap_year_boundary() { + // 2023-02-28 00:00:00 UTC (2023 is not a leap year) + assert_eq!(fmt_epoch(1_677_542_400), "2023-02-28 00:00:00"); + // 2023-03-01 00:00:00 UTC — next day after Feb 28 in non-leap year + assert_eq!(fmt_epoch(1_677_628_800), "2023-03-01 00:00:00"); + } + + #[test] + fn fmt_epoch_century_non_leap_year_1900_equivalent() { + // 1900 is NOT a leap year (div by 100 but not 400). + // 1900-03-01 00:00:00 UTC: days from epoch = (1900-1970)*365.25 ≈ use known anchor. + // 2100-02-28 00:00:00 UTC = epoch 4107456000; next day is Mar 1 (not Feb 29). + // We verify via the leap day boundary: 2100-02-28 + 86400 must be 2100-03-01. + assert_eq!(fmt_epoch(4_107_456_000), "2100-02-28 00:00:00"); + assert_eq!(fmt_epoch(4_107_456_000 + 86_400), "2100-03-01 00:00:00"); + } + + #[test] + fn fmt_epoch_midnight_vs_end_of_day() { + // 2022-06-15 00:00:00 UTC + assert_eq!(fmt_epoch(1_655_251_200), "2022-06-15 00:00:00"); + // 2022-06-15 23:59:59 UTC + assert_eq!(fmt_epoch(1_655_337_599), "2022-06-15 23:59:59"); + } + + #[test] + fn fmt_epoch_time_of_day_components() { + // 1970-01-01 01:02:03 UTC + assert_eq!(fmt_epoch(3723), "1970-01-01 01:02:03"); + // 1970-01-01 23:59:59 UTC + assert_eq!(fmt_epoch(86_399), "1970-01-01 23:59:59"); + } } diff --git a/src/watch.rs b/src/watch.rs index 5290239..30f2360 100644 --- a/src/watch.rs +++ b/src/watch.rs @@ -4,6 +4,7 @@ use std::sync::mpsc::{self, Receiver}; use std::thread; use std::time::{Duration, Instant}; +use crate::backend::{Backend, System}; use crate::config::Config; use crate::flow; use crate::notify::{log, notify, Urgency}; @@ -15,32 +16,34 @@ use crate::tailscale::TsHealth; enum Health { Up, DownNoNet, + /// Associated, but a captive portal is intercepting traffic (manual login). + CaptivePortal, DownTailscaleManual, DownTailscaleOther, NoAdapter, } -fn classify(cfg: &Config, profile: &str) -> (Health, Option) { - let s = status::gather(cfg, profile); - if s.iface.is_none() { - return (Health::NoAdapter, None); - } - let ssid = s.ssid.clone(); - if !s.internet { - return (Health::DownNoNet, ssid); - } - if s.tailscale_required { +fn classify(be: &dyn Backend, cfg: &Config, profile: &str) -> (Health, status::Status) { + let s = status::gather(be, cfg, profile); + let health = if s.iface.is_none() { + Health::NoAdapter + } else if s.portal.is_some() { + Health::CaptivePortal + } else if !s.internet { + Health::DownNoNet + } else if s.tailscale_required { match s.tailscale { - Some(TsHealth::Ok) => (Health::Up, ssid), + Some(TsHealth::Ok) => Health::Up, Some(TsHealth::NeedsLogin) | Some(TsHealth::NotInstalled) => { - (Health::DownTailscaleManual, ssid) + Health::DownTailscaleManual } - Some(_) => (Health::DownTailscaleOther, ssid), - None => (Health::DownTailscaleManual, ssid), + Some(_) => Health::DownTailscaleOther, + None => Health::DownTailscaleManual, } } else { - (Health::Up, ssid) - } + Health::Up + }; + (health, s) } /// Tail `nmcli monitor` and ping the channel on link-state churn so we react @@ -105,17 +108,18 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { let (tx, rx) = mpsc::channel::<()>(); spawn_nm_monitor(tx); + let be = System; 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); + let (h, _) = classify(&be, &cfg, &profile); if h == Health::Up { log(&format!( "watch: already healthy on start (profile={profile}); skipping initial flow" )); } else { log(&format!("watch: initial flow for profile={profile}")); - let _ = flow::run(&cfg, &profile); + let _ = flow::run(&be, &cfg, &profile); } } @@ -147,7 +151,8 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { last_flow_at = None; // allow immediate recovery on profile change } - let (health, ssid) = classify(&cfg, &profile); + let (health, s) = classify(&be, &cfg, &profile); + let ssid = s.ssid.clone(); let transition = prev_health.as_ref() != Some(&health); match &health { @@ -164,6 +169,18 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { } fail_streak = 0; } + Health::CaptivePortal => { + // Associated but gated behind a sign-in page we can't automate; + // notify once and don't hammer flow (reconnecting won't help). + if transition { + let body = match s.portal.as_deref().filter(|u| !u.is_empty()) { + Some(url) => format!("Sign in to continue: {url}"), + None => format!("Sign in to continue ({profile})."), + }; + notify("breadcrumbs: captive portal", &body, Urgency::Normal); + } + fail_streak = 0; + } Health::NoAdapter => { if transition { notify( @@ -186,7 +203,8 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { } // Re-run flow only on transition so we land on the bootstrap net. if transition || profile_changed { - let _ = flow::run(&cfg, &profile); + let _ = flow::run(&be, &cfg, &profile); + last_flow_at = Some(Instant::now()); } fail_streak = fail_streak.saturating_add(1); } @@ -206,7 +224,7 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { "watch: down ({:?}) profile={profile} ssid={:?} — running flow", health, ssid )); - let outcome = flow::run(&cfg, &profile); + let outcome = flow::run(&be, &cfg, &profile); log(&format!("watch: recovery outcome = {:?}", outcome)); last_flow_at = Some(Instant::now()); fail_streak = if outcome.ok() { diff --git a/tests/cli.rs b/tests/cli.rs index 297173a..3722709 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -142,3 +142,350 @@ fn unknown_profile_override_is_reported() { let o = sb.cmd(&["--profile", "nope", "init"]); assert!(!o.status.success()); } + +#[test] +fn profile_list_marks_active_profile() { + let sb = Sandbox::new(); + + // Default profile is "away". + let o = sb.cmd(&["profile", "list"]); + assert!(o.status.success()); + let out = stdout(&o); + // The active profile line starts with "* ". + assert!( + out.lines().any(|l| l.starts_with("* away")), + "active profile marker missing: {out}" + ); + // Inactive profiles start with " ". + assert!( + out.lines().any(|l| l.starts_with(" home")), + "inactive profile format wrong: {out}" + ); + + // After switching to "work" the marker should move. + sb.cmd(&["profile", "set", "work", "--no-apply"]); + let o = sb.cmd(&["profile", "list"]); + let out = stdout(&o); + assert!(out.lines().any(|l| l.starts_with("* work"))); + assert!(out.lines().any(|l| l.starts_with(" away"))); +} + +#[test] +fn add_saves_network_to_config() { + let sb = Sandbox::new(); + + let o = sb.cmd(&["add", "CafeWifi", "mypassword"]); + assert!( + o.status.success(), + "stderr: {}", + String::from_utf8_lossy(&o.stderr) + ); + assert!(stdout(&o).contains("saved")); + + // The network should now appear in the config file. + let text = fs::read_to_string(sb.config_file()).unwrap(); + assert!(text.contains("CafeWifi"), "SSID missing from config"); + assert!(text.contains("mypassword"), "password missing from config"); +} + +#[test] +fn add_attaches_ssid_to_profile_at_position() { + let sb = Sandbox::new(); + + // First add without attaching, then add again with --to. + sb.cmd(&["add", "HomeWifi", "pw1"]); + let o = sb.cmd(&["add", "HomeWifi", "pw1", "--to", "home"]); + assert!( + o.status.success(), + "stderr: {}", + String::from_utf8_lossy(&o.stderr) + ); + + let text = fs::read_to_string(sb.config_file()).unwrap(); + // After attaching, the home profile's networks list should contain HomeWifi. + assert!( + text.contains("HomeWifi"), + "SSID not found in config after --to: {text}" + ); +} + +#[test] +fn add_to_unknown_profile_fails() { + let sb = Sandbox::new(); + let o = sb.cmd(&["add", "SomeNet", "pw", "--to", "nonexistent"]); + assert!(!o.status.success()); +} + +#[test] +fn add_updates_existing_network_password() { + let sb = Sandbox::new(); + + sb.cmd(&["add", "MyNet", "oldpass"]); + let o = sb.cmd(&["add", "MyNet", "newpass"]); + assert!(o.status.success()); + + let text = fs::read_to_string(sb.config_file()).unwrap(); + assert!(text.contains("newpass"), "updated password missing"); + // Old password must be gone. + assert!(!text.contains("oldpass"), "old password still present"); + // Only one entry for MyNet. + assert_eq!( + text.matches("MyNet").count(), + 1, + "duplicate network entries" + ); +} + +#[test] +fn forget_removes_network_from_config() { + let sb = Sandbox::new(); + + sb.cmd(&["add", "ToDelete", "pw"]); + let text = fs::read_to_string(sb.config_file()).unwrap(); + assert!(text.contains("ToDelete")); + + let o = sb.cmd(&["forget", "ToDelete"]); + assert!(o.status.success()); + assert!(stdout(&o).contains("forgot")); + + let text = fs::read_to_string(sb.config_file()).unwrap(); + assert!( + !text.contains("ToDelete"), + "network still in config after forget" + ); +} + +#[test] +fn forget_nonexistent_network_is_graceful() { + let sb = Sandbox::new(); + // Should succeed (idempotent) even if the SSID was never saved. + let o = sb.cmd(&["forget", "NeverSaved"]); + assert!(o.status.success()); +} + +#[test] +fn forget_removes_ssid_from_profile_networks_list() { + let sb = Sandbox::new(); + + // Add "WorkNet" and attach it to the "work" profile. + sb.cmd(&["add", "WorkNet", "pw", "--to", "work"]); + let text = fs::read_to_string(sb.config_file()).unwrap(); + assert!(text.contains("WorkNet")); + + sb.cmd(&["forget", "WorkNet"]); + + let text = fs::read_to_string(sb.config_file()).unwrap(); + assert!( + !text.contains("WorkNet"), + "SSID still in profile after forget" + ); +} + +#[test] +fn list_masks_passwords_by_default() { + let sb = Sandbox::new(); + sb.cmd(&["add", "SecretNet", "hunter2"]); + + let o = sb.cmd(&["list"]); + assert!(o.status.success()); + let out = stdout(&o); + assert!( + !out.contains("hunter2"), + "plain-text password exposed in list" + ); + // A masking bullet should appear. + assert!(out.contains('•'), "no masking character in list output"); +} + +#[test] +fn list_masks_multibyte_password_without_panicking() { + let sb = Sandbox::new(); + // A password whose first character is multi-byte UTF-8 used to panic the + // byte-slicing mask(); list must mask it cleanly instead. + sb.cmd(&["add", "UnicodeNet", "ñoño-café-🔐"]); + + let o = sb.cmd(&["list"]); + assert!( + o.status.success(), + "list crashed on multibyte password: {}", + String::from_utf8_lossy(&o.stderr) + ); + let out = stdout(&o); + assert!( + !out.contains("ñoño-café-🔐"), + "password leaked in masked list" + ); + assert!(out.contains('•'), "no masking character in list output"); +} + +#[test] +fn list_show_passwords_reveals_password() { + let sb = Sandbox::new(); + sb.cmd(&["add", "SecretNet", "hunter2"]); + + let o = sb.cmd(&["list", "--show-passwords"]); + assert!(o.status.success()); + assert!( + stdout(&o).contains("hunter2"), + "password not shown with --show-passwords" + ); +} + +#[test] +fn cd_prints_config_directory() { + let sb = Sandbox::new(); + // Trigger config creation first. + sb.cmd(&["list"]); + + let o = sb.cmd(&["cd"]); + assert!(o.status.success()); + let out = stdout(&o).trim().to_string(); + assert!(!out.is_empty(), "cd produced no output"); + // The printed path must end with "breadcrumbs" (the config subdirectory). + assert!(out.ends_with("breadcrumbs"), "unexpected config dir: {out}"); +} + +#[test] +fn doctor_runs_without_crashing() { + let sb = Sandbox::new(); + // With an empty PATH, nmcli/tailscale are absent. Doctor should report + // "MISSING"/"absent" but still exit successfully (it's a diag tool). + let o = sb.cmd(&["doctor"]); + assert!( + o.status.success(), + "stderr: {}", + String::from_utf8_lossy(&o.stderr) + ); + let out = stdout(&o); + assert!(out.contains("nmcli"), "doctor missing nmcli line"); + assert!(out.contains("tailscale"), "doctor missing tailscale line"); +} + +#[test] +fn status_exits_nonzero_when_unhealthy() { + let sb = Sandbox::new(); + // No nmcli/tailscale → internet check fails → unhealthy → exit code 1. + let o = sb.cmd(&["status"]); + assert!( + !o.status.success(), + "expected non-zero exit for unhealthy status" + ); + let out = stdout(&o); + assert!( + out.contains("breadcrumbs"), + "missing header in status output" + ); + assert!( + out.contains("profile"), + "missing profile line in status output" + ); +} + +#[test] +fn profile_override_flag_does_not_persist() { + let sb = Sandbox::new(); + + // Set profile to "home" persistently. + sb.cmd(&["profile", "set", "home", "--no-apply"]); + + // Use --profile flag to override for a single run (status). + let o = sb.cmd(&["--profile", "work", "status"]); + // Status exits non-zero (no network), but it should show the overridden profile. + let out = stdout(&o); + assert!(out.contains("work"), "override profile not shown in status"); + + // The persistent profile must still be "home". + let o = sb.cmd(&["profile", "get"]); + assert_eq!(stdout(&o).trim(), "home"); +} + +#[test] +fn add_hidden_flag_is_persisted() { + let sb = Sandbox::new(); + let o = sb.cmd(&["add", "HiddenNet", "pw", "--hidden"]); + assert!(o.status.success()); + + let text = fs::read_to_string(sb.config_file()).unwrap(); + assert!(text.contains("hidden = true"), "hidden flag not persisted"); +} + +#[test] +fn status_json_is_valid_and_machine_readable() { + let sb = Sandbox::new(); + // No nmcli/tailscale → unhealthy, exit 1, but JSON must still be valid. + let o = sb.cmd(&["status", "--json"]); + assert!( + !o.status.success(), + "expected non-zero exit for unhealthy status" + ); + let v: serde_json::Value = + serde_json::from_str(&stdout(&o)).expect("status --json did not emit valid JSON"); + assert_eq!(v["profile"], "away"); + assert_eq!(v["internet"], false); + assert_eq!(v["healthy"], false); + assert!(v["tailscale"].is_object()); +} + +#[test] +fn profile_add_persists_detect_ssids_and_remove_deletes() { + let sb = Sandbox::new(); + + let o = sb.cmd(&[ + "profile", "add", "lab", "--detect", "LabWifi", "--detect", "LabGuest", + ]); + assert!( + o.status.success(), + "stderr: {}", + String::from_utf8_lossy(&o.stderr) + ); + + let text = fs::read_to_string(sb.config_file()).unwrap(); + assert!( + text.contains("[profiles.lab]"), + "profile not written: {text}" + ); + assert!( + text.contains("LabWifi") && text.contains("LabGuest"), + "detect ssids missing" + ); + + // Adding the same profile again is rejected. + assert!(!sb.cmd(&["profile", "add", "lab"]).status.success()); + + // Remove it. + let o = sb.cmd(&["profile", "remove", "lab"]); + assert!(o.status.success()); + let text = fs::read_to_string(sb.config_file()).unwrap(); + assert!( + !text.contains("[profiles.lab]"), + "profile still present after remove" + ); +} + +#[test] +fn profile_remove_core_is_rejected() { + let sb = Sandbox::new(); + let o = sb.cmd(&["profile", "remove", "home"]); + assert!(!o.status.success(), "removing a core profile should fail"); +} + +#[test] +fn install_service_writes_unit_file() { + let sb = Sandbox::new(); + // Install but don't enable (systemctl is absent in the empty PATH, but + // writing the unit file should succeed regardless). + let o = sb.cmd(&["install-service", "--no-enable"]); + assert!( + o.status.success(), + "stderr: {}", + String::from_utf8_lossy(&o.stderr) + ); + let unit = sb.root.join(".config/systemd/user/breadcrumbs.service"); + assert!(unit.exists(), "unit file not written at {}", unit.display()); + let content = fs::read_to_string(&unit).unwrap(); + assert!(content.contains("ExecStart="), "unit missing ExecStart"); + assert!( + content.contains("breadcrumbs watch"), + "unit missing watch subcommand" + ); +} From 0fdac8e07c2f87f03bab5d9d8d568c8139b32e91 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 24 Jun 2026 07:04:31 +0800 Subject: [PATCH 2/2] Add join, networks, and scan-list commands; bump to v2.1.1 --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/main.rs | 76 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index afa4c8a..441dae3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -54,7 +54,7 @@ dependencies = [ [[package]] name = "breadcrumbs" -version = "2.1.0" +version = "2.1.1" dependencies = [ "clap", "serde", diff --git a/Cargo.toml b/Cargo.toml index 8b52159..b48ab9c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadcrumbs" -version = "2.1.0" +version = "2.1.1" edition = "2021" description = "Profile-aware Wi-Fi state machine with Tailscale handling and self-healing watch daemon" license = "MIT" diff --git a/src/main.rs b/src/main.rs index 86cf93d..3e178c0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -99,6 +99,20 @@ enum Cmd { #[arg(long)] show_passwords: bool, }, + /// Connect to a specific saved network by SSID, bypassing profile routing + Join { ssid: String }, + /// List saved network SSIDs + Networks { + /// Emit a JSON array instead of one-per-line + #[arg(long)] + json: bool, + }, + /// Scan for visible networks and list them with signal strength + ScanList { + /// Emit JSON instead of human-readable output + #[arg(long)] + json: bool, + }, /// Open the config file in $EDITOR Edit, /// Quick connectivity / Tailscale diagnostics @@ -192,6 +206,9 @@ fn real_main(cli: Cli) -> Result { at, } => cmd_add(&mut cfg, ssid, password, hidden, to, at), Cmd::Forget { ssid } => cmd_forget(&mut cfg, &ssid), + Cmd::Join { ssid } => cmd_join(&be, &cfg, &ssid), + Cmd::Networks { json } => cmd_networks(&cfg, json), + Cmd::ScanList { json } => cmd_scan_list(&cfg, json), Cmd::Scan { to } => cmd_scan(&mut cfg, to), Cmd::List { show_passwords } => cmd_list(&cfg, show_passwords), Cmd::Edit => cmd_edit(), @@ -514,6 +531,65 @@ fn cmd_add( Ok(0) } +fn cmd_join(be: &dyn Backend, cfg: &Config, ssid: &str) -> Result { + let net = cfg + .network(ssid) + .ok_or_else(|| format!("no saved network '{ssid}' — add it first with `breadcrumbs add {ssid}`"))?; + let iface = be + .wifi_interface() + .ok_or_else(|| "no Wi-Fi adapter found".to_string())?; + be.radio_on(); + match nm::connect_verbose(&iface, net, cfg.settings.nmcli_wait, &cfg.settings.dns) { + Ok(()) => { + println!("{C_GREEN}connected{C_RESET} {C_BOLD}{ssid}{C_RESET}"); + Ok(0) + } + Err(e) => { + eprintln!("{C_RED}connect failed{C_RESET}: {e}"); + Ok(1) + } + } +} + +fn cmd_scan_list(cfg: &Config, json: bool) -> Result { + let iface = nm::wifi_interface().ok_or("no Wi-Fi adapter found")?; + let entries = nm::scan_list(&iface); + let saved: std::collections::HashSet<&str> = + cfg.networks.iter().map(|n| n.ssid.as_str()).collect(); + if json { + let v: Vec = entries + .iter() + .map(|e| { + serde_json::json!({ + "ssid": e.ssid, + "signal": e.signal, + "security": e.security, + "saved": saved.contains(e.ssid.as_str()), + }) + }) + .collect(); + println!("{}", serde_json::to_string(&v).unwrap_or_else(|_| "[]".into())); + } else { + for e in &entries { + let mark = if saved.contains(e.ssid.as_str()) { "*" } else { " " }; + println!("{mark} {:>3}% {} {}", e.signal, e.ssid, e.security); + } + } + Ok(0) +} + +fn cmd_networks(cfg: &Config, json: bool) -> Result { + let ssids: Vec<&str> = cfg.networks.iter().map(|n| n.ssid.as_str()).collect(); + if json { + println!("{}", serde_json::to_string(&ssids).unwrap_or_else(|_| "[]".into())); + } else { + for ssid in &ssids { + println!("{ssid}"); + } + } + Ok(0) +} + fn cmd_forget(cfg: &mut Config, ssid: &str) -> Result { let before = cfg.networks.len(); cfg.networks.retain(|n| n.ssid != ssid);