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).
This commit is contained in:
parent
8aceab7857
commit
d3c1e19ba3
17 changed files with 1662 additions and 217 deletions
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
|
|
@ -17,9 +17,6 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- 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
|
- name: build
|
||||||
run: cargo build --release --locked
|
run: cargo build --release --locked
|
||||||
|
|
||||||
|
|
|
||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -54,7 +54,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "breadcrumbs"
|
name = "breadcrumbs"
|
||||||
version = "2.0.1"
|
version = "2.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "breadcrumbs"
|
name = "breadcrumbs"
|
||||||
version = "2.0.1"
|
version = "2.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Profile-aware Wi-Fi state machine with Tailscale handling and self-healing watch daemon"
|
description = "Profile-aware Wi-Fi state machine with Tailscale handling and self-healing watch daemon"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
|
||||||
12
README.md
12
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
|
- **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
|
- **Bootstrap + Tailscale gating** — connect to an interim network first, bring up Tailscale, then move to the target network
|
||||||
- **Self-healing watch daemon** — monitors for drops, auto-recovers, reacts within seconds via `nmcli monitor`
|
- **Self-healing watch daemon** — monitors for drops, auto-recovers, reacts within seconds via `nmcli monitor`
|
||||||
- **Auto-detection** — scans visible SSIDs and guesses your location from config-defined markers
|
- **Auto-detection** — scans visible SSIDs and guesses your location from config-defined markers (picks the profile with the most markers in range)
|
||||||
- **Secure credential handling** — passwords fed to `nmcli` via stdin (never in argv/`ps`), config stored at 0600
|
- **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)
|
- **Desktop notifications** via `notify-send` (optional)
|
||||||
- **systemd user service** generation via `breadcrumbs install-service`
|
- **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
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/breadway/breadcrumbs
|
git clone https://github.com/Breadway/breadcrumbs
|
||||||
cd breadcrumbs
|
cd breadcrumbs
|
||||||
cargo build --release
|
cargo build --release
|
||||||
# Copy to somewhere on your PATH:
|
# Copy to somewhere on your PATH:
|
||||||
|
|
@ -95,12 +97,14 @@ breadcrumbs [--profile <name>] <command>
|
||||||
|
|
||||||
| Command | Description |
|
| 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 |
|
| `init` | Run the full connect sequence for the active profile |
|
||||||
| `watch [--no-initial]` | Self-healing daemon: monitors and auto-recovers drops |
|
| `watch [--no-initial]` | Self-healing daemon: monitors and auto-recovers drops |
|
||||||
| `profile get` | Print the active profile |
|
| `profile get` | Print the active profile |
|
||||||
| `profile set <name>` | Switch profile (and apply it, unless `--no-apply`) |
|
| `profile set <name>` | Switch profile (and apply it, unless `--no-apply`) |
|
||||||
| `profile list` | List all profiles |
|
| `profile list` | List all profiles |
|
||||||
|
| `profile add <name> [--detect <ssid>]…` | Create a new (empty) profile, optionally with detection markers |
|
||||||
|
| `profile remove <name>` | Delete a profile (core `home`/`work`/`away` are protected) |
|
||||||
| `detect [--apply]` | Guess profile from visible networks; optionally apply it |
|
| `detect [--apply]` | Guess profile from visible networks; optionally apply it |
|
||||||
| `add <ssid> [password]` | Add or update a saved network |
|
| `add <ssid> [password]` | Add or update a saved network |
|
||||||
| `forget <ssid>` | Remove a network from config and NetworkManager |
|
| `forget <ssid>` | Remove a network from config and NetworkManager |
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ nmcli_wait = 8
|
||||||
exit_node = "my-exit-node" # Tailscale hostname of your preferred exit node
|
exit_node = "my-exit-node" # Tailscale hostname of your preferred exit node
|
||||||
default_profile = "away"
|
default_profile = "away"
|
||||||
watch_interval = 12
|
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"
|
connectivity_url = "http://connectivitycheck.gstatic.com/generate_204"
|
||||||
ping_host = "1.1.1.1"
|
ping_host = "1.1.1.1"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Maintainer: Breadway <rileyhorsham@gmail.com>
|
# Maintainer: Breadway <rileyhorsham@gmail.com>
|
||||||
|
|
||||||
pkgname=breadcrumbs
|
pkgname=breadcrumbs
|
||||||
pkgver=0.1.0
|
pkgver=2.1.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Profile-aware Wi-Fi state machine with Tailscale integration"
|
pkgdesc="Profile-aware Wi-Fi state machine with Tailscale integration"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
|
|
|
||||||
80
src/backend.rs
Normal file
80
src/backend.rs
Normal file
|
|
@ -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<String>;
|
||||||
|
fn radio_on(&self);
|
||||||
|
fn rescan(&self, iface: &str, ssids: &[String]);
|
||||||
|
fn visible_ssids(&self, iface: &str) -> HashSet<String>;
|
||||||
|
fn active_ssid(&self, iface: &str) -> Option<String>;
|
||||||
|
fn ipv4(&self, iface: &str) -> Option<String>;
|
||||||
|
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<String> {
|
||||||
|
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<String> {
|
||||||
|
nm::visible_ssids(iface)
|
||||||
|
}
|
||||||
|
fn active_ssid(&self, iface: &str) -> Option<String> {
|
||||||
|
nm::active_ssid(iface)
|
||||||
|
}
|
||||||
|
fn ipv4(&self, iface: &str) -> Option<String> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
131
src/config.rs
131
src/config.rs
|
|
@ -158,13 +158,10 @@ impl Config {
|
||||||
fs::create_dir_all(&dir).map_err(|e| format!("creating {}: {e}", dir.display()))?;
|
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 text = toml::to_string_pretty(self).map_err(|e| format!("serializing config: {e}"))?;
|
||||||
let path = config_path();
|
let path = config_path();
|
||||||
fs::write(&path, text).map_err(|e| format!("writing {}: {e}", path.display()))?;
|
// Plaintext Wi-Fi passwords live here: write atomically and owner-only,
|
||||||
// Plaintext Wi-Fi passwords live here — keep it owner-only.
|
// so there's no torn read and no world-readable window.
|
||||||
#[cfg(unix)]
|
crate::util::write_atomic(&path, &text, 0o600)
|
||||||
{
|
.map_err(|e| format!("writing {}: {e}", path.display()))?;
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -274,4 +271,124 @@ mod tests {
|
||||||
assert!(cfg.profile("work").is_some());
|
assert!(cfg.profile("work").is_some());
|
||||||
assert!(cfg.profile("away").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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
499
src/flow.rs
499
src/flow.rs
|
|
@ -1,8 +1,8 @@
|
||||||
|
use crate::backend::Backend;
|
||||||
use crate::config::{Config, NetworkDef};
|
use crate::config::{Config, NetworkDef};
|
||||||
use crate::nm;
|
use crate::notify::Urgency;
|
||||||
use crate::notify::{log, notify, Urgency};
|
use crate::status::Connectivity;
|
||||||
use crate::status::internet_ok;
|
use crate::tailscale::TsHealth;
|
||||||
use crate::tailscale::{self, TsHealth};
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum Outcome {
|
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.
|
/// Try to connect + confirm it actually carries traffic.
|
||||||
/// Returns Ok(()) on success, Err(reason) on failure.
|
/// Returns Ok(()) on success, Err(reason) on failure.
|
||||||
fn connect_and_verify(iface: &str, def: &NetworkDef, cfg: &Config) -> Result<(), String> {
|
fn connect_and_verify(
|
||||||
nm::connect_verbose(iface, def, cfg.settings.nmcli_wait, &cfg.settings.dns)?;
|
be: &dyn Backend,
|
||||||
if !nm::device_connected(iface) {
|
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());
|
return Err("device not connected after nmcli success".into());
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Describe post-association connectivity as an optional caveat note.
|
||||||
|
fn connectivity_note(be: &dyn Backend, cfg: &Config) -> Option<String> {
|
||||||
|
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`.
|
/// 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) {
|
let profile = match cfg.profile(profile_name) {
|
||||||
Some(p) => p.clone(),
|
Some(p) => p.clone(),
|
||||||
None => {
|
None => {
|
||||||
notify(
|
be.notify(
|
||||||
"breadcrumbs: unknown profile",
|
"breadcrumbs: unknown profile",
|
||||||
&format!("'{profile_name}' is not defined in breadcrumbs.toml"),
|
&format!("'{profile_name}' is not defined in breadcrumbs.toml"),
|
||||||
Urgency::Critical,
|
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,
|
Some(i) => i,
|
||||||
None => {
|
None => {
|
||||||
notify(
|
be.notify(
|
||||||
"breadcrumbs: no Wi-Fi adapter",
|
"breadcrumbs: no Wi-Fi adapter",
|
||||||
"Hardware issue — Wi-Fi device not found. Manual check needed.",
|
"Hardware issue — Wi-Fi device not found. Manual check needed.",
|
||||||
Urgency::Critical,
|
Urgency::Critical,
|
||||||
|
|
@ -81,7 +96,7 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
|
||||||
return Outcome::NoInterface;
|
return Outcome::NoInterface;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
nm::radio_on();
|
be.radio_on();
|
||||||
|
|
||||||
let exit_node = profile
|
let exit_node = profile
|
||||||
.exit_node
|
.exit_node
|
||||||
|
|
@ -89,7 +104,7 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
|
||||||
.unwrap_or_else(|| cfg.settings.exit_node.clone());
|
.unwrap_or_else(|| cfg.settings.exit_node.clone());
|
||||||
let candidates = resolve_candidates(cfg, &profile);
|
let candidates = resolve_candidates(cfg, &profile);
|
||||||
|
|
||||||
log(&format!(
|
be.log(&format!(
|
||||||
"flow start: profile={profile_name} iface={iface} tailscale={} candidates=[{}]",
|
"flow start: profile={profile_name} iface={iface} tailscale={} candidates=[{}]",
|
||||||
profile.tailscale,
|
profile.tailscale,
|
||||||
candidates
|
candidates
|
||||||
|
|
@ -104,8 +119,8 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
|
||||||
if let Some(bs) = &profile.bootstrap {
|
if let Some(bs) = &profile.bootstrap {
|
||||||
scan_targets.push(bs.clone());
|
scan_targets.push(bs.clone());
|
||||||
}
|
}
|
||||||
nm::rescan(&iface, &scan_targets);
|
be.rescan(&iface, &scan_targets);
|
||||||
let visible = nm::visible_ssids(&iface);
|
let visible = be.visible_ssids(&iface);
|
||||||
|
|
||||||
// ---- Tailscale-gated profiles (e.g. school) -------------------------
|
// ---- Tailscale-gated profiles (e.g. school) -------------------------
|
||||||
let mut on_bootstrap = false;
|
let mut on_bootstrap = false;
|
||||||
|
|
@ -114,29 +129,29 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
|
||||||
match cfg.network(&bs_ssid) {
|
match cfg.network(&bs_ssid) {
|
||||||
Some(bdef) => {
|
Some(bdef) => {
|
||||||
if visible.contains(&bdef.ssid) || bdef.hidden {
|
if visible.contains(&bdef.ssid) || bdef.hidden {
|
||||||
match connect_and_verify(&iface, bdef, cfg) {
|
match connect_and_verify(be, &iface, bdef, cfg) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
on_bootstrap = true;
|
on_bootstrap = true;
|
||||||
log(&format!("bootstrap connected: {}", bdef.ssid));
|
be.log(&format!("bootstrap connected: {}", bdef.ssid));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log(&format!("bootstrap connect failed: {} — {e}", bdef.ssid))
|
be.log(&format!("bootstrap connect failed: {} — {e}", bdef.ssid))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} 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"
|
"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() {
|
if !ts.is_ok() {
|
||||||
let ssid = nm::active_ssid(&iface).or_else(|| profile.bootstrap.clone());
|
let ssid = be.active_ssid(&iface).or_else(|| profile.bootstrap.clone());
|
||||||
notify(
|
be.notify(
|
||||||
"Tailscale Error",
|
"Tailscale Error",
|
||||||
&format!(
|
&format!(
|
||||||
"{} — staying on {}",
|
"{} — staying on {}",
|
||||||
|
|
@ -147,12 +162,12 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
|
||||||
);
|
);
|
||||||
return Outcome::TailscaleError { ssid, health: ts };
|
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.
|
// 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 ----------------------------------
|
// ---- Connect to the priority list ----------------------------------
|
||||||
// Pass 1: visible networks in priority order.
|
// Pass 1: visible networks in priority order.
|
||||||
|
|
@ -160,20 +175,16 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
|
||||||
for def in &candidates {
|
for def in &candidates {
|
||||||
if visible.contains(&def.ssid) {
|
if visible.contains(&def.ssid) {
|
||||||
any_attempted = true;
|
any_attempted = true;
|
||||||
match connect_and_verify(&iface, def, cfg) {
|
match connect_and_verify(be, &iface, def, cfg) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let note = if internet_ok(cfg) {
|
let note = connectivity_note(be, cfg);
|
||||||
None
|
finish_connected(be, &def.ssid, profile_name, ¬e);
|
||||||
} else {
|
|
||||||
Some("associated but no internet yet".to_string())
|
|
||||||
};
|
|
||||||
finish_connected(&def.ssid, profile_name, ¬e);
|
|
||||||
return Outcome::Connected {
|
return Outcome::Connected {
|
||||||
ssid: def.ssid.clone(),
|
ssid: def.ssid.clone(),
|
||||||
note,
|
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 {
|
for def in &candidates {
|
||||||
if def.hidden && !visible.contains(&def.ssid) {
|
if def.hidden && !visible.contains(&def.ssid) {
|
||||||
any_attempted = true;
|
any_attempted = true;
|
||||||
match connect_and_verify(&iface, def, cfg) {
|
match connect_and_verify(be, &iface, def, cfg) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let note = if internet_ok(cfg) {
|
let note = connectivity_note(be, cfg);
|
||||||
None
|
finish_connected(be, &def.ssid, profile_name, ¬e);
|
||||||
} else {
|
|
||||||
Some("associated but no internet yet".to_string())
|
|
||||||
};
|
|
||||||
finish_connected(&def.ssid, profile_name, ¬e);
|
|
||||||
return Outcome::Connected {
|
return Outcome::Connected {
|
||||||
ssid: def.ssid.clone(),
|
ssid: def.ssid.clone(),
|
||||||
note,
|
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
|
.bootstrap
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| "bootstrap".into());
|
.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)) {
|
if let Some(bdef) = profile.bootstrap.as_deref().and_then(|s| cfg.network(s)) {
|
||||||
match connect_and_verify(&iface, bdef, cfg) {
|
match connect_and_verify(be, &iface, bdef, cfg) {
|
||||||
Ok(()) => log(&format!("bootstrap reconnected: {}", bdef.ssid)),
|
Ok(()) => be.log(&format!("bootstrap reconnected: {}", bdef.ssid)),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log(&format!("bootstrap reconnect failed: {} — {e}", bdef.ssid));
|
be.log(&format!("bootstrap reconnect failed: {} — {e}", bdef.ssid));
|
||||||
on_bootstrap = false;
|
on_bootstrap = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -224,8 +231,8 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
|
||||||
} else {
|
} else {
|
||||||
format!("target network not in range — staying on {bs_ssid} (Tailscale OK)")
|
format!("target network not in range — staying on {bs_ssid} (Tailscale OK)")
|
||||||
};
|
};
|
||||||
notify("breadcrumbs: using bootstrap", &reason, Urgency::Normal);
|
be.notify("breadcrumbs: using bootstrap", &reason, Urgency::Normal);
|
||||||
log(&format!("flow end: on bootstrap {bs_ssid}; {reason}"));
|
be.log(&format!("flow end: on bootstrap {bs_ssid}; {reason}"));
|
||||||
return Outcome::Connected {
|
return Outcome::Connected {
|
||||||
ssid: bs_ssid,
|
ssid: bs_ssid,
|
||||||
note: Some(reason),
|
note: Some(reason),
|
||||||
|
|
@ -238,34 +245,34 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
|
||||||
.map(|c| c.ssid.as_str())
|
.map(|c| c.ssid.as_str())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ");
|
.join(", ");
|
||||||
notify(
|
be.notify(
|
||||||
"breadcrumbs: no known networks",
|
"breadcrumbs: no known networks",
|
||||||
&format!("profile '{profile_name}': none of [{names}] are in range"),
|
&format!("profile '{profile_name}': none of [{names}] are in range"),
|
||||||
Urgency::Critical,
|
Urgency::Critical,
|
||||||
);
|
);
|
||||||
log(&format!(
|
be.log(&format!(
|
||||||
"flow end: no networks connected (profile={profile_name})"
|
"flow end: no networks connected (profile={profile_name})"
|
||||||
));
|
));
|
||||||
Outcome::NoNetworks
|
Outcome::NoNetworks
|
||||||
}
|
}
|
||||||
|
|
||||||
fn finish_connected(ssid: &str, profile: &str, note: &Option<String>) {
|
fn finish_connected(be: &dyn Backend, ssid: &str, profile: &str, note: &Option<String>) {
|
||||||
match note {
|
match note {
|
||||||
None => {
|
None => {
|
||||||
notify(
|
be.notify(
|
||||||
"breadcrumbs: connected",
|
"breadcrumbs: connected",
|
||||||
&format!("{ssid} ({profile})"),
|
&format!("{ssid} ({profile})"),
|
||||||
Urgency::Low,
|
Urgency::Low,
|
||||||
);
|
);
|
||||||
log(&format!("flow end: connected {ssid} (profile={profile})"));
|
be.log(&format!("flow end: connected {ssid} (profile={profile})"));
|
||||||
}
|
}
|
||||||
Some(n) => {
|
Some(n) => {
|
||||||
notify(
|
be.notify(
|
||||||
"breadcrumbs: connected (degraded)",
|
"breadcrumbs: connected (degraded)",
|
||||||
&format!("{ssid} ({profile}) — {n}"),
|
&format!("{ssid} ({profile}) — {n}"),
|
||||||
Urgency::Normal,
|
Urgency::Normal,
|
||||||
);
|
);
|
||||||
log(&format!(
|
be.log(&format!(
|
||||||
"flow end: connected {ssid} (profile={profile}) note={n}"
|
"flow end: connected {ssid} (profile={profile}) note={n}"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
@ -276,6 +283,7 @@ fn finish_connected(ssid: &str, profile: &str, note: &Option<String>) {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::{Profile, Settings};
|
use crate::config::{Profile, Settings};
|
||||||
|
use crate::tailscale::TsHealth;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
fn net(ssid: &str) -> NetworkDef {
|
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<String>,
|
||||||
|
visible: HashSet<String>,
|
||||||
|
connectable: HashSet<String>,
|
||||||
|
connected: RefCell<Option<String>>,
|
||||||
|
ts: TsHealth,
|
||||||
|
conn: Connectivity,
|
||||||
|
notes: RefCell<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String> {
|
||||||
|
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<String> {
|
||||||
|
self.iface.clone()
|
||||||
|
}
|
||||||
|
fn radio_on(&self) {}
|
||||||
|
fn rescan(&self, _: &str, _: &[String]) {}
|
||||||
|
fn visible_ssids(&self, _: &str) -> HashSet<String> {
|
||||||
|
self.visible.clone()
|
||||||
|
}
|
||||||
|
fn active_ssid(&self, _: &str) -> Option<String> {
|
||||||
|
self.connected.borrow().clone()
|
||||||
|
}
|
||||||
|
fn ipv4(&self, _: &str) -> Option<String> {
|
||||||
|
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<String>) {
|
||||||
|
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]
|
#[test]
|
||||||
fn candidates_follow_priority_order() {
|
fn candidates_follow_priority_order() {
|
||||||
let c = cfg();
|
let c = cfg();
|
||||||
|
|
@ -345,4 +688,50 @@ mod tests {
|
||||||
.collect();
|
.collect();
|
||||||
assert_eq!(got, vec!["WorkNet"]);
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
178
src/main.rs
178
src/main.rs
|
|
@ -1,3 +1,4 @@
|
||||||
|
mod backend;
|
||||||
mod config;
|
mod config;
|
||||||
mod flow;
|
mod flow;
|
||||||
mod nm;
|
mod nm;
|
||||||
|
|
@ -14,6 +15,7 @@ use std::time::Duration;
|
||||||
|
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
|
use backend::Backend;
|
||||||
use config::{Config, NetworkDef};
|
use config::{Config, NetworkDef};
|
||||||
use state::State;
|
use state::State;
|
||||||
use util::{command_exists, home_dir, run};
|
use util::{command_exists, home_dir, run};
|
||||||
|
|
@ -44,7 +46,11 @@ struct Cli {
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum Cmd {
|
enum Cmd {
|
||||||
/// Show current Wi-Fi / profile / Tailscale status (default)
|
/// 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
|
/// Run the full connect sequence for the active profile
|
||||||
#[command(visible_aliases = ["up", "connect", "i"])]
|
#[command(visible_aliases = ["up", "connect", "i"])]
|
||||||
Init,
|
Init,
|
||||||
|
|
@ -126,6 +132,15 @@ enum ProfileCmd {
|
||||||
},
|
},
|
||||||
/// List available profiles
|
/// List available profiles
|
||||||
List,
|
List,
|
||||||
|
/// Create a new (empty) profile
|
||||||
|
Add {
|
||||||
|
name: String,
|
||||||
|
/// SSID whose presence marks this location (repeatable, for `detect`)
|
||||||
|
#[arg(long = "detect")]
|
||||||
|
detect: Vec<String>,
|
||||||
|
},
|
||||||
|
/// Delete a profile (core profiles home/work/away cannot be removed)
|
||||||
|
Remove { name: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
|
@ -148,7 +163,7 @@ fn active_profile(cfg: &Config, override_p: &Option<String>) -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn real_main(cli: Cli) -> Result<i32, String> {
|
fn real_main(cli: Cli) -> Result<i32, String> {
|
||||||
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.
|
// `cd` and `install-service` don't need a parsed config first.
|
||||||
if let Cmd::Cd { shell } = &cmd {
|
if let Cmd::Cd { shell } = &cmd {
|
||||||
|
|
@ -156,18 +171,19 @@ fn real_main(cli: Cli) -> Result<i32, String> {
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut cfg = Config::load()?;
|
let mut cfg = Config::load()?;
|
||||||
|
let be = backend::System;
|
||||||
|
|
||||||
match cmd {
|
match cmd {
|
||||||
Cmd::Status => cmd_status(&cfg, &cli.profile),
|
Cmd::Status { json } => cmd_status(&be, &cfg, &cli.profile, json),
|
||||||
Cmd::Init => {
|
Cmd::Init => {
|
||||||
let p = active_profile(&cfg, &cli.profile);
|
let p = active_profile(&cfg, &cli.profile);
|
||||||
let outcome = flow::run(&cfg, &p);
|
let outcome = flow::run(&be, &cfg, &p);
|
||||||
print_outcome(&p, &outcome);
|
print_outcome(&p, &outcome);
|
||||||
Ok(if outcome.ok() { 0 } else { 1 })
|
Ok(if outcome.ok() { 0 } else { 1 })
|
||||||
}
|
}
|
||||||
Cmd::Watch { no_initial } => Ok(watch::run(cfg, !no_initial)),
|
Cmd::Watch { no_initial } => Ok(watch::run(cfg, !no_initial)),
|
||||||
Cmd::Profile { action } => cmd_profile(&cfg, action),
|
Cmd::Profile { action } => cmd_profile(&be, &mut cfg, action),
|
||||||
Cmd::Detect { apply } => cmd_detect(&cfg, apply),
|
Cmd::Detect { apply } => cmd_detect(&be, &cfg, apply),
|
||||||
Cmd::Add {
|
Cmd::Add {
|
||||||
ssid,
|
ssid,
|
||||||
password,
|
password,
|
||||||
|
|
@ -179,7 +195,7 @@ fn real_main(cli: Cli) -> Result<i32, String> {
|
||||||
Cmd::Scan { to } => cmd_scan(&mut cfg, to),
|
Cmd::Scan { to } => cmd_scan(&mut cfg, to),
|
||||||
Cmd::List { show_passwords } => cmd_list(&cfg, show_passwords),
|
Cmd::List { show_passwords } => cmd_list(&cfg, show_passwords),
|
||||||
Cmd::Edit => cmd_edit(),
|
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::InstallService { no_enable } => cmd_install_service(!no_enable),
|
||||||
Cmd::Cd { .. } => unreachable!(),
|
Cmd::Cd { .. } => unreachable!(),
|
||||||
}
|
}
|
||||||
|
|
@ -213,9 +229,45 @@ fn print_outcome(profile: &str, o: &flow::Outcome) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_status(cfg: &Config, override_p: &Option<String>) -> Result<i32, String> {
|
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<String>,
|
||||||
|
json: bool,
|
||||||
|
) -> Result<i32, String> {
|
||||||
let p = active_profile(cfg, override_p);
|
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| {
|
let dot = |ok: bool| {
|
||||||
if ok {
|
if ok {
|
||||||
|
|
@ -248,6 +300,14 @@ fn cmd_status(cfg: &Config, override_p: &Option<String>) -> Result<i32, String>
|
||||||
dot(s.internet),
|
dot(s.internet),
|
||||||
if s.internet { "ok" } else { "down" }
|
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) {
|
match (&s.tailscale, s.tailscale_required) {
|
||||||
(Some(h), req) => {
|
(Some(h), req) => {
|
||||||
|
|
@ -263,9 +323,6 @@ fn cmd_status(cfg: &Config, override_p: &Option<String>) -> Result<i32, String>
|
||||||
(None, _) => println!(" tailscale {C_DIM}not installed{C_RESET}"),
|
(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!(
|
println!(
|
||||||
" state {}",
|
" state {}",
|
||||||
if healthy {
|
if healthy {
|
||||||
|
|
@ -277,7 +334,13 @@ fn cmd_status(cfg: &Config, override_p: &Option<String>) -> Result<i32, String>
|
||||||
Ok(if healthy { 0 } else { 1 })
|
Ok(if healthy { 0 } else { 1 })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_profile(cfg: &Config, action: Option<ProfileCmd>) -> Result<i32, String> {
|
const CORE_PROFILES: [&str; 3] = ["home", "work", "away"];
|
||||||
|
|
||||||
|
fn cmd_profile(
|
||||||
|
be: &dyn Backend,
|
||||||
|
cfg: &mut Config,
|
||||||
|
action: Option<ProfileCmd>,
|
||||||
|
) -> Result<i32, String> {
|
||||||
match action.unwrap_or(ProfileCmd::Get) {
|
match action.unwrap_or(ProfileCmd::Get) {
|
||||||
ProfileCmd::Get => {
|
ProfileCmd::Get => {
|
||||||
println!("{}", State::load(&cfg.settings.default_profile).profile);
|
println!("{}", State::load(&cfg.settings.default_profile).profile);
|
||||||
|
|
@ -291,6 +354,34 @@ fn cmd_profile(cfg: &Config, action: Option<ProfileCmd>) -> Result<i32, String>
|
||||||
}
|
}
|
||||||
Ok(0)
|
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 } => {
|
ProfileCmd::Set { name, no_apply } => {
|
||||||
if !cfg.profiles.contains_key(&name) {
|
if !cfg.profiles.contains_key(&name) {
|
||||||
let avail: Vec<&String> = cfg.profiles.keys().collect();
|
let avail: Vec<&String> = cfg.profiles.keys().collect();
|
||||||
|
|
@ -306,40 +397,46 @@ fn cmd_profile(cfg: &Config, action: Option<ProfileCmd>) -> Result<i32, String>
|
||||||
if no_apply {
|
if no_apply {
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
let outcome = flow::run(cfg, &name);
|
let outcome = flow::run(be, cfg, &name);
|
||||||
print_outcome(&name, &outcome);
|
print_outcome(&name, &outcome);
|
||||||
Ok(if outcome.ok() { 0 } else { 1 })
|
Ok(if outcome.ok() { 0 } else { 1 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn detect_profile(cfg: &Config) -> Option<String> {
|
fn detect_profile(be: &dyn Backend, cfg: &Config) -> Option<String> {
|
||||||
let iface = nm::wifi_interface()?;
|
let iface = be.wifi_interface()?;
|
||||||
nm::radio_on();
|
be.radio_on();
|
||||||
nm::rescan(&iface, &[]);
|
be.rescan(&iface, &[]);
|
||||||
let visible = nm::visible_ssids(&iface);
|
let visible = be.visible_ssids(&iface);
|
||||||
|
|
||||||
// Profiles are stored in a BTreeMap so iteration order is deterministic
|
// Pick the profile with the most marker SSIDs in range, so overlapping
|
||||||
// (alphabetical). The caller can rely on that for tie-breaking.
|
// 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 {
|
for (name, profile) in &cfg.profiles {
|
||||||
if profile.detect_ssids.is_empty() {
|
let score = profile
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if profile
|
|
||||||
.detect_ssids
|
.detect_ssids
|
||||||
.iter()
|
.iter()
|
||||||
.any(|s| visible.contains(s.as_str()))
|
.filter(|s| visible.contains(s.as_str()))
|
||||||
{
|
.count();
|
||||||
return Some(name.clone());
|
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.
|
// 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<i32, String> {
|
fn cmd_detect(be: &dyn Backend, cfg: &Config, apply: bool) -> Result<i32, String> {
|
||||||
match detect_profile(cfg) {
|
match detect_profile(be, cfg) {
|
||||||
Some(p) => {
|
Some(p) => {
|
||||||
println!("{p}");
|
println!("{p}");
|
||||||
if apply {
|
if apply {
|
||||||
|
|
@ -348,7 +445,7 @@ fn cmd_detect(cfg: &Config, apply: bool) -> Result<i32, String> {
|
||||||
updated: util::timestamp(),
|
updated: util::timestamp(),
|
||||||
}
|
}
|
||||||
.save()?;
|
.save()?;
|
||||||
let outcome = flow::run(cfg, &p);
|
let outcome = flow::run(be, cfg, &p);
|
||||||
print_outcome(&p, &outcome);
|
print_outcome(&p, &outcome);
|
||||||
return Ok(if outcome.ok() { 0 } else { 1 });
|
return Ok(if outcome.ok() { 0 } else { 1 });
|
||||||
}
|
}
|
||||||
|
|
@ -497,10 +594,14 @@ fn cmd_scan(cfg: &mut Config, to: Option<String>) -> Result<i32, String> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mask(p: &str) -> String {
|
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()
|
"••".into()
|
||||||
} else {
|
} 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<i32, String> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_doctor(cfg: &Config, override_p: &Option<String>, full: bool) -> Result<i32, String> {
|
fn cmd_doctor(
|
||||||
|
be: &dyn Backend,
|
||||||
|
cfg: &Config,
|
||||||
|
override_p: &Option<String>,
|
||||||
|
full: bool,
|
||||||
|
) -> Result<i32, String> {
|
||||||
if full {
|
if full {
|
||||||
let script = config::config_dir().join("diag.sh");
|
let script = config::config_dir().join("diag.sh");
|
||||||
if !script.exists() {
|
if !script.exists() {
|
||||||
|
|
@ -590,7 +696,7 @@ fn cmd_doctor(cfg: &Config, override_p: &Option<String>, full: bool) -> Result<i
|
||||||
}
|
}
|
||||||
|
|
||||||
let p = active_profile(cfg, override_p);
|
let p = active_profile(cfg, override_p);
|
||||||
let s = status::gather(cfg, &p);
|
let s = status::gather(be, cfg, &p);
|
||||||
println!("{C_BOLD}breadcrumbs doctor{C_RESET} (profile {p})");
|
println!("{C_BOLD}breadcrumbs doctor{C_RESET} (profile {p})");
|
||||||
println!(
|
println!(
|
||||||
" nmcli {}",
|
" nmcli {}",
|
||||||
|
|
|
||||||
247
src/nm.rs
247
src/nm.rs
|
|
@ -1,8 +1,9 @@
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::config::NetworkDef;
|
use crate::config::{state_dir, NetworkDef};
|
||||||
use crate::util::{run, run_ok};
|
use crate::util::{run, run_ok, run_with_stdin};
|
||||||
|
|
||||||
/// nmcli `-t` escapes `:` and `\` in field values; undo that.
|
/// nmcli `-t` escapes `:` and `\` in field values; undo that.
|
||||||
fn unescape(s: &str) -> String {
|
fn unescape(s: &str) -> 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
|
/// Return the name of the first saved NM connection profile whose name is
|
||||||
/// either exactly `ssid` or `ssid N` (NM's numbered-duplicate convention).
|
/// either exactly `ssid` or `ssid N` (NM's numbered-duplicate convention).
|
||||||
/// Returns `None` if no such profile exists.
|
/// Returns `None` if no such profile exists.
|
||||||
|
|
@ -254,26 +268,58 @@ fn first_profile_for_ssid(ssid: &str) -> Option<String> {
|
||||||
}
|
}
|
||||||
let mut fallback: Option<String> = None;
|
let mut fallback: Option<String> = None;
|
||||||
for line in o.stdout.lines() {
|
for line in o.stdout.lines() {
|
||||||
let parts: Vec<&str> = line.splitn(2, ':').collect();
|
// NAME may itself contain an (escaped) ':', so a plain splitn would
|
||||||
if parts.len() < 2 || !parts[1].contains("wireless") {
|
// 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;
|
continue;
|
||||||
}
|
}
|
||||||
let name = unescape(parts[0]);
|
let name = fields[0].clone();
|
||||||
if name == ssid {
|
if name == ssid {
|
||||||
return Some(name);
|
return Some(name);
|
||||||
}
|
}
|
||||||
if fallback.is_none() {
|
if fallback.is_none() && is_numbered_nm_duplicate(&name, ssid) {
|
||||||
if let Some(suffix) = name.strip_prefix(ssid) {
|
fallback = Some(name);
|
||||||
let s = suffix.trim();
|
|
||||||
if !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()) {
|
|
||||||
fallback = Some(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fallback
|
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<PathBuf> {
|
||||||
|
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.
|
/// Connect to a network and pin DNS. Returns true only if associated.
|
||||||
pub fn connect(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> bool {
|
pub fn connect(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> bool {
|
||||||
connect_verbose(iface, net, wait, dns).is_ok()
|
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();
|
let wait_s = wait.to_string();
|
||||||
|
|
||||||
if let Some(profile) = first_profile_for_ssid(&net.ssid) {
|
if let Some(profile) = first_profile_for_ssid(&net.ssid) {
|
||||||
// Update the saved PSK and, for hidden networks, ensure the flag is set.
|
// Ensure the hidden flag is set — this carries no secret, so passing it
|
||||||
if !net.password.is_empty() {
|
// in argv is safe.
|
||||||
let _ = run(
|
|
||||||
"nmcli",
|
|
||||||
&[
|
|
||||||
"connection",
|
|
||||||
"modify",
|
|
||||||
&profile,
|
|
||||||
"802-11-wireless-security.psk",
|
|
||||||
net.password.as_str(),
|
|
||||||
],
|
|
||||||
Duration::from_secs(6),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if net.hidden {
|
if net.hidden {
|
||||||
let _ = run(
|
let _ = run(
|
||||||
"nmcli",
|
"nmcli",
|
||||||
|
|
@ -317,50 +351,64 @@ pub fn connect_verbose(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> R
|
||||||
Duration::from_secs(6),
|
Duration::from_secs(6),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let o = run(
|
// Supply the PSK to the activation via a 0600 passwd-file rather than
|
||||||
"nmcli",
|
// argv. Harmless if NM already has a matching secret stored.
|
||||||
&[
|
let psk_file = if net.password.is_empty() {
|
||||||
"--wait",
|
None
|
||||||
&wait_s,
|
} else {
|
||||||
"connection",
|
write_psk_file(&net.password)
|
||||||
"up",
|
};
|
||||||
&profile,
|
let psk_path = psk_file.as_ref().map(|p| p.display().to_string());
|
||||||
"ifname",
|
let mut args: Vec<&str> = vec![
|
||||||
iface,
|
"--wait",
|
||||||
],
|
&wait_s,
|
||||||
Duration::from_secs(wait as u64 + 15),
|
"connection",
|
||||||
);
|
"up",
|
||||||
if !o.success {
|
&profile,
|
||||||
let detail = o.stderr.trim().to_string();
|
"ifname",
|
||||||
return Err(if detail.is_empty() {
|
iface,
|
||||||
o.stdout.trim().to_string()
|
];
|
||||||
} else {
|
if let Some(ref p) = psk_path {
|
||||||
detail
|
args.push("passwd-file");
|
||||||
});
|
args.push(p.as_str());
|
||||||
}
|
}
|
||||||
if let Some(uuid) = active_uuid(iface) {
|
let o = run("nmcli", &args, Duration::from_secs(wait as u64 + 15));
|
||||||
enforce_dns(&uuid, iface, dns);
|
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 hidden = if net.hidden { "yes" } else { "no" };
|
||||||
let args = [
|
let args = [
|
||||||
|
"--ask",
|
||||||
"--wait",
|
"--wait",
|
||||||
&wait_s,
|
&wait_s,
|
||||||
"device",
|
"device",
|
||||||
"wifi",
|
"wifi",
|
||||||
"connect",
|
"connect",
|
||||||
net.ssid.as_str(),
|
net.ssid.as_str(),
|
||||||
"password",
|
|
||||||
net.password.as_str(),
|
|
||||||
"hidden",
|
"hidden",
|
||||||
hidden,
|
hidden,
|
||||||
"ifname",
|
"ifname",
|
||||||
iface,
|
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 {
|
if !o.success {
|
||||||
let detail = o.stderr.trim().to_string();
|
let detail = o.stderr.trim().to_string();
|
||||||
return Err(if detail.is_empty() {
|
return Err(if detail.is_empty() {
|
||||||
|
|
@ -388,15 +436,12 @@ pub fn delete_connections_for_ssid(ssid: &str) -> bool {
|
||||||
}
|
}
|
||||||
let mut removed = false;
|
let mut removed = false;
|
||||||
for line in list.stdout.lines() {
|
for line in list.stdout.lines() {
|
||||||
let parts: Vec<&str> = line.splitn(2, ':').collect();
|
// NAME may contain an escaped ':' — parse rather than naive-split.
|
||||||
if parts.len() < 2 {
|
let fields = parse_scan_line(line);
|
||||||
continue;
|
if fields.len() < 2 || !fields[1].contains("wireless") {
|
||||||
}
|
|
||||||
let name = unescape(parts[0]);
|
|
||||||
let typ = parts[1];
|
|
||||||
if !typ.contains("wireless") {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let name = fields[0].clone();
|
||||||
let conn_ssid = run(
|
let conn_ssid = run(
|
||||||
"nmcli",
|
"nmcli",
|
||||||
&["-g", "802-11-wireless.ssid", "connection", "show", &name],
|
&["-g", "802-11-wireless.ssid", "connection", "show", &name],
|
||||||
|
|
@ -428,6 +473,21 @@ mod tests {
|
||||||
assert_eq!(unescape("trailing\\"), "trailing\\");
|
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]
|
#[test]
|
||||||
fn parse_scan_line_splits_and_unescapes() {
|
fn parse_scan_line_splits_and_unescapes() {
|
||||||
// SSID:SIGNAL:SECURITY with an escaped ':' inside the SSID.
|
// SSID:SIGNAL:SECURITY with an escaped ':' inside the SSID.
|
||||||
|
|
@ -442,4 +502,71 @@ mod tests {
|
||||||
let f = parse_scan_line(":40:WPA3");
|
let f = parse_scan_line(":40:WPA3");
|
||||||
assert_eq!(f, vec!["", "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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ impl State {
|
||||||
pub fn save(&self) -> Result<(), String> {
|
pub fn save(&self) -> Result<(), String> {
|
||||||
fs::create_dir_all(state_dir()).map_err(|e| format!("creating state dir: {e}"))?;
|
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}"))?;
|
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}"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,31 @@
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::backend::Backend;
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::nm;
|
use crate::tailscale::TsHealth;
|
||||||
use crate::tailscale::{self, TsHealth};
|
|
||||||
use crate::util::{command_exists, run};
|
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<String>),
|
||||||
|
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") {
|
if command_exists("curl") {
|
||||||
let o = run(
|
let o = run(
|
||||||
"curl",
|
"curl",
|
||||||
|
|
@ -14,28 +34,45 @@ pub fn internet_ok(cfg: &Config) -> bool {
|
||||||
"-o",
|
"-o",
|
||||||
"/dev/null",
|
"/dev/null",
|
||||||
"-w",
|
"-w",
|
||||||
"%{http_code}",
|
"%{http_code} %{redirect_url}",
|
||||||
"--max-time",
|
"--max-time",
|
||||||
"4",
|
"4",
|
||||||
&cfg.settings.connectivity_url,
|
&cfg.settings.connectivity_url,
|
||||||
],
|
],
|
||||||
Duration::from_secs(6),
|
Duration::from_secs(6),
|
||||||
);
|
);
|
||||||
let code = o.stdout.trim();
|
let mut parts = o.stdout.split_whitespace();
|
||||||
if code == "204" || code == "200" || code == "301" || code == "302" {
|
let code = parts.next().unwrap_or("");
|
||||||
return true;
|
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.
|
let ping = run(
|
||||||
run(
|
|
||||||
"ping",
|
"ping",
|
||||||
&["-c", "1", "-W", "2", &cfg.settings.ping_host],
|
&["-c", "1", "-W", "2", &cfg.settings.ping_host],
|
||||||
Duration::from_secs(4),
|
Duration::from_secs(4),
|
||||||
)
|
)
|
||||||
.success
|
.success;
|
||||||
|
if ping {
|
||||||
|
Connectivity::Online
|
||||||
|
} else {
|
||||||
|
Connectivity::Offline
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ipv4(iface: &str) -> Option<String> {
|
/// Best-effort IPv4 address of `iface` via nmcli, with the CIDR prefix stripped.
|
||||||
|
pub fn ipv4(iface: &str) -> Option<String> {
|
||||||
let o = run(
|
let o = run(
|
||||||
"nmcli",
|
"nmcli",
|
||||||
&["-g", "IP4.ADDRESS", "device", "show", iface],
|
&["-g", "IP4.ADDRESS", "device", "show", iface],
|
||||||
|
|
@ -48,7 +85,9 @@ fn ipv4(iface: &str) -> Option<String> {
|
||||||
if s.is_empty() {
|
if s.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} 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<String>,
|
pub ssid: Option<String>,
|
||||||
pub ip: Option<String>,
|
pub ip: Option<String>,
|
||||||
pub internet: bool,
|
pub internet: bool,
|
||||||
|
/// Set when a captive portal was detected; inner string is its URL if known.
|
||||||
|
pub portal: Option<String>,
|
||||||
pub tailscale_required: bool,
|
pub tailscale_required: bool,
|
||||||
pub tailscale: Option<TsHealth>,
|
pub tailscale: Option<TsHealth>,
|
||||||
pub exit_node: String,
|
pub exit_node: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn gather(cfg: &Config, profile_name: &str) -> Status {
|
pub fn gather(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Status {
|
||||||
let iface = nm::wifi_interface();
|
let iface = be.wifi_interface();
|
||||||
let ssid = iface.as_deref().and_then(nm::active_ssid);
|
let ssid = iface.as_deref().and_then(|i| be.active_ssid(i));
|
||||||
let ip = iface.as_deref().and_then(ipv4);
|
let ip = iface.as_deref().and_then(|i| be.ipv4(i));
|
||||||
let internet = internet_ok(cfg);
|
|
||||||
|
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 prof = cfg.profile(profile_name);
|
||||||
let ts_required = prof.map(|p| p.tailscale).unwrap_or(false);
|
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())
|
.and_then(|p| p.exit_node.clone())
|
||||||
.unwrap_or_else(|| cfg.settings.exit_node.clone());
|
.unwrap_or_else(|| cfg.settings.exit_node.clone());
|
||||||
|
|
||||||
let tailscale = if tailscale::installed() {
|
let tailscale = if be.tailscale_installed() {
|
||||||
Some(tailscale::check(&exit_node))
|
Some(be.tailscale_check(&exit_node))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
@ -85,6 +132,7 @@ pub fn gather(cfg: &Config, profile_name: &str) -> Status {
|
||||||
ssid,
|
ssid,
|
||||||
ip,
|
ip,
|
||||||
internet,
|
internet,
|
||||||
|
portal,
|
||||||
tailscale_required: ts_required,
|
tailscale_required: ts_required,
|
||||||
tailscale,
|
tailscale,
|
||||||
exit_node,
|
exit_node,
|
||||||
|
|
|
||||||
110
src/tailscale.rs
110
src/tailscale.rs
|
|
@ -313,6 +313,54 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use serde_json::json;
|
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]
|
#[test]
|
||||||
fn backend_state_extraction() {
|
fn backend_state_extraction() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -322,6 +370,13 @@ mod tests {
|
||||||
assert_eq!(backend_state(&json!({})), "");
|
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]
|
#[test]
|
||||||
fn exit_node_healthy_and_selected() {
|
fn exit_node_healthy_and_selected() {
|
||||||
let v = json!({
|
let v = json!({
|
||||||
|
|
@ -381,4 +436,59 @@ mod tests {
|
||||||
});
|
});
|
||||||
assert_eq!(exit_node_state(&v, "exitnode"), (true, true, false));
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
115
src/util.rs
115
src/util.rs
|
|
@ -1,5 +1,6 @@
|
||||||
|
use std::fs;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
@ -10,6 +11,42 @@ pub fn home_dir() -> PathBuf {
|
||||||
.unwrap_or_else(|| PathBuf::from("/root"))
|
.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 {
|
pub fn command_exists(name: &str) -> bool {
|
||||||
if let Some(paths) = std::env::var_os("PATH") {
|
if let Some(paths) = std::env::var_os("PATH") {
|
||||||
for dir in std::env::split_paths(&paths) {
|
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)
|
let mut child = match Command::new(prog)
|
||||||
.args(args)
|
.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)
|
.stdin(stdin_cfg)
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(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(),
|
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 stdout_pipe = child.stdout.take();
|
||||||
let mut stderr_pipe = child.stderr.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
|
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 start = Instant::now();
|
||||||
let status = loop {
|
let status = loop {
|
||||||
match child.try_wait() {
|
match child.try_wait() {
|
||||||
|
|
@ -176,4 +221,58 @@ mod tests {
|
||||||
// Leap day 2024-02-29 12:00:00 UTC
|
// Leap day 2024-02-29 12:00:00 UTC
|
||||||
assert_eq!(fmt_epoch(1_709_208_000), "2024-02-29 12:00:00");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
60
src/watch.rs
60
src/watch.rs
|
|
@ -4,6 +4,7 @@ use std::sync::mpsc::{self, Receiver};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use crate::backend::{Backend, System};
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::flow;
|
use crate::flow;
|
||||||
use crate::notify::{log, notify, Urgency};
|
use crate::notify::{log, notify, Urgency};
|
||||||
|
|
@ -15,32 +16,34 @@ use crate::tailscale::TsHealth;
|
||||||
enum Health {
|
enum Health {
|
||||||
Up,
|
Up,
|
||||||
DownNoNet,
|
DownNoNet,
|
||||||
|
/// Associated, but a captive portal is intercepting traffic (manual login).
|
||||||
|
CaptivePortal,
|
||||||
DownTailscaleManual,
|
DownTailscaleManual,
|
||||||
DownTailscaleOther,
|
DownTailscaleOther,
|
||||||
NoAdapter,
|
NoAdapter,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn classify(cfg: &Config, profile: &str) -> (Health, Option<String>) {
|
fn classify(be: &dyn Backend, cfg: &Config, profile: &str) -> (Health, status::Status) {
|
||||||
let s = status::gather(cfg, profile);
|
let s = status::gather(be, cfg, profile);
|
||||||
if s.iface.is_none() {
|
let health = if s.iface.is_none() {
|
||||||
return (Health::NoAdapter, None);
|
Health::NoAdapter
|
||||||
}
|
} else if s.portal.is_some() {
|
||||||
let ssid = s.ssid.clone();
|
Health::CaptivePortal
|
||||||
if !s.internet {
|
} else if !s.internet {
|
||||||
return (Health::DownNoNet, ssid);
|
Health::DownNoNet
|
||||||
}
|
} else if s.tailscale_required {
|
||||||
if s.tailscale_required {
|
|
||||||
match s.tailscale {
|
match s.tailscale {
|
||||||
Some(TsHealth::Ok) => (Health::Up, ssid),
|
Some(TsHealth::Ok) => Health::Up,
|
||||||
Some(TsHealth::NeedsLogin) | Some(TsHealth::NotInstalled) => {
|
Some(TsHealth::NeedsLogin) | Some(TsHealth::NotInstalled) => {
|
||||||
(Health::DownTailscaleManual, ssid)
|
Health::DownTailscaleManual
|
||||||
}
|
}
|
||||||
Some(_) => (Health::DownTailscaleOther, ssid),
|
Some(_) => Health::DownTailscaleOther,
|
||||||
None => (Health::DownTailscaleManual, ssid),
|
None => Health::DownTailscaleManual,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
(Health::Up, ssid)
|
Health::Up
|
||||||
}
|
};
|
||||||
|
(health, s)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tail `nmcli monitor` and ping the channel on link-state churn so we react
|
/// 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::<()>();
|
let (tx, rx) = mpsc::channel::<()>();
|
||||||
spawn_nm_monitor(tx);
|
spawn_nm_monitor(tx);
|
||||||
|
|
||||||
|
let be = System;
|
||||||
let mut profile = State::load(&cfg.settings.default_profile).profile;
|
let mut profile = State::load(&cfg.settings.default_profile).profile;
|
||||||
if run_initial {
|
if run_initial {
|
||||||
// Don't churn an already-working connection on (re)start.
|
// 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 {
|
if h == Health::Up {
|
||||||
log(&format!(
|
log(&format!(
|
||||||
"watch: already healthy on start (profile={profile}); skipping initial flow"
|
"watch: already healthy on start (profile={profile}); skipping initial flow"
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
log(&format!("watch: initial flow for profile={profile}"));
|
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
|
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);
|
let transition = prev_health.as_ref() != Some(&health);
|
||||||
|
|
||||||
match &health {
|
match &health {
|
||||||
|
|
@ -164,6 +169,18 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
||||||
}
|
}
|
||||||
fail_streak = 0;
|
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 => {
|
Health::NoAdapter => {
|
||||||
if transition {
|
if transition {
|
||||||
notify(
|
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.
|
// Re-run flow only on transition so we land on the bootstrap net.
|
||||||
if transition || profile_changed {
|
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);
|
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",
|
"watch: down ({:?}) profile={profile} ssid={:?} — running flow",
|
||||||
health, ssid
|
health, ssid
|
||||||
));
|
));
|
||||||
let outcome = flow::run(&cfg, &profile);
|
let outcome = flow::run(&be, &cfg, &profile);
|
||||||
log(&format!("watch: recovery outcome = {:?}", outcome));
|
log(&format!("watch: recovery outcome = {:?}", outcome));
|
||||||
last_flow_at = Some(Instant::now());
|
last_flow_at = Some(Instant::now());
|
||||||
fail_streak = if outcome.ok() {
|
fail_streak = if outcome.ok() {
|
||||||
|
|
|
||||||
347
tests/cli.rs
347
tests/cli.rs
|
|
@ -142,3 +142,350 @@ fn unknown_profile_override_is_reported() {
|
||||||
let o = sb.cmd(&["--profile", "nope", "init"]);
|
let o = sb.cmd(&["--profile", "nope", "init"]);
|
||||||
assert!(!o.status.success());
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue