Harden breadcrumbs: fix real bugs, restructure as lib, stop storing PSKs twice
Bug fixes: - mask() panicked on multi-byte UTF-8 passwords (byte-slicing a char boundary); now masks by char count and never echoes a real character - `cd --shell` interpolated the config path into a shell -c string via Debug formatting, which doesn't neutralize shell metacharacters; now passed as a positional shell argument instead - connecting to open (no-password) networks failed because an empty PSK was always sent to nmcli, which nmcli treats as secured-with-no-password instead of open; the password arg is now omitted entirely when empty - five nmcli terse-output parse sites used a raw splitn(2, ':'), which mis-splits any device/connection name containing a literal ':'; unified on the existing escape-aware field splitter - watch's health classifier silently read a config-deleted profile as "healthy" off a bare internet check instead of surfacing the misconfig - the nmcli-monitor thread seeded its debounce clock with `Instant::now() - 10s`, which panics on the monotonic clock near boot — exactly when the generated systemd unit tends to start the watcher Architecture: - extracted src/lib.rs + src/app.rs so command logic can be exercised in-process by tests instead of only by spawning the compiled binary - added a Runner trait (src/util.rs) so subprocess calls can be faked in tests; flow::run and watch::classify are now covered by real in-process tests of the connect state machine and health transitions, not just their pure helpers - Wi-Fi passwords are no longer kept in breadcrumbs' config once NetworkManager durably holds them: NetworkDef.password is now optional, and a successful password-based connect clears + persists it immediately, so it's never sent again on subsequent connects - saved networks (SSID + optional local password) moved out of breadcrumbs.toml into a separate networks.toml; old configs with inline [[networks]] still load and migrate automatically on next save - corrected a false README claim that passwords are never in nmcli argv Test count: 20 -> 89 (52 unit, 24 CLI integration, 13 in-process state-machine tests). Full clean run: cargo build/build --release/ test/clippy --all-targets, verified from a `cargo clean` rebuild.
This commit is contained in:
parent
d177cc8d82
commit
037c6e54c9
16 changed files with 2688 additions and 834 deletions
87
src/watch.rs
87
src/watch.rs
|
|
@ -11,16 +11,30 @@ use crate::state::State;
|
|||
use crate::status::{self};
|
||||
use crate::tailscale::TsHealth;
|
||||
|
||||
/// Coarse health classification the watch loop reacts to each tick. `pub`
|
||||
/// (and so is [`classify`]) purely so integration tests can drive the real
|
||||
/// classification logic in-process against a faked [`crate::util::Runner`],
|
||||
/// instead of only being able to observe it indirectly through the watch
|
||||
/// loop's side effects.
|
||||
#[derive(PartialEq, Eq, Clone, Debug)]
|
||||
enum Health {
|
||||
pub enum Health {
|
||||
Up,
|
||||
DownNoNet,
|
||||
DownTailscaleManual,
|
||||
DownTailscaleOther,
|
||||
NoAdapter,
|
||||
/// `profile` isn't defined in the config (e.g. state still points at a
|
||||
/// custom profile the user deleted from breadcrumbs.toml).
|
||||
UnknownProfile,
|
||||
}
|
||||
|
||||
fn classify(cfg: &Config, profile: &str) -> (Health, Option<String>) {
|
||||
pub fn classify(cfg: &Config, profile: &str) -> (Health, Option<String>) {
|
||||
// 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);
|
||||
}
|
||||
let s = status::gather(cfg, profile);
|
||||
if s.iface.is_none() {
|
||||
return (Health::NoAdapter, None);
|
||||
|
|
@ -43,6 +57,15 @@ fn classify(cfg: &Config, profile: &str) -> (Health, Option<String>) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Whether a debounced signal is allowed to fire. `None` (never fired) always
|
||||
/// fires; otherwise it fires only once more than `gap` has elapsed since the
|
||||
/// last fire. Pulled out as a pure helper so the debounce logic is testable and
|
||||
/// so the "first event fires immediately" case is expressed without the
|
||||
/// panic-prone `Instant::now() - gap` seed.
|
||||
fn debounce_ready(last: Option<Instant>, gap: Duration) -> bool {
|
||||
last.map(|t| t.elapsed() > gap).unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Tail `nmcli monitor` and ping the channel on link-state churn so we react
|
||||
/// to drops within a second instead of waiting out the poll interval.
|
||||
fn spawn_nm_monitor(tx: mpsc::Sender<()>) {
|
||||
|
|
@ -62,14 +85,21 @@ fn spawn_nm_monitor(tx: mpsc::Sender<()>) {
|
|||
};
|
||||
if let Some(out) = child.stdout.take() {
|
||||
let reader = BufReader::new(out);
|
||||
let mut last = Instant::now() - Duration::from_secs(10);
|
||||
// `None` means "haven't fired yet, so fire on the first interesting
|
||||
// line". Storing an `Option` instead of seeding with
|
||||
// `Instant::now() - 10s` avoids a panic: `Instant - Duration`
|
||||
// underflows (and panics) when the monotonic clock is younger than
|
||||
// the offset, which happens if `watch` starts within ~10s of boot —
|
||||
// exactly when the systemd unit (ordered after graphical-session)
|
||||
// tends to launch.
|
||||
let mut last: Option<Instant> = None;
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
let l = line.to_lowercase();
|
||||
let interesting = l.contains("disconnect")
|
||||
|| l.contains("unavailable")
|
||||
|| l.contains("failed");
|
||||
if interesting && last.elapsed() > Duration::from_millis(1500) {
|
||||
last = Instant::now();
|
||||
if interesting && debounce_ready(last, Duration::from_millis(1500)) {
|
||||
last = Some(Instant::now());
|
||||
let _ = tx.send(());
|
||||
}
|
||||
}
|
||||
|
|
@ -116,7 +146,7 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
|||
));
|
||||
} else {
|
||||
log(&format!("watch: initial flow for profile={profile}"));
|
||||
let _ = flow::run(&cfg, &profile);
|
||||
let _ = flow::run(&mut cfg, &profile);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -128,6 +158,12 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
|||
|
||||
loop {
|
||||
// Reload config + state so edits and `profile set` take effect live.
|
||||
// This always runs *before* `flow::run` below (never after, within
|
||||
// the same tick), so a password `flow::run` clears-and-saves this
|
||||
// iteration is durably on disk by the time the *next* iteration's
|
||||
// reload runs — there's no window where a stale (still-has-password)
|
||||
// reload could clobber the save, since the two never race on
|
||||
// different threads: everything here is sequential on this loop.
|
||||
if let Ok(fresh) = Config::load() {
|
||||
cfg = fresh;
|
||||
}
|
||||
|
|
@ -175,6 +211,15 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
|||
}
|
||||
fail_streak = fail_streak.saturating_add(1);
|
||||
}
|
||||
Health::UnknownProfile => {
|
||||
// flow::run() already notifies + logs the "unknown profile"
|
||||
// critical error; re-running it here would just spam that on
|
||||
// every tick, so only surface it once per transition.
|
||||
if transition || profile_changed {
|
||||
let _ = flow::run(&mut cfg, &profile);
|
||||
}
|
||||
fail_streak = fail_streak.saturating_add(1);
|
||||
}
|
||||
Health::DownTailscaleManual => {
|
||||
// Can't be auto-fixed (login / not installed). Notify once.
|
||||
if transition {
|
||||
|
|
@ -187,7 +232,7 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
|||
}
|
||||
// Re-run flow only on transition so we land on the bootstrap net.
|
||||
if transition || profile_changed {
|
||||
let _ = flow::run(&cfg, &profile);
|
||||
let _ = flow::run(&mut cfg, &profile);
|
||||
}
|
||||
fail_streak = fail_streak.saturating_add(1);
|
||||
}
|
||||
|
|
@ -205,7 +250,7 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
|||
"watch: down ({:?}) profile={profile} ssid={:?} — running flow",
|
||||
health, ssid
|
||||
));
|
||||
let outcome = flow::run(&cfg, &profile);
|
||||
let outcome = flow::run(&mut cfg, &profile);
|
||||
log(&format!("watch: recovery outcome = {:?}", outcome));
|
||||
last_flow_at = Some(Instant::now());
|
||||
fail_streak = if outcome.ok() {
|
||||
|
|
@ -230,3 +275,29 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
|||
wait_for_tick(&rx, dur);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn debounce_fires_immediately_when_never_fired() {
|
||||
// Regression guard for the old `Instant::now() - Duration::from_secs(10)`
|
||||
// seed, which panicked near boot. `None` must fire without any
|
||||
// subtraction on the clock.
|
||||
assert!(debounce_ready(None, Duration::from_millis(1500)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debounce_suppresses_immediately_after_firing() {
|
||||
let just_now = Instant::now();
|
||||
assert!(!debounce_ready(Some(just_now), Duration::from_secs(3600)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debounce_fires_again_after_gap_elapses() {
|
||||
// A zero gap is always already-elapsed, so a prior fire doesn't block.
|
||||
let earlier = Instant::now();
|
||||
assert!(debounce_ready(Some(earlier), Duration::from_millis(0)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue