diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml deleted file mode 100644 index c019ff9..0000000 --- a/.forgejo/workflows/mirror.yml +++ /dev/null @@ -1,21 +0,0 @@ -name: Mirror to GitHub - -on: - push: - branches: ['**'] - tags: ['**'] - -jobs: - mirror: - runs-on: [self-hosted, hestia] - steps: - - name: Mirror to GitHub - run: | - set -euo pipefail - git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git - cd repo.git - # Mirror only branches and tags (not refs/pull/*, which GitHub rejects); - # --prune deletes GitHub refs that no longer exist on Forgejo. - git push --prune \ - "https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadcrumbs.git" \ - '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml deleted file mode 100644 index 1b86c83..0000000 --- a/.forgejo/workflows/package.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Build and publish package - -on: - push: - tags: ['v*'] - -jobs: - package: - runs-on: [self-hosted, hestia] - container: - image: archlinux:latest - steps: - # Note: no actions/checkout — the archlinux image has no Node, which JS - # actions require. Everything runs as shell steps and clones manually. - - name: Build and publish - env: - PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }} - run: | - set -euo pipefail - VERSION="${GITHUB_REF_NAME#v}" - pacman -Syu --noconfirm base-devel git rust cargo networkmanager - useradd -m builder - git config --global --add safe.directory '*' - git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ - "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src - cd /home/builder/src - git archive --format=tar.gz --prefix="breadcrumbs-${VERSION}/" HEAD \ - > packaging/arch/breadcrumbs-${VERSION}.tar.gz - SHA=$(sha256sum packaging/arch/breadcrumbs-${VERSION}.tar.gz | awk '{print $1}') - sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD - sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD - chown -R builder:builder /home/builder/src - # --nocheck: packaging builds the artifact; tests belong in a CI job. - su builder -c "cd /home/builder/src/packaging/arch && makepkg -f --noconfirm --nocheck" - PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1) - curl -fsS -X PUT \ - -H "Authorization: token ${PUBLISH_TOKEN}" \ - -H "Content-Type: application/octet-stream" \ - --data-binary "@${PKG}" \ - "https://git.breadway.dev/api/packages/Breadway/arch/os" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2def9df..1fa3722 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,7 @@ permissions: env: DL_DIR: /srv/breadway-dl - ECOSYSTEM_DIR: /tmp/bread-ecosystem-ci + ECOSYSTEM_DIR: /home/breadway/Projects/bread-ecosystem jobs: build: @@ -17,6 +17,9 @@ 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 @@ -34,12 +37,16 @@ jobs: > "${PKG_DIR}/breadcrumbs-x86_64.sha256" cp breadcrumbs.example.toml "${PKG_DIR}/" cp bakery.toml "${PKG_DIR}/bakery.toml" - ln -sfn "${VERSION}" "${DL_DIR}/breadcrumbs/latest" + ln -sfn "${PKG_DIR}" "${DL_DIR}/breadcrumbs/latest" - name: ensure bread-ecosystem run: | - rm -rf "${ECOSYSTEM_DIR}" - git clone https://github.com/Breadway/bread-ecosystem.git "${ECOSYSTEM_DIR}" + if [[ -d "${ECOSYSTEM_DIR}/.git" ]]; then + git -C "${ECOSYSTEM_DIR}" pull --ff-only + else + mkdir -p "$(dirname "${ECOSYSTEM_DIR}")" + git clone https://github.com/Breadway/bread-ecosystem.git "${ECOSYSTEM_DIR}" + fi - name: regenerate index.json run: bash "${ECOSYSTEM_DIR}/scripts/gen-index.sh" diff --git a/Cargo.lock b/Cargo.lock index 441dae3..06c815b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -54,7 +54,7 @@ dependencies = [ [[package]] name = "breadcrumbs" -version = "2.1.1" +version = "2.0.0" dependencies = [ "clap", "serde", diff --git a/Cargo.toml b/Cargo.toml index b48ab9c..16d3717 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "breadcrumbs" -version = "2.1.1" +version = "2.0.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 be1bd9d..8f55369 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,8 @@ 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 (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 +- **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 - **Desktop notifications** via `notify-send` (optional) - **systemd user service** generation via `breadcrumbs install-service` @@ -27,7 +25,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: @@ -97,14 +95,12 @@ breadcrumbs [--profile ] | Command | Description | |---------|-------------| -| `status [--json]` | Show current Wi-Fi / Tailscale health (default); `--json` for scripts | +| `status` | Show current Wi-Fi / Tailscale health (default) | | `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/bakery.toml b/bakery.toml index d26fd8e..70c256f 100644 --- a/bakery.toml +++ b/bakery.toml @@ -2,7 +2,6 @@ name = "breadcrumbs" description = "Profile-aware Wi-Fi state machine with Tailscale integration" binaries = ["breadcrumbs"] system_deps = ["networkmanager"] -optional_system_deps = ["tailscale", "sudo", "xdg-utils"] bread_deps = [] [config] diff --git a/breadcrumbs.example.toml b/breadcrumbs.example.toml index 6d42e44..ead73f0 100644 --- a/breadcrumbs.example.toml +++ b/breadcrumbs.example.toml @@ -11,8 +11,6 @@ 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 deleted file mode 100644 index 8b84450..0000000 --- a/packaging/arch/PKGBUILD +++ /dev/null @@ -1,36 +0,0 @@ -# Maintainer: Breadway - -pkgname=breadcrumbs -pkgver=2.1.0 -pkgrel=1 -pkgdesc="Profile-aware Wi-Fi state machine with Tailscale integration" -arch=('x86_64') -url="https://github.com/Breadway/breadcrumbs" -license=('MIT') -# Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's -# default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read, -# causing undefined-symbol errors. Disable LTO. -options=(!lto !debug) -depends=('networkmanager') -optdepends=( - 'tailscale: Tailscale VPN profile integration' -) -makedepends=('rust' 'cargo') -source=("${pkgname}-${pkgver}.tar.gz") -sha256sums=('SKIP') - -build() { - cd "${srcdir}/${pkgname}-${pkgver}" - cargo build --release --locked -} - -check() { - cd "${srcdir}/${pkgname}-${pkgver}" - cargo test --release --locked -} - -package() { - cd "${srcdir}/${pkgname}-${pkgver}" - install -Dm755 target/release/breadcrumbs "${pkgdir}/usr/bin/breadcrumbs" - install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" -} diff --git a/src/backend.rs b/src/backend.rs deleted file mode 100644 index 48511cb..0000000 --- a/src/backend.rs +++ /dev/null @@ -1,80 +0,0 @@ -//! 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 da094aa..c258d31 100644 --- a/src/config.rs +++ b/src/config.rs @@ -158,10 +158,13 @@ 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(); - // 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()))?; + 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)); + } Ok(()) } } @@ -271,124 +274,4 @@ 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 0261496..71f33f6 100644 --- a/src/flow.rs +++ b/src/flow.rs @@ -1,8 +1,8 @@ -use crate::backend::Backend; use crate::config::{Config, NetworkDef}; -use crate::notify::Urgency; -use crate::status::Connectivity; -use crate::tailscale::TsHealth; +use crate::nm; +use crate::notify::{log, notify, Urgency}; +use crate::status::internet_ok; +use crate::tailscale::{self, TsHealth}; #[derive(Debug)] pub enum Outcome { @@ -48,35 +48,20 @@ 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( - 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) { +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()); } 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(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Outcome { +pub fn run(cfg: &Config, profile_name: &str) -> Outcome { let profile = match cfg.profile(profile_name) { Some(p) => p.clone(), None => { - be.notify( + notify( "breadcrumbs: unknown profile", &format!("'{profile_name}' is not defined in breadcrumbs.toml"), Urgency::Critical, @@ -85,10 +70,10 @@ pub fn run(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Outcome { } }; - let iface = match be.wifi_interface() { + let iface = match nm::wifi_interface() { Some(i) => i, None => { - be.notify( + notify( "breadcrumbs: no Wi-Fi adapter", "Hardware issue — Wi-Fi device not found. Manual check needed.", Urgency::Critical, @@ -96,7 +81,7 @@ pub fn run(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Outcome { return Outcome::NoInterface; } }; - be.radio_on(); + nm::radio_on(); let exit_node = profile .exit_node @@ -104,7 +89,7 @@ pub fn run(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Outcome { .unwrap_or_else(|| cfg.settings.exit_node.clone()); let candidates = resolve_candidates(cfg, &profile); - be.log(&format!( + log(&format!( "flow start: profile={profile_name} iface={iface} tailscale={} candidates=[{}]", profile.tailscale, candidates @@ -119,8 +104,8 @@ pub fn run(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Outcome { if let Some(bs) = &profile.bootstrap { scan_targets.push(bs.clone()); } - be.rescan(&iface, &scan_targets); - let visible = be.visible_ssids(&iface); + nm::rescan(&iface, &scan_targets); + let visible = nm::visible_ssids(&iface); // ---- Tailscale-gated profiles (e.g. school) ------------------------- let mut on_bootstrap = false; @@ -129,29 +114,27 @@ pub fn run(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Outcome { match cfg.network(&bs_ssid) { Some(bdef) => { if visible.contains(&bdef.ssid) || bdef.hidden { - match connect_and_verify(be, &iface, bdef, cfg) { + match connect_and_verify(&iface, bdef, cfg) { Ok(()) => { on_bootstrap = true; - be.log(&format!("bootstrap connected: {}", bdef.ssid)); - } - Err(e) => { - be.log(&format!("bootstrap connect failed: {} — {e}", bdef.ssid)) + log(&format!("bootstrap connected: {}", bdef.ssid)); } + Err(e) => log(&format!("bootstrap connect failed: {} — {e}", bdef.ssid)), } } else { - be.log(&format!("bootstrap not in range: {}", bdef.ssid)); + log(&format!("bootstrap not in range: {}", bdef.ssid)); } } - None => be.log(&format!( + None => log(&format!( "bootstrap SSID '{bs_ssid}' has no credentials in config" )), } } - let ts = be.ensure_exit_node(&exit_node); + let ts = tailscale::ensure_exit_node(&exit_node); if !ts.is_ok() { - let ssid = be.active_ssid(&iface).or_else(|| profile.bootstrap.clone()); - be.notify( + let ssid = nm::active_ssid(&iface).or_else(|| profile.bootstrap.clone()); + notify( "Tailscale Error", &format!( "{} — staying on {}", @@ -162,12 +145,12 @@ pub fn run(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Outcome { ); return Outcome::TailscaleError { ssid, health: ts }; } - be.log(&format!("tailscale healthy via exit node {exit_node}")); + log(&format!("tailscale healthy via exit node {exit_node}")); // Refresh visibility before moving to the target network. - be.rescan(&iface, &scan_targets); + nm::rescan(&iface, &scan_targets); } - let visible = be.visible_ssids(&iface); + let visible = nm::visible_ssids(&iface); // ---- Connect to the priority list ---------------------------------- // Pass 1: visible networks in priority order. @@ -175,16 +158,20 @@ pub fn run(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Outcome { for def in &candidates { if visible.contains(&def.ssid) { any_attempted = true; - match connect_and_verify(be, &iface, def, cfg) { + match connect_and_verify(&iface, def, cfg) { Ok(()) => { - let note = connectivity_note(be, cfg); - finish_connected(be, &def.ssid, profile_name, ¬e); + let note = if internet_ok(cfg) { + None + } else { + Some("associated but no internet yet".to_string()) + }; + finish_connected(&def.ssid, profile_name, ¬e); return Outcome::Connected { ssid: def.ssid.clone(), note, }; } - Err(e) => be.log(&format!("connect failed (visible): {} — {e}", def.ssid)), + Err(e) => log(&format!("connect failed (visible): {} — {e}", def.ssid)), } } } @@ -192,16 +179,20 @@ pub fn run(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Outcome { for def in &candidates { if def.hidden && !visible.contains(&def.ssid) { any_attempted = true; - match connect_and_verify(be, &iface, def, cfg) { + match connect_and_verify(&iface, def, cfg) { Ok(()) => { - let note = connectivity_note(be, cfg); - finish_connected(be, &def.ssid, profile_name, ¬e); + let note = if internet_ok(cfg) { + None + } else { + Some("associated but no internet yet".to_string()) + }; + finish_connected(&def.ssid, profile_name, ¬e); return Outcome::Connected { ssid: def.ssid.clone(), note, }; } - Err(e) => be.log(&format!("connect failed (hidden): {} — {e}", def.ssid)), + Err(e) => log(&format!("connect failed (hidden): {} — {e}", def.ssid)), } } } @@ -214,12 +205,12 @@ pub fn run(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Outcome { .bootstrap .clone() .unwrap_or_else(|| "bootstrap".into()); - if !be.device_connected(&iface) { + if !nm::device_connected(&iface) { if let Some(bdef) = profile.bootstrap.as_deref().and_then(|s| cfg.network(s)) { - match connect_and_verify(be, &iface, bdef, cfg) { - Ok(()) => be.log(&format!("bootstrap reconnected: {}", bdef.ssid)), + match connect_and_verify(&iface, bdef, cfg) { + Ok(()) => log(&format!("bootstrap reconnected: {}", bdef.ssid)), Err(e) => { - be.log(&format!("bootstrap reconnect failed: {} — {e}", bdef.ssid)); + log(&format!("bootstrap reconnect failed: {} — {e}", bdef.ssid)); on_bootstrap = false; } } @@ -231,8 +222,8 @@ pub fn run(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Outcome { } else { format!("target network not in range — staying on {bs_ssid} (Tailscale OK)") }; - be.notify("breadcrumbs: using bootstrap", &reason, Urgency::Normal); - be.log(&format!("flow end: on bootstrap {bs_ssid}; {reason}")); + notify("breadcrumbs: using bootstrap", &reason, Urgency::Normal); + log(&format!("flow end: on bootstrap {bs_ssid}; {reason}")); return Outcome::Connected { ssid: bs_ssid, note: Some(reason), @@ -245,34 +236,34 @@ pub fn run(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Outcome { .map(|c| c.ssid.as_str()) .collect::>() .join(", "); - be.notify( + notify( "breadcrumbs: no known networks", &format!("profile '{profile_name}': none of [{names}] are in range"), Urgency::Critical, ); - be.log(&format!( + log(&format!( "flow end: no networks connected (profile={profile_name})" )); Outcome::NoNetworks } -fn finish_connected(be: &dyn Backend, ssid: &str, profile: &str, note: &Option) { +fn finish_connected(ssid: &str, profile: &str, note: &Option) { match note { None => { - be.notify( + notify( "breadcrumbs: connected", &format!("{ssid} ({profile})"), Urgency::Low, ); - be.log(&format!("flow end: connected {ssid} (profile={profile})")); + log(&format!("flow end: connected {ssid} (profile={profile})")); } Some(n) => { - be.notify( + notify( "breadcrumbs: connected (degraded)", &format!("{ssid} ({profile}) — {n}"), Urgency::Normal, ); - be.log(&format!( + log(&format!( "flow end: connected {ssid} (profile={profile}) note={n}" )); } @@ -283,7 +274,6 @@ fn finish_connected(be: &dyn Backend, ssid: &str, profile: &str, note: &Option NetworkDef { @@ -307,341 +297,6 @@ 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(); @@ -688,50 +343,4 @@ 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 3e178c0..548da4b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,3 @@ -mod backend; mod config; mod flow; mod nm; @@ -15,7 +14,6 @@ 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}; @@ -46,11 +44,7 @@ struct Cli { #[derive(Subcommand)] enum Cmd { /// Show current Wi-Fi / profile / Tailscale status (default) - Status { - /// Emit machine-readable JSON instead of the human summary - #[arg(long)] - json: bool, - }, + Status, /// Run the full connect sequence for the active profile #[command(visible_aliases = ["up", "connect", "i"])] Init, @@ -99,20 +93,6 @@ 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 @@ -146,15 +126,6 @@ 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() { @@ -177,7 +148,7 @@ fn active_profile(cfg: &Config, override_p: &Option) -> String { } fn real_main(cli: Cli) -> Result { - let cmd = cli.cmd.unwrap_or(Cmd::Status { json: false }); + let cmd = cli.cmd.unwrap_or(Cmd::Status); // `cd` and `install-service` don't need a parsed config first. if let Cmd::Cd { shell } = &cmd { @@ -185,19 +156,18 @@ fn real_main(cli: Cli) -> Result { } let mut cfg = Config::load()?; - let be = backend::System; match cmd { - Cmd::Status { json } => cmd_status(&be, &cfg, &cli.profile, json), + Cmd::Status => cmd_status(&cfg, &cli.profile), Cmd::Init => { let p = active_profile(&cfg, &cli.profile); - let outcome = flow::run(&be, &cfg, &p); + let outcome = flow::run(&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(&be, &mut cfg, action), - Cmd::Detect { apply } => cmd_detect(&be, &cfg, apply), + Cmd::Profile { action } => cmd_profile(&cfg, action), + Cmd::Detect { apply } => cmd_detect(&cfg, apply), Cmd::Add { ssid, password, @@ -206,13 +176,10 @@ 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(), - Cmd::Doctor { full } => cmd_doctor(&be, &cfg, &cli.profile, full), + Cmd::Doctor { full } => cmd_doctor(&cfg, &cli.profile, full), Cmd::InstallService { no_enable } => cmd_install_service(!no_enable), Cmd::Cd { .. } => unreachable!(), } @@ -246,45 +213,9 @@ fn print_outcome(profile: &str, o: &flow::Outcome) { } } -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 { +fn cmd_status(cfg: &Config, override_p: &Option) -> Result { let p = active_profile(cfg, override_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 s = status::gather(cfg, &p); let dot = |ok: bool| { if ok { @@ -317,14 +248,6 @@ fn cmd_status( 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) => { @@ -340,6 +263,9 @@ fn cmd_status( (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 { @@ -351,13 +277,7 @@ fn cmd_status( Ok(if healthy { 0 } else { 1 }) } -const CORE_PROFILES: [&str; 3] = ["home", "work", "away"]; - -fn cmd_profile( - be: &dyn Backend, - cfg: &mut Config, - action: Option, -) -> Result { +fn cmd_profile(cfg: &Config, action: Option) -> Result { match action.unwrap_or(ProfileCmd::Get) { ProfileCmd::Get => { println!("{}", State::load(&cfg.settings.default_profile).profile); @@ -371,34 +291,6 @@ fn cmd_profile( } 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(); @@ -414,46 +306,40 @@ fn cmd_profile( if no_apply { return Ok(0); } - let outcome = flow::run(be, cfg, &name); + let outcome = flow::run(cfg, &name); print_outcome(&name, &outcome); Ok(if outcome.ok() { 0 } else { 1 }) } } } -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); +fn detect_profile(cfg: &Config) -> Option { + let iface = nm::wifi_interface()?; + nm::radio_on(); + nm::rescan(&iface, &[]); + let visible = nm::visible_ssids(&iface); - // 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; + // Profiles are stored in a BTreeMap so iteration order is deterministic + // (alphabetical). The caller can rely on that for tie-breaking. for (name, profile) in &cfg.profiles { - let score = profile - .detect_ssids - .iter() - .filter(|s| visible.contains(s.as_str())) - .count(); - if score == 0 { + if profile.detect_ssids.is_empty() { continue; } - if best.as_ref().map(|(s, _)| score > *s).unwrap_or(true) { - best = Some((score, name.clone())); + if profile + .detect_ssids + .iter() + .any(|s| visible.contains(s.as_str())) + { + return Some(name.clone()); } } // Fall back to the default profile if no markers matched. - Some( - best.map(|(_, name)| name) - .unwrap_or_else(|| cfg.settings.default_profile.clone()), - ) + Some(cfg.settings.default_profile.clone()) } -fn cmd_detect(be: &dyn Backend, cfg: &Config, apply: bool) -> Result { - match detect_profile(be, cfg) { +fn cmd_detect(cfg: &Config, apply: bool) -> Result { + match detect_profile(cfg) { Some(p) => { println!("{p}"); if apply { @@ -462,7 +348,7 @@ fn cmd_detect(be: &dyn Backend, cfg: &Config, apply: bool) -> Result 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); @@ -670,14 +497,10 @@ fn cmd_scan(cfg: &mut Config, to: Option) -> Result { } fn mask(p: &str) -> String { - // 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 { + if p.len() <= 2 { "••".into() } else { - let first: String = p.chars().take(1).collect(); - format!("{}{}", first, "•".repeat(count - 1)) + format!("{}{}", &p[..1], "•".repeat(p.len().saturating_sub(1))) } } @@ -750,12 +573,7 @@ fn cmd_edit() -> Result { } } -fn cmd_doctor( - be: &dyn Backend, - cfg: &Config, - override_p: &Option, - full: bool, -) -> Result { +fn cmd_doctor(cfg: &Config, override_p: &Option, full: bool) -> Result { if full { let script = config::config_dir().join("diag.sh"); if !script.exists() { @@ -772,7 +590,7 @@ fn cmd_doctor( } let p = active_profile(cfg, override_p); - let s = status::gather(be, cfg, &p); + let s = status::gather(cfg, &p); println!("{C_BOLD}breadcrumbs doctor{C_RESET} (profile {p})"); println!( " nmcli {}", diff --git a/src/nm.rs b/src/nm.rs index df90791..0c37754 100644 --- a/src/nm.rs +++ b/src/nm.rs @@ -1,9 +1,8 @@ use std::collections::HashSet; -use std::path::PathBuf; use std::time::Duration; -use crate::config::{state_dir, NetworkDef}; -use crate::util::{run, run_ok, run_with_stdin}; +use crate::config::NetworkDef; +use crate::util::{run, run_ok}; /// nmcli `-t` escapes `:` and `\` in field values; undo that. fn unescape(s: &str) -> String { @@ -241,85 +240,6 @@ 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. -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() { - // 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 = fields[0].clone(); - if name == ssid { - return 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() @@ -327,88 +247,28 @@ 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 -/// 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. +/// Uses `nmcli device wifi connect ... password ` so the provided PSK +/// always takes effect, even when NM already has a stale saved profile for +/// the same SSID. The PSK is briefly visible in /proc//cmdline, which is +/// an acceptable trade-off for a personal desktop tool. pub fn connect_verbose(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> Result<(), String> { let wait_s = wait.to_string(); - - if let Some(profile) = first_profile_for_ssid(&net.ssid) { - // Ensure the hidden flag is set — this carries no secret, so passing it - // in argv is safe. - if net.hidden { - let _ = run( - "nmcli", - &[ - "connection", - "modify", - &profile, - "802-11-wireless.hidden", - "yes", - ], - Duration::from_secs(6), - ); - } - // 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()); - } - let o = run("nmcli", &args, Duration::from_secs(wait as u64 + 15)); - if let Some(p) = psk_file { - let _ = std::fs::remove_file(p); - } - 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 (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 stdin = format!("{}\n", net.password); - let o = run_with_stdin( - "nmcli", - &args, - Some(&stdin), - Duration::from_secs(wait as u64 + 15), - ); + 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() { @@ -436,12 +296,15 @@ pub fn delete_connections_for_ssid(ssid: &str) -> bool { } let mut removed = false; for line in list.stdout.lines() { - // NAME may contain an escaped ':' — parse rather than naive-split. - let fields = parse_scan_line(line); - if fields.len() < 2 || !fields[1].contains("wireless") { + 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") { continue; } - let name = fields[0].clone(); let conn_ssid = run( "nmcli", &["-g", "802-11-wireless.ssid", "connection", "show", &name], @@ -473,21 +336,6 @@ 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. @@ -502,71 +350,4 @@ 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 8565e0d..0366959 100644 --- a/src/state.rs +++ b/src/state.rs @@ -29,7 +29,6 @@ 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}"))?; - crate::util::write_atomic(&state_path(), &text, 0o644) - .map_err(|e| format!("writing state: {e}")) + fs::write(state_path(), text).map_err(|e| format!("writing state: {e}")) } } diff --git a/src/status.rs b/src/status.rs index 0deaa83..b06dea9 100644 --- a/src/status.rs +++ b/src/status.rs @@ -1,31 +1,11 @@ use std::time::Duration; -use crate::backend::Backend; use crate::config::Config; -use crate::tailscale::TsHealth; +use crate::nm; +use crate::tailscale::{self, TsHealth}; use crate::util::{command_exists, run}; -/// 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 { +pub fn internet_ok(cfg: &Config) -> bool { if command_exists("curl") { let o = run( "curl", @@ -34,45 +14,28 @@ pub fn connectivity(cfg: &Config) -> Connectivity { "-o", "/dev/null", "-w", - "%{http_code} %{redirect_url}", + "%{http_code}", "--max-time", "4", &cfg.settings.connectivity_url, ], Duration::from_secs(6), ); - 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. - _ => {} + let code = o.stdout.trim(); + if code == "204" || code == "200" || code == "301" || code == "302" { + return true; } } - let ping = run( + // Fallback: ICMP to the configured host. + run( "ping", &["-c", "1", "-W", "2", &cfg.settings.ping_host], Duration::from_secs(4), ) - .success; - if ping { - Connectivity::Online - } else { - Connectivity::Offline - } + .success } -/// Best-effort IPv4 address of `iface` via nmcli, with the CIDR prefix stripped. -pub fn ipv4(iface: &str) -> Option { +fn ipv4(iface: &str) -> Option { let o = run( "nmcli", &["-g", "IP4.ADDRESS", "device", "show", iface], @@ -85,9 +48,7 @@ pub fn ipv4(iface: &str) -> Option { if s.is_empty() { None } else { - // 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()) + Some(s.lines().next().unwrap_or(s).trim().to_string()) } } @@ -96,24 +57,16 @@ 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(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, - }; +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); let prof = cfg.profile(profile_name); let ts_required = prof.map(|p| p.tailscale).unwrap_or(false); @@ -121,8 +74,8 @@ pub fn gather(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Status { .and_then(|p| p.exit_node.clone()) .unwrap_or_else(|| cfg.settings.exit_node.clone()); - let tailscale = if be.tailscale_installed() { - Some(be.tailscale_check(&exit_node)) + let tailscale = if tailscale::installed() { + Some(tailscale::check(&exit_node)) } else { None }; @@ -132,7 +85,6 @@ pub fn gather(be: &dyn Backend, 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 79aaa34..a5aa49a 100644 --- a/src/tailscale.rs +++ b/src/tailscale.rs @@ -313,54 +313,6 @@ 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!( @@ -370,13 +322,6 @@ 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!({ @@ -436,59 +381,4 @@ 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 db9bc8d..04f3740 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,6 +1,5 @@ -use std::fs; use std::io::{Read, Write}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::process::{Command, Stdio}; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -11,42 +10,6 @@ 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) { @@ -92,11 +55,6 @@ 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()) @@ -106,6 +64,13 @@ 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(); @@ -124,16 +89,6 @@ 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() { @@ -221,58 +176,4 @@ 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 30f2360..b95ffbf 100644 --- a/src/watch.rs +++ b/src/watch.rs @@ -4,7 +4,6 @@ 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}; @@ -16,34 +15,32 @@ use crate::tailscale::TsHealth; enum Health { Up, DownNoNet, - /// Associated, but a captive portal is intercepting traffic (manual login). - CaptivePortal, DownTailscaleManual, DownTailscaleOther, NoAdapter, } -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 { +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 { match s.tailscale { - Some(TsHealth::Ok) => Health::Up, + Some(TsHealth::Ok) => (Health::Up, ssid), Some(TsHealth::NeedsLogin) | Some(TsHealth::NotInstalled) => { - Health::DownTailscaleManual + (Health::DownTailscaleManual, ssid) } - Some(_) => Health::DownTailscaleOther, - None => Health::DownTailscaleManual, + Some(_) => (Health::DownTailscaleOther, ssid), + None => (Health::DownTailscaleManual, ssid), } } else { - Health::Up - }; - (health, s) + (Health::Up, ssid) + } } /// Tail `nmcli monitor` and ping the channel on link-state churn so we react @@ -68,8 +65,12 @@ fn spawn_nm_monitor(tx: mpsc::Sender<()>) { let mut last = Instant::now() - Duration::from_secs(10); 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("connected") + || l.contains("connection") + || l.contains("now") + || l.contains("state"); if interesting && last.elapsed() > Duration::from_millis(1500) { last = Instant::now(); let _ = tx.send(()); @@ -108,26 +109,23 @@ 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(&be, &cfg, &profile); + let (h, _) = classify(&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(&be, &cfg, &profile); + let _ = flow::run(&cfg, &profile); } } let mut prev_health: Option = None; let mut prev_profile = profile.clone(); let mut fail_streak: u32 = 0; - let mut last_flow_at: Option = None; - const FLOW_COOLDOWN: u64 = 20; loop { // Reload config + state so edits and `profile set` take effect live. @@ -148,11 +146,9 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { ); prev_profile = profile.clone(); prev_health = None; // force re-evaluation/recovery for new profile - last_flow_at = None; // allow immediate recovery on profile change } - let (health, s) = classify(&be, &cfg, &profile); - let ssid = s.ssid.clone(); + let (health, ssid) = classify(&cfg, &profile); let transition = prev_health.as_ref() != Some(&health); match &health { @@ -169,18 +165,6 @@ 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( @@ -203,8 +187,7 @@ 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(&be, &cfg, &profile); - last_flow_at = Some(Instant::now()); + let _ = flow::run(&cfg, &profile); } fail_streak = fail_streak.saturating_add(1); } @@ -216,28 +199,17 @@ 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 { - log(&format!( - "watch: down ({:?}) profile={profile} ssid={:?} — running flow", - health, ssid - )); - let outcome = flow::run(&be, &cfg, &profile); - log(&format!("watch: recovery outcome = {:?}", outcome)); - last_flow_at = Some(Instant::now()); - fail_streak = if outcome.ok() { - 0 - } else { - fail_streak.saturating_add(1) - }; + log(&format!( + "watch: down ({:?}) profile={profile} ssid={:?} — running flow", + health, ssid + )); + let outcome = flow::run(&cfg, &profile); + log(&format!("watch: recovery outcome = {:?}", outcome)); + fail_streak = if outcome.ok() { + 0 } else { - log(&format!( - "watch: down ({:?}) — cooldown ({elapsed}s/{FLOW_COOLDOWN}s), skipping flow", - health - )); - } + fail_streak.saturating_add(1) + }; } } diff --git a/tests/cli.rs b/tests/cli.rs index 3722709..297173a 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -142,350 +142,3 @@ 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" - ); -}