Add feature batch: captive-portal detection, schedules, exit-node failover, 802.1x, per-network DNS
Implements the planned feature sweep: tri-state connectivity with portal
detection, time-based profile schedules, priority exit-node list with
failover, enterprise (802.1x) network support, per-network DNS, signal-
aware selection, auto-learn markers, suspend/resume recovery, prune
command, scored detection, and richer bread events (network.changed,
tailscale.changed). CLI gains --json output, init --wait, and add --dns/
--eap/--identity/--ca-cert. Adds regression coverage for each feature;
141 tests pass and clippy is clean with -D warnings.
Generated with Codebuff 🤖
Co-Authored-By: Codebuff <noreply@codebuff.com>
This commit is contained in:
parent
c02360a873
commit
13c7743d48
13 changed files with 2178 additions and 359 deletions
|
|
@ -25,8 +25,10 @@ still switches the profile on disk — there is just nobody listening for
|
|||
|
||||
| Event | Data | When |
|
||||
|-------|------|------|
|
||||
| `bread.crumbs.profile.changed` | `{ "from": "<name>", "to": "<name>" }` | The watch loop observes that the persisted active profile is no longer the one it last acted on (CLI `profile set`, `detect --apply`, or `bread.command.crumbs.set_profile`). Not emitted on watcher start just because a profile is already selected. |
|
||||
| `bread.crumbs.health.changed` | `{ "profile": "<name>", "health": "<variant>", "ssid": <string or null> }` | The watch loop's health classification changes — including the first observation after start, and the forced re-evaluation after a profile change. **Not** emitted on every poll tick while the classification stays the same. |
|
||||
| `bread.crumbs.profile.changed` | `{ "from": "<name>", "to": "<name>" }` | The watch loop observes that the persisted active profile is no longer the one it last acted on (CLI `profile set`, `detect --apply`, `bread.command.crumbs.set_profile`, or a time-of-day schedule switch). Not emitted on watcher start just because a profile is already selected. |
|
||||
| `bread.crumbs.health.changed` | `{ "profile": "<name>", "health": "<variant>", "ssid": <string or null>, "iface": <string or null>, "ip": <string or null>, "exit_node": "<string>", "tailscale": <variant or null> }` | The watch loop's health classification changes — including the first observation after start, and the forced re-evaluation after a profile change. **Not** emitted on every poll tick while the classification stays the same. |
|
||||
| `bread.crumbs.network.changed` | `{ "from": <ssid or null>, "to": <ssid or null>, "profile": "<name>" }` | The active SSID changed between watch-loop ticks. `from` is `null` on the first association observed after start (or after a profile switch). |
|
||||
| `bread.crumbs.tailscale.changed` | `{ "profile": "<name>", "state": <variant or null>, "exit_node": "<string>" }` | The Tailscale health state (or its mere presence) changed between ticks. `state` is the `TsHealth` variant name or `null` when Tailscale isn't installed. |
|
||||
| `bread.crumbs.set_profile.done` | `{ "profile": "<name>" }` | `bread.command.crumbs.set_profile` persisted the new profile. |
|
||||
| `bread.crumbs.set_profile.failed` | `{ "error": "<message>" }` | `bread.command.crumbs.set_profile` was received but rejected (unknown profile, missing `profile` field, config unreadable). |
|
||||
|
||||
|
|
@ -36,6 +38,7 @@ still switches the profile on disk — there is just nobody listening for
|
|||
|---------|---------|
|
||||
| `Up` | Adapter present, internet reachable, Tailscale healthy if the profile requires it. |
|
||||
| `DownNoNet` | No internet. |
|
||||
| `CaptivePortal` | No internet and an HTTP response arrived that wasn't the 204 generate_204 returns — traffic is being intercepted (captive/guest portal). Needs a browser sign-in, not a reconnect. |
|
||||
| `DownTailscaleManual` | Tailscale required but needs login / isn't installed — cannot auto-fix. |
|
||||
| `DownTailscaleOther` | Tailscale required and unhealthy for some other (usually auto-recoverable) reason. |
|
||||
| `NoAdapter` | No Wi-Fi interface. |
|
||||
|
|
|
|||
39
README.md
39
README.md
|
|
@ -54,6 +54,13 @@ Settings and location profiles live in `breadcrumbs.toml` — the file people ac
|
|||
dns = "1.1.1.1" # DNS server pinned on every connection
|
||||
nmcli_wait = 8 # seconds to wait for nmcli connect
|
||||
exit_node = "myhostname" # default Tailscale exit node
|
||||
exit_nodes = ["a", "b"] # optional priority list; tried in order (fallback nodes)
|
||||
interface = "wlan0" # optional preferred Wi-Fi interface
|
||||
schedule = [] # optional time-of-day profile switches, e.g.
|
||||
# [[settings.schedule]]
|
||||
# profile = "home"
|
||||
# from = "18:00"
|
||||
# to = "08:00" # from >= to = overnight window
|
||||
default_profile = "away"
|
||||
watch_interval = 12 # seconds between health checks (minimum 4)
|
||||
connectivity_url = "http://connectivitycheck.gstatic.com/generate_204"
|
||||
|
|
@ -80,6 +87,15 @@ Saved networks (SSID + optional local password) live separately, in `networks.to
|
|||
ssid = "MyHomeNetwork"
|
||||
password = "hunter2" # optional — see "Credential handling" below
|
||||
hidden = false
|
||||
dns = "1.1.1.1" # optional per-network DNS override; "" disables pinning
|
||||
|
||||
# WPA-Enterprise (802.1x) networks use these instead of a PSK:
|
||||
# [[networks]]
|
||||
# ssid = "CorpEAP"
|
||||
# eap = "peap" # or "tls"
|
||||
# identity = "user@corp"
|
||||
# password = "..." # 802.1x password
|
||||
# ca_cert = "/etc/ssl/certs/corp-ca.pem" # optional
|
||||
```
|
||||
|
||||
`password` is only needed the first time breadcrumbs connects to a network. Once NetworkManager durably saves the credential, breadcrumbs clears its local copy and omits the key on the next save — an existing config with `password = "..."` still loads fine either way, no migration step needed. A config with `[[networks]]` still written inline in `breadcrumbs.toml` (from before this split) also still loads: it's read once, then migrated into `networks.toml` automatically on the next save.
|
||||
|
|
@ -95,7 +111,8 @@ Each profile defines:
|
|||
| `bootstrap` | SSID to connect to first (e.g. guest Wi-Fi that allows Tailscale traffic). |
|
||||
| `exit_node` | Tailscale exit node for this profile (overrides `settings.exit_node`). |
|
||||
| `include_all_known` | After the priority list, also try every other known network. |
|
||||
| `detect_ssids` | Any visible SSID in this list marks this profile as a candidate for `breadcrumbs detect`. |
|
||||
| `detect_ssids` | Any visible SSID in this list marks this profile as a candidate for `breadcrumbs detect`. Profiles with more matching markers win. |
|
||||
| `learn` | If `true`, SSIDs this profile successfully connects to are appended to `detect_ssids` (bounded), so `detect` improves without hand-editing. Off by default. |
|
||||
|
||||
## Usage
|
||||
|
||||
|
|
@ -105,15 +122,16 @@ breadcrumbs [--profile <name>] <command>
|
|||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `status` | Show current Wi-Fi / Tailscale health (default) |
|
||||
| `init` | Run the full connect sequence for the active profile |
|
||||
| `status [--json]` | Show current Wi-Fi / Tailscale health (default) |
|
||||
| `init [--wait <s>]` | Run the full connect sequence; `--wait` retries until connected or the timeout elapses |
|
||||
| `watch [--no-initial]` | Self-healing daemon: monitors and auto-recovers drops |
|
||||
| `profile get` | Print the active profile |
|
||||
| `profile set <name>` | Switch profile (and apply it, unless `--no-apply`) |
|
||||
| `profile list` | List all profiles |
|
||||
| `detect [--apply]` | Guess profile from visible networks; optionally apply it |
|
||||
| `add <ssid> [password]` | Add or update a saved network |
|
||||
| `detect [--apply] [--json]` | Guess profile from visible networks; optionally apply it |
|
||||
| `add <ssid> [password]` | Add or update a saved network (`--dns`, `--eap`, `--identity`, `--ca-cert`, `--hidden`, `--to`, `--at`) |
|
||||
| `forget <ssid>` | Remove a network from config and NetworkManager |
|
||||
| `prune [--dry-run]` | Remove NetworkManager wireless profiles whose SSID is no longer in the config |
|
||||
| `scan [--to <profile>]` | Interactive scan, pick, connect and save |
|
||||
| `list [--show-passwords]` | Show config: settings, networks, profiles |
|
||||
| `edit` | Open config in `$EDITOR`, validate on exit |
|
||||
|
|
@ -154,6 +172,17 @@ breadcrumbs install-service
|
|||
2. Reacts immediately to link-state changes via `nmcli monitor`
|
||||
3. Runs `flow::run` (the connect state machine) on any detected drop
|
||||
4. Handles profile changes live — re-reads config and state on every tick
|
||||
5. Distinguishes captive portals from plain no-internet (a 200/301/302 instead
|
||||
of the 204 generate_204 returns) and tells you to sign in instead of
|
||||
pointlessly reconnecting
|
||||
6. Applies a `[settings.schedule]` time-of-day profile switch, respecting a
|
||||
30-minute grace window after a manual `profile set`
|
||||
7. Detects suspend/resume (a large gap between ticks) and forces an immediate
|
||||
recovery check instead of waiting out the poll interval
|
||||
|
||||
When a Tailscale profile is connected through a bootstrap network and the
|
||||
connectivity check is intercepted, the watcher stays put and notifies once —
|
||||
it does not churn reconnects against a portal.
|
||||
|
||||
Install as a systemd user service:
|
||||
|
||||
|
|
|
|||
279
src/app.rs
279
src/app.rs
|
|
@ -38,13 +38,54 @@ struct Cli {
|
|||
cmd: Option<Cmd>,
|
||||
}
|
||||
|
||||
/// Optional flags for `add`. Flattened into the `Add` subcommand so the
|
||||
/// CLI surface is unchanged while keeping `cmd_add`'s signature small.
|
||||
#[derive(clap::Args)]
|
||||
struct AddOpts {
|
||||
/// Password (prompted if omitted)
|
||||
password: Option<String>,
|
||||
/// Network is hidden (does not broadcast its SSID).
|
||||
/// `--hidden` sets it; `--hidden=false` clears it on an existing
|
||||
/// entry; omitted leaves an existing entry's flag untouched.
|
||||
#[arg(long, num_args = 0..=1, default_missing_value = "true")]
|
||||
hidden: Option<bool>,
|
||||
/// Per-network DNS override (empty string disables DNS pinning
|
||||
/// for this network)
|
||||
#[arg(long)]
|
||||
dns: Option<String>,
|
||||
/// 802.1x EAP method for enterprise networks (e.g. "peap", "tls")
|
||||
#[arg(long)]
|
||||
eap: Option<String>,
|
||||
/// 802.1x identity for enterprise networks
|
||||
#[arg(long)]
|
||||
identity: Option<String>,
|
||||
/// Path to a CA certificate for 802.1x
|
||||
#[arg(long)]
|
||||
ca_cert: Option<String>,
|
||||
/// Attach this SSID to a profile's priority list
|
||||
#[arg(long)]
|
||||
to: Option<String>,
|
||||
/// Position in the profile list (0 = highest priority)
|
||||
#[arg(long)]
|
||||
at: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Show current Wi-Fi / profile / Tailscale status (default)
|
||||
Status,
|
||||
Status {
|
||||
/// Emit machine-readable JSON
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Run the full connect sequence for the active profile
|
||||
#[command(visible_aliases = ["up", "connect", "i"])]
|
||||
Init,
|
||||
Init {
|
||||
/// Retry until connected or this many seconds have elapsed
|
||||
/// (0 = single attempt)
|
||||
#[arg(long, default_value_t = 0)]
|
||||
wait: u64,
|
||||
},
|
||||
/// Run as a daemon: watch for drops and auto-recover
|
||||
Watch {
|
||||
/// Skip the connect attempt on startup
|
||||
|
|
@ -61,26 +102,25 @@ enum Cmd {
|
|||
/// Set + apply the detected profile
|
||||
#[arg(long)]
|
||||
apply: bool,
|
||||
/// Emit machine-readable JSON
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Add or update a saved network
|
||||
Add {
|
||||
ssid: String,
|
||||
/// Password (prompted if omitted)
|
||||
password: Option<String>,
|
||||
/// Network is hidden (does not broadcast its SSID).
|
||||
/// `--hidden` sets it; `--hidden=false` clears it on an existing
|
||||
/// entry; omitted leaves an existing entry's flag untouched.
|
||||
#[arg(long, num_args = 0..=1, default_missing_value = "true")]
|
||||
hidden: Option<bool>,
|
||||
/// Attach this SSID to a profile's priority list
|
||||
#[arg(long)]
|
||||
to: Option<String>,
|
||||
/// Position in the profile list (0 = highest priority)
|
||||
#[arg(long)]
|
||||
at: Option<usize>,
|
||||
#[command(flatten)]
|
||||
opts: AddOpts,
|
||||
},
|
||||
/// Remove a saved network (config + NetworkManager)
|
||||
Forget { ssid: String },
|
||||
/// Remove NetworkManager wireless profiles whose SSID is no longer in
|
||||
/// the breadcrumbs config
|
||||
Prune {
|
||||
/// Only list what would be removed
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
},
|
||||
/// Scan, pick, connect and save a network interactively
|
||||
Scan {
|
||||
/// Attach the saved network to this profile
|
||||
|
|
@ -147,7 +187,7 @@ fn active_profile(cfg: &Config, override_p: &Option<String>) -> 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.
|
||||
if let Cmd::Cd { shell } = &cmd {
|
||||
|
|
@ -157,24 +197,14 @@ fn real_main(cli: Cli) -> Result<i32, String> {
|
|||
let mut cfg = Config::load()?;
|
||||
|
||||
match cmd {
|
||||
Cmd::Status => cmd_status(&cfg, &cli.profile),
|
||||
Cmd::Init => {
|
||||
let p = active_profile(&cfg, &cli.profile);
|
||||
let outcome = flow::run(&mut cfg, &p);
|
||||
print_outcome(&p, &outcome);
|
||||
Ok(if outcome.ok() { 0 } else { 1 })
|
||||
}
|
||||
Cmd::Status { json } => cmd_status(&cfg, &cli.profile, json),
|
||||
Cmd::Init { wait } => cmd_init(&mut cfg, &cli.profile, wait),
|
||||
Cmd::Watch { no_initial } => Ok(watch::run(cfg, !no_initial)),
|
||||
Cmd::Profile { action } => cmd_profile(&mut cfg, action),
|
||||
Cmd::Detect { apply } => cmd_detect(&mut cfg, apply),
|
||||
Cmd::Add {
|
||||
ssid,
|
||||
password,
|
||||
hidden,
|
||||
to,
|
||||
at,
|
||||
} => cmd_add(&mut cfg, ssid, password, hidden, to, at),
|
||||
Cmd::Detect { apply, json } => cmd_detect(&mut cfg, apply, json),
|
||||
Cmd::Add { ssid, opts } => cmd_add(&mut cfg, ssid, opts),
|
||||
Cmd::Forget { ssid } => cmd_forget(&mut cfg, &ssid),
|
||||
Cmd::Prune { dry_run } => cmd_prune(&cfg, dry_run),
|
||||
Cmd::Scan { to } => cmd_scan(&mut cfg, to),
|
||||
Cmd::List { show_passwords } => cmd_list(&cfg, show_passwords),
|
||||
Cmd::Edit => cmd_edit(),
|
||||
|
|
@ -184,6 +214,32 @@ fn real_main(cli: Cli) -> Result<i32, String> {
|
|||
}
|
||||
}
|
||||
|
||||
fn cmd_init(cfg: &mut Config, override_p: &Option<String>, wait: u64) -> Result<i32, String> {
|
||||
let p = active_profile(cfg, override_p);
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(wait);
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
// First attempt notifies normally (user-initiated); retries are
|
||||
// quiet so a long --wait run doesn't spam notifications.
|
||||
let outcome = if attempt == 0 {
|
||||
flow::run(cfg, &p)
|
||||
} else {
|
||||
flow::run_quiet(cfg, &p)
|
||||
};
|
||||
if outcome.ok() {
|
||||
print_outcome(&p, &outcome);
|
||||
return Ok(0);
|
||||
}
|
||||
if wait == 0 || std::time::Instant::now() >= deadline {
|
||||
print_outcome(&p, &outcome);
|
||||
return Ok(1);
|
||||
}
|
||||
attempt += 1;
|
||||
println!("{C_DIM}not connected yet — retrying in 3s…{C_RESET}");
|
||||
std::thread::sleep(Duration::from_secs(3));
|
||||
}
|
||||
}
|
||||
|
||||
fn print_outcome(profile: &str, o: &flow::Outcome) {
|
||||
match o {
|
||||
flow::Outcome::Connected { ssid, note } => {
|
||||
|
|
@ -212,10 +268,34 @@ fn print_outcome(profile: &str, o: &flow::Outcome) {
|
|||
}
|
||||
}
|
||||
|
||||
fn cmd_status(cfg: &Config, override_p: &Option<String>) -> Result<i32, String> {
|
||||
fn cmd_status(cfg: &Config, override_p: &Option<String>, json: bool) -> Result<i32, String> {
|
||||
let p = active_profile(cfg, override_p);
|
||||
let s = crate::status::gather(cfg, &p);
|
||||
|
||||
let healthy = s.internet
|
||||
&& s.iface.is_some()
|
||||
&& (!s.tailscale_required || s.tailscale.as_ref().map(|h| h.is_ok()).unwrap_or(false));
|
||||
|
||||
if json {
|
||||
let tailscale = s.tailscale.as_ref().map(|h| h.state_str());
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"profile": p,
|
||||
"iface": s.iface,
|
||||
"ssid": s.ssid,
|
||||
"ip": s.ip,
|
||||
"internet": s.internet,
|
||||
"portal": s.portal,
|
||||
"tailscale_required": s.tailscale_required,
|
||||
"tailscale": tailscale,
|
||||
"exit_node": s.exit_node,
|
||||
"healthy": healthy,
|
||||
})
|
||||
);
|
||||
return Ok(if healthy { 0 } else { 1 });
|
||||
}
|
||||
|
||||
let dot = |ok: bool| {
|
||||
if ok {
|
||||
format!("{C_GREEN}●{C_RESET}")
|
||||
|
|
@ -262,9 +342,6 @@ fn cmd_status(cfg: &Config, override_p: &Option<String>) -> Result<i32, String>
|
|||
(None, _) => println!(" tailscale {C_DIM}not installed{C_RESET}"),
|
||||
}
|
||||
|
||||
let healthy = s.internet
|
||||
&& s.iface.is_some()
|
||||
&& (!s.tailscale_required || s.tailscale.as_ref().map(|h| h.is_ok()).unwrap_or(false));
|
||||
println!(
|
||||
" state {}",
|
||||
if healthy {
|
||||
|
|
@ -304,42 +381,61 @@ fn cmd_profile(cfg: &mut Config, action: Option<ProfileCmd>) -> Result<i32, Stri
|
|||
}
|
||||
|
||||
fn detect_profile(cfg: &Config) -> Option<String> {
|
||||
let iface = nm::wifi_interface()?;
|
||||
let iface = nm::wifi_interface_preferred(cfg.settings.interface.as_deref())?;
|
||||
nm::radio_on();
|
||||
nm::rescan(&iface, &[]);
|
||||
let visible = nm::visible_ssids(&iface);
|
||||
let visible = nm::visible_signals(&iface);
|
||||
|
||||
// Profiles are stored in a BTreeMap so iteration order is deterministic
|
||||
// (alphabetical). The caller can rely on that for tie-breaking.
|
||||
// Scored detection: the profile with the most matching markers wins, so
|
||||
// a 2-marker match beats a 1-marker one. Profiles are stored in a
|
||||
// BTreeMap, so ties resolve deterministically (alphabetically first).
|
||||
let mut best: Option<(String, usize)> = None;
|
||||
for (name, profile) in &cfg.profiles {
|
||||
if profile.detect_ssids.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if profile
|
||||
let count = profile
|
||||
.detect_ssids
|
||||
.iter()
|
||||
.any(|s| visible.contains(s.as_str()))
|
||||
{
|
||||
return Some(name.clone());
|
||||
.filter(|s| visible.contains_key(s.as_str()))
|
||||
.count();
|
||||
if count > 0 {
|
||||
let better = match &best {
|
||||
None => true,
|
||||
Some((_, c)) => count > *c,
|
||||
};
|
||||
if better {
|
||||
best = Some((name.clone(), count));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to the default profile if no markers matched — but only if
|
||||
// it actually exists: a stale `default_profile` name is a config error,
|
||||
// not a detection result, and persisting it would wedge the watcher in
|
||||
// UnknownProfile forever.
|
||||
best.map(|(p, _)| p).or_else(|| {
|
||||
// Fall back to the default profile if no markers matched — but only
|
||||
// if it actually exists: a stale `default_profile` name is a config
|
||||
// error, not a detection result, and persisting it would wedge the
|
||||
// watcher in UnknownProfile forever.
|
||||
if cfg.profiles.contains_key(&cfg.settings.default_profile) {
|
||||
Some(cfg.settings.default_profile.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn cmd_detect(cfg: &mut Config, apply: bool) -> Result<i32, String> {
|
||||
fn cmd_detect(cfg: &mut Config, apply: bool, json: bool) -> Result<i32, String> {
|
||||
match detect_profile(cfg) {
|
||||
Some(p) => {
|
||||
println!("{p}");
|
||||
if json && !apply {
|
||||
println!("{}", serde_json::json!({ "profile": p }));
|
||||
return Ok(0);
|
||||
}
|
||||
if apply {
|
||||
if json {
|
||||
println!("{}", serde_json::json!({ "profile": p }));
|
||||
} else {
|
||||
println!("{p}");
|
||||
}
|
||||
// Route through state::set_profile (like the CLI and the
|
||||
// bread bus do) so an unknown fallback is rejected with a
|
||||
// proper error instead of being persisted as active.
|
||||
|
|
@ -348,6 +444,7 @@ fn cmd_detect(cfg: &mut Config, apply: bool) -> Result<i32, String> {
|
|||
print_outcome(&p, &outcome);
|
||||
return Ok(if outcome.ok() { 0 } else { 1 });
|
||||
}
|
||||
println!("{p}");
|
||||
Ok(0)
|
||||
}
|
||||
None => Err("could not detect a profile (no Wi-Fi adapter, or no \
|
||||
|
|
@ -392,16 +489,28 @@ fn prompt_secret(msg: &str) -> String {
|
|||
val
|
||||
}
|
||||
|
||||
fn cmd_add(
|
||||
cfg: &mut Config,
|
||||
ssid: String,
|
||||
password: Option<String>,
|
||||
hidden: Option<bool>,
|
||||
to: Option<String>,
|
||||
at: Option<usize>,
|
||||
) -> Result<i32, String> {
|
||||
fn cmd_add(cfg: &mut Config, ssid: String, opts: AddOpts) -> Result<i32, String> {
|
||||
let AddOpts {
|
||||
password,
|
||||
hidden,
|
||||
dns,
|
||||
eap,
|
||||
identity,
|
||||
ca_cert,
|
||||
to,
|
||||
at,
|
||||
} = opts;
|
||||
// `--dns ""` is the explicit "don't pin DNS" opt-out; normalize an
|
||||
// absent flag to None (use the global setting).
|
||||
let dns = match dns {
|
||||
Some(s) if s.is_empty() => Some(String::new()),
|
||||
Some(s) => Some(s),
|
||||
None => None,
|
||||
};
|
||||
// For enterprise networks, the password is the 802.1x password.
|
||||
let password = match password {
|
||||
Some(p) => p,
|
||||
None if eap.is_some() => prompt_secret(&format!("802.1x password for '{ssid}': ")),
|
||||
None => prompt_secret(&format!("Password for '{ssid}': ")),
|
||||
};
|
||||
let password = non_empty(password);
|
||||
|
|
@ -414,10 +523,26 @@ fn cmd_add(
|
|||
if let Some(h) = hidden {
|
||||
n.hidden = h;
|
||||
}
|
||||
if dns.is_some() {
|
||||
n.dns = dns;
|
||||
}
|
||||
if eap.is_some() {
|
||||
n.eap = eap;
|
||||
}
|
||||
if identity.is_some() {
|
||||
n.identity = identity;
|
||||
}
|
||||
if ca_cert.is_some() {
|
||||
n.ca_cert = ca_cert;
|
||||
}
|
||||
}
|
||||
None => cfg.networks.push(NetworkDef {
|
||||
ssid: ssid.clone(),
|
||||
password,
|
||||
dns,
|
||||
eap,
|
||||
identity,
|
||||
ca_cert,
|
||||
hidden: hidden.unwrap_or(false),
|
||||
}),
|
||||
}
|
||||
|
|
@ -458,6 +583,39 @@ fn cmd_forget(cfg: &mut Config, ssid: &str) -> Result<i32, String> {
|
|||
Ok(0)
|
||||
}
|
||||
|
||||
/// Remove NetworkManager wireless profiles whose SSID is no longer known to
|
||||
/// breadcrumbs (config `networks`, or any profile's priority list or
|
||||
/// bootstrap). `--dry-run` only lists. Returns the number removed.
|
||||
fn cmd_prune(cfg: &Config, dry_run: bool) -> Result<i32, String> {
|
||||
let known: Vec<&str> = cfg
|
||||
.networks
|
||||
.iter()
|
||||
.map(|n| n.ssid.as_str())
|
||||
.chain(
|
||||
cfg.profiles
|
||||
.values()
|
||||
.flat_map(|p| p.networks.iter().map(|s| s.as_str()).chain(p.bootstrap.iter().map(|s| s.as_str()))),
|
||||
)
|
||||
.collect();
|
||||
let stale: Vec<(String, String)> = nm::wireless_profiles()
|
||||
.into_iter()
|
||||
.filter(|(_name, ssid)| !known.contains(&ssid.as_str()))
|
||||
.collect();
|
||||
if stale.is_empty() {
|
||||
println!("{C_GREEN}nothing to prune{C_RESET}");
|
||||
return Ok(0);
|
||||
}
|
||||
for (name, ssid) in &stale {
|
||||
if dry_run {
|
||||
println!("{C_DIM}would remove{C_RESET} {name} ({ssid})");
|
||||
} else {
|
||||
println!("{C_GREEN}removed{C_RESET} {name} ({ssid})");
|
||||
let _ = nm::delete_connections_for_ssid(ssid);
|
||||
}
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn cmd_scan(cfg: &mut Config, to: Option<String>) -> Result<i32, String> {
|
||||
// Validate `--to` up front, before any side effects (connecting is
|
||||
// one): `add --to` errors on an unknown profile, so `scan --to` must
|
||||
|
|
@ -467,7 +625,8 @@ fn cmd_scan(cfg: &mut Config, to: Option<String>) -> Result<i32, String> {
|
|||
return Err(format!("unknown profile '{prof_name}'"));
|
||||
}
|
||||
}
|
||||
let iface = nm::wifi_interface().ok_or("no Wi-Fi adapter")?;
|
||||
let iface = nm::wifi_interface_preferred(cfg.settings.interface.as_deref())
|
||||
.ok_or("no Wi-Fi adapter")?;
|
||||
nm::radio_on();
|
||||
nm::rescan(&iface, &[]);
|
||||
let entries = nm::scan_list(&iface);
|
||||
|
|
@ -501,6 +660,10 @@ fn cmd_scan(cfg: &mut Config, to: Option<String>) -> Result<i32, String> {
|
|||
let mut def = NetworkDef {
|
||||
ssid: ssid.clone(),
|
||||
password,
|
||||
dns: None,
|
||||
eap: None,
|
||||
identity: None,
|
||||
ca_cert: None,
|
||||
hidden: false,
|
||||
};
|
||||
if !nm::connect(&iface, &def, cfg.settings.nmcli_wait, &cfg.settings.dns) {
|
||||
|
|
|
|||
|
|
@ -26,13 +26,57 @@ pub fn emit_profile_changed(client: &BreadClient, from: &str, to: &str) {
|
|||
);
|
||||
}
|
||||
|
||||
pub fn emit_health_changed(client: &BreadClient, profile: &str, health: &str, ssid: Option<&str>) {
|
||||
/// Payload for `bread.crumbs.health.changed`. Constructed by the watch
|
||||
/// loop from a classification and passed as a unit so the emit functions
|
||||
/// stay small.
|
||||
pub struct HealthChanged<'a> {
|
||||
pub profile: &'a str,
|
||||
pub health: &'a str,
|
||||
pub ssid: Option<&'a str>,
|
||||
pub iface: Option<&'a str>,
|
||||
pub ip: Option<&'a str>,
|
||||
pub exit_node: &'a str,
|
||||
pub tailscale: Option<&'a str>,
|
||||
}
|
||||
|
||||
pub fn emit_health_changed(client: &BreadClient, ev: HealthChanged<'_>) {
|
||||
client.emit(
|
||||
"bread.crumbs.health.changed",
|
||||
serde_json::json!({
|
||||
"profile": ev.profile,
|
||||
"health": ev.health,
|
||||
"ssid": ev.ssid,
|
||||
"iface": ev.iface,
|
||||
"ip": ev.ip,
|
||||
"exit_node": ev.exit_node,
|
||||
"tailscale": ev.tailscale,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// `bread.crumbs.network.changed` — the watch loop observed the active SSID
|
||||
/// transition. `from` is `null` when there was no previous association.
|
||||
pub fn emit_network_changed(client: &BreadClient, from: Option<&str>, to: Option<&str>, profile: &str) {
|
||||
client.emit(
|
||||
"bread.crumbs.network.changed",
|
||||
serde_json::json!({ "from": from, "to": to, "profile": profile }),
|
||||
);
|
||||
}
|
||||
|
||||
/// `bread.crumbs.tailscale.changed` — the Tailscale health state (or its
|
||||
/// mere presence) changed between watch-loop ticks.
|
||||
pub fn emit_tailscale_changed(
|
||||
client: &BreadClient,
|
||||
profile: &str,
|
||||
state: Option<&str>,
|
||||
exit_node: &str,
|
||||
) {
|
||||
client.emit(
|
||||
"bread.crumbs.tailscale.changed",
|
||||
serde_json::json!({
|
||||
"profile": profile,
|
||||
"health": health,
|
||||
"ssid": ssid,
|
||||
"state": state,
|
||||
"exit_node": exit_node,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
258
src/config.rs
258
src/config.rs
|
|
@ -28,6 +28,47 @@ fn default_ping_host() -> String {
|
|||
"1.1.1.1".to_string()
|
||||
}
|
||||
|
||||
/// Parse "HH:MM" (24h) into minutes since midnight; `None` if malformed.
|
||||
pub fn hhmm_to_minutes(s: &str) -> Option<u32> {
|
||||
let (h, m) = s.trim().split_once(':')?;
|
||||
let h: u32 = h.parse().ok()?;
|
||||
let m: u32 = m.parse().ok()?;
|
||||
if h > 23 || m > 59 {
|
||||
return None;
|
||||
}
|
||||
Some(h * 60 + m)
|
||||
}
|
||||
|
||||
/// Does the window `[from, to)` (minutes since midnight) contain `now`?
|
||||
/// `from >= to` means an overnight window (e.g. 22:00–07:00).
|
||||
pub fn window_contains(from: u32, to: u32, now: u32) -> bool {
|
||||
if from < to {
|
||||
now >= from && now < to
|
||||
} else {
|
||||
now >= from || now < to
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ScheduleEntry {
|
||||
/// Profile to switch to while the window is active.
|
||||
pub profile: String,
|
||||
/// "HH:MM", inclusive start.
|
||||
pub from: String,
|
||||
/// "HH:MM", exclusive end (`from >= to` means overnight).
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
impl ScheduleEntry {
|
||||
/// Whether the window contains `now_minutes` (minutes since midnight).
|
||||
pub fn contains(&self, now_minutes: u32) -> bool {
|
||||
match (hhmm_to_minutes(&self.from), hhmm_to_minutes(&self.to)) {
|
||||
(Some(f), Some(t)) => window_contains(f, t, now_minutes),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_false(b: &bool) -> bool {
|
||||
!b
|
||||
}
|
||||
|
|
@ -56,6 +97,21 @@ pub struct Settings {
|
|||
/// configs keep parsing exactly as before.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub core_profiles_initialized: bool,
|
||||
/// Preferred Wi-Fi interface (e.g. "wlan0"). When set, this exact
|
||||
/// device is used if present; otherwise the first Wi-Fi device wins.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub interface: Option<String>,
|
||||
/// Priority-ordered fallback exit nodes. Tried in order by the flow;
|
||||
/// the first healthy one is selected. Falls back to `exit_node` when
|
||||
/// empty.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub exit_nodes: Vec<String>,
|
||||
/// Optional time-of-day schedule: at a given time, switch to the listed
|
||||
/// profile automatically (respecting a manual-override grace window;
|
||||
/// see the watch loop). First matching rule wins; outside every window
|
||||
/// nothing is switched.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub schedule: Vec<ScheduleEntry>,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
|
|
@ -69,10 +125,24 @@ impl Default for Settings {
|
|||
connectivity_url: default_connectivity_url(),
|
||||
ping_host: default_ping_host(),
|
||||
core_profiles_initialized: false,
|
||||
interface: None,
|
||||
exit_nodes: Vec::new(),
|
||||
schedule: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// The profile a time-of-day schedule picks for `now_minutes` (minutes
|
||||
/// since midnight), if any — first matching rule wins.
|
||||
pub fn scheduled_profile(&self, now_minutes: u32) -> Option<String> {
|
||||
self.schedule
|
||||
.iter()
|
||||
.find(|e| e.contains(now_minutes))
|
||||
.map(|e| e.profile.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NetworkDef {
|
||||
pub ssid: String,
|
||||
|
|
@ -87,10 +157,33 @@ pub struct NetworkDef {
|
|||
/// fed to `nmcli --ask` on stdin, never as an argv element.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub password: Option<String>,
|
||||
/// Per-network DNS override. `None` falls back to `settings.dns`;
|
||||
/// an explicitly empty string disables DNS pinning for this network.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dns: Option<String>,
|
||||
/// WPA-Enterprise (802.1x). When `eap` is set the network is treated as
|
||||
/// enterprise: `identity` + `password` (reused) + optional `ca_cert`
|
||||
/// path. `eap` is e.g. "peap" or "tls".
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub eap: Option<String>,
|
||||
/// 802.1x identity (e.g. `user@corp`) for enterprise networks.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub identity: Option<String>,
|
||||
/// Path to a CA certificate for 802.1x (optional).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub ca_cert: Option<String>,
|
||||
#[serde(default)]
|
||||
pub hidden: bool,
|
||||
}
|
||||
|
||||
impl NetworkDef {
|
||||
/// The DNS to pin for this network: the per-network override if set,
|
||||
/// otherwise the global setting.
|
||||
pub fn effective_dns<'a>(&'a self, fallback: &'a str) -> &'a str {
|
||||
self.dns.as_deref().unwrap_or(fallback)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Profile {
|
||||
/// Optional SSID connected first to bootstrap connectivity (e.g. for Tailscale).
|
||||
|
|
@ -112,6 +205,11 @@ pub struct Profile {
|
|||
/// Used by `breadcrumbs detect` to guess the active profile.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub detect_ssids: Vec<String>,
|
||||
/// Opt-in learning: on a successful connect, the SSID is appended to
|
||||
/// `detect_ssids` (bounded) so `breadcrumbs detect` improves without
|
||||
/// hand-editing. Off by default to keep detect predictable.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub learn: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -184,6 +282,24 @@ impl Config {
|
|||
self.networks.iter().find(|n| n.ssid == ssid)
|
||||
}
|
||||
|
||||
/// The effective exit-node list for a profile, in priority order:
|
||||
/// per-profile `exit_node`, else `settings.exit_nodes`, else
|
||||
/// `settings.exit_node`. Empty entries are filtered out.
|
||||
pub fn exit_nodes_for(&self, profile: &str) -> Vec<String> {
|
||||
if let Some(p) = self.profiles.get(profile).and_then(|p| p.exit_node.clone()) {
|
||||
return vec![p];
|
||||
}
|
||||
let list = if self.settings.exit_nodes.is_empty() {
|
||||
vec![self.settings.exit_node.clone()]
|
||||
} else {
|
||||
self.settings.exit_nodes.clone()
|
||||
};
|
||||
list.into_iter()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Load config, creating a skeleton one on first run.
|
||||
pub fn load() -> Result<Config, String> {
|
||||
let path = config_path();
|
||||
|
|
@ -303,6 +419,7 @@ fn core_profiles() -> BTreeMap<String, Profile> {
|
|||
exit_node: None,
|
||||
include_all_known: false,
|
||||
detect_ssids: vec![],
|
||||
learn: false,
|
||||
},
|
||||
);
|
||||
p.insert(
|
||||
|
|
@ -314,6 +431,7 @@ fn core_profiles() -> BTreeMap<String, Profile> {
|
|||
exit_node: None,
|
||||
include_all_known: false,
|
||||
detect_ssids: vec![],
|
||||
learn: false,
|
||||
},
|
||||
);
|
||||
p.insert(
|
||||
|
|
@ -325,6 +443,7 @@ fn core_profiles() -> BTreeMap<String, Profile> {
|
|||
exit_node: None,
|
||||
include_all_known: true,
|
||||
detect_ssids: vec![],
|
||||
learn: false,
|
||||
},
|
||||
);
|
||||
p
|
||||
|
|
@ -474,6 +593,10 @@ hidden = false"#;
|
|||
let n = NetworkDef {
|
||||
ssid: "Cafe".into(),
|
||||
password: None,
|
||||
dns: None,
|
||||
eap: None,
|
||||
identity: None,
|
||||
ca_cert: None,
|
||||
hidden: false,
|
||||
};
|
||||
let text = toml::to_string_pretty(&n).unwrap();
|
||||
|
|
@ -485,6 +608,10 @@ hidden = false"#;
|
|||
let n = NetworkDef {
|
||||
ssid: "Cafe".into(),
|
||||
password: Some("hunter2".into()),
|
||||
dns: None,
|
||||
eap: None,
|
||||
identity: None,
|
||||
ca_cert: None,
|
||||
hidden: false,
|
||||
};
|
||||
let text = toml::to_string_pretty(&n).unwrap();
|
||||
|
|
@ -529,4 +656,135 @@ dns = "9.9.9.9""#;
|
|||
assert!(cfg.networks.is_empty());
|
||||
assert!(cfg.profiles.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_nodes_for_prioritizes_profile_then_list_then_single_node() {
|
||||
let mut cfg = build_initial_config();
|
||||
cfg.settings.exit_node = "global".into();
|
||||
cfg.settings.exit_nodes = vec!["listA".into(), "listB".into()];
|
||||
cfg.profiles.get_mut("home").unwrap().exit_node = Some("profile".into());
|
||||
|
||||
// Per-profile override wins outright.
|
||||
assert_eq!(cfg.exit_nodes_for("home"), vec!["profile".to_string()]);
|
||||
|
||||
// Otherwise the priority list is used verbatim.
|
||||
cfg.profiles.get_mut("home").unwrap().exit_node = None;
|
||||
assert_eq!(
|
||||
cfg.exit_nodes_for("home"),
|
||||
vec!["listA".to_string(), "listB".to_string()]
|
||||
);
|
||||
|
||||
// Without a list, the single setting is the (one-element) fallback.
|
||||
cfg.settings.exit_nodes = vec![];
|
||||
assert_eq!(cfg.exit_nodes_for("home"), vec!["global".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_nodes_for_filters_empty_and_whitespace_entries() {
|
||||
let mut cfg = build_initial_config();
|
||||
cfg.settings.exit_nodes = vec![
|
||||
" ".into(),
|
||||
"nodeA".into(),
|
||||
"".into(),
|
||||
" nodeB ".into(),
|
||||
];
|
||||
assert_eq!(
|
||||
cfg.exit_nodes_for("home"),
|
||||
vec!["nodeA".to_string(), "nodeB".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hhmm_to_minutes_parses_and_rejects_malformed() {
|
||||
assert_eq!(hhmm_to_minutes("09:30"), Some(570));
|
||||
assert_eq!(hhmm_to_minutes("00:00"), Some(0));
|
||||
assert_eq!(hhmm_to_minutes("23:59"), Some(1439));
|
||||
assert_eq!(hhmm_to_minutes("9:30"), Some(570)); // lenient about padding
|
||||
assert_eq!(hhmm_to_minutes("24:00"), None); // hour out of range
|
||||
assert_eq!(hhmm_to_minutes("12:60"), None); // minute out of range
|
||||
assert_eq!(hhmm_to_minutes("0930"), None); // no colon
|
||||
assert_eq!(hhmm_to_minutes(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_contains_handles_same_day_and_overnight() {
|
||||
// Same-day window 09:00–17:00 (end exclusive).
|
||||
assert!(window_contains(540, 1020, 600));
|
||||
assert!(!window_contains(540, 1020, 1020));
|
||||
assert!(!window_contains(540, 1020, 500));
|
||||
// Overnight window 22:00–07:00.
|
||||
assert!(window_contains(1320, 420, 1380)); // 23:00
|
||||
assert!(window_contains(1320, 420, 60)); // 01:00
|
||||
assert!(!window_contains(1320, 420, 720)); // 12:00
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheduled_profile_returns_first_matching_rule() {
|
||||
let mut cfg = build_initial_config();
|
||||
cfg.settings.schedule = vec![
|
||||
ScheduleEntry {
|
||||
profile: "work".into(),
|
||||
from: "09:00".into(),
|
||||
to: "17:00".into(),
|
||||
},
|
||||
ScheduleEntry {
|
||||
profile: "home".into(),
|
||||
from: "09:30".into(),
|
||||
to: "18:00".into(),
|
||||
},
|
||||
];
|
||||
// 10:00 matches both — the first rule (work) wins.
|
||||
assert_eq!(cfg.settings.scheduled_profile(600), Some("work".into()));
|
||||
// Outside every window → no schedule applies.
|
||||
assert_eq!(cfg.settings.scheduled_profile(60), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_dns_uses_per_network_override_then_global_fallback() {
|
||||
let n = NetworkDef {
|
||||
ssid: "x".into(),
|
||||
password: None,
|
||||
dns: Some("9.9.9.9".into()),
|
||||
eap: None,
|
||||
identity: None,
|
||||
ca_cert: None,
|
||||
hidden: false,
|
||||
};
|
||||
assert_eq!(n.effective_dns("1.1.1.1"), "9.9.9.9");
|
||||
|
||||
let n2 = NetworkDef { dns: None, ..n.clone() };
|
||||
assert_eq!(n2.effective_dns("1.1.1.1"), "1.1.1.1");
|
||||
|
||||
// An explicit empty string is a valid per-network opt-out.
|
||||
let n3 = NetworkDef { dns: Some(String::new()), ..n.clone() };
|
||||
assert_eq!(n3.effective_dns("1.1.1.1"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enterprise_fields_round_trip_and_omit_when_none() {
|
||||
let n = NetworkDef {
|
||||
ssid: "Corp".into(),
|
||||
password: Some("pw".into()),
|
||||
dns: None,
|
||||
eap: Some("peap".into()),
|
||||
identity: Some("user@corp".into()),
|
||||
ca_cert: Some("/etc/ca.pem".into()),
|
||||
hidden: false,
|
||||
};
|
||||
let text = toml::to_string_pretty(&n).unwrap();
|
||||
assert!(text.contains("eap") && text.contains("identity") && text.contains("ca_cert"));
|
||||
let back: NetworkDef = toml::from_str(&text).unwrap();
|
||||
assert_eq!(back.eap.as_deref(), Some("peap"));
|
||||
assert_eq!(back.identity.as_deref(), Some("user@corp"));
|
||||
assert_eq!(back.ca_cert.as_deref(), Some("/etc/ca.pem"));
|
||||
|
||||
let plain = NetworkDef {
|
||||
eap: None,
|
||||
identity: None,
|
||||
ca_cert: None,
|
||||
..n
|
||||
};
|
||||
let t2 = toml::to_string_pretty(&plain).unwrap();
|
||||
assert!(!t2.contains("eap") && !t2.contains("identity") && !t2.contains("ca_cert"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
55
src/flow.rs
55
src/flow.rs
|
|
@ -77,10 +77,27 @@ fn clear_password_if_used(cfg: &mut Config, ssid: &str) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Opt-in learning (`profiles.<name>.learn = true`): remember the SSIDs a
|
||||
/// profile successfully connects to so `breadcrumbs detect` improves without
|
||||
/// hand-editing. Bounded to keep the list sane; never touches an existing
|
||||
/// marker.
|
||||
fn learn_ssid(cfg: &mut Config, profile: &str, ssid: &str) {
|
||||
let Some(p) = cfg.profiles.get_mut(profile) else {
|
||||
return;
|
||||
};
|
||||
if !p.learn || p.detect_ssids.len() >= 8 || p.detect_ssids.iter().any(|s| s == ssid) {
|
||||
return;
|
||||
}
|
||||
p.detect_ssids.push(ssid.to_string());
|
||||
if let Err(e) = cfg.save() {
|
||||
log(&format!("failed to persist learned SSID {ssid} for {profile}: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to connect + confirm the device actually landed on the *requested*
|
||||
/// SSID. Returns Ok(()) on success, Err(reason) on failure.
|
||||
fn connect_and_verify(iface: &str, def: &NetworkDef, cfg: &Config) -> Result<(), String> {
|
||||
nm::connect_verbose(iface, def, cfg.settings.nmcli_wait, &cfg.settings.dns)?;
|
||||
nm::connect_verbose(iface, def, cfg.settings.nmcli_wait, def.effective_dns(&cfg.settings.dns))?;
|
||||
// Confirm the SSID, not just "device connected": NM autoconnect can win
|
||||
// a race and leave the device on a different network, and the wifi list
|
||||
// can lag activation by a moment — so poll briefly before giving up.
|
||||
|
|
@ -140,7 +157,7 @@ fn run_inner(cfg: &mut Config, profile_name: &str, notify_user: bool) -> Outcome
|
|||
}
|
||||
};
|
||||
|
||||
let iface = match nm::wifi_interface() {
|
||||
let iface = match nm::wifi_interface_preferred(cfg.settings.interface.as_deref()) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
if notify_user {
|
||||
|
|
@ -155,10 +172,8 @@ fn run_inner(cfg: &mut Config, profile_name: &str, notify_user: bool) -> Outcome
|
|||
};
|
||||
nm::radio_on();
|
||||
|
||||
let exit_node = profile
|
||||
.exit_node
|
||||
.clone()
|
||||
.unwrap_or_else(|| cfg.settings.exit_node.clone());
|
||||
let exit_nodes = cfg.exit_nodes_for(profile_name);
|
||||
let exit_node = exit_nodes.first().cloned().unwrap_or_default();
|
||||
let candidates = resolve_candidates(cfg, &profile);
|
||||
|
||||
log(&format!(
|
||||
|
|
@ -208,7 +223,7 @@ fn run_inner(cfg: &mut Config, profile_name: &str, notify_user: bool) -> Outcome
|
|||
}
|
||||
}
|
||||
|
||||
let ts = tailscale::ensure_exit_node(&exit_node);
|
||||
let ts = tailscale::ensure_exit_node(&exit_nodes);
|
||||
if !ts.is_ok() {
|
||||
let ssid = nm::active_ssid(&iface).or_else(|| profile.bootstrap.clone());
|
||||
if notify_user {
|
||||
|
|
@ -229,17 +244,29 @@ fn run_inner(cfg: &mut Config, profile_name: &str, notify_user: bool) -> Outcome
|
|||
nm::rescan(&iface, &scan_targets);
|
||||
}
|
||||
|
||||
let visible = nm::visible_ssids(&iface);
|
||||
// Signals are re-read after the Tailscale gate so pass 1 can prefer the
|
||||
// strongest AP among a profile's visible networks.
|
||||
let visible_sig = nm::visible_signals(&iface);
|
||||
|
||||
// ---- Connect to the priority list ----------------------------------
|
||||
// Pass 1: visible networks in priority order.
|
||||
// Pass 1: visible networks, strongest signal first (priority order is
|
||||
// the stable tiebreaker for equal signals).
|
||||
let mut visible_candidates: Vec<&NetworkDef> = candidates
|
||||
.iter()
|
||||
.filter(|d| visible_sig.contains_key(&d.ssid))
|
||||
.collect();
|
||||
visible_candidates.sort_by(|a, b| {
|
||||
visible_sig
|
||||
.get(&b.ssid)
|
||||
.cmp(&visible_sig.get(&a.ssid))
|
||||
});
|
||||
let mut any_attempted = false;
|
||||
for def in &candidates {
|
||||
if visible.contains(&def.ssid) {
|
||||
for def in &visible_candidates {
|
||||
any_attempted = true;
|
||||
match connect_and_verify(&iface, def, cfg) {
|
||||
Ok(()) => {
|
||||
clear_password_if_used(cfg, &def.ssid);
|
||||
learn_ssid(cfg, profile_name, &def.ssid);
|
||||
let note = if internet_ok(cfg) {
|
||||
None
|
||||
} else {
|
||||
|
|
@ -254,7 +281,6 @@ fn run_inner(cfg: &mut Config, profile_name: &str, notify_user: bool) -> Outcome
|
|||
Err(e) => log(&format!("connect failed (visible): {} — {e}", def.ssid)),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pass 2: hidden networks we couldn't see in the scan.
|
||||
for def in &candidates {
|
||||
if def.hidden && !visible.contains(&def.ssid) {
|
||||
|
|
@ -262,6 +288,7 @@ fn run_inner(cfg: &mut Config, profile_name: &str, notify_user: bool) -> Outcome
|
|||
match connect_and_verify(&iface, def, cfg) {
|
||||
Ok(()) => {
|
||||
clear_password_if_used(cfg, &def.ssid);
|
||||
learn_ssid(cfg, profile_name, &def.ssid);
|
||||
let note = if internet_ok(cfg) {
|
||||
None
|
||||
} else {
|
||||
|
|
@ -379,6 +406,10 @@ mod tests {
|
|||
NetworkDef {
|
||||
ssid: ssid.into(),
|
||||
password: Some("x".into()),
|
||||
dns: None,
|
||||
eap: None,
|
||||
identity: None,
|
||||
ca_cert: None,
|
||||
hidden: false,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
334
src/nm.rs
334
src/nm.rs
|
|
@ -1,8 +1,8 @@
|
|||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config::NetworkDef;
|
||||
use crate::util::{run, run_ok, run_with_stdin};
|
||||
use crate::util::{run, run_ok};
|
||||
|
||||
/// nmcli `-t` escapes `:` and `\` in field values; undo that.
|
||||
fn unescape(s: &str) -> String {
|
||||
|
|
@ -49,6 +49,13 @@ fn split_fields(line: &str) -> Vec<String> {
|
|||
}
|
||||
|
||||
pub fn wifi_interface() -> Option<String> {
|
||||
wifi_interface_preferred(None)
|
||||
}
|
||||
|
||||
/// Find the Wi-Fi interface. When `pref` is `Some`, that exact device is
|
||||
/// used if present; otherwise (or if the preferred device is missing — e.g.
|
||||
/// an unplugged USB dongle) the first Wi-Fi device wins.
|
||||
pub fn wifi_interface_preferred(pref: Option<&str>) -> Option<String> {
|
||||
let o = run(
|
||||
"nmcli",
|
||||
&["-t", "-f", "DEVICE,TYPE", "device", "status"],
|
||||
|
|
@ -57,13 +64,19 @@ pub fn wifi_interface() -> Option<String> {
|
|||
if !o.success {
|
||||
return None;
|
||||
}
|
||||
let mut devices: Vec<String> = Vec::new();
|
||||
for line in o.stdout.lines() {
|
||||
let fields = split_fields(line);
|
||||
if fields.len() >= 2 && fields[1] == "wifi" {
|
||||
return Some(fields[0].clone());
|
||||
devices.push(fields[0].clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
if let Some(p) = pref {
|
||||
if devices.iter().any(|d| d == p) {
|
||||
return Some(p.to_string());
|
||||
}
|
||||
}
|
||||
devices.into_iter().next()
|
||||
}
|
||||
|
||||
pub fn radio_on() {
|
||||
|
|
@ -107,6 +120,49 @@ pub fn visible_ssids(iface: &str) -> HashSet<String> {
|
|||
set
|
||||
}
|
||||
|
||||
/// Visible SSIDs with their signal strength (0–100), one entry per SSID
|
||||
/// (strongest BSSID wins). Used for signal-aware network selection and
|
||||
/// scored detection.
|
||||
pub fn visible_signals(iface: &str) -> HashMap<String, i32> {
|
||||
let o = run(
|
||||
"nmcli",
|
||||
&[
|
||||
"-t",
|
||||
"-f",
|
||||
"SSID,SIGNAL",
|
||||
"device",
|
||||
"wifi",
|
||||
"list",
|
||||
"ifname",
|
||||
iface,
|
||||
],
|
||||
Duration::from_secs(12),
|
||||
);
|
||||
let mut m: HashMap<String, i32> = HashMap::new();
|
||||
if !o.success {
|
||||
return m;
|
||||
}
|
||||
for line in o.stdout.lines() {
|
||||
let f = split_fields(line);
|
||||
if f.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let ssid = f[0].trim().to_string();
|
||||
if ssid.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let sig = signal_strength(&f[1]);
|
||||
m.entry(ssid)
|
||||
.and_modify(|e| {
|
||||
if sig > *e {
|
||||
*e = sig;
|
||||
}
|
||||
})
|
||||
.or_insert(sig);
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScanEntry {
|
||||
pub ssid: String,
|
||||
|
|
@ -307,8 +363,8 @@ pub fn connect(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> bool {
|
|||
|
||||
/// Connect to a network and pin DNS. Returns the nmcli error on failure.
|
||||
///
|
||||
/// Reuses an existing saved profile for the SSID when one exists so that
|
||||
/// repeated connections do not accumulate numbered duplicates in
|
||||
/// Reuses an existing saved profile for the SSID when one exists (updating its
|
||||
/// PSK) so that repeated connections do not accumulate numbered duplicates in
|
||||
/// NetworkManager ("NCC", "NCC 1", "NCC 2", …). Falls back to
|
||||
/// `nmcli device wifi connect` — which creates a new profile — only when no
|
||||
/// saved profile is found.
|
||||
|
|
@ -316,18 +372,47 @@ pub fn connect(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> bool {
|
|||
/// `net.password` is only sent when `Some`: on the reuse path, `None` means
|
||||
/// "leave the saved PSK alone" (either NetworkManager already durably owns
|
||||
/// it, or the network is open); on the create path it means "no password
|
||||
/// at all", which is also how a genuinely open (no-security) SSID is
|
||||
/// connected. See the field doc on [`NetworkDef::password`] for how a local
|
||||
/// secret transitions to `None` after its first successful use.
|
||||
/// argument at all", which is also how a genuinely open (no-security) SSID
|
||||
/// is connected. See the field doc on [`NetworkDef::password`] for how a
|
||||
/// local secret transitions to `None` after its first successful use.
|
||||
///
|
||||
/// When a password *is* sent it goes to `nmcli --ask` on stdin, never on
|
||||
/// argv — `/proc/<pid>/cmdline` is world-readable. After the first success
|
||||
/// breadcrumbs clears its local copy, so subsequent connects pass nothing.
|
||||
/// KNOWN LIMITATION (credential exposure): when a password *is* sent, it's
|
||||
/// passed to `nmcli` as a plain command-line argument
|
||||
/// (`802-11-wireless-security.psk <pw>` on the reuse path, `password <pw>`
|
||||
/// on the create path). For the lifetime of that `nmcli` child, the secret
|
||||
/// is readable by other local users via `/proc/<pid>/cmdline`.
|
||||
/// `util::run_with_stdin` exists to feed secrets on stdin instead, but
|
||||
/// wiring it up correctly needs either verified `nmcli --ask` piped-stdin
|
||||
/// behavior or NetworkManager's D-Bus secret-agent API — neither of which
|
||||
/// can be validated without a live NetworkManager connection — so this is
|
||||
/// left as documented tech debt rather than a guess. In practice this
|
||||
/// exposure window now only exists on a network's *first* connect: once
|
||||
/// NetworkManager has the credential, breadcrumbs clears its local copy, so
|
||||
/// there's nothing left to pass on argv for every subsequent connect.
|
||||
pub fn connect_verbose(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> Result<(), String> {
|
||||
let wait_s = wait.to_string();
|
||||
let timeout = Duration::from_secs(wait as u64 + 15);
|
||||
|
||||
if let Some(profile) = first_profile_for_ssid(&net.ssid) {
|
||||
// Update the saved credentials and, for hidden networks, ensure the
|
||||
// flag is set. PSK vs 802.1x (enterprise) profiles are updated with
|
||||
// their own property sets.
|
||||
if let Some(pw) = &net.password {
|
||||
if net.eap.is_some() {
|
||||
enterprise_modify(&profile, net);
|
||||
} else {
|
||||
let _ = run(
|
||||
"nmcli",
|
||||
&[
|
||||
"connection",
|
||||
"modify",
|
||||
&profile,
|
||||
"802-11-wireless-security.psk",
|
||||
pw.as_str(),
|
||||
],
|
||||
Duration::from_secs(6),
|
||||
);
|
||||
}
|
||||
}
|
||||
if net.hidden {
|
||||
let _ = run(
|
||||
"nmcli",
|
||||
|
|
@ -341,52 +426,44 @@ pub fn connect_verbose(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> R
|
|||
Duration::from_secs(6),
|
||||
);
|
||||
}
|
||||
let o = if let Some(pw) = &net.password {
|
||||
// WHY: a stored PSK makes NM skip the secret agent, so --ask
|
||||
// would never read stdin. Resetting the property (empty value,
|
||||
// not a secret) forces a request; the new PSK arrives on stdin.
|
||||
let _ = run(
|
||||
let o = run(
|
||||
"nmcli",
|
||||
&[
|
||||
"connection",
|
||||
"modify",
|
||||
&profile,
|
||||
"802-11-wireless-security.psk",
|
||||
"",
|
||||
],
|
||||
Duration::from_secs(6),
|
||||
&["--wait", &wait_s, "connection", "up", &profile, "ifname", iface],
|
||||
Duration::from_secs(wait as u64 + 15),
|
||||
);
|
||||
let stdin = format!("{pw}\n");
|
||||
run_with_stdin(
|
||||
"nmcli",
|
||||
&[
|
||||
"--ask",
|
||||
"--wait",
|
||||
&wait_s,
|
||||
"connection",
|
||||
"up",
|
||||
&profile,
|
||||
"ifname",
|
||||
iface,
|
||||
],
|
||||
Some(&stdin),
|
||||
timeout,
|
||||
)
|
||||
if !o.success {
|
||||
let detail = o.stderr.trim().to_string();
|
||||
return Err(if detail.is_empty() {
|
||||
o.stdout.trim().to_string()
|
||||
} else {
|
||||
run(
|
||||
detail
|
||||
});
|
||||
}
|
||||
if let Some(uuid) = active_uuid(iface) {
|
||||
enforce_dns(&uuid, iface, dns);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if net.eap.is_some() {
|
||||
// Enterprise networks can't be created via `device wifi connect`
|
||||
// (no 802-1x options) — create the profile explicitly, then bring
|
||||
// it up.
|
||||
let args = enterprise_create_args(net, &wait_s, iface);
|
||||
let o = run("nmcli", &args, Duration::from_secs(wait as u64 + 15));
|
||||
if !o.success {
|
||||
let detail = o.stderr.trim().to_string();
|
||||
return Err(if detail.is_empty() {
|
||||
o.stdout.trim().to_string()
|
||||
} else {
|
||||
detail
|
||||
});
|
||||
}
|
||||
let o = run(
|
||||
"nmcli",
|
||||
&[
|
||||
"--wait",
|
||||
&wait_s,
|
||||
"connection",
|
||||
"up",
|
||||
&profile,
|
||||
"ifname",
|
||||
iface,
|
||||
],
|
||||
timeout,
|
||||
)
|
||||
};
|
||||
&["--wait", &wait_s, "connection", "up", &net.ssid, "ifname", iface],
|
||||
Duration::from_secs(wait as u64 + 15),
|
||||
);
|
||||
if !o.success {
|
||||
let detail = o.stderr.trim().to_string();
|
||||
return Err(if detail.is_empty() {
|
||||
|
|
@ -403,51 +480,29 @@ pub fn connect_verbose(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> R
|
|||
|
||||
// No saved profile — create one via device wifi connect.
|
||||
let hidden = if net.hidden { "yes" } else { "no" };
|
||||
let o = if let Some(pw) = &net.password {
|
||||
// WHY: never put the PSK on argv — /proc/<pid>/cmdline is
|
||||
// world-readable. `nmcli --ask` registers as a secret agent and
|
||||
// nmc_readline reads the PSK from stdin (one line). Open networks
|
||||
// stay on the no-ask path so we don't hang on a prompt.
|
||||
let stdin = format!("{pw}\n");
|
||||
run_with_stdin(
|
||||
"nmcli",
|
||||
&[
|
||||
"--ask",
|
||||
let mut args: Vec<&str> = vec![
|
||||
"--wait",
|
||||
&wait_s,
|
||||
"device",
|
||||
"wifi",
|
||||
"connect",
|
||||
net.ssid.as_str(),
|
||||
"hidden",
|
||||
hidden,
|
||||
"ifname",
|
||||
iface,
|
||||
],
|
||||
Some(&stdin),
|
||||
timeout,
|
||||
)
|
||||
} else {
|
||||
// No local PSK: either the SSID is open, or NM should already hold
|
||||
// the secret — the latter only succeeds if a saved profile exists,
|
||||
// which is why we only reach this branch when that assumption held.
|
||||
run(
|
||||
"nmcli",
|
||||
&[
|
||||
"--wait",
|
||||
&wait_s,
|
||||
"device",
|
||||
"wifi",
|
||||
"connect",
|
||||
net.ssid.as_str(),
|
||||
"hidden",
|
||||
hidden,
|
||||
"ifname",
|
||||
iface,
|
||||
],
|
||||
timeout,
|
||||
)
|
||||
};
|
||||
];
|
||||
// Only pass `password` when we actually have one. An empty/missing PSK
|
||||
// argument makes nmcli treat the network as open (no security), which is
|
||||
// what we want both for genuinely open SSIDs and for a network whose
|
||||
// secret NetworkManager should already hold — though the latter case
|
||||
// only succeeds if a saved profile in fact exists, which is why we only
|
||||
// reach this branch (no saved profile found) when that assumption held.
|
||||
if let Some(pw) = &net.password {
|
||||
args.push("password");
|
||||
args.push(pw.as_str());
|
||||
}
|
||||
args.push("hidden");
|
||||
args.push(hidden);
|
||||
args.push("ifname");
|
||||
args.push(iface);
|
||||
let o = run("nmcli", &args, Duration::from_secs(wait as u64 + 15));
|
||||
if !o.success {
|
||||
let detail = o.stderr.trim().to_string();
|
||||
return Err(if detail.is_empty() {
|
||||
|
|
@ -462,6 +517,97 @@ pub fn connect_verbose(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> R
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// `nmcli connection modify` args switching an existing profile to the
|
||||
/// network's 802.1x settings (`wifi-sec.key-mgmt wpa-eap` + 802-1x props).
|
||||
fn enterprise_modify(profile: &str, net: &NetworkDef) {
|
||||
let mut args: Vec<&str> = vec![
|
||||
"connection",
|
||||
"modify",
|
||||
profile,
|
||||
"wifi-sec.key-mgmt",
|
||||
"wpa-eap",
|
||||
];
|
||||
enterprise_props(&mut args, net);
|
||||
let _ = run("nmcli", &args, Duration::from_secs(6));
|
||||
}
|
||||
|
||||
/// Append the 802.1x property pairs for `net` to `args`.
|
||||
fn enterprise_props<'a>(args: &mut Vec<&'a str>, net: &'a NetworkDef) {
|
||||
if let Some(eap) = &net.eap {
|
||||
args.push("802-1x.eap");
|
||||
args.push(eap.as_str());
|
||||
}
|
||||
if let Some(id) = &net.identity {
|
||||
args.push("802-1x.identity");
|
||||
args.push(id.as_str());
|
||||
}
|
||||
if let Some(ca) = &net.ca_cert {
|
||||
args.push("802-1x.ca-cert");
|
||||
args.push(ca.as_str());
|
||||
}
|
||||
if let Some(pw) = &net.password {
|
||||
args.push("802-1x.password");
|
||||
args.push(pw.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
/// `nmcli connection add` args for an enterprise (802.1x) network — the
|
||||
/// create path, since `device wifi connect` can't express 802-1x settings.
|
||||
fn enterprise_create_args<'a>(net: &'a NetworkDef, wait_s: &'a str, iface: &'a str) -> Vec<&'a str> {
|
||||
let mut args: Vec<&str> = vec![
|
||||
"--wait",
|
||||
wait_s,
|
||||
"connection",
|
||||
"add",
|
||||
"type",
|
||||
"wifi",
|
||||
"con-name",
|
||||
net.ssid.as_str(),
|
||||
"ssid",
|
||||
net.ssid.as_str(),
|
||||
"wifi-sec.key-mgmt",
|
||||
"wpa-eap",
|
||||
];
|
||||
enterprise_props(&mut args, net);
|
||||
if net.hidden {
|
||||
args.push("802-11-wireless.hidden");
|
||||
args.push("yes");
|
||||
}
|
||||
args.push("ifname");
|
||||
args.push(iface);
|
||||
args
|
||||
}
|
||||
|
||||
/// List all wireless connection profiles as `(name, ssid)` pairs, using the
|
||||
/// profile's `802-11-wireless.ssid` setting when present (falling back to
|
||||
/// the profile name). Used by `breadcrumbs prune`.
|
||||
pub fn wireless_profiles() -> Vec<(String, String)> {
|
||||
let list = run(
|
||||
"nmcli",
|
||||
&["-t", "-f", "NAME,TYPE", "connection", "show"],
|
||||
Duration::from_secs(8),
|
||||
);
|
||||
let mut out = Vec::new();
|
||||
if !list.success {
|
||||
return out;
|
||||
}
|
||||
for line in list.stdout.lines() {
|
||||
let fields = split_fields(line);
|
||||
if fields.len() < 2 || !fields[1].contains("wireless") {
|
||||
continue;
|
||||
}
|
||||
let name = fields[0].clone();
|
||||
let conn_ssid = run(
|
||||
"nmcli",
|
||||
&["-g", "802-11-wireless.ssid", "connection", "show", &name],
|
||||
Duration::from_secs(6),
|
||||
);
|
||||
let conn_ssid = conn_ssid.stdout.trim().to_string();
|
||||
out.push((name.clone(), if conn_ssid.is_empty() { name } else { conn_ssid }));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Delete every saved connection profile whose name or 802-11-wireless SSID
|
||||
/// matches `ssid` (used by `breadcrumbs forget` to purge stale entries).
|
||||
pub fn delete_connections_for_ssid(ssid: &str) -> bool {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,19 @@ use crate::nm;
|
|||
use crate::tailscale::{self, TsHealth};
|
||||
use crate::util::{command_exists, run};
|
||||
|
||||
pub fn internet_ok(cfg: &Config) -> bool {
|
||||
/// Connectivity verdict. `Portal` is the interesting case: an HTTP response
|
||||
/// arrived (200/301/302) but it wasn't the 204 the generate_204 endpoint
|
||||
/// returns for genuine internet — the classic captive/guest-portal
|
||||
/// signature, and the reason `classify` can tell "no internet at all" from
|
||||
/// "internet but intercepted".
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Connectivity {
|
||||
Online,
|
||||
Portal,
|
||||
NoNet,
|
||||
}
|
||||
|
||||
pub fn connectivity(cfg: &Config) -> Connectivity {
|
||||
if command_exists("curl") {
|
||||
let o = run(
|
||||
"curl",
|
||||
|
|
@ -22,21 +34,35 @@ pub fn internet_ok(cfg: &Config) -> bool {
|
|||
Duration::from_secs(6),
|
||||
);
|
||||
// Only a 204 counts as real internet. Captive/guest portals answer
|
||||
// 200 (a login page) or 302 (a redirect to it) — accepting those
|
||||
// would classify a portal-trapped device as "Up". The default
|
||||
// endpoint is generate_204, which returns 204 precisely when
|
||||
// traffic isn't being intercepted.
|
||||
if o.stdout.trim() == "204" {
|
||||
return true;
|
||||
// 200 (a login page) or 302 (a redirect to it). The default endpoint
|
||||
// is generate_204, which returns 204 precisely when traffic isn't
|
||||
// being intercepted.
|
||||
let code = o.stdout.trim();
|
||||
if code == "204" {
|
||||
return Connectivity::Online;
|
||||
}
|
||||
if code == "200" || code == "301" || code == "302" {
|
||||
return Connectivity::Portal;
|
||||
}
|
||||
}
|
||||
// Fallback: ICMP to the configured host.
|
||||
run(
|
||||
// Fallback: ICMP to the configured host. A working ping overrides a
|
||||
// non-204 curl answer that wasn't portal-shaped (e.g. a 403 from an
|
||||
// overzealous firewall); a portal usually blocks ICMP too, so this
|
||||
// stays Portal for the genuine case.
|
||||
let ping = run(
|
||||
"ping",
|
||||
&["-c", "1", "-W", "2", &cfg.settings.ping_host],
|
||||
Duration::from_secs(4),
|
||||
)
|
||||
.success
|
||||
);
|
||||
if ping.success {
|
||||
Connectivity::Online
|
||||
} else {
|
||||
Connectivity::NoNet
|
||||
}
|
||||
}
|
||||
|
||||
pub fn internet_ok(cfg: &Config) -> bool {
|
||||
matches!(connectivity(cfg), Connectivity::Online)
|
||||
}
|
||||
|
||||
fn ipv4(iface: &str) -> Option<String> {
|
||||
|
|
@ -61,28 +87,40 @@ pub struct Status {
|
|||
pub ssid: Option<String>,
|
||||
pub ip: Option<String>,
|
||||
pub internet: bool,
|
||||
/// True when traffic is being intercepted (captive/guest portal).
|
||||
pub portal: bool,
|
||||
pub tailscale_required: bool,
|
||||
pub tailscale: Option<TsHealth>,
|
||||
pub exit_node: String,
|
||||
}
|
||||
|
||||
pub fn gather(cfg: &Config, profile_name: &str) -> Status {
|
||||
let iface = nm::wifi_interface();
|
||||
let iface = nm::wifi_interface_preferred(cfg.settings.interface.as_deref());
|
||||
let ssid = iface.as_deref().and_then(nm::active_ssid);
|
||||
let ip = iface.as_deref().and_then(ipv4);
|
||||
// Skip the (potentially 4s-blocking) connectivity probe when there's no
|
||||
// Wi-Fi interface at all: the watch loop classifies NoAdapter and would
|
||||
// otherwise burn a network round-trip (curl/ping) every tick for nothing.
|
||||
let internet = iface.is_some() && internet_ok(cfg);
|
||||
let (internet, portal) = if iface.is_some() {
|
||||
match connectivity(cfg) {
|
||||
Connectivity::Online => (true, false),
|
||||
Connectivity::Portal => (false, true),
|
||||
Connectivity::NoNet => (false, false),
|
||||
}
|
||||
} else {
|
||||
(false, false)
|
||||
};
|
||||
|
||||
let prof = cfg.profile(profile_name);
|
||||
let ts_required = prof.map(|p| p.tailscale).unwrap_or(false);
|
||||
let exit_node = prof
|
||||
.and_then(|p| p.exit_node.clone())
|
||||
.unwrap_or_else(|| cfg.settings.exit_node.clone());
|
||||
let exit_nodes = cfg.exit_nodes_for(profile_name);
|
||||
let exit_node = exit_nodes.first().cloned().unwrap_or_default();
|
||||
|
||||
// Checked whenever tailscale is installed so `status`/`doctor` can show
|
||||
// it even for non-required profiles; classify only consults it when the
|
||||
// profile requires Tailscale.
|
||||
let tailscale = if tailscale::installed() {
|
||||
Some(tailscale::check(&exit_node))
|
||||
Some(tailscale::check(&exit_nodes))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
|
@ -92,6 +130,7 @@ pub fn gather(cfg: &Config, profile_name: &str) -> Status {
|
|||
ssid,
|
||||
ip,
|
||||
internet,
|
||||
portal,
|
||||
tailscale_required: ts_required,
|
||||
tailscale,
|
||||
exit_node,
|
||||
|
|
|
|||
181
src/tailscale.rs
181
src/tailscale.rs
|
|
@ -33,6 +33,21 @@ impl TsHealth {
|
|||
matches!(self, TsHealth::Ok)
|
||||
}
|
||||
|
||||
/// Wire name for `bread.crumbs.*` event payloads (variant name, like
|
||||
/// `Health::as_str`).
|
||||
pub fn state_str(&self) -> &'static str {
|
||||
match self {
|
||||
TsHealth::Ok => "Ok",
|
||||
TsHealth::NotInstalled => "NotInstalled",
|
||||
TsHealth::NeedsLogin => "NeedsLogin",
|
||||
TsHealth::Stopped => "Stopped",
|
||||
TsHealth::ExitNodeMissing => "ExitNodeMissing",
|
||||
TsHealth::ExitNodeOffline => "ExitNodeOffline",
|
||||
TsHealth::NoExitNode => "NoExitNode",
|
||||
TsHealth::Error(_) => "Error",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn describe(&self) -> String {
|
||||
match self {
|
||||
TsHealth::Ok => "ok".into(),
|
||||
|
|
@ -224,13 +239,59 @@ fn run_login() {
|
|||
}
|
||||
}
|
||||
|
||||
/// Bring Tailscale to a state where `node` is the active, online exit node.
|
||||
/// Performs at most one bring-up/login and one `tailscale set` attempt.
|
||||
pub fn ensure_exit_node(node: &str) -> TsHealth {
|
||||
/// Strip whitespace and drop empty entries from the acceptable-node list.
|
||||
fn effective_nodes(nodes: &[String]) -> Vec<String> {
|
||||
nodes
|
||||
.iter()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Given a status JSON and the acceptable exit nodes, report `Ok` when the
|
||||
/// active selection is one of them and online; otherwise the closest
|
||||
/// actionable failure: not-selected (present + online, flow must select),
|
||||
/// offline, or missing.
|
||||
fn exit_node_health(nodes: &[String], v: &Value) -> TsHealth {
|
||||
let mut any_exists = false;
|
||||
let mut any_online = false;
|
||||
let mut any_selected = false;
|
||||
for node in nodes {
|
||||
let (exists, online, selected) = exit_node_state(v, node);
|
||||
if exists {
|
||||
any_exists = true;
|
||||
if online {
|
||||
any_online = true;
|
||||
}
|
||||
if selected {
|
||||
any_selected = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if any_selected && any_online {
|
||||
TsHealth::Ok
|
||||
} else if any_selected {
|
||||
// The active exit node is one of ours but offline.
|
||||
TsHealth::ExitNodeOffline
|
||||
} else if any_online {
|
||||
// Present + online but not selected — the flow will select it.
|
||||
TsHealth::Error("exit node not selected".into())
|
||||
} else if any_exists {
|
||||
TsHealth::ExitNodeOffline
|
||||
} else {
|
||||
TsHealth::ExitNodeMissing
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring Tailscale to a state where one of `nodes` (priority order) is the
|
||||
/// active, online exit node. Performs at most one bring-up/login, then one
|
||||
/// `tailscale set` per node until one takes.
|
||||
pub fn ensure_exit_node(nodes: &[String]) -> TsHealth {
|
||||
if !installed() {
|
||||
return TsHealth::NotInstalled;
|
||||
}
|
||||
if node.trim().is_empty() {
|
||||
let eff = effective_nodes(nodes);
|
||||
if eff.is_empty() {
|
||||
// Never run `tailscale set --exit-node=` with an empty node — that
|
||||
// would clear the user's current exit-node selection. This is a
|
||||
// config error, surfaced as its own health state.
|
||||
|
|
@ -274,44 +335,41 @@ pub fn ensure_exit_node(node: &str) -> TsHealth {
|
|||
_ => {}
|
||||
}
|
||||
|
||||
// Select the exit node (idempotent).
|
||||
// Failover: try each acceptable node in priority order until one is
|
||||
// selected and online.
|
||||
for node in &eff {
|
||||
let _ = run(
|
||||
"tailscale",
|
||||
&["set", &format!("--exit-node={node}")],
|
||||
Duration::from_secs(10),
|
||||
);
|
||||
if let Some(v2) = status_json() {
|
||||
if matches!(exit_node_health(std::slice::from_ref(node), &v2), TsHealth::Ok) {
|
||||
return TsHealth::Ok;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let v = match status_json() {
|
||||
Some(v) => v,
|
||||
None => return TsHealth::Error("could not re-read tailscale status".into()),
|
||||
};
|
||||
|
||||
match backend_state(&v).as_str() {
|
||||
"Running" => {}
|
||||
"NeedsLogin" | "NoState" => return TsHealth::NeedsLogin,
|
||||
"Stopped" => return TsHealth::Stopped,
|
||||
other => return TsHealth::Error(format!("backend state: {other}")),
|
||||
}
|
||||
|
||||
let (exists, online, selected) = exit_node_state(&v, node);
|
||||
if !exists {
|
||||
TsHealth::ExitNodeMissing
|
||||
} else if !online {
|
||||
TsHealth::ExitNodeOffline
|
||||
} else if !selected {
|
||||
// Online and present but our set didn't take — treat as missing/selectable error.
|
||||
TsHealth::Error("exit node not selected".into())
|
||||
} else {
|
||||
TsHealth::Ok
|
||||
}
|
||||
exit_node_health(&eff, &v)
|
||||
}
|
||||
|
||||
/// Lightweight health check without trying to (re)configure anything.
|
||||
pub fn check(node: &str) -> TsHealth {
|
||||
pub fn check(nodes: &[String]) -> TsHealth {
|
||||
if !installed() {
|
||||
return TsHealth::NotInstalled;
|
||||
}
|
||||
if node.trim().is_empty() {
|
||||
let eff = effective_nodes(nodes);
|
||||
if eff.is_empty() {
|
||||
// Read-only check, so never runs `tailscale set` — an empty node is
|
||||
// a config error, not something this probe can fix.
|
||||
return TsHealth::NoExitNode;
|
||||
|
|
@ -326,16 +384,7 @@ pub fn check(node: &str) -> TsHealth {
|
|||
"Stopped" => return TsHealth::Stopped,
|
||||
other => return TsHealth::Error(format!("backend state: {other}")),
|
||||
}
|
||||
let (exists, online, selected) = exit_node_state(&v, node);
|
||||
if !exists {
|
||||
TsHealth::ExitNodeMissing
|
||||
} else if !online {
|
||||
TsHealth::ExitNodeOffline
|
||||
} else if !selected {
|
||||
TsHealth::Error("exit node not selected".into())
|
||||
} else {
|
||||
TsHealth::Ok
|
||||
}
|
||||
exit_node_health(&eff, &v)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -486,4 +535,76 @@ mod tests {
|
|||
);
|
||||
assert_eq!(extract_url("no url on this line"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_nodes_trims_and_drops_empties() {
|
||||
assert_eq!(
|
||||
effective_nodes(&[" a ".into(), "".into(), "b".into()]),
|
||||
vec!["a".to_string(), "b".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_node_health_ok_when_any_node_selected_and_online() {
|
||||
let v = json!({
|
||||
"BackendState": "Running",
|
||||
"Peer": {
|
||||
"k1": { "HostName": "nodeB", "DNSName": "nodeB.ts.net.",
|
||||
"Online": true, "ExitNode": true, "ExitNodeOption": true }
|
||||
}
|
||||
});
|
||||
assert_eq!(
|
||||
exit_node_health(&["nodeA".into(), "nodeB".into()], &v),
|
||||
TsHealth::Ok
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_node_health_reports_not_selected_when_a_node_is_online_but_unselected() {
|
||||
let v = json!({
|
||||
"Peer": {
|
||||
"k1": { "HostName": "nodeA", "DNSName": "nodeA.ts.net.",
|
||||
"Online": true, "ExitNode": false, "ExitNodeOption": true }
|
||||
}
|
||||
});
|
||||
assert_eq!(
|
||||
exit_node_health(&["nodeA".into()], &v),
|
||||
TsHealth::Error("exit node not selected".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_node_health_reports_offline_when_all_exist_but_none_online() {
|
||||
let v = json!({
|
||||
"Peer": {
|
||||
"k1": { "HostName": "nodeA", "DNSName": "nodeA.ts.net.",
|
||||
"Online": false, "ExitNode": false, "ExitNodeOption": true }
|
||||
}
|
||||
});
|
||||
assert_eq!(
|
||||
exit_node_health(&["nodeA".into()], &v),
|
||||
TsHealth::ExitNodeOffline
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_node_health_reports_missing_when_no_node_present() {
|
||||
let v = json!({ "BackendState": "Running" });
|
||||
assert_eq!(
|
||||
exit_node_health(&["nodeA".into()], &v),
|
||||
TsHealth::ExitNodeMissing
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_str_is_the_variant_name() {
|
||||
assert_eq!(TsHealth::Ok.state_str(), "Ok");
|
||||
assert_eq!(TsHealth::NotInstalled.state_str(), "NotInstalled");
|
||||
assert_eq!(TsHealth::NeedsLogin.state_str(), "NeedsLogin");
|
||||
assert_eq!(TsHealth::Stopped.state_str(), "Stopped");
|
||||
assert_eq!(TsHealth::ExitNodeMissing.state_str(), "ExitNodeMissing");
|
||||
assert_eq!(TsHealth::ExitNodeOffline.state_str(), "ExitNodeOffline");
|
||||
assert_eq!(TsHealth::NoExitNode.state_str(), "NoExitNode");
|
||||
assert_eq!(TsHealth::Error("x".into()).state_str(), "Error");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
src/util.rs
13
src/util.rs
|
|
@ -193,6 +193,19 @@ fn spawn_run(prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration)
|
|||
}
|
||||
}
|
||||
|
||||
/// Current local "HH:MM" (24h), for the time-of-day schedule. `None` if the
|
||||
/// clock can't be read — the schedule is skipped, never guessed.
|
||||
pub fn local_hhmm() -> Option<String> {
|
||||
let o = run("date", &["+%H:%M"], Duration::from_secs(2));
|
||||
if o.success {
|
||||
let t = o.stdout.trim().to_string();
|
||||
if t.len() == 5 && t.as_bytes()[2] == b':' {
|
||||
return Some(t);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Local "YYYY-MM-DD HH:MM:SS". Uses `date` for correct local time, falling
|
||||
/// back to a dependency-free UTC computation if it is unavailable.
|
||||
pub fn timestamp() -> String {
|
||||
|
|
|
|||
239
src/watch.rs
239
src/watch.rs
|
|
@ -10,7 +10,7 @@ use crate::bread_events;
|
|||
use crate::config::Config;
|
||||
use crate::flow;
|
||||
use crate::notify::{log, notify, Urgency};
|
||||
use crate::state::State;
|
||||
use crate::state::{self, State};
|
||||
use crate::status::{self};
|
||||
use crate::tailscale::TsHealth;
|
||||
|
||||
|
|
@ -23,6 +23,10 @@ use crate::tailscale::TsHealth;
|
|||
pub enum Health {
|
||||
Up,
|
||||
DownNoNet,
|
||||
/// Traffic is being intercepted — a captive/guest portal answered the
|
||||
/// connectivity check with 200/301/302 instead of 204. Not something
|
||||
/// reconnecting fixes; the user must sign in.
|
||||
CaptivePortal,
|
||||
DownTailscaleManual,
|
||||
DownTailscaleOther,
|
||||
NoAdapter,
|
||||
|
|
@ -38,6 +42,7 @@ impl Health {
|
|||
match self {
|
||||
Health::Up => "Up",
|
||||
Health::DownNoNet => "DownNoNet",
|
||||
Health::CaptivePortal => "CaptivePortal",
|
||||
Health::DownTailscaleManual => "DownTailscaleManual",
|
||||
Health::DownTailscaleOther => "DownTailscaleOther",
|
||||
Health::NoAdapter => "NoAdapter",
|
||||
|
|
@ -46,34 +51,71 @@ impl Health {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn classify(cfg: &Config, profile: &str) -> (Health, Option<String>) {
|
||||
/// Everything the watch loop needs to know about one health observation:
|
||||
/// the classification plus the context used for events and notifications.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Classification {
|
||||
pub health: Health,
|
||||
pub ssid: Option<String>,
|
||||
pub iface: Option<String>,
|
||||
pub ip: Option<String>,
|
||||
pub tailscale: Option<TsHealth>,
|
||||
pub exit_node: String,
|
||||
}
|
||||
|
||||
pub fn classify(cfg: &Config, profile: &str) -> Classification {
|
||||
// Checked before gather(): a profile missing from config would otherwise
|
||||
// silently fall back to "tailscale not required" and read as healthy off
|
||||
// of nothing but a bare internet check, never surfacing the misconfig.
|
||||
if cfg.profile(profile).is_none() {
|
||||
return (Health::UnknownProfile, None);
|
||||
return Classification {
|
||||
health: Health::UnknownProfile,
|
||||
ssid: None,
|
||||
iface: None,
|
||||
ip: None,
|
||||
tailscale: None,
|
||||
exit_node: String::new(),
|
||||
};
|
||||
}
|
||||
let s = status::gather(cfg, profile);
|
||||
if s.iface.is_none() {
|
||||
return (Health::NoAdapter, None);
|
||||
return Classification {
|
||||
health: Health::NoAdapter,
|
||||
ssid: None,
|
||||
iface: None,
|
||||
ip: None,
|
||||
tailscale: None,
|
||||
exit_node: s.exit_node,
|
||||
};
|
||||
}
|
||||
let ssid = s.ssid.clone();
|
||||
if !s.internet {
|
||||
return (Health::DownNoNet, ssid);
|
||||
let health = if !s.internet {
|
||||
if s.portal {
|
||||
Health::CaptivePortal
|
||||
} else {
|
||||
Health::DownNoNet
|
||||
}
|
||||
if s.tailscale_required {
|
||||
} else if s.tailscale_required {
|
||||
match s.tailscale {
|
||||
Some(TsHealth::Ok) => (Health::Up, ssid),
|
||||
Some(TsHealth::Ok) => Health::Up,
|
||||
// NeedsLogin / NotInstalled / NoExitNode all need human action:
|
||||
// a missing exit-node config can't be auto-fixed either.
|
||||
Some(TsHealth::NeedsLogin)
|
||||
| Some(TsHealth::NotInstalled)
|
||||
| Some(TsHealth::NoExitNode) => (Health::DownTailscaleManual, ssid),
|
||||
Some(_) => (Health::DownTailscaleOther, ssid),
|
||||
None => (Health::DownTailscaleManual, ssid),
|
||||
| Some(TsHealth::NoExitNode) => Health::DownTailscaleManual,
|
||||
Some(_) => Health::DownTailscaleOther,
|
||||
None => Health::DownTailscaleManual,
|
||||
}
|
||||
} else {
|
||||
(Health::Up, ssid)
|
||||
Health::Up
|
||||
};
|
||||
Classification {
|
||||
health,
|
||||
ssid,
|
||||
iface: s.iface,
|
||||
ip: s.ip,
|
||||
tailscale: s.tailscale,
|
||||
exit_node: s.exit_node,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -86,6 +128,15 @@ fn debounce_ready(last: Option<Instant>, gap: Duration) -> bool {
|
|||
last.map(|t| t.elapsed() > gap).unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Whether the flow-recovery cooldown has elapsed since the last `flow::run`
|
||||
/// (or one never ran). Pure so the recovery pacing is unit-testable.
|
||||
fn recovery_due(last_flow_at: Option<Instant>, now: Instant, cooldown_secs: u64) -> bool {
|
||||
last_flow_at
|
||||
.map(|t| now.duration_since(t).as_secs())
|
||||
.unwrap_or(u64::MAX)
|
||||
>= cooldown_secs
|
||||
}
|
||||
|
||||
/// A wake signal for the watch loop. `SetProfile` is an *action* (applied on
|
||||
/// the loop thread), `LinkChurn` is just "go look" — the distinction keeps
|
||||
/// every config/state file access on the single loop thread, so the bread
|
||||
|
|
@ -208,8 +259,8 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
|||
let mut profile = State::load(&cfg.settings.default_profile).profile;
|
||||
if run_initial {
|
||||
// Don't churn an already-working connection on (re)start.
|
||||
let (h, _) = classify(&cfg, &profile);
|
||||
if h == Health::Up {
|
||||
let class = classify(&cfg, &profile);
|
||||
if class.health == Health::Up {
|
||||
log(&format!(
|
||||
"watch: already healthy on start (profile={profile}); skipping initial flow"
|
||||
));
|
||||
|
|
@ -221,9 +272,20 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
|||
|
||||
let mut prev_health: Option<Health> = None;
|
||||
let mut prev_profile = profile.clone();
|
||||
let mut prev_ssid: Option<String> = None;
|
||||
let mut prev_ts: Option<&'static str> = None;
|
||||
let mut fail_streak: u32 = 0;
|
||||
let mut last_flow_at: Option<Instant> = None;
|
||||
const FLOW_COOLDOWN: u64 = 20;
|
||||
const RESUME_SLACK: Duration = Duration::from_secs(60);
|
||||
const SCHEDULE_GRACE: Duration = Duration::from_secs(30 * 60);
|
||||
let mut prev_wait = Duration::from_secs(base);
|
||||
let mut last_tick_at = Instant::now();
|
||||
// Tracks what the *schedule* last applied, so the loop can tell a
|
||||
// manual `profile set` (CLI or bus) apart from its own switch and give
|
||||
// manual changes a grace window before the schedule overrides them.
|
||||
let mut last_schedule_applied: Option<String> = Some(profile.clone());
|
||||
let mut manual_set_at: Option<Instant> = None;
|
||||
|
||||
loop {
|
||||
// Reload config + state so edits and `profile set` take effect live.
|
||||
|
|
@ -239,6 +301,45 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
|||
}
|
||||
profile = State::load(&cfg.settings.default_profile).profile;
|
||||
|
||||
// Suspend/resume: `nmcli monitor` sees nothing while the machine
|
||||
// sleeps, so a large wall-clock gap means the network state may have
|
||||
// changed underneath us — allow an immediate recovery run instead of
|
||||
// waiting out any remaining flow cooldown.
|
||||
if last_tick_at.elapsed() > prev_wait + RESUME_SLACK {
|
||||
log("watch: large gap since last tick (suspend/resume?) — forcing recovery check");
|
||||
last_flow_at = None;
|
||||
}
|
||||
|
||||
// Time-of-day schedule: switch to the scheduled profile when its
|
||||
// window is active, unless the user manually set the profile within
|
||||
// the grace window.
|
||||
if last_schedule_applied.as_deref() != Some(profile.as_str()) {
|
||||
// The persisted profile changed and it wasn't our own schedule
|
||||
// switch — a manual set (CLI or bus). Start the grace window.
|
||||
if last_schedule_applied.is_some() {
|
||||
manual_set_at = Some(Instant::now());
|
||||
}
|
||||
last_schedule_applied = Some(profile.clone());
|
||||
}
|
||||
if let Some(sched) = scheduled_profile_now(&cfg) {
|
||||
if sched != profile {
|
||||
let grace_ok = manual_set_at
|
||||
.map(|t| t.elapsed() >= SCHEDULE_GRACE)
|
||||
.unwrap_or(true);
|
||||
if grace_ok {
|
||||
if state::set_profile(&cfg, &sched).is_ok() {
|
||||
log(&format!("watch: schedule applied profile {sched}"));
|
||||
last_schedule_applied = Some(sched.clone());
|
||||
manual_set_at = None;
|
||||
}
|
||||
} else {
|
||||
log(&format!(
|
||||
"watch: schedule would switch to {sched}, but a manual set is still in grace"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let profile_changed = profile != prev_profile;
|
||||
if profile_changed {
|
||||
log(&format!(
|
||||
|
|
@ -252,13 +353,42 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
|||
bread_events::emit_profile_changed(&bread, &prev_profile, &profile);
|
||||
prev_profile = profile.clone();
|
||||
prev_health = None; // force re-evaluation/recovery for new profile
|
||||
prev_ssid = None; // a profile switch is a fresh network context
|
||||
prev_ts = None;
|
||||
last_flow_at = None; // allow immediate recovery on profile change
|
||||
}
|
||||
|
||||
let (health, ssid) = classify(&cfg, &profile);
|
||||
let class = classify(&cfg, &profile);
|
||||
let health = class.health.clone();
|
||||
let ssid = class.ssid.clone();
|
||||
let transition = prev_health.as_ref() != Some(&health);
|
||||
if transition {
|
||||
bread_events::emit_health_changed(&bread, &profile, health.as_str(), ssid.as_deref());
|
||||
bread_events::emit_health_changed(
|
||||
&bread,
|
||||
bread_events::HealthChanged {
|
||||
profile: &profile,
|
||||
health: health.as_str(),
|
||||
ssid: ssid.as_deref(),
|
||||
iface: class.iface.as_deref(),
|
||||
ip: class.ip.as_deref(),
|
||||
exit_node: &class.exit_node,
|
||||
tailscale: class.tailscale.as_ref().map(|t| t.state_str()),
|
||||
},
|
||||
);
|
||||
}
|
||||
if class.ssid != prev_ssid {
|
||||
bread_events::emit_network_changed(
|
||||
&bread,
|
||||
prev_ssid.as_deref(),
|
||||
class.ssid.as_deref(),
|
||||
&profile,
|
||||
);
|
||||
prev_ssid = class.ssid.clone();
|
||||
}
|
||||
let ts_state = class.tailscale.as_ref().map(|t| t.state_str());
|
||||
if ts_state != prev_ts {
|
||||
bread_events::emit_tailscale_changed(&bread, &profile, ts_state, &class.exit_node);
|
||||
prev_ts = ts_state;
|
||||
}
|
||||
|
||||
match &health {
|
||||
|
|
@ -297,6 +427,19 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
|||
}
|
||||
fail_streak = fail_streak.saturating_add(1);
|
||||
}
|
||||
Health::CaptivePortal => {
|
||||
if transition {
|
||||
notify(
|
||||
"breadcrumbs: captive portal detected",
|
||||
"Traffic is being intercepted — open a browser and sign in.",
|
||||
Urgency::Normal,
|
||||
);
|
||||
}
|
||||
// Reconnecting won't fix a portal; keep the poll fast (don't
|
||||
// count it as a failure) so a successful sign-in is noticed
|
||||
// promptly and the state flips back to Up.
|
||||
fail_streak = 0;
|
||||
}
|
||||
Health::DownTailscaleManual => {
|
||||
// Can't be auto-fixed (login / install / exit-node config).
|
||||
// Notify once per transition.
|
||||
|
|
@ -313,8 +456,7 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
|||
// state: login may have completed since the last attempt, or
|
||||
// the user may have missed the browser window. Quiet — a
|
||||
// still-broken state must not re-notify on every retry.
|
||||
let elapsed = last_flow_at.map(|t| t.elapsed().as_secs()).unwrap_or(u64::MAX);
|
||||
if elapsed >= FLOW_COOLDOWN {
|
||||
if recovery_due(last_flow_at, Instant::now(), FLOW_COOLDOWN) {
|
||||
let outcome = flow::run_quiet(&mut cfg, &profile);
|
||||
last_flow_at = Some(Instant::now());
|
||||
fail_streak = if outcome.ok() {
|
||||
|
|
@ -332,10 +474,7 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
|||
Urgency::Normal,
|
||||
);
|
||||
}
|
||||
let elapsed = last_flow_at
|
||||
.map(|t| t.elapsed().as_secs())
|
||||
.unwrap_or(u64::MAX);
|
||||
if elapsed >= FLOW_COOLDOWN {
|
||||
if recovery_due(last_flow_at, Instant::now(), FLOW_COOLDOWN) {
|
||||
log(&format!(
|
||||
"watch: down ({:?}) profile={profile} ssid={:?} — running flow",
|
||||
health, ssid
|
||||
|
|
@ -350,7 +489,7 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
|||
};
|
||||
} else {
|
||||
log(&format!(
|
||||
"watch: down ({:?}) — cooldown ({elapsed}s/{FLOW_COOLDOWN}s), skipping flow",
|
||||
"watch: down ({:?}) — cooldown, skipping flow",
|
||||
health
|
||||
));
|
||||
}
|
||||
|
|
@ -362,6 +501,8 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
|||
// Adaptive backoff: healthy -> base; failing -> grow up to ~6x.
|
||||
let mult = 1 + fail_streak.min(5);
|
||||
let dur = Duration::from_secs(base * mult as u64);
|
||||
prev_wait = dur;
|
||||
last_tick_at = Instant::now();
|
||||
// Apply a queued set_profile on this thread — the single owner of
|
||||
// config/state file access — and emit the confirmation. The next
|
||||
// iteration's reload sees the new profile and recovers accordingly.
|
||||
|
|
@ -371,6 +512,14 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
|||
}
|
||||
}
|
||||
|
||||
/// The profile a time-of-day schedule wants right now, if any. Returns
|
||||
/// `None` when no schedule is configured or the local time can't be read.
|
||||
fn scheduled_profile_now(cfg: &Config) -> Option<String> {
|
||||
let hhmm = crate::util::local_hhmm()?;
|
||||
let mins = crate::config::hhmm_to_minutes(&hhmm)?;
|
||||
cfg.settings.scheduled_profile(mins)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -405,4 +554,48 @@ mod tests {
|
|||
assert_eq!(Health::NoAdapter.as_str(), "NoAdapter");
|
||||
assert_eq!(Health::UnknownProfile.as_str(), "UnknownProfile");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_for_tick_returns_pending_set_profile_and_drains_churn() {
|
||||
// A queued set_profile is an action, not a signal: it must survive
|
||||
// the churn-burst drain and be returned to the loop.
|
||||
let (tx, rx) = mpsc::channel::<Wake>();
|
||||
let _ = tx.send(Wake::LinkChurn);
|
||||
let _ = tx.send(Wake::SetProfile("home".into()));
|
||||
let _ = tx.send(Wake::LinkChurn);
|
||||
|
||||
let wake = wait_for_tick(&rx, Duration::from_millis(10));
|
||||
assert!(matches!(wake, Some(Wake::SetProfile(n)) if n == "home"));
|
||||
// The burst was fully drained.
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_for_tick_drains_churn_burst_without_action() {
|
||||
// A burst of monitor signals collapses to one wake with no action.
|
||||
let (tx, rx) = mpsc::channel::<Wake>();
|
||||
let _ = tx.send(Wake::LinkChurn);
|
||||
let _ = tx.send(Wake::LinkChurn);
|
||||
let _ = tx.send(Wake::LinkChurn);
|
||||
|
||||
assert!(wait_for_tick(&rx, Duration::from_millis(10)).is_none());
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_for_tick_times_out_with_no_signal() {
|
||||
let (_tx, rx) = mpsc::channel::<Wake>();
|
||||
assert!(wait_for_tick(&rx, Duration::from_millis(10)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_due_fires_when_never_run_and_after_cooldown() {
|
||||
// Never run → due immediately (the map() to u64::MAX path).
|
||||
assert!(recovery_due(None, Instant::now(), 20));
|
||||
// Just ran → not due again yet.
|
||||
let now = Instant::now();
|
||||
assert!(!recovery_due(Some(now), now, 20));
|
||||
// A zero cooldown is always already-elapsed.
|
||||
assert!(recovery_due(Some(now), now, 0));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
351
tests/cli.rs
351
tests/cli.rs
|
|
@ -426,6 +426,8 @@ case "$args" in
|
|||
"device wifi rescan"*) ;;
|
||||
"-t -f SSID device wifi list ifname wlan0")
|
||||
echo "TestNet" ;;
|
||||
"-t -f SSID,SIGNAL device wifi list ifname wlan0")
|
||||
echo "TestNet:80" ;;
|
||||
"-t -f ACTIVE,SSID device wifi list ifname wlan0")
|
||||
echo "yes:TestNet" ;;
|
||||
"-t -f NAME,TYPE connection show")
|
||||
|
|
@ -566,6 +568,8 @@ case "$args" in
|
|||
"device wifi rescan"*) ;;
|
||||
"-t -f SSID device wifi list ifname wlan0")
|
||||
echo "CorpWifi" ;;
|
||||
"-t -f SSID,SIGNAL device wifi list ifname wlan0")
|
||||
echo "CorpWifi:80" ;;
|
||||
*) ;;
|
||||
esac
|
||||
exit 0
|
||||
|
|
@ -590,3 +594,350 @@ fn detect_picks_profile_whose_detect_ssids_are_visible() {
|
|||
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||
assert_eq!(stdout(&o).trim(), "work");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Regression tests for the audit fixes (XDG paths, EDITOR args, config
|
||||
// merging/clamping, core-profile ownership, scan validation).
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn edit_splits_editor_arguments() {
|
||||
// EDITOR="code -w" style values must be split into program + args
|
||||
// instead of being treated as one (nonexistent) binary path.
|
||||
let sb = Sandbox::new();
|
||||
sb.write_fake_bin(
|
||||
"fake-editor",
|
||||
"#!/bin/sh\necho \"$@\" > \"$HOME/editor-args\"\nexit 0\n",
|
||||
);
|
||||
|
||||
let o = sb.cmd_env(&["edit"], &[("EDITOR", "fake-editor --wait")]);
|
||||
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||
assert!(stdout(&o).contains("config OK"));
|
||||
let args = fs::read_to_string(sb.root.join("editor-args")).unwrap();
|
||||
assert!(args.contains("--wait"), "editor args must be split off: {args}");
|
||||
assert!(
|
||||
args.contains("breadcrumbs.toml"),
|
||||
"config path must be appended as its own argument: {args}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_to_unknown_profile_errors_like_add() {
|
||||
// `scan --to bogus` must fail up front, matching `add --to`, instead of
|
||||
// silently saving a network that never gets attached.
|
||||
let sb = Sandbox::new();
|
||||
sb.cmd(&["list"]); // bootstrap the config
|
||||
let o = sb.cmd(&["scan", "--to", "bogus"]);
|
||||
assert!(!o.status.success());
|
||||
assert!(
|
||||
stderr(&o).contains("unknown profile 'bogus'"),
|
||||
"stderr: {}",
|
||||
stderr(&o)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watch_interval_below_minimum_is_clamped_to_four() {
|
||||
// `list` and the watch loop must agree on the poll interval: a value
|
||||
// below the documented minimum of 4 is clamped at load, not just
|
||||
// silently clamped inside the watch loop.
|
||||
let sb = Sandbox::new();
|
||||
fs::create_dir_all(sb.root.join("config/breadcrumbs")).unwrap();
|
||||
fs::write(sb.config_file(), "[settings]\nwatch_interval = 1\n").unwrap();
|
||||
|
||||
let o = sb.cmd(&["list"]);
|
||||
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||
assert!(
|
||||
stdout(&o).contains("watch every 4s"),
|
||||
"watch interval must clamp to the minimum: {}",
|
||||
stdout(&o)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_inline_networks_merge_with_networks_toml_instead_of_dropping() {
|
||||
// A breadcrumbs.toml still carrying a legacy inline `[[networks]]` block
|
||||
// must keep those networks even when networks.toml already exists — the
|
||||
// merge is completed (and the inline block dropped) on the next save.
|
||||
let sb = Sandbox::new();
|
||||
fs::create_dir_all(sb.root.join("config/breadcrumbs")).unwrap();
|
||||
fs::write(
|
||||
sb.config_file(),
|
||||
"[settings]\ndefault_profile = \"away\"\n\n[[networks]]\nssid = \"InlineNet\"\npassword = \"pw-inline\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
sb.networks_file(),
|
||||
"[[networks]]\nssid = \"FileNet\"\npassword = \"pw-file\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let o = sb.cmd(&["list"]);
|
||||
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||
let out = stdout(&o);
|
||||
assert!(out.contains("InlineNet"), "inline network must survive the merge: {out}");
|
||||
assert!(out.contains("FileNet"), "networks.toml network must be present: {out}");
|
||||
|
||||
// A later save migrates the merged set into networks.toml and drops the
|
||||
// inline block from breadcrumbs.toml.
|
||||
let o2 = sb.cmd(&["add", "OtherNet", "pw3"]);
|
||||
assert!(o2.status.success(), "stderr: {}", stderr(&o2));
|
||||
let networks = fs::read_to_string(sb.networks_file()).unwrap();
|
||||
assert!(
|
||||
networks.contains("InlineNet")
|
||||
&& networks.contains("FileNet")
|
||||
&& networks.contains("OtherNet"),
|
||||
"save must persist the merged set: {networks}"
|
||||
);
|
||||
let config_text = fs::read_to_string(sb.config_file()).unwrap();
|
||||
assert!(
|
||||
!config_text.contains("[[networks]]"),
|
||||
"inline block should be gone after migration: {config_text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_profiles_are_not_resurrected_once_config_is_user_owned() {
|
||||
// After the first save the config is user-owned: a deliberately deleted
|
||||
// core profile must stay deleted.
|
||||
let sb = Sandbox::new();
|
||||
fs::create_dir_all(sb.root.join("config/breadcrumbs")).unwrap();
|
||||
fs::write(
|
||||
sb.config_file(),
|
||||
"[settings]\ncore_profiles_initialized = true\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let o = sb.cmd(&["profile", "list"]);
|
||||
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||
let out = stdout(&o);
|
||||
assert!(
|
||||
!out.contains("home") && !out.contains("work") && !out.contains("away"),
|
||||
"deleted core profiles must stay deleted: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_config_without_profiles_gets_core_profiles_backfilled() {
|
||||
// Pre-ownership configs (no flag yet) still get the core profiles
|
||||
// backfilled once — the self-heal that makes bare `[settings]` configs
|
||||
// usable.
|
||||
let sb = Sandbox::new();
|
||||
fs::create_dir_all(sb.root.join("config/breadcrumbs")).unwrap();
|
||||
fs::write(sb.config_file(), "[settings]\n").unwrap();
|
||||
|
||||
let o = sb.cmd(&["profile", "list"]);
|
||||
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||
let out = stdout(&o);
|
||||
assert!(
|
||||
out.contains("home") && out.contains("work") && out.contains("away"),
|
||||
"legacy configs get the core profiles backfilled once: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// New features: per-network DNS, enterprise (802.1x) networks, --json
|
||||
// output, prune, scored detection, and init --wait retry.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn add_with_dns_persists_per_network_override() {
|
||||
let sb = Sandbox::new();
|
||||
let o = sb.cmd(&["add", "CafeWifi", "pw", "--dns", "9.9.9.9"]);
|
||||
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||
let networks = fs::read_to_string(sb.networks_file()).unwrap();
|
||||
assert!(
|
||||
networks.contains("dns = \"9.9.9.9\""),
|
||||
"per-network DNS override must be persisted: {networks}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_enterprise_fields_persist() {
|
||||
let sb = Sandbox::new();
|
||||
let o = sb.cmd(&[
|
||||
"add",
|
||||
"CorpWifi",
|
||||
"pw",
|
||||
"--eap",
|
||||
"peap",
|
||||
"--identity",
|
||||
"user@corp",
|
||||
"--ca-cert",
|
||||
"/etc/ca.pem",
|
||||
]);
|
||||
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||
let networks = fs::read_to_string(sb.networks_file()).unwrap();
|
||||
assert!(networks.contains("eap = \"peap\""), "networks: {networks}");
|
||||
assert!(networks.contains("identity = \"user@corp\""));
|
||||
assert!(networks.contains("ca_cert = \"/etc/ca.pem\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_json_emits_machine_readable_output() {
|
||||
let sb = Sandbox::new();
|
||||
let o = sb.cmd(&["status", "--json"]);
|
||||
// No adapter in the sandbox → unhealthy (exit 1), but still valid JSON.
|
||||
assert_eq!(o.status.code(), Some(1));
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(&stdout(&o)).expect("status --json must emit valid JSON");
|
||||
assert_eq!(v["profile"].as_str(), Some("away"));
|
||||
assert_eq!(v["healthy"].as_bool(), Some(false));
|
||||
assert_eq!(v["internet"].as_bool(), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_json_emits_machine_readable_output() {
|
||||
let sb = Sandbox::new();
|
||||
sb.write_fake_bin("nmcli", FAKE_NMCLI_DETECT);
|
||||
sb.cmd(&["list"]); // bootstrap the default config
|
||||
|
||||
let text = fs::read_to_string(sb.config_file()).unwrap();
|
||||
let patched = text.replace(
|
||||
"[profiles.work]",
|
||||
"[profiles.work]\ndetect_ssids = [\"CorpWifi\"]",
|
||||
);
|
||||
fs::write(sb.config_file(), patched).unwrap();
|
||||
|
||||
let o = sb.cmd(&["detect", "--json"]);
|
||||
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(&stdout(&o)).expect("detect --json must emit valid JSON");
|
||||
assert_eq!(v["profile"].as_str(), Some("work"));
|
||||
}
|
||||
|
||||
const FAKE_NMCLI_DETECT_TWO: &str = r#"#!/bin/sh
|
||||
args="$*"
|
||||
case "$args" in
|
||||
"-t -f DEVICE,TYPE device status")
|
||||
echo "wlan0:wifi" ;;
|
||||
"radio wifi on") ;;
|
||||
"device wifi rescan"*) ;;
|
||||
"-t -f SSID,SIGNAL device wifi list ifname wlan0")
|
||||
echo "CorpWifi:80"
|
||||
echo "CafeWifi:70" ;;
|
||||
*) ;;
|
||||
esac
|
||||
exit 0
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn detect_prefers_profile_with_more_matching_markers() {
|
||||
let sb = Sandbox::new();
|
||||
sb.write_fake_bin("nmcli", FAKE_NMCLI_DETECT_TWO);
|
||||
sb.cmd(&["list"]); // bootstrap
|
||||
|
||||
// home matches 1 marker (CorpWifi); work matches 2 (CorpWifi + CafeWifi).
|
||||
let text = fs::read_to_string(sb.config_file()).unwrap();
|
||||
let patched = text
|
||||
.replace(
|
||||
"[profiles.home]",
|
||||
"[profiles.home]\ndetect_ssids = [\"CorpWifi\"]",
|
||||
)
|
||||
.replace(
|
||||
"[profiles.work]",
|
||||
"[profiles.work]\ndetect_ssids = [\"CorpWifi\", \"CafeWifi\"]",
|
||||
);
|
||||
fs::write(sb.config_file(), patched).unwrap();
|
||||
|
||||
let o = sb.cmd(&["detect"]);
|
||||
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||
assert_eq!(
|
||||
stdout(&o).trim(),
|
||||
"work",
|
||||
"the profile with more matching markers must win"
|
||||
);
|
||||
}
|
||||
|
||||
const FAKE_NMCLI_PRUNE: &str = r#"#!/bin/sh
|
||||
args="$*"
|
||||
case "$args" in
|
||||
"-t -f NAME,TYPE connection show")
|
||||
echo "OldCafe:802-11-wireless" ;;
|
||||
"-g 802-11-wireless.ssid connection show OldCafe")
|
||||
echo "OldCafe" ;;
|
||||
"connection delete id OldCafe") ;;
|
||||
*) ;;
|
||||
esac
|
||||
exit 0
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn prune_dry_run_lists_stale_nm_profiles() {
|
||||
let sb = Sandbox::new();
|
||||
sb.write_fake_bin("nmcli", FAKE_NMCLI_PRUNE);
|
||||
sb.cmd(&["list"]); // bootstrap (no saved networks → everything is stale)
|
||||
|
||||
let o = sb.cmd(&["prune", "--dry-run"]);
|
||||
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||
let out = stdout(&o);
|
||||
assert!(
|
||||
out.contains("would remove") && out.contains("OldCafe"),
|
||||
"out: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_removes_stale_nm_profiles() {
|
||||
let sb = Sandbox::new();
|
||||
sb.write_fake_bin("nmcli", FAKE_NMCLI_PRUNE);
|
||||
sb.cmd(&["list"]);
|
||||
|
||||
let o = sb.cmd(&["prune"]);
|
||||
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||
let out = stdout(&o);
|
||||
assert!(
|
||||
out.contains("removed") && out.contains("OldCafe"),
|
||||
"out: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
const FAKE_NMCLI_RETRY: &str = r#"#!/bin/sh
|
||||
marker="$HOME/.nmcli-connect-ok"
|
||||
args="$*"
|
||||
case "$args" in
|
||||
"-t -f DEVICE,TYPE device status")
|
||||
echo "wlan0:wifi" ;;
|
||||
"radio wifi on") ;;
|
||||
"device wifi rescan"*) ;;
|
||||
"-t -f SSID device wifi list ifname wlan0")
|
||||
echo "HomeWifi" ;;
|
||||
"-t -f SSID,SIGNAL device wifi list ifname wlan0")
|
||||
echo "HomeWifi:80" ;;
|
||||
"-t -f ACTIVE,SSID device wifi list ifname wlan0")
|
||||
echo "yes:HomeWifi" ;;
|
||||
"-t -f NAME,TYPE connection show") ;;
|
||||
*"device wifi connect HomeWifi"*)
|
||||
if [ -f "$marker" ]; then
|
||||
exit 0
|
||||
else
|
||||
: > "$marker"
|
||||
exit 1
|
||||
fi ;;
|
||||
*"connection up HomeWifi"*)
|
||||
exit 0 ;;
|
||||
"-g GENERAL.CON-UUID device show wlan0")
|
||||
echo "uuid-1" ;;
|
||||
*"ipv4.ignore-auto-dns"*) ;;
|
||||
"device reapply wlan0") ;;
|
||||
"-t -f DEVICE,STATE device status")
|
||||
echo "wlan0:connected" ;;
|
||||
*) ;;
|
||||
esac
|
||||
exit 0
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn init_wait_retries_until_connect_succeeds() {
|
||||
let sb = Sandbox::new();
|
||||
sb.write_fake_bin("nmcli", FAKE_NMCLI_RETRY);
|
||||
// "away" defaults to include_all_known, so HomeWifi is a candidate.
|
||||
let add = sb.cmd(&["add", "HomeWifi", "hunter2"]);
|
||||
assert!(add.status.success(), "stderr: {}", stderr(&add));
|
||||
|
||||
// The fake's first `device wifi connect` fails; the retry succeeds.
|
||||
// `--wait` must keep going past the first failure rather than bailing.
|
||||
let o = sb.cmd(&["init", "--wait", "5"]);
|
||||
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||
assert!(stdout(&o).contains("connected"), "out: {}", stdout(&o));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ use bread_utils::bread_client::BreadEvent;
|
|||
use breadcrumbs::bread_events;
|
||||
use breadcrumbs::config::{Config, NetworkDef, Profile, Settings};
|
||||
use breadcrumbs::flow;
|
||||
use breadcrumbs::nm;
|
||||
use breadcrumbs::state::{self, State};
|
||||
use breadcrumbs::util::with_runner;
|
||||
use breadcrumbs::watch::{classify, Health};
|
||||
|
|
@ -25,6 +24,10 @@ fn net(ssid: &str, password: Option<&str>) -> NetworkDef {
|
|||
NetworkDef {
|
||||
ssid: ssid.to_string(),
|
||||
password: password.map(str::to_string),
|
||||
dns: None,
|
||||
eap: None,
|
||||
identity: None,
|
||||
ca_cert: None,
|
||||
hidden: false,
|
||||
}
|
||||
}
|
||||
|
|
@ -33,6 +36,10 @@ fn hidden_net(ssid: &str, password: Option<&str>) -> NetworkDef {
|
|||
NetworkDef {
|
||||
ssid: ssid.to_string(),
|
||||
password: password.map(str::to_string),
|
||||
dns: None,
|
||||
eap: None,
|
||||
identity: None,
|
||||
ca_cert: None,
|
||||
hidden: true,
|
||||
}
|
||||
}
|
||||
|
|
@ -52,17 +59,28 @@ fn base_config() -> Config {
|
|||
/// the device reports connected after any successful connect attempt.
|
||||
fn base_nm(visible_ssids: &[&str]) -> FakeRunner {
|
||||
let visible = visible_ssids.join("\n");
|
||||
// `-f SSID,SIGNAL` lines: all SSIDs at the same (strong) signal, so
|
||||
// priority order — not signal — decides between them.
|
||||
let with_signal = visible_ssids
|
||||
.iter()
|
||||
.map(|s| format!("{s}:80"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let runner = FakeRunner::new()
|
||||
.on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi"))
|
||||
.on_contains("nmcli", "radio wifi on", ok(""))
|
||||
.on_contains("nmcli", "wifi rescan", ok(""))
|
||||
// Exact match: `-f ACTIVE,SSID` queries (which contain the substring
|
||||
// "SSID device wifi list") must NOT be answered with the visible
|
||||
// list — they go to the stateful rule below.
|
||||
// Exact matches: `-f ACTIVE,SSID` queries (which contain the
|
||||
// substring "SSID device wifi list") must NOT be answered with the
|
||||
// visible list — they go to the stateful rule below.
|
||||
.on(
|
||||
move |_prog, args| args.join(" ") == "-t -f SSID device wifi list ifname wlan0",
|
||||
ok(&visible),
|
||||
)
|
||||
.on(
|
||||
move |_prog, args| args.join(" ") == "-t -f SSID,SIGNAL device wifi list ifname wlan0",
|
||||
ok(&with_signal),
|
||||
)
|
||||
.on_contains("nmcli", "NAME,TYPE", ok("")) // no saved profiles
|
||||
.on_contains("nmcli", "GENERAL.CON-UUID", ok("uuid-1"))
|
||||
.on_contains("nmcli", "ipv4.ignore-auto-dns", ok(""))
|
||||
|
|
@ -121,7 +139,10 @@ fn flow_run_connects_to_first_visible_candidate_in_priority_order() {
|
|||
let _env = EnvSandbox::new();
|
||||
|
||||
let mut cfg = base_config();
|
||||
cfg.networks = vec![net("First", Some("pw1")), net("Second", Some("pw2"))];
|
||||
cfg.networks = vec![
|
||||
net("First", Some("pw1")),
|
||||
net("Second", Some("pw2")),
|
||||
];
|
||||
cfg.profiles.insert(
|
||||
"home".into(),
|
||||
Profile {
|
||||
|
|
@ -147,11 +168,10 @@ fn flow_run_connects_to_first_visible_candidate_in_priority_order() {
|
|||
|
||||
// Priority order actually mattered: "Second" was never dialed even
|
||||
// though it was visible and would have succeeded too.
|
||||
let dialed_second = calls.borrow().iter().any(|c| {
|
||||
c.prog == "nmcli"
|
||||
&& c.args.contains(&"connect".to_string())
|
||||
&& c.args.iter().any(|a| a == "Second")
|
||||
});
|
||||
let dialed_second = calls
|
||||
.borrow()
|
||||
.iter()
|
||||
.any(|c| c.prog == "nmcli" && c.args.contains(&"connect".to_string()) && c.args.iter().any(|a| a == "Second"));
|
||||
assert!(!dialed_second, "connected to Second when First should win");
|
||||
|
||||
// The password used for the winning connect is now NM's problem, not
|
||||
|
|
@ -346,7 +366,9 @@ fn classify_reports_unknown_profile_without_touching_nm() {
|
|||
|
||||
let runner = FakeRunner::new();
|
||||
let calls = runner.calls_handle();
|
||||
let (health, ssid) = with_runner(runner, || classify(&cfg, "ghost"));
|
||||
let class = with_runner(runner, || classify(&cfg, "ghost"));
|
||||
let health = class.health;
|
||||
let ssid = class.ssid;
|
||||
|
||||
assert_eq!(health, Health::UnknownProfile);
|
||||
assert_eq!(ssid, None);
|
||||
|
|
@ -361,7 +383,8 @@ fn classify_reports_no_adapter_when_wifi_interface_absent() {
|
|||
|
||||
// `device status` succeeds but lists no wifi-type device.
|
||||
let runner = FakeRunner::new().on_contains("nmcli", "DEVICE,TYPE", ok("eth0:ethernet"));
|
||||
let (health, _) = with_runner(runner, || classify(&cfg, "away"));
|
||||
let class = with_runner(runner, || classify(&cfg, "away"));
|
||||
let health = class.health;
|
||||
|
||||
assert_eq!(health, Health::NoAdapter);
|
||||
}
|
||||
|
|
@ -377,7 +400,9 @@ fn classify_reports_down_no_net_when_internet_check_fails() {
|
|||
.on_contains("nmcli", "ACTIVE,SSID", ok("yes:HomeWifi"))
|
||||
.on_contains("nmcli", "IP4.ADDRESS", ok("192.168.1.50/24"))
|
||||
.on(|prog, _| prog == "curl" || prog == "ping", fail(""));
|
||||
let (health, ssid) = with_runner(runner, || classify(&cfg, "away"));
|
||||
let class = with_runner(runner, || classify(&cfg, "away"));
|
||||
let health = class.health;
|
||||
let ssid = class.ssid;
|
||||
|
||||
assert_eq!(health, Health::DownNoNet);
|
||||
assert_eq!(ssid, Some("HomeWifi".to_string()));
|
||||
|
|
@ -395,7 +420,9 @@ fn classify_reports_up_when_healthy_and_tailscale_not_required() {
|
|||
.on_contains("nmcli", "IP4.ADDRESS", ok("192.168.1.50/24"))
|
||||
.with_command("curl")
|
||||
.on(|prog, _| prog == "curl", ok("204"));
|
||||
let (health, ssid) = with_runner(runner, || classify(&cfg, "home"));
|
||||
let class = with_runner(runner, || classify(&cfg, "home"));
|
||||
let health = class.health;
|
||||
let ssid = class.ssid;
|
||||
|
||||
assert_eq!(health, Health::Up);
|
||||
assert_eq!(ssid, Some("HomeWifi".to_string()));
|
||||
|
|
@ -421,7 +448,8 @@ fn classify_reports_down_tailscale_manual_when_not_installed() {
|
|||
.on_contains("nmcli", "IP4.ADDRESS", ok("10.0.0.5/24"))
|
||||
.with_command("curl")
|
||||
.on(|prog, _| prog == "curl", ok("204"));
|
||||
let (health, _) = with_runner(runner, || classify(&cfg, "work"));
|
||||
let class = with_runner(runner, || classify(&cfg, "work"));
|
||||
let health = class.health;
|
||||
|
||||
assert_eq!(health, Health::DownTailscaleManual);
|
||||
}
|
||||
|
|
@ -449,7 +477,8 @@ fn classify_reports_down_tailscale_manual_when_needs_login() {
|
|||
|prog, args| prog == "tailscale" && args.contains(&"status"),
|
||||
ok(r#"{"BackendState":"NeedsLogin"}"#),
|
||||
);
|
||||
let (health, _) = with_runner(runner, || classify(&cfg, "work"));
|
||||
let class = with_runner(runner, || classify(&cfg, "work"));
|
||||
let health = class.health;
|
||||
|
||||
assert_eq!(health, Health::DownTailscaleManual);
|
||||
}
|
||||
|
|
@ -479,7 +508,8 @@ fn classify_reports_down_tailscale_other_when_exit_node_offline() {
|
|||
|prog, args| prog == "tailscale" && args.contains(&"status"),
|
||||
ok(json),
|
||||
);
|
||||
let (health, _) = with_runner(runner, || classify(&cfg, "work"));
|
||||
let class = with_runner(runner, || classify(&cfg, "work"));
|
||||
let health = class.health;
|
||||
|
||||
assert_eq!(health, Health::DownTailscaleOther);
|
||||
}
|
||||
|
|
@ -505,7 +535,8 @@ fn classify_reports_up_when_tailscale_healthy() {
|
|||
.with_command("tailscale")
|
||||
.on(|prog, _| prog == "curl", ok("204"))
|
||||
.on_contains("tailscale", "status", ok(&tailscale_json_ok("exitnode")));
|
||||
let (health, _) = with_runner(runner, || classify(&cfg, "work"));
|
||||
let class = with_runner(runner, || classify(&cfg, "work"));
|
||||
let health = class.health;
|
||||
|
||||
assert_eq!(health, Health::Up);
|
||||
}
|
||||
|
|
@ -622,82 +653,479 @@ fn handle_command_ignores_events_outside_its_own_command_namespace() {
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// PSK never on argv (first connect feeds nmcli --ask on stdin)
|
||||
// Regression tests for the audit fixes.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
fn assert_psk_not_on_argv(calls: &[common::RecordedCall], psk: &str) {
|
||||
for c in calls {
|
||||
if c.prog != "nmcli" {
|
||||
continue;
|
||||
#[test]
|
||||
fn flow_run_reports_no_exit_node_and_never_clears_selection() {
|
||||
// A tailscale profile with no exit node configured must report
|
||||
// TsHealth::NoExitNode — and must never run `tailscale set --exit-node=`
|
||||
// with an empty value, which would clear the user's current selection.
|
||||
let _env = EnvSandbox::new();
|
||||
let mut cfg = base_config();
|
||||
cfg.networks = vec![net("Corp", Some("corp-pw"))];
|
||||
cfg.profiles.insert(
|
||||
"work".into(),
|
||||
Profile {
|
||||
networks: vec!["Corp".into()],
|
||||
tailscale: true,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let runner = base_nm(&["Corp"]).with_command("tailscale");
|
||||
let calls = runner.calls_handle();
|
||||
let outcome = with_runner(runner, || flow::run(&mut cfg, "work"));
|
||||
|
||||
match &outcome {
|
||||
flow::Outcome::TailscaleError { health, .. } => {
|
||||
assert_eq!(*health, breadcrumbs::tailscale::TsHealth::NoExitNode);
|
||||
}
|
||||
other => panic!("expected TailscaleError(NoExitNode), got {other:?}"),
|
||||
}
|
||||
assert!(
|
||||
!c.args.iter().any(|a| a == psk),
|
||||
"PSK leaked onto nmcli argv: {:?}",
|
||||
c.args
|
||||
!calls.borrow().iter().any(|c| c.prog == "tailscale"),
|
||||
"with no exit node configured, tailscale must not be touched: {:?}",
|
||||
calls.borrow()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_verbose_create_feeds_psk_on_stdin_never_argv() {
|
||||
fn classify_reports_down_tailscale_manual_when_no_exit_node_configured() {
|
||||
// An unset exit node needs human action (config edit), so it must
|
||||
// classify as DownTailscaleManual — not DownTailscaleOther, which would
|
||||
// make the watcher spin auto-recovery forever.
|
||||
let _env = EnvSandbox::new();
|
||||
let mut cfg = base_config();
|
||||
cfg.profiles.insert(
|
||||
"work".into(),
|
||||
Profile {
|
||||
tailscale: true,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let runner = FakeRunner::new()
|
||||
.on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi"))
|
||||
.on_contains("nmcli", "ACTIVE,SSID", ok("yes:CorpWifi"))
|
||||
.on_contains("nmcli", "IP4.ADDRESS", ok("10.0.0.5/24"))
|
||||
.with_command("curl")
|
||||
.with_command("tailscale")
|
||||
.on(|prog, _| prog == "curl", ok("204"));
|
||||
let class = with_runner(runner, || classify(&cfg, "work"));
|
||||
let health = class.health;
|
||||
let ssid = class.ssid;
|
||||
|
||||
assert_eq!(health, Health::DownTailscaleManual);
|
||||
assert_eq!(ssid, Some("CorpWifi".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_exit_node_attempts_to_start_unreachable_daemon() {
|
||||
// `tailscale status --json` with empty stdout is the "daemon not
|
||||
// running" signature (the error goes to stderr). ensure_exit_node must
|
||||
// try `tailscale up` and re-read instead of bailing out with an opaque
|
||||
// error — the old dead-code path that made the Stopped recovery
|
||||
// unreachable.
|
||||
let _env = EnvSandbox::new();
|
||||
let runner = FakeRunner::new()
|
||||
.with_command("tailscale")
|
||||
.on(|p, args| p == "tailscale" && args.contains(&"status"), ok(""))
|
||||
.on(|p, args| p == "tailscale" && args.contains(&"up"), ok(""));
|
||||
let calls = runner.calls_handle();
|
||||
let health =
|
||||
with_runner(runner, || {
|
||||
breadcrumbs::tailscale::ensure_exit_node(&["exitnode".to_string()])
|
||||
});
|
||||
assert!(
|
||||
matches!(health, breadcrumbs::tailscale::TsHealth::Error(_)),
|
||||
"daemon still unreachable after `up` → Error, got {health:?}"
|
||||
);
|
||||
let tailscale_calls: Vec<String> = calls
|
||||
.borrow()
|
||||
.iter()
|
||||
.filter(|c| c.prog == "tailscale")
|
||||
.map(|c| c.args.join(" "))
|
||||
.collect();
|
||||
assert!(
|
||||
tailscale_calls.iter().any(|c| c.starts_with("up")),
|
||||
"must attempt `tailscale up` when the daemon is unreachable: {tailscale_calls:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flow_run_fails_when_device_lands_on_wrong_ssid() {
|
||||
// NM autoconnect race: the connect succeeds but the device ends up on a
|
||||
// *different* network than requested. flow must not report Connected to
|
||||
// the requested SSID, and must not clear its password.
|
||||
let _env = EnvSandbox::new();
|
||||
let mut cfg = base_config();
|
||||
cfg.networks = vec![net("First", Some("pw1"))];
|
||||
cfg.profiles.insert(
|
||||
"home".into(),
|
||||
Profile {
|
||||
networks: vec!["First".into()],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let runner = FakeRunner::new()
|
||||
.on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi"))
|
||||
.on_contains("nmcli", "radio wifi on", ok(""))
|
||||
.on_contains("nmcli", "wifi rescan", ok(""))
|
||||
.on(
|
||||
|_p, args| args.join(" ") == "-t -f SSID device wifi list ifname wlan0",
|
||||
ok("First"),
|
||||
)
|
||||
.on(
|
||||
|_p, args| args.join(" ") == "-t -f SSID,SIGNAL device wifi list ifname wlan0",
|
||||
ok("First:80"),
|
||||
)
|
||||
.on_contains("nmcli", "NAME,TYPE", ok(""))
|
||||
.on_contains("nmcli", "GENERAL.CON-UUID", ok("uuid-1"))
|
||||
.on_contains("nmcli", "ipv4.ignore-auto-dns", ok(""))
|
||||
.on_contains("nmcli", "device reapply", ok(""))
|
||||
.on_contains("nmcli", "DEVICE,STATE", ok("wlan0:connected"))
|
||||
// The connect itself succeeds...
|
||||
.on_contains("nmcli", "device wifi connect First", ok(""))
|
||||
// ...but the device reports being on a different network.
|
||||
.on(
|
||||
|_p, args| args.join(" ") == "-t -f ACTIVE,SSID device wifi list ifname wlan0",
|
||||
ok("yes:OtherNet"),
|
||||
);
|
||||
let outcome = with_runner(runner, || flow::run(&mut cfg, "home"));
|
||||
|
||||
assert!(
|
||||
!matches!(outcome, flow::Outcome::Connected { .. }),
|
||||
"must not report Connected when the device is on a different SSID: {outcome:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.network("First").unwrap().password,
|
||||
Some("pw1".to_string()),
|
||||
"password must not be cleared for a network that was never joined"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_quiet_suppresses_notifications_that_run_emits() {
|
||||
// The watch loop calls flow::run_quiet so a persistent failure doesn't
|
||||
// re-notify on every retry; the CLI keeps flow::run's notifications.
|
||||
let _env = EnvSandbox::new();
|
||||
let mut cfg = base_config(); // no profiles → UnknownProfile path notifies
|
||||
|
||||
let runner = FakeRunner::new().with_command("notify-send");
|
||||
let calls = runner.calls_handle();
|
||||
with_runner(runner, || flow::run_quiet(&mut cfg, "ghost"));
|
||||
assert!(
|
||||
!calls.borrow().iter().any(|c| c.prog == "notify-send"),
|
||||
"run_quiet must not fire desktop notifications: {:?}",
|
||||
calls.borrow()
|
||||
);
|
||||
|
||||
let runner = FakeRunner::new().with_command("notify-send");
|
||||
let calls = runner.calls_handle();
|
||||
with_runner(runner, || flow::run(&mut cfg, "ghost"));
|
||||
assert!(
|
||||
calls.borrow().iter().any(|c| c.prog == "notify-send"),
|
||||
"run (CLI path) must still notify: {:?}",
|
||||
calls.borrow()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internet_ok_requires_204_and_falls_back_to_ping() {
|
||||
// Only 204 counts as internet: captive/guest portals answer 200/301/302
|
||||
// with a login page or redirect, so those must not report healthy.
|
||||
let _env = EnvSandbox::new();
|
||||
let cfg = base_config();
|
||||
|
||||
let r = FakeRunner::new().with_command("curl").on(|p, _| p == "curl", ok("204"));
|
||||
assert!(with_runner(r, || breadcrumbs::status::internet_ok(&cfg)));
|
||||
|
||||
for code in ["200", "301", "302"] {
|
||||
let r = FakeRunner::new()
|
||||
.with_command("curl")
|
||||
.on(|p, _| p == "curl", ok(code))
|
||||
.on(|p, _| p == "ping", fail(""));
|
||||
assert!(
|
||||
!with_runner(r, || breadcrumbs::status::internet_ok(&cfg)),
|
||||
"{code} must not count as internet"
|
||||
);
|
||||
}
|
||||
|
||||
// curl absent → the ping fallback decides.
|
||||
let r = FakeRunner::new().with_command("ping").on(|p, _| p == "ping", ok(""));
|
||||
assert!(with_runner(r, || breadcrumbs::status::internet_ok(&cfg)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_list_dedups_by_ssid_keeping_strongest_signal() {
|
||||
// One line per BSSID: the same SSID broadcast by several APs must show
|
||||
// once, at its strongest signal (not the first, possibly weak, listing).
|
||||
let _env = EnvSandbox::new();
|
||||
let runner = FakeRunner::new().on_contains(
|
||||
"nmcli",
|
||||
"SSID,SIGNAL,SECURITY",
|
||||
ok("Cafe:40:WPA2\nCafe:80:WPA2\nOffice:60:WPA3\nCafe:90 %:WPA2\n:70:WPA2"),
|
||||
);
|
||||
let list = with_runner(runner, || breadcrumbs::nm::scan_list("wlan0"));
|
||||
|
||||
assert_eq!(list.len(), 2, "dedup by SSID, hidden (empty SSID) skipped: {list:?}");
|
||||
let cafe = list.iter().find(|e| e.ssid == "Cafe").unwrap();
|
||||
assert_eq!(cafe.signal, "90 %", "strongest signal wins");
|
||||
let office = list.iter().find(|e| e.ssid == "Office").unwrap();
|
||||
assert_eq!(office.signal, "60");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// New features: signal-aware selection, per-network DNS, learning,
|
||||
// captive portals, exit-node failover, preferred interface, enterprise
|
||||
// (802.1x) connect.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn flow_run_prefers_strongest_visible_signal_over_priority_order() {
|
||||
let _env = EnvSandbox::new();
|
||||
let mut cfg = base_config();
|
||||
cfg.networks = vec![net("Weak", Some("pw1")), net("Strong", Some("pw2"))];
|
||||
cfg.profiles.insert(
|
||||
"home".into(),
|
||||
Profile {
|
||||
networks: vec!["Weak".into(), "Strong".into()],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
// "Weak" is listed first (higher priority), but "Strong" has the better
|
||||
// signal — signal-aware selection must dial Strong first.
|
||||
let runner = FakeRunner::new()
|
||||
.on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi"))
|
||||
.on_contains("nmcli", "radio wifi on", ok(""))
|
||||
.on_contains("nmcli", "wifi rescan", ok(""))
|
||||
.on(
|
||||
|_p, args| args.join(" ") == "-t -f SSID device wifi list ifname wlan0",
|
||||
ok("Weak\nStrong"),
|
||||
)
|
||||
.on(
|
||||
|_p, args| args.join(" ") == "-t -f SSID,SIGNAL device wifi list ifname wlan0",
|
||||
ok("Weak:40\nStrong:90"),
|
||||
)
|
||||
.on_contains("nmcli", "NAME,TYPE", ok(""))
|
||||
.on_contains("nmcli", "GENERAL.CON-UUID", ok("uuid-1"))
|
||||
.on_contains("nmcli", "ipv4.ignore-auto-dns", ok(""))
|
||||
.on_contains("nmcli", "device reapply", ok(""))
|
||||
.on_contains("nmcli", "DEVICE,STATE", ok("wlan0:connected"))
|
||||
.on(
|
||||
|_p, args| args.join(" ") == "-t -f ACTIVE,SSID device wifi list ifname wlan0",
|
||||
ok("yes:Strong"),
|
||||
)
|
||||
.on(
|
||||
|p, args| p == "nmcli" && args.contains(&"connect") && args.contains(&"Strong"),
|
||||
ok(""),
|
||||
)
|
||||
.on(|p, _| p == "curl", ok("204"))
|
||||
.with_command("curl");
|
||||
let calls = runner.calls_handle();
|
||||
|
||||
let outcome = with_runner(runner, || flow::run(&mut cfg, "home"));
|
||||
match &outcome {
|
||||
flow::Outcome::Connected { ssid, .. } => assert_eq!(ssid, "Strong"),
|
||||
other => panic!("expected Connected to Strong, got {other:?}"),
|
||||
}
|
||||
|
||||
let dialed = |s: &str| {
|
||||
calls.borrow().iter().any(|c| {
|
||||
c.args.contains(&"connect".to_string()) && c.args.iter().any(|a| a == s)
|
||||
})
|
||||
};
|
||||
assert!(dialed("Strong"), "the stronger network must be dialed");
|
||||
assert!(
|
||||
!dialed("Weak"),
|
||||
"the weaker network must not be dialed despite higher priority"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flow_run_pins_per_network_dns_override() {
|
||||
let _env = EnvSandbox::new();
|
||||
let mut cfg = base_config();
|
||||
cfg.settings.dns = "1.1.1.1".into();
|
||||
let mut def = net("Home", Some("pw"));
|
||||
def.dns = Some("9.9.9.9".into());
|
||||
cfg.networks = vec![def];
|
||||
cfg.profiles.insert(
|
||||
"home".into(),
|
||||
Profile {
|
||||
networks: vec!["Home".into()],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let runner = allow_connects(base_nm(&["Home"]), &["Home"])
|
||||
.on(|p, _| p == "curl", ok("204"))
|
||||
.with_command("curl");
|
||||
let calls = runner.calls_handle();
|
||||
let outcome = with_runner(runner, || flow::run(&mut cfg, "home"));
|
||||
assert!(matches!(outcome, flow::Outcome::Connected { .. }));
|
||||
|
||||
// The DNS-pinning `connection modify` must carry the per-network override,
|
||||
// not the global 1.1.1.1.
|
||||
let dns_arg = calls.borrow().iter().any(|c| {
|
||||
c.args.join(" ").contains("ipv4.dns") && c.args.iter().any(|a| a == "9.9.9.9")
|
||||
});
|
||||
assert!(dns_arg, "per-network DNS override must reach nmcli");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flow_run_appends_learned_ssid_to_detect_ssids() {
|
||||
let _env = EnvSandbox::new();
|
||||
let mut cfg = base_config();
|
||||
cfg.networks = vec![net("Home", Some("pw"))];
|
||||
cfg.profiles.insert(
|
||||
"home".into(),
|
||||
Profile {
|
||||
networks: vec!["Home".into()],
|
||||
learn: true,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let runner = allow_connects(base_nm(&["Home"]), &["Home"])
|
||||
.on(|p, _| p == "curl", ok("204"))
|
||||
.with_command("curl");
|
||||
let outcome = with_runner(runner, || flow::run(&mut cfg, "home"));
|
||||
assert!(matches!(outcome, flow::Outcome::Connected { .. }));
|
||||
|
||||
assert_eq!(
|
||||
cfg.profile("home").unwrap().detect_ssids,
|
||||
vec!["Home".to_string()],
|
||||
"a successful connect on a learn=true profile must record the SSID"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_reports_captive_portal_when_connectivity_returns_200() {
|
||||
let _env = EnvSandbox::new();
|
||||
let mut cfg = base_config();
|
||||
cfg.profiles.insert("home".into(), Profile::default());
|
||||
|
||||
let runner = FakeRunner::new()
|
||||
.on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi"))
|
||||
.on_contains("nmcli", "ACTIVE,SSID", ok("yes:HomeWifi"))
|
||||
.on_contains("nmcli", "IP4.ADDRESS", ok("192.168.1.50/24"))
|
||||
.with_command("curl")
|
||||
.on(|p, _| p == "curl", ok("200"));
|
||||
let class = with_runner(runner, || classify(&cfg, "home"));
|
||||
|
||||
assert_eq!(class.health, Health::CaptivePortal);
|
||||
assert_eq!(class.ssid, Some("HomeWifi".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_exit_node_failover_tries_nodes_in_priority_order() {
|
||||
let _env = EnvSandbox::new();
|
||||
// The status never shows nodeA; it always shows nodeB selected + online.
|
||||
// ensure_exit_node must therefore try nodeA (fail), then nodeB (succeed),
|
||||
// in that exact priority order.
|
||||
let json = r#"{"BackendState":"Running","Peer":{"k1":{"HostName":"nodeB","DNSName":"nodeB.ts.net.","Online":true,"ExitNode":true,"ExitNodeOption":true}}}"#;
|
||||
let runner = FakeRunner::new()
|
||||
.with_command("tailscale")
|
||||
.on_contains("tailscale", "status", ok(json))
|
||||
.on(|p, args| p == "tailscale" && args.contains(&"set"), ok(""));
|
||||
let calls = runner.calls_handle();
|
||||
|
||||
let health = with_runner(runner, || {
|
||||
breadcrumbs::tailscale::ensure_exit_node(&["nodeA".into(), "nodeB".into()])
|
||||
});
|
||||
assert_eq!(health, breadcrumbs::tailscale::TsHealth::Ok);
|
||||
|
||||
let sets: Vec<String> = calls
|
||||
.borrow()
|
||||
.iter()
|
||||
.filter(|c| c.args.iter().any(|a| a == "set"))
|
||||
.map(|c| c.args.join(" "))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
sets,
|
||||
vec!["set --exit-node=nodeA".to_string(), "set --exit-node=nodeB".to_string()],
|
||||
"failover must try nodes in priority order"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wifi_interface_preferred_picks_named_device_over_first_wifi() {
|
||||
let runner = FakeRunner::new().on_contains(
|
||||
"nmcli",
|
||||
"DEVICE,TYPE",
|
||||
ok("wlan0:wifi\nwlan1:wifi"),
|
||||
);
|
||||
let iface = with_runner(runner, || {
|
||||
breadcrumbs::nm::wifi_interface_preferred(Some("wlan1"))
|
||||
});
|
||||
assert_eq!(iface.as_deref(), Some("wlan1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wifi_interface_preferred_falls_back_to_first_wifi_when_pref_missing() {
|
||||
let runner = FakeRunner::new().on_contains(
|
||||
"nmcli",
|
||||
"DEVICE,TYPE",
|
||||
ok("wlan0:wifi\nwlan1:wifi"),
|
||||
);
|
||||
let iface = with_runner(runner, || {
|
||||
breadcrumbs::nm::wifi_interface_preferred(Some("wlan9"))
|
||||
});
|
||||
assert_eq!(iface.as_deref(), Some("wlan0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_signals_dedups_by_strongest_signal() {
|
||||
let runner = FakeRunner::new().on_contains(
|
||||
"nmcli",
|
||||
"SSID,SIGNAL",
|
||||
ok("Cafe:40\nCafe:85\nOffice:60\n:90"),
|
||||
);
|
||||
let map = with_runner(runner, || breadcrumbs::nm::visible_signals("wlan0"));
|
||||
assert_eq!(map.get("Cafe"), Some(&85));
|
||||
assert_eq!(map.get("Office"), Some(&60));
|
||||
assert!(!map.contains_key(""), "hidden/empty SSID must be skipped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_verbose_enterprise_creates_8021x_profile() {
|
||||
let _env = EnvSandbox::new();
|
||||
let mut def = net("Corp", Some("pw"));
|
||||
def.eap = Some("peap".into());
|
||||
def.identity = Some("user@corp".into());
|
||||
def.ca_cert = Some("/etc/ca.pem".into());
|
||||
|
||||
// No saved profile (NAME,TYPE empty), so the enterprise create path runs.
|
||||
let runner = FakeRunner::new()
|
||||
.on_contains("nmcli", "NAME,TYPE", ok(""))
|
||||
.on_contains("nmcli", "connect", ok(""))
|
||||
.on(
|
||||
|p, args| p == "nmcli" && args.contains(&"add") && args.contains(&"connection"),
|
||||
ok(""),
|
||||
)
|
||||
.on(|p, args| p == "nmcli" && args.contains(&"up"), ok(""))
|
||||
.on_contains("nmcli", "GENERAL.CON-UUID", ok("uuid-1"))
|
||||
.on_contains("nmcli", "ipv4.ignore-auto-dns", ok(""))
|
||||
.on_contains("nmcli", "device reapply", ok(""));
|
||||
let calls = runner.calls_handle();
|
||||
|
||||
let net = net("Cafe", Some("super-secret-psk"));
|
||||
let result = with_runner(runner, || nm::connect_verbose("wlan0", &net, 8, "1.1.1.1"));
|
||||
assert!(result.is_ok(), "{result:?}");
|
||||
|
||||
let calls = calls.borrow();
|
||||
assert_psk_not_on_argv(&calls, "super-secret-psk");
|
||||
let connect = calls
|
||||
.iter()
|
||||
.find(|c| c.prog == "nmcli" && c.args.iter().any(|a| a == "connect"))
|
||||
.expect("expected device wifi connect");
|
||||
assert!(
|
||||
connect.args.iter().any(|a| a == "--ask"),
|
||||
"create path must use --ask: {:?}",
|
||||
connect.args
|
||||
);
|
||||
assert_eq!(connect.stdin.as_deref(), Some("super-secret-psk\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_verbose_reuse_feeds_psk_on_stdin_never_argv() {
|
||||
let runner = FakeRunner::new()
|
||||
.on_contains("nmcli", "NAME,TYPE", ok("Cafe:802-11-wireless"))
|
||||
.on_contains("nmcli", "connection modify", ok(""))
|
||||
.on_contains("nmcli", "connection up", ok(""))
|
||||
.on_contains("nmcli", "GENERAL.CON-UUID", ok("uuid-1"))
|
||||
.on_contains("nmcli", "ipv4.ignore-auto-dns", ok(""))
|
||||
.on_contains("nmcli", "device reapply", ok(""));
|
||||
let calls = runner.calls_handle();
|
||||
|
||||
let net = net("Cafe", Some("super-secret-psk"));
|
||||
let result = with_runner(runner, || nm::connect_verbose("wlan0", &net, 8, "1.1.1.1"));
|
||||
assert!(result.is_ok(), "{result:?}");
|
||||
|
||||
let calls = calls.borrow();
|
||||
assert_psk_not_on_argv(&calls, "super-secret-psk");
|
||||
let up = calls
|
||||
.iter()
|
||||
.find(|c| c.prog == "nmcli" && c.args.iter().any(|a| a == "up"))
|
||||
.expect("expected connection up");
|
||||
assert!(
|
||||
up.args.iter().any(|a| a == "--ask"),
|
||||
"reuse path must use --ask: {:?}",
|
||||
up.args
|
||||
);
|
||||
assert_eq!(up.stdin.as_deref(), Some("super-secret-psk\n"));
|
||||
// Clearing the stored PSK uses an empty argv value, never the secret.
|
||||
let cleared = calls.iter().any(|c| {
|
||||
c.prog == "nmcli"
|
||||
&& c.args.iter().any(|a| a == "802-11-wireless-security.psk")
|
||||
&& c.args.last().is_some_and(|a| a.is_empty())
|
||||
let res = with_runner(runner, || {
|
||||
breadcrumbs::nm::connect_verbose("wlan0", &def, 8, "1.1.1.1")
|
||||
});
|
||||
assert!(cleared, "reuse+password should reset stored PSK: {calls:?}");
|
||||
assert!(res.is_ok(), "enterprise connect should succeed: {res:?}");
|
||||
|
||||
let calls_ref = calls.borrow();
|
||||
let add = calls_ref
|
||||
.iter()
|
||||
.find(|c| c.args.contains(&"add".to_string()) && c.args.contains(&"connection".to_string()))
|
||||
.expect("enterprise path must create a profile via `connection add`");
|
||||
let joined = add.args.join(" ");
|
||||
assert!(joined.contains("wpa-eap"));
|
||||
assert!(joined.contains("peap"));
|
||||
assert!(joined.contains("user@corp"));
|
||||
assert!(joined.contains("/etc/ca.pem"));
|
||||
assert!(joined.contains("802-1x.password"));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue