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
796
src/app.rs
Normal file
796
src/app.rs
Normal file
|
|
@ -0,0 +1,796 @@
|
|||
//! CLI argument parsing and command handlers. This is the only module
|
||||
//! `src/main.rs` calls into; everything else (the actual state machine,
|
||||
//! nmcli/tailscale wrappers, config, …) is exercised directly by library
|
||||
//! consumers (including the integration tests under `tests/`).
|
||||
|
||||
use std::io::{BufRead, Write};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
use crate::config::{Config, NetworkDef};
|
||||
use crate::state::State;
|
||||
use crate::util::{self, command_exists, home_dir};
|
||||
use crate::{config, flow, nm, notify, watch};
|
||||
|
||||
const C_RESET: &str = "\x1b[0m";
|
||||
const C_BOLD: &str = "\x1b[1m";
|
||||
const C_GREEN: &str = "\x1b[32m";
|
||||
const C_RED: &str = "\x1b[31m";
|
||||
const C_YELLOW: &str = "\x1b[33m";
|
||||
const C_DIM: &str = "\x1b[2m";
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "breadcrumbs",
|
||||
version,
|
||||
about = "Profile-aware Wi-Fi state machine with Tailscale handling",
|
||||
disable_help_subcommand = true
|
||||
)]
|
||||
struct Cli {
|
||||
/// Override the active profile for this run only (does not persist)
|
||||
#[arg(long, short, global = true)]
|
||||
profile: Option<String>,
|
||||
|
||||
#[command(subcommand)]
|
||||
cmd: Option<Cmd>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Show current Wi-Fi / profile / Tailscale status (default)
|
||||
Status,
|
||||
/// Run the full connect sequence for the active profile
|
||||
#[command(visible_aliases = ["up", "connect", "i"])]
|
||||
Init,
|
||||
/// Run as a daemon: watch for drops and auto-recover
|
||||
Watch {
|
||||
/// Skip the connect attempt on startup
|
||||
#[arg(long)]
|
||||
no_initial: bool,
|
||||
},
|
||||
/// Get / set / list location profiles (the state machine)
|
||||
Profile {
|
||||
#[command(subcommand)]
|
||||
action: Option<ProfileCmd>,
|
||||
},
|
||||
/// Guess the profile from visible networks
|
||||
Detect {
|
||||
/// Set + apply the detected profile
|
||||
#[arg(long)]
|
||||
apply: 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)
|
||||
#[arg(long)]
|
||||
hidden: 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>,
|
||||
},
|
||||
/// Remove a saved network (config + NetworkManager)
|
||||
Forget { ssid: String },
|
||||
/// Scan, pick, connect and save a network interactively
|
||||
Scan {
|
||||
/// Attach the saved network to this profile
|
||||
#[arg(long)]
|
||||
to: Option<String>,
|
||||
},
|
||||
/// List configured networks and profiles
|
||||
List {
|
||||
#[arg(long)]
|
||||
show_passwords: bool,
|
||||
},
|
||||
/// Open the config file in $EDITOR
|
||||
Edit,
|
||||
/// Quick connectivity / Tailscale diagnostics
|
||||
Doctor {
|
||||
/// Run the full diag.sh report from the config directory
|
||||
#[arg(long)]
|
||||
full: bool,
|
||||
},
|
||||
/// Print the breadcrumbs config directory
|
||||
Cd {
|
||||
#[arg(long)]
|
||||
shell: bool,
|
||||
},
|
||||
/// Install + enable the systemd user watcher service
|
||||
InstallService {
|
||||
/// Install the unit but do not enable/start it
|
||||
#[arg(long)]
|
||||
no_enable: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ProfileCmd {
|
||||
/// Print the active profile
|
||||
Get,
|
||||
/// Set the active profile (and apply it unless --no-apply)
|
||||
Set {
|
||||
name: String,
|
||||
#[arg(long)]
|
||||
no_apply: bool,
|
||||
},
|
||||
/// List available profiles
|
||||
List,
|
||||
}
|
||||
|
||||
/// Parse `argv` and run the requested command. Returns the process exit code.
|
||||
pub fn run() -> i32 {
|
||||
let cli = Cli::parse();
|
||||
match real_main(cli) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("{C_RED}error:{C_RESET} {e}");
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn active_profile(cfg: &Config, override_p: &Option<String>) -> String {
|
||||
if let Some(p) = override_p {
|
||||
return p.clone();
|
||||
}
|
||||
State::load(&cfg.settings.default_profile).profile
|
||||
}
|
||||
|
||||
fn real_main(cli: Cli) -> Result<i32, String> {
|
||||
let cmd = cli.cmd.unwrap_or(Cmd::Status);
|
||||
|
||||
// `cd` and `install-service` don't need a parsed config first.
|
||||
if let Cmd::Cd { shell } = &cmd {
|
||||
return cmd_cd(*shell);
|
||||
}
|
||||
|
||||
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::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::Forget { ssid } => cmd_forget(&mut cfg, &ssid),
|
||||
Cmd::Scan { to } => cmd_scan(&mut cfg, to),
|
||||
Cmd::List { show_passwords } => cmd_list(&cfg, show_passwords),
|
||||
Cmd::Edit => cmd_edit(),
|
||||
Cmd::Doctor { full } => cmd_doctor(&cfg, &cli.profile, full),
|
||||
Cmd::InstallService { no_enable } => cmd_install_service(!no_enable),
|
||||
Cmd::Cd { .. } => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_outcome(profile: &str, o: &flow::Outcome) {
|
||||
match o {
|
||||
flow::Outcome::Connected { ssid, note } => {
|
||||
print!("{C_GREEN}connected{C_RESET} {C_BOLD}{ssid}{C_RESET} ({profile})");
|
||||
match note {
|
||||
Some(n) => println!(" {C_YELLOW}— {n}{C_RESET}"),
|
||||
None => println!(),
|
||||
}
|
||||
}
|
||||
flow::Outcome::TailscaleError { ssid, health } => {
|
||||
println!(
|
||||
"{C_RED}tailscale error{C_RESET}: {} {C_DIM}(on {}){C_RESET}",
|
||||
health.describe(),
|
||||
ssid.clone().unwrap_or_else(|| "—".into())
|
||||
);
|
||||
}
|
||||
flow::Outcome::NoInterface => {
|
||||
println!("{C_RED}no Wi-Fi adapter{C_RESET} — hardware issue")
|
||||
}
|
||||
flow::Outcome::NoNetworks => {
|
||||
println!("{C_RED}no known networks in range{C_RESET} (profile {profile})")
|
||||
}
|
||||
flow::Outcome::UnknownProfile(p) => {
|
||||
println!("{C_RED}unknown profile{C_RESET}: {p}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_status(cfg: &Config, override_p: &Option<String>) -> Result<i32, String> {
|
||||
let p = active_profile(cfg, override_p);
|
||||
let s = crate::status::gather(cfg, &p);
|
||||
|
||||
let dot = |ok: bool| {
|
||||
if ok {
|
||||
format!("{C_GREEN}●{C_RESET}")
|
||||
} else {
|
||||
format!("{C_RED}●{C_RESET}")
|
||||
}
|
||||
};
|
||||
|
||||
println!("{C_BOLD}breadcrumbs{C_RESET}");
|
||||
println!(" profile {C_BOLD}{p}{C_RESET}");
|
||||
println!(
|
||||
" adapter {}",
|
||||
s.iface
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{C_RED}none{C_RESET}"))
|
||||
);
|
||||
println!(
|
||||
" ssid {}",
|
||||
s.ssid
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{C_DIM}—{C_RESET}"))
|
||||
);
|
||||
println!(
|
||||
" ip {}",
|
||||
s.ip.clone().unwrap_or_else(|| format!("{C_DIM}—{C_RESET}"))
|
||||
);
|
||||
println!(
|
||||
" internet {} {}",
|
||||
dot(s.internet),
|
||||
if s.internet { "ok" } else { "down" }
|
||||
);
|
||||
|
||||
match (&s.tailscale, s.tailscale_required) {
|
||||
(Some(h), req) => {
|
||||
let ok = h.is_ok();
|
||||
println!(
|
||||
" tailscale {} {} {C_DIM}(exit: {}{}){C_RESET}",
|
||||
dot(ok || !req),
|
||||
h.describe(),
|
||||
s.exit_node,
|
||||
if req { "" } else { ", optional" }
|
||||
);
|
||||
}
|
||||
(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 {
|
||||
format!("{C_GREEN}healthy{C_RESET}")
|
||||
} else {
|
||||
format!("{C_YELLOW}needs attention{C_RESET} — run `breadcrumbs init`")
|
||||
}
|
||||
);
|
||||
Ok(if healthy { 0 } else { 1 })
|
||||
}
|
||||
|
||||
fn cmd_profile(cfg: &mut Config, action: Option<ProfileCmd>) -> Result<i32, String> {
|
||||
match action.unwrap_or(ProfileCmd::Get) {
|
||||
ProfileCmd::Get => {
|
||||
println!("{}", State::load(&cfg.settings.default_profile).profile);
|
||||
Ok(0)
|
||||
}
|
||||
ProfileCmd::List => {
|
||||
let cur = State::load(&cfg.settings.default_profile).profile;
|
||||
for name in cfg.profiles.keys() {
|
||||
let mark = if *name == cur { "*" } else { " " };
|
||||
println!("{mark} {name}");
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
ProfileCmd::Set { name, no_apply } => {
|
||||
if !cfg.profiles.contains_key(&name) {
|
||||
let avail: Vec<&String> = cfg.profiles.keys().collect();
|
||||
return Err(format!("unknown profile '{name}'. Available: {avail:?}"));
|
||||
}
|
||||
let st = State {
|
||||
profile: name.clone(),
|
||||
updated: crate::util::timestamp(),
|
||||
};
|
||||
st.save()?;
|
||||
notify::log(&format!("profile set -> {name}"));
|
||||
println!("profile = {C_BOLD}{name}{C_RESET}");
|
||||
if no_apply {
|
||||
return Ok(0);
|
||||
}
|
||||
let outcome = flow::run(cfg, &name);
|
||||
print_outcome(&name, &outcome);
|
||||
Ok(if outcome.ok() { 0 } else { 1 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_profile(cfg: &Config) -> Option<String> {
|
||||
let iface = nm::wifi_interface()?;
|
||||
nm::radio_on();
|
||||
nm::rescan(&iface, &[]);
|
||||
let visible = nm::visible_ssids(&iface);
|
||||
|
||||
// Profiles are stored in a BTreeMap so iteration order is deterministic
|
||||
// (alphabetical). The caller can rely on that for tie-breaking.
|
||||
for (name, profile) in &cfg.profiles {
|
||||
if profile.detect_ssids.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if profile
|
||||
.detect_ssids
|
||||
.iter()
|
||||
.any(|s| visible.contains(s.as_str()))
|
||||
{
|
||||
return Some(name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to the default profile if no markers matched.
|
||||
Some(cfg.settings.default_profile.clone())
|
||||
}
|
||||
|
||||
fn cmd_detect(cfg: &mut Config, apply: bool) -> Result<i32, String> {
|
||||
match detect_profile(cfg) {
|
||||
Some(p) => {
|
||||
println!("{p}");
|
||||
if apply {
|
||||
State {
|
||||
profile: p.clone(),
|
||||
updated: crate::util::timestamp(),
|
||||
}
|
||||
.save()?;
|
||||
let outcome = flow::run(cfg, &p);
|
||||
print_outcome(&p, &outcome);
|
||||
return Ok(if outcome.ok() { 0 } else { 1 });
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
None => Err("could not detect a profile (no Wi-Fi adapter?)".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_line(msg: &str) -> String {
|
||||
print!("{msg}");
|
||||
let _ = std::io::stdout().flush();
|
||||
let mut s = String::new();
|
||||
let _ = std::io::stdin().lock().read_line(&mut s);
|
||||
s.trim_end_matches(['\n', '\r']).to_string()
|
||||
}
|
||||
|
||||
/// An empty string entered for a password (CLI arg or a blank prompt
|
||||
/// response) means "this network has no password" (open Wi-Fi) — normalize
|
||||
/// it to `None` right at the point of entry so it flows the same way a
|
||||
/// genuinely absent/cleared password does. Without this, `Some("")` would
|
||||
/// make `nm::connect_verbose` send an empty PSK argument, which nmcli treats
|
||||
/// as "secured with a blank password" rather than "open", and the connect
|
||||
/// fails against a real open SSID.
|
||||
fn non_empty(s: String) -> Option<String> {
|
||||
if s.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s)
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_secret(msg: &str) -> String {
|
||||
// `util::run` redirects child stdin to /dev/null, so plain `stty -echo`
|
||||
// would target the wrong fd and silently leave echo ON (leaking the
|
||||
// password to the screen). `-F /dev/tty` makes stty act on the controlling
|
||||
// terminal directly. If there is no tty we fall back to visible input.
|
||||
let had_tty = util::run("stty", &["-F", "/dev/tty", "-echo"], Duration::from_secs(2)).success;
|
||||
let val = prompt_line(msg);
|
||||
if had_tty {
|
||||
let _ = util::run("stty", &["-F", "/dev/tty", "echo"], Duration::from_secs(2));
|
||||
println!();
|
||||
}
|
||||
val
|
||||
}
|
||||
|
||||
fn cmd_add(
|
||||
cfg: &mut Config,
|
||||
ssid: String,
|
||||
password: Option<String>,
|
||||
hidden: bool,
|
||||
to: Option<String>,
|
||||
at: Option<usize>,
|
||||
) -> Result<i32, String> {
|
||||
let password = match password {
|
||||
Some(p) => p,
|
||||
None => prompt_secret(&format!("Password for '{ssid}': ")),
|
||||
};
|
||||
let password = non_empty(password);
|
||||
match cfg.networks.iter_mut().find(|n| n.ssid == ssid) {
|
||||
Some(n) => {
|
||||
n.password = password;
|
||||
n.hidden = hidden || n.hidden;
|
||||
}
|
||||
None => cfg.networks.push(NetworkDef {
|
||||
ssid: ssid.clone(),
|
||||
password,
|
||||
hidden,
|
||||
}),
|
||||
}
|
||||
if let Some(prof_name) = to {
|
||||
let prof = cfg
|
||||
.profiles
|
||||
.get_mut(&prof_name)
|
||||
.ok_or_else(|| format!("unknown profile '{prof_name}'"))?;
|
||||
prof.networks.retain(|s| s != &ssid);
|
||||
let idx = at.unwrap_or(prof.networks.len()).min(prof.networks.len());
|
||||
prof.networks.insert(idx, ssid.clone());
|
||||
}
|
||||
cfg.save()?;
|
||||
println!("{C_GREEN}saved{C_RESET} {ssid}");
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn cmd_forget(cfg: &mut Config, ssid: &str) -> Result<i32, String> {
|
||||
let before = cfg.networks.len();
|
||||
cfg.networks.retain(|n| n.ssid != ssid);
|
||||
for p in cfg.profiles.values_mut() {
|
||||
p.networks.retain(|s| s != ssid);
|
||||
if p.bootstrap.as_deref() == Some(ssid) {
|
||||
p.bootstrap = None;
|
||||
}
|
||||
}
|
||||
cfg.save()?;
|
||||
let removed = nm::delete_connections_for_ssid(ssid);
|
||||
println!(
|
||||
"{C_GREEN}forgot{C_RESET} {ssid} (config: {}, NetworkManager: {})",
|
||||
if cfg.networks.len() < before {
|
||||
"removed"
|
||||
} else {
|
||||
"not present"
|
||||
},
|
||||
if removed { "removed" } else { "not present" }
|
||||
);
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn cmd_scan(cfg: &mut Config, to: Option<String>) -> Result<i32, String> {
|
||||
let iface = nm::wifi_interface().ok_or("no Wi-Fi adapter")?;
|
||||
nm::radio_on();
|
||||
nm::rescan(&iface, &[]);
|
||||
let entries = nm::scan_list(&iface);
|
||||
if entries.is_empty() {
|
||||
return Err("no networks found".into());
|
||||
}
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
println!(
|
||||
"{:>2}. {C_BOLD}{}{C_RESET} {C_DIM}sig {} {}{C_RESET}",
|
||||
i + 1,
|
||||
if e.ssid.is_empty() {
|
||||
"<hidden>"
|
||||
} else {
|
||||
&e.ssid
|
||||
},
|
||||
e.signal,
|
||||
e.security
|
||||
);
|
||||
}
|
||||
let sel = prompt_line("Select number: ");
|
||||
let idx: usize = sel
|
||||
.parse::<usize>()
|
||||
.ok()
|
||||
.filter(|n| *n >= 1 && *n <= entries.len())
|
||||
.ok_or("invalid selection")?;
|
||||
let ssid = entries[idx - 1].ssid.clone();
|
||||
if ssid.is_empty() {
|
||||
return Err("cannot select a hidden SSID here; use `breadcrumbs add`".into());
|
||||
}
|
||||
let password = non_empty(prompt_secret(&format!("Password for '{ssid}': ")));
|
||||
let mut def = NetworkDef {
|
||||
ssid: ssid.clone(),
|
||||
password,
|
||||
hidden: false,
|
||||
};
|
||||
if !nm::connect(&iface, &def, cfg.settings.nmcli_wait, &cfg.settings.dns) {
|
||||
return Err(format!("failed to connect to {ssid}"));
|
||||
}
|
||||
// A successful connect means NetworkManager now durably holds the PSK
|
||||
// (either in a freshly created profile, or one whose PSK we just set) —
|
||||
// breadcrumbs no longer needs to keep its own plaintext copy.
|
||||
def.password = None;
|
||||
match cfg.networks.iter_mut().find(|n| n.ssid == ssid) {
|
||||
Some(n) => n.password = None,
|
||||
None => cfg.networks.push(def),
|
||||
}
|
||||
if let Some(prof_name) = to {
|
||||
if let Some(prof) = cfg.profiles.get_mut(&prof_name) {
|
||||
if !prof.networks.contains(&ssid) {
|
||||
prof.networks.push(ssid.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
cfg.save()?;
|
||||
println!("{C_GREEN}connected + saved{C_RESET} {ssid}");
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Mask a secret for display. Operates on chars (not bytes) so multi-byte
|
||||
/// UTF-8 passwords don't panic on a mid-character byte slice, and never
|
||||
/// echoes back any real character of the secret (previously the first byte
|
||||
/// was shown unmasked).
|
||||
fn mask(p: &str) -> String {
|
||||
"•".repeat(p.chars().count().max(2))
|
||||
}
|
||||
|
||||
fn cmd_list(cfg: &Config, show_pw: bool) -> Result<i32, String> {
|
||||
println!("{C_BOLD}settings{C_RESET}");
|
||||
println!(" dns {}", cfg.settings.dns);
|
||||
println!(" exit_node {}", cfg.settings.exit_node);
|
||||
println!(" default {}", cfg.settings.default_profile);
|
||||
println!(" watch every {}s", cfg.settings.watch_interval);
|
||||
|
||||
println!("\n{C_BOLD}networks{C_RESET}");
|
||||
for n in &cfg.networks {
|
||||
let pw_display = match &n.password {
|
||||
Some(p) if show_pw => p.clone(),
|
||||
Some(p) => mask(p),
|
||||
// No local secret: NetworkManager already owns the credential for
|
||||
// this SSID, so there is nothing to mask — showing dots here
|
||||
// would falsely imply breadcrumbs is still hiding a password.
|
||||
None => format!("{C_DIM}managed by NetworkManager{C_RESET}"),
|
||||
};
|
||||
println!(
|
||||
" {C_BOLD}{}{C_RESET} {C_DIM}{}{}{C_RESET}",
|
||||
n.ssid,
|
||||
pw_display,
|
||||
if n.hidden { " (hidden)" } else { "" }
|
||||
);
|
||||
}
|
||||
|
||||
println!("\n{C_BOLD}profiles{C_RESET}");
|
||||
let cur = State::load(&cfg.settings.default_profile).profile;
|
||||
for (name, p) in &cfg.profiles {
|
||||
let mark = if *name == cur {
|
||||
format!("{C_GREEN}*{C_RESET}")
|
||||
} else {
|
||||
" ".into()
|
||||
};
|
||||
println!("{mark} {C_BOLD}{name}{C_RESET}");
|
||||
if let Some(b) = &p.bootstrap {
|
||||
println!(" bootstrap {b}");
|
||||
}
|
||||
if p.tailscale {
|
||||
println!(
|
||||
" tailscale required (exit: {})",
|
||||
p.exit_node
|
||||
.clone()
|
||||
.unwrap_or_else(|| cfg.settings.exit_node.clone())
|
||||
);
|
||||
}
|
||||
let mut order: Vec<String> = p.networks.clone();
|
||||
if p.include_all_known {
|
||||
order.push("…all other known networks".into());
|
||||
}
|
||||
println!(" priority {}", order.join(" > "));
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn cmd_edit() -> Result<i32, String> {
|
||||
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".into());
|
||||
let path = config::config_path();
|
||||
let status = Command::new(&editor)
|
||||
.arg(&path)
|
||||
.status()
|
||||
.map_err(|e| format!("launching {editor}: {e}"))?;
|
||||
if !status.success() {
|
||||
return Err("editor exited with error".into());
|
||||
}
|
||||
match Config::load() {
|
||||
Ok(_) => {
|
||||
println!("{C_GREEN}config OK{C_RESET}");
|
||||
Ok(0)
|
||||
}
|
||||
Err(e) => Err(format!("config is now invalid: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_doctor(cfg: &Config, override_p: &Option<String>, full: bool) -> Result<i32, String> {
|
||||
if full {
|
||||
let script = config::config_dir().join("diag.sh");
|
||||
if !script.exists() {
|
||||
return Err(format!(
|
||||
"diag.sh not found (expected at {})",
|
||||
script.display()
|
||||
));
|
||||
}
|
||||
let st = Command::new("bash")
|
||||
.arg(&script)
|
||||
.status()
|
||||
.map_err(|e| format!("running diag: {e}"))?;
|
||||
return Ok(st.code().unwrap_or(1));
|
||||
}
|
||||
|
||||
let p = active_profile(cfg, override_p);
|
||||
let s = crate::status::gather(cfg, &p);
|
||||
println!("{C_BOLD}breadcrumbs doctor{C_RESET} (profile {p})");
|
||||
println!(
|
||||
" nmcli {}",
|
||||
if command_exists("nmcli") {
|
||||
"present"
|
||||
} else {
|
||||
"MISSING"
|
||||
}
|
||||
);
|
||||
println!(
|
||||
" tailscale {}",
|
||||
if command_exists("tailscale") {
|
||||
"present"
|
||||
} else {
|
||||
"absent"
|
||||
}
|
||||
);
|
||||
println!(
|
||||
" adapter {}",
|
||||
s.iface.clone().unwrap_or_else(|| "none".into())
|
||||
);
|
||||
println!(
|
||||
" ssid {}",
|
||||
s.ssid.clone().unwrap_or_else(|| "—".into())
|
||||
);
|
||||
println!(
|
||||
" ip {}",
|
||||
s.ip.clone().unwrap_or_else(|| "—".into())
|
||||
);
|
||||
println!(" internet {}", if s.internet { "ok" } else { "DOWN" });
|
||||
if let Some(h) = &s.tailscale {
|
||||
println!(" tailscale {} (exit {})", h.describe(), s.exit_node);
|
||||
}
|
||||
|
||||
if let Some(iface) = &s.iface {
|
||||
let visible = nm::visible_ssids(iface);
|
||||
let known: Vec<&str> = cfg
|
||||
.networks
|
||||
.iter()
|
||||
.filter(|n| visible.contains(&n.ssid))
|
||||
.map(|n| n.ssid.as_str())
|
||||
.collect();
|
||||
println!(
|
||||
" in range {}",
|
||||
if known.is_empty() {
|
||||
"none of your saved networks".into()
|
||||
} else {
|
||||
known.join(", ")
|
||||
}
|
||||
);
|
||||
}
|
||||
println!("\nFull report: {C_DIM}breadcrumbs doctor --full{C_RESET}");
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn cmd_cd(shell: bool) -> Result<i32, String> {
|
||||
let dir = config::config_dir();
|
||||
if shell {
|
||||
let sh = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into());
|
||||
let err = exec_replace(&sh, &dir);
|
||||
return Err(err);
|
||||
}
|
||||
println!("{}", dir.display());
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// Re-exec into an interactive login shell inside `dir`, replacing the
|
||||
/// current process. `dir` is passed as `$1` to the shell script rather than
|
||||
/// interpolated into the script text — the config dir can come from
|
||||
/// `$XDG_CONFIG_HOME`/`$HOME`, and string-formatting an arbitrary path
|
||||
/// straight into a `sh -c` command would let shell metacharacters (`$(...)`,
|
||||
/// backticks, etc.) in that path execute as commands.
|
||||
fn exec_replace(prog: &str, dir: &std::path::Path) -> String {
|
||||
use std::os::unix::process::CommandExt;
|
||||
let e = Command::new(prog)
|
||||
.arg("-lc")
|
||||
.arg("cd \"$1\" && exec \"$0\"")
|
||||
.arg(prog)
|
||||
.arg(dir)
|
||||
.exec();
|
||||
format!("exec {prog} failed: {e}")
|
||||
}
|
||||
|
||||
fn cmd_install_service(enable: bool) -> Result<i32, String> {
|
||||
let unit_dir = home_dir().join(".config/systemd/user");
|
||||
std::fs::create_dir_all(&unit_dir)
|
||||
.map_err(|e| format!("creating {}: {e}", unit_dir.display()))?;
|
||||
let bin = std::env::current_exe().map_err(|e| format!("resolving current executable: {e}"))?;
|
||||
// Ordering against graphical-session.target lets the watcher inherit the
|
||||
// session's DISPLAY/WAYLAND_DISPLAY/DBUS so notify-send and the Tailscale
|
||||
// login browser-open actually work. PATH is pinned because systemd --user
|
||||
// units do not get the login shell's PATH, and the watcher shells out to
|
||||
// nmcli/tailscale/sudo/xdg-open by name.
|
||||
let unit = format!(
|
||||
"[Unit]\n\
|
||||
Description=breadcrumbs Wi-Fi state machine watcher\n\
|
||||
After=network.target NetworkManager.service graphical-session.target\n\
|
||||
Wants=network.target graphical-session.target\n\n\
|
||||
[Service]\n\
|
||||
Type=simple\n\
|
||||
Environment=PATH=/usr/local/bin:/usr/bin:/bin\n\
|
||||
ExecStart={bin} watch\n\
|
||||
Restart=always\n\
|
||||
RestartSec=5\n\
|
||||
Nice=5\n\n\
|
||||
[Install]\n\
|
||||
WantedBy=default.target\n",
|
||||
bin = bin.display()
|
||||
);
|
||||
let unit_path = unit_dir.join("breadcrumbs.service");
|
||||
std::fs::write(&unit_path, unit)
|
||||
.map_err(|e| format!("writing {}: {e}", unit_path.display()))?;
|
||||
println!("{C_GREEN}wrote{C_RESET} {}", unit_path.display());
|
||||
|
||||
let _ = util::run(
|
||||
"systemctl",
|
||||
&["--user", "daemon-reload"],
|
||||
Duration::from_secs(10),
|
||||
);
|
||||
if enable {
|
||||
let o = util::run(
|
||||
"systemctl",
|
||||
&["--user", "enable", "--now", "breadcrumbs.service"],
|
||||
Duration::from_secs(15),
|
||||
);
|
||||
if o.success {
|
||||
println!("{C_GREEN}enabled + started{C_RESET} breadcrumbs.service");
|
||||
} else {
|
||||
println!(
|
||||
"{C_YELLOW}unit installed{C_RESET}; enable failed: {}",
|
||||
o.stderr.trim()
|
||||
);
|
||||
return Ok(1);
|
||||
}
|
||||
} else {
|
||||
println!("Run: systemctl --user enable --now breadcrumbs.service");
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mask_empty_password() {
|
||||
// len() == 0 <= 2 branch: still at least 2 dots so an empty saved
|
||||
// password doesn't visually collapse to nothing in `list`.
|
||||
assert_eq!(mask(""), "••");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_short_passwords_reveal_nothing() {
|
||||
assert_eq!(mask("a"), "••");
|
||||
assert_eq!(mask("ab"), "••");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_never_echoes_a_real_character() {
|
||||
let pw = "hunter2";
|
||||
let masked = mask(pw);
|
||||
assert_eq!(masked, "•".repeat(pw.len()));
|
||||
assert!(!masked.contains('h'), "masked output leaked the first char");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_multibyte_password_does_not_panic() {
|
||||
// Regression test: the old byte-slicing `&p[..1]` panicked whenever
|
||||
// the first character of the password was multi-byte UTF-8 (e.g. an
|
||||
// emoji or accented character), since byte index 1 can land mid-char.
|
||||
let pw = "日本語パスワード";
|
||||
let masked = mask(pw);
|
||||
assert_eq!(masked.chars().count(), pw.chars().count());
|
||||
assert!(masked.chars().all(|c| c == '•'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_emoji_first_character_does_not_panic() {
|
||||
let pw = "🔒password123";
|
||||
let masked = mask(pw);
|
||||
assert_eq!(masked.chars().count(), pw.chars().count());
|
||||
}
|
||||
}
|
||||
211
src/config.rs
211
src/config.rs
|
|
@ -63,7 +63,16 @@ impl Default for Settings {
|
|||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NetworkDef {
|
||||
pub ssid: String,
|
||||
pub password: String,
|
||||
/// A password is only needed once. On first successful connect,
|
||||
/// NetworkManager durably saves the credential (a new connection
|
||||
/// profile, or an updated PSK on an existing one); breadcrumbs then
|
||||
/// clears this field and, on the next save, omits the key entirely
|
||||
/// rather than writing a plaintext copy that's no longer needed. `None`
|
||||
/// means either "NetworkManager already owns this secret" or "this is
|
||||
/// an open (unsecured) network" — both cases behave the same way on
|
||||
/// connect: no password argument is ever sent.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub password: Option<String>,
|
||||
#[serde(default)]
|
||||
pub hidden: bool,
|
||||
}
|
||||
|
|
@ -95,12 +104,30 @@ pub struct Profile {
|
|||
pub struct Config {
|
||||
#[serde(default)]
|
||||
pub settings: Settings,
|
||||
#[serde(default, rename = "networks")]
|
||||
/// Saved networks (SSID + optional local password). Persisted to the
|
||||
/// separate `networks.toml` file (see [`networks_path`]), not to
|
||||
/// `breadcrumbs.toml` — kept here, and still deserialized from a
|
||||
/// `[[networks]]` block if one is present in `breadcrumbs.toml`, purely
|
||||
/// for backward compatibility with configs written before the secrets
|
||||
/// split: an old-format file's inline networks load in on first read and
|
||||
/// migrate to `networks.toml` automatically on the next `save()`, no
|
||||
/// explicit migration step required.
|
||||
#[serde(default, rename = "networks", skip_serializing)]
|
||||
pub networks: Vec<NetworkDef>,
|
||||
#[serde(default)]
|
||||
pub profiles: BTreeMap<String, Profile>,
|
||||
}
|
||||
|
||||
/// The on-disk shape of `networks.toml`: just the `[[networks]]` array,
|
||||
/// split out of the main config so a file that's mostly just settings and
|
||||
/// profiles (the parts people actually hand-edit or dotfile) doesn't also
|
||||
/// carry whatever plaintext Wi-Fi credentials breadcrumbs still holds.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
struct NetworksFile {
|
||||
#[serde(default, rename = "networks")]
|
||||
networks: Vec<NetworkDef>,
|
||||
}
|
||||
|
||||
pub fn config_dir() -> PathBuf {
|
||||
std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
|
|
@ -112,6 +139,13 @@ pub fn config_path() -> PathBuf {
|
|||
config_dir().join("breadcrumbs.toml")
|
||||
}
|
||||
|
||||
/// Where saved networks (SSID + optional local password) live, split out of
|
||||
/// `breadcrumbs.toml`. Not meant to be hand-edited — managed via
|
||||
/// `breadcrumbs add` / `scan` / `forget`.
|
||||
pub fn networks_path() -> PathBuf {
|
||||
config_dir().join("networks.toml")
|
||||
}
|
||||
|
||||
pub fn state_dir() -> PathBuf {
|
||||
std::env::var_os("XDG_STATE_HOME")
|
||||
.map(PathBuf::from)
|
||||
|
|
@ -146,29 +180,69 @@ impl Config {
|
|||
}
|
||||
let text =
|
||||
fs::read_to_string(&path).map_err(|e| format!("reading {}: {e}", path.display()))?;
|
||||
// May carry a legacy inline `[[networks]]` block (pre-split
|
||||
// configs) — that's fine, see the field doc on `Config::networks`.
|
||||
let mut cfg: Config =
|
||||
toml::from_str(&text).map_err(|e| format!("parsing {}: {e}", path.display()))?;
|
||||
|
||||
let net_path = networks_path();
|
||||
if net_path.exists() {
|
||||
let net_text = fs::read_to_string(&net_path)
|
||||
.map_err(|e| format!("reading {}: {e}", net_path.display()))?;
|
||||
let nf: NetworksFile = toml::from_str(&net_text)
|
||||
.map_err(|e| format!("parsing {}: {e}", net_path.display()))?;
|
||||
cfg.networks = nf.networks;
|
||||
}
|
||||
// else: no networks.toml yet — keep whatever legacy inline networks
|
||||
// were read from breadcrumbs.toml above (or none, on a genuinely
|
||||
// fresh config). The next `save()` writes them to networks.toml and
|
||||
// stops writing them into breadcrumbs.toml, completing the migration.
|
||||
|
||||
// Self-heal: guarantee the three core profiles always exist.
|
||||
ensure_core_profiles(&mut cfg);
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
/// Persist settings + profiles to `breadcrumbs.toml` and networks to the
|
||||
/// separate `networks.toml`, both `0600`. Every mutating command (`add`,
|
||||
/// `forget`, `scan`, `profile set`, and `flow::run`'s own credential
|
||||
/// clearing) goes through this single method so the two files never
|
||||
/// drift out of sync with each other.
|
||||
pub fn save(&self) -> Result<(), String> {
|
||||
let dir = config_dir();
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("creating {}: {e}", dir.display()))?;
|
||||
|
||||
let text = toml::to_string_pretty(self).map_err(|e| format!("serializing config: {e}"))?;
|
||||
let path = config_path();
|
||||
fs::write(&path, text).map_err(|e| format!("writing {}: {e}", path.display()))?;
|
||||
// Plaintext Wi-Fi passwords live here — keep it owner-only.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
secure_permissions(&path);
|
||||
|
||||
let nf = NetworksFile {
|
||||
networks: self.networks.clone(),
|
||||
};
|
||||
let net_text =
|
||||
toml::to_string_pretty(&nf).map_err(|e| format!("serializing networks: {e}"))?;
|
||||
let net_path = networks_path();
|
||||
fs::write(&net_path, net_text)
|
||||
.map_err(|e| format!("writing {}: {e}", net_path.display()))?;
|
||||
secure_permissions(&net_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Any local Wi-Fi passwords still held (pre-first-connect, or a network
|
||||
/// breadcrumbs doesn't yet know NetworkManager owns) live in plaintext on
|
||||
/// disk — keep both config files owner-only. Best-effort: a failure here
|
||||
/// isn't fatal to saving the config itself.
|
||||
#[cfg(unix)]
|
||||
fn secure_permissions(path: &std::path::Path) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
fn secure_permissions(_path: &std::path::Path) {}
|
||||
|
||||
/// Initial skeleton networks generated for a brand-new installation.
|
||||
/// Passwords are intentionally blank — secrets never live in source.
|
||||
/// Users fill them via `breadcrumbs add`, `breadcrumbs scan`, or
|
||||
|
|
@ -274,4 +348,125 @@ mod tests {
|
|||
assert!(cfg.profile("work").is_some());
|
||||
assert!(cfg.profile("away").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_core_profiles_preserves_user_customized_core_profile() {
|
||||
// A user-edited "home" (custom SSIDs) must not be clobbered by the
|
||||
// self-heal backfill — only genuinely *missing* core profiles should
|
||||
// be inserted.
|
||||
let mut profiles = BTreeMap::new();
|
||||
profiles.insert(
|
||||
"home".to_string(),
|
||||
Profile {
|
||||
networks: vec!["CustomSSID".into()],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let mut cfg = Config {
|
||||
settings: Settings::default(),
|
||||
networks: vec![],
|
||||
profiles,
|
||||
};
|
||||
ensure_core_profiles(&mut cfg);
|
||||
assert_eq!(
|
||||
cfg.profile("home").unwrap().networks,
|
||||
vec!["CustomSSID".to_string()]
|
||||
);
|
||||
// Still backfills the ones that were actually missing.
|
||||
assert!(cfg.profile("work").is_some());
|
||||
assert!(cfg.profile("away").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_default_matches_documented_defaults() {
|
||||
let s = Settings::default();
|
||||
assert_eq!(s.dns, "1.1.1.1");
|
||||
assert_eq!(s.nmcli_wait, 8);
|
||||
assert_eq!(s.default_profile, "away");
|
||||
assert_eq!(s.watch_interval, 12);
|
||||
assert_eq!(s.ping_host, "1.1.1.1");
|
||||
assert!(s.exit_node.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_def_hidden_defaults_false_when_omitted() {
|
||||
let text = r#"ssid = "Cafe"
|
||||
password = "pw""#;
|
||||
let n: NetworkDef = toml::from_str(text).unwrap();
|
||||
assert!(!n.hidden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_def_password_defaults_to_none_when_key_absent() {
|
||||
// No `password` key at all — e.g. a network whose secret breadcrumbs
|
||||
// already cleared after NetworkManager took it over.
|
||||
let text = r#"ssid = "Cafe"
|
||||
hidden = false"#;
|
||||
let n: NetworkDef = toml::from_str(text).unwrap();
|
||||
assert_eq!(n.password, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_def_omits_password_key_entirely_when_none() {
|
||||
// Round-tripping a cleared password must not write `password = ""`
|
||||
// (which would read back as "has an empty secret") or any other
|
||||
// stand-in — the key should be gone, full stop.
|
||||
let n = NetworkDef {
|
||||
ssid: "Cafe".into(),
|
||||
password: None,
|
||||
hidden: false,
|
||||
};
|
||||
let text = toml::to_string_pretty(&n).unwrap();
|
||||
assert!(!text.contains("password"), "text: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_def_password_round_trips_through_toml() {
|
||||
let n = NetworkDef {
|
||||
ssid: "Cafe".into(),
|
||||
password: Some("hunter2".into()),
|
||||
hidden: false,
|
||||
};
|
||||
let text = toml::to_string_pretty(&n).unwrap();
|
||||
assert!(text.contains("hunter2"));
|
||||
let back: NetworkDef = toml::from_str(&text).unwrap();
|
||||
assert_eq!(back.password, Some("hunter2".to_string()));
|
||||
}
|
||||
|
||||
// Note: `Config::save`/`Config::load`'s real filesystem behavior (the
|
||||
// networks.toml split, and a cleared password actually landing on disk)
|
||||
// is covered by `tests/cli.rs`'s Sandbox-isolated integration tests
|
||||
// (`networks_are_stored_separately_from_settings_and_profiles`,
|
||||
// `password_is_cleared_after_first_connect_and_never_sent_again`) rather
|
||||
// than here — this module's tests stay pure per the project's test
|
||||
// discipline (no real fs/env/subprocess access from a `#[cfg(test)]`
|
||||
// unit test).
|
||||
|
||||
#[test]
|
||||
fn profile_default_has_no_bootstrap_or_tailscale() {
|
||||
let p = Profile::default();
|
||||
assert!(p.bootstrap.is_none());
|
||||
assert!(!p.tailscale);
|
||||
assert!(!p.include_all_known);
|
||||
assert!(p.networks.is_empty());
|
||||
assert!(p.detect_ssids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_toml_fails_to_parse() {
|
||||
let bad = "this is not [ valid toml";
|
||||
assert!(toml::from_str::<Config>(bad).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_with_only_settings_defaults_networks_and_profiles() {
|
||||
// A hand-written config that only sets `[settings]` shouldn't require
|
||||
// `networks`/`profiles` sections — both must fall back to `#[serde(default)]`.
|
||||
let text = r#"[settings]
|
||||
dns = "9.9.9.9""#;
|
||||
let cfg: Config = toml::from_str(text).unwrap();
|
||||
assert_eq!(cfg.settings.dns, "9.9.9.9");
|
||||
assert!(cfg.networks.is_empty());
|
||||
assert!(cfg.profiles.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
149
src/flow.rs
149
src/flow.rs
|
|
@ -27,25 +27,54 @@ impl Outcome {
|
|||
}
|
||||
}
|
||||
|
||||
fn resolve_candidates<'a>(cfg: &'a Config, p: &crate::config::Profile) -> Vec<&'a NetworkDef> {
|
||||
let mut out: Vec<&NetworkDef> = Vec::new();
|
||||
/// Resolve a profile's priority list to concrete network definitions.
|
||||
///
|
||||
/// Returns owned clones rather than borrows of `cfg` — small structs, and it
|
||||
/// decouples the result's lifetime from `cfg` so callers (namely [`run`]) can
|
||||
/// still mutate `cfg` (to clear a used password and persist it) while a
|
||||
/// candidate from this list is being acted on.
|
||||
fn resolve_candidates(cfg: &Config, p: &crate::config::Profile) -> Vec<NetworkDef> {
|
||||
let mut out: Vec<NetworkDef> = Vec::new();
|
||||
for ssid in &p.networks {
|
||||
if let Some(def) = cfg.network(ssid) {
|
||||
if !out.iter().any(|d| d.ssid == def.ssid) {
|
||||
out.push(def);
|
||||
out.push(def.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if p.include_all_known {
|
||||
for def in &cfg.networks {
|
||||
if !out.iter().any(|d| d.ssid == def.ssid) {
|
||||
out.push(def);
|
||||
out.push(def.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A network was just connected to using a local password, and NetworkManager
|
||||
/// now durably holds that secret — either in a freshly created connection
|
||||
/// profile (`device wifi connect`) or an existing one whose PSK we just
|
||||
/// updated (`connection modify`). Either way breadcrumbs no longer needs its
|
||||
/// own plaintext copy: clear it and persist immediately, so it can't be
|
||||
/// re-sent as an argv argument on the next connect and doesn't sit on disk
|
||||
/// any longer than necessary. A no-op (no save) if the network has no local
|
||||
/// password to begin with.
|
||||
fn clear_password_if_used(cfg: &mut Config, ssid: &str) {
|
||||
let Some(def) = cfg.networks.iter_mut().find(|n| n.ssid == ssid) else {
|
||||
return;
|
||||
};
|
||||
if def.password.is_none() {
|
||||
return;
|
||||
}
|
||||
def.password = None;
|
||||
if let Err(e) = cfg.save() {
|
||||
log(&format!(
|
||||
"failed to persist cleared password for {ssid}: {e}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to connect + confirm it actually carries traffic.
|
||||
/// Returns Ok(()) on success, Err(reason) on failure.
|
||||
fn connect_and_verify(iface: &str, def: &NetworkDef, cfg: &Config) -> Result<(), String> {
|
||||
|
|
@ -57,7 +86,13 @@ fn connect_and_verify(iface: &str, def: &NetworkDef, cfg: &Config) -> Result<(),
|
|||
}
|
||||
|
||||
/// Run the connection state machine for `profile_name`.
|
||||
pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
|
||||
///
|
||||
/// Takes `cfg` mutably: a successful connect that used a local password
|
||||
/// clears that network's `password` field and persists the config
|
||||
/// immediately (see [`clear_password_if_used`]) — this is the only way that
|
||||
/// clearing happens for the `init` / `profile set --apply` / `detect --apply`
|
||||
/// commands and the watch loop, all of which route through here.
|
||||
pub fn run(cfg: &mut Config, profile_name: &str) -> Outcome {
|
||||
let profile = match cfg.profile(profile_name) {
|
||||
Some(p) => p.clone(),
|
||||
None => {
|
||||
|
|
@ -111,13 +146,16 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
|
|||
let mut on_bootstrap = false;
|
||||
if profile.tailscale {
|
||||
if let Some(bs_ssid) = profile.bootstrap.clone() {
|
||||
match cfg.network(&bs_ssid) {
|
||||
// Owned clone (not a borrow of `cfg`) so we're free to mutate
|
||||
// `cfg` below on a successful connect.
|
||||
match cfg.network(&bs_ssid).cloned() {
|
||||
Some(bdef) => {
|
||||
if visible.contains(&bdef.ssid) || bdef.hidden {
|
||||
match connect_and_verify(&iface, bdef, cfg) {
|
||||
match connect_and_verify(&iface, &bdef, cfg) {
|
||||
Ok(()) => {
|
||||
on_bootstrap = true;
|
||||
log(&format!("bootstrap connected: {}", bdef.ssid));
|
||||
clear_password_if_used(cfg, &bdef.ssid);
|
||||
}
|
||||
Err(e) => log(&format!("bootstrap connect failed: {} — {e}", bdef.ssid)),
|
||||
}
|
||||
|
|
@ -160,6 +198,7 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
|
|||
any_attempted = true;
|
||||
match connect_and_verify(&iface, def, cfg) {
|
||||
Ok(()) => {
|
||||
clear_password_if_used(cfg, &def.ssid);
|
||||
let note = if internet_ok(cfg) {
|
||||
None
|
||||
} else {
|
||||
|
|
@ -181,6 +220,7 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
|
|||
any_attempted = true;
|
||||
match connect_and_verify(&iface, def, cfg) {
|
||||
Ok(()) => {
|
||||
clear_password_if_used(cfg, &def.ssid);
|
||||
let note = if internet_ok(cfg) {
|
||||
None
|
||||
} else {
|
||||
|
|
@ -206,9 +246,15 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
|
|||
.clone()
|
||||
.unwrap_or_else(|| "bootstrap".into());
|
||||
if !nm::device_connected(&iface) {
|
||||
if let Some(bdef) = profile.bootstrap.as_deref().and_then(|s| cfg.network(s)) {
|
||||
match connect_and_verify(&iface, bdef, cfg) {
|
||||
Ok(()) => log(&format!("bootstrap reconnected: {}", bdef.ssid)),
|
||||
// Owned clone (not a borrow of `cfg`) so a successful reconnect
|
||||
// is free to mutate `cfg` to clear the used password.
|
||||
if let Some(bdef) = profile.bootstrap.as_deref().and_then(|s| cfg.network(s).cloned())
|
||||
{
|
||||
match connect_and_verify(&iface, &bdef, cfg) {
|
||||
Ok(()) => {
|
||||
log(&format!("bootstrap reconnected: {}", bdef.ssid));
|
||||
clear_password_if_used(cfg, &bdef.ssid);
|
||||
}
|
||||
Err(e) => {
|
||||
log(&format!("bootstrap reconnect failed: {} — {e}", bdef.ssid));
|
||||
on_bootstrap = false;
|
||||
|
|
@ -279,7 +325,7 @@ mod tests {
|
|||
fn net(ssid: &str) -> NetworkDef {
|
||||
NetworkDef {
|
||||
ssid: ssid.into(),
|
||||
password: "x".into(),
|
||||
password: Some("x".into()),
|
||||
hidden: false,
|
||||
}
|
||||
}
|
||||
|
|
@ -304,10 +350,8 @@ mod tests {
|
|||
networks: vec!["FallbackNet".into(), "HomeWifi".into()],
|
||||
..Default::default()
|
||||
};
|
||||
let got: Vec<&str> = resolve_candidates(&c, &p)
|
||||
.iter()
|
||||
.map(|n| n.ssid.as_str())
|
||||
.collect();
|
||||
let candidates = resolve_candidates(&c, &p);
|
||||
let got: Vec<&str> = candidates.iter().map(|n| n.ssid.as_str()).collect();
|
||||
assert_eq!(got, vec!["FallbackNet", "HomeWifi"]);
|
||||
}
|
||||
|
||||
|
|
@ -319,10 +363,8 @@ mod tests {
|
|||
include_all_known: true,
|
||||
..Default::default()
|
||||
};
|
||||
let got: Vec<&str> = resolve_candidates(&c, &p)
|
||||
.iter()
|
||||
.map(|n| n.ssid.as_str())
|
||||
.collect();
|
||||
let candidates = resolve_candidates(&c, &p);
|
||||
let got: Vec<&str> = candidates.iter().map(|n| n.ssid.as_str()).collect();
|
||||
assert_eq!(got[0], "HomeWifi");
|
||||
assert_eq!(got.len(), 4);
|
||||
assert!(got.contains(&"WorkNet"));
|
||||
|
|
@ -337,10 +379,71 @@ mod tests {
|
|||
networks: vec!["Ghost".into(), "WorkNet".into()],
|
||||
..Default::default()
|
||||
};
|
||||
let got: Vec<&str> = resolve_candidates(&c, &p)
|
||||
.iter()
|
||||
.map(|n| n.ssid.as_str())
|
||||
.collect();
|
||||
let candidates = resolve_candidates(&c, &p);
|
||||
let got: Vec<&str> = candidates.iter().map(|n| n.ssid.as_str()).collect();
|
||||
assert_eq!(got, vec!["WorkNet"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_profile_network_list_yields_no_candidates() {
|
||||
let c = cfg();
|
||||
let p = Profile::default();
|
||||
assert!(resolve_candidates(&c, &p).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_ssids_within_profile_list_are_deduped() {
|
||||
let c = cfg();
|
||||
let p = Profile {
|
||||
networks: vec!["HomeWifi".into(), "HomeWifi".into(), "WorkNet".into()],
|
||||
..Default::default()
|
||||
};
|
||||
let candidates = resolve_candidates(&c, &p);
|
||||
let got: Vec<&str> = candidates.iter().map(|n| n.ssid.as_str()).collect();
|
||||
assert_eq!(got, vec!["HomeWifi", "WorkNet"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn include_all_known_with_full_priority_list_appends_nothing_new() {
|
||||
let c = cfg();
|
||||
let p = Profile {
|
||||
networks: vec![
|
||||
"HomeWifi".into(),
|
||||
"WorkNet".into(),
|
||||
"CafeWifi".into(),
|
||||
"FallbackNet".into(),
|
||||
],
|
||||
include_all_known: true,
|
||||
..Default::default()
|
||||
};
|
||||
let got = resolve_candidates(&c, &p);
|
||||
assert_eq!(got.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn include_all_known_on_empty_priority_list_returns_all_networks() {
|
||||
let c = cfg();
|
||||
let p = Profile {
|
||||
include_all_known: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(resolve_candidates(&c, &p).len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outcome_ok_is_true_only_for_connected() {
|
||||
assert!(Outcome::Connected {
|
||||
ssid: "x".into(),
|
||||
note: None
|
||||
}
|
||||
.ok());
|
||||
assert!(!Outcome::NoInterface.ok());
|
||||
assert!(!Outcome::NoNetworks.ok());
|
||||
assert!(!Outcome::UnknownProfile("ghost".into()).ok());
|
||||
assert!(!Outcome::TailscaleError {
|
||||
ssid: None,
|
||||
health: crate::tailscale::TsHealth::NotInstalled
|
||||
}
|
||||
.ok());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
19
src/lib.rs
Normal file
19
src/lib.rs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
//! breadcrumbs library crate.
|
||||
//!
|
||||
//! All actual logic lives here; `src/main.rs` is a thin binary shim that
|
||||
//! parses no arguments itself — it just calls [`app::run`]. Splitting things
|
||||
//! this way means integration tests can link against `breadcrumbs` as an
|
||||
//! ordinary library crate and drive the real state machine (`flow::run`,
|
||||
//! `watch::classify`, …) in-process, instead of only being able to spawn the
|
||||
//! compiled binary.
|
||||
|
||||
pub mod app;
|
||||
pub mod config;
|
||||
pub mod flow;
|
||||
pub mod nm;
|
||||
pub mod notify;
|
||||
pub mod state;
|
||||
pub mod status;
|
||||
pub mod tailscale;
|
||||
pub mod util;
|
||||
pub mod watch;
|
||||
722
src/main.rs
722
src/main.rs
|
|
@ -1,721 +1,7 @@
|
|||
mod config;
|
||||
mod flow;
|
||||
mod nm;
|
||||
mod notify;
|
||||
mod state;
|
||||
mod status;
|
||||
mod tailscale;
|
||||
mod util;
|
||||
mod watch;
|
||||
|
||||
use std::io::{BufRead, Write};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
use config::{Config, NetworkDef};
|
||||
use state::State;
|
||||
use util::{command_exists, home_dir, run};
|
||||
|
||||
const C_RESET: &str = "\x1b[0m";
|
||||
const C_BOLD: &str = "\x1b[1m";
|
||||
const C_GREEN: &str = "\x1b[32m";
|
||||
const C_RED: &str = "\x1b[31m";
|
||||
const C_YELLOW: &str = "\x1b[33m";
|
||||
const C_DIM: &str = "\x1b[2m";
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "breadcrumbs",
|
||||
version,
|
||||
about = "Profile-aware Wi-Fi state machine with Tailscale handling",
|
||||
disable_help_subcommand = true
|
||||
)]
|
||||
struct Cli {
|
||||
/// Override the active profile for this run only (does not persist)
|
||||
#[arg(long, short, global = true)]
|
||||
profile: Option<String>,
|
||||
|
||||
#[command(subcommand)]
|
||||
cmd: Option<Cmd>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Show current Wi-Fi / profile / Tailscale status (default)
|
||||
Status,
|
||||
/// Run the full connect sequence for the active profile
|
||||
#[command(visible_aliases = ["up", "connect", "i"])]
|
||||
Init,
|
||||
/// Run as a daemon: watch for drops and auto-recover
|
||||
Watch {
|
||||
/// Skip the connect attempt on startup
|
||||
#[arg(long)]
|
||||
no_initial: bool,
|
||||
},
|
||||
/// Get / set / list location profiles (the state machine)
|
||||
Profile {
|
||||
#[command(subcommand)]
|
||||
action: Option<ProfileCmd>,
|
||||
},
|
||||
/// Guess the profile from visible networks
|
||||
Detect {
|
||||
/// Set + apply the detected profile
|
||||
#[arg(long)]
|
||||
apply: 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)
|
||||
#[arg(long)]
|
||||
hidden: 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>,
|
||||
},
|
||||
/// Remove a saved network (config + NetworkManager)
|
||||
Forget { ssid: String },
|
||||
/// Scan, pick, connect and save a network interactively
|
||||
Scan {
|
||||
/// Attach the saved network to this profile
|
||||
#[arg(long)]
|
||||
to: Option<String>,
|
||||
},
|
||||
/// List configured networks and profiles
|
||||
List {
|
||||
#[arg(long)]
|
||||
show_passwords: bool,
|
||||
},
|
||||
/// Open the config file in $EDITOR
|
||||
Edit,
|
||||
/// Quick connectivity / Tailscale diagnostics
|
||||
Doctor {
|
||||
/// Run the full diag.sh report from the config directory
|
||||
#[arg(long)]
|
||||
full: bool,
|
||||
},
|
||||
/// Print the breadcrumbs config directory
|
||||
Cd {
|
||||
#[arg(long)]
|
||||
shell: bool,
|
||||
},
|
||||
/// Install + enable the systemd user watcher service
|
||||
InstallService {
|
||||
/// Install the unit but do not enable/start it
|
||||
#[arg(long)]
|
||||
no_enable: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ProfileCmd {
|
||||
/// Print the active profile
|
||||
Get,
|
||||
/// Set the active profile (and apply it unless --no-apply)
|
||||
Set {
|
||||
name: String,
|
||||
#[arg(long)]
|
||||
no_apply: bool,
|
||||
},
|
||||
/// List available profiles
|
||||
List,
|
||||
}
|
||||
//! Thin binary entry point. All argument parsing and command logic lives in
|
||||
//! the library crate (`breadcrumbs::app`) so it can also be exercised
|
||||
//! in-process by the integration tests under `tests/`.
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
let code = match real_main(cli) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("{C_RED}error:{C_RESET} {e}");
|
||||
1
|
||||
}
|
||||
};
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
fn active_profile(cfg: &Config, override_p: &Option<String>) -> String {
|
||||
if let Some(p) = override_p {
|
||||
return p.clone();
|
||||
}
|
||||
State::load(&cfg.settings.default_profile).profile
|
||||
}
|
||||
|
||||
fn real_main(cli: Cli) -> Result<i32, String> {
|
||||
let cmd = cli.cmd.unwrap_or(Cmd::Status);
|
||||
|
||||
// `cd` and `install-service` don't need a parsed config first.
|
||||
if let Cmd::Cd { shell } = &cmd {
|
||||
return cmd_cd(*shell);
|
||||
}
|
||||
|
||||
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(&cfg, &p);
|
||||
print_outcome(&p, &outcome);
|
||||
Ok(if outcome.ok() { 0 } else { 1 })
|
||||
}
|
||||
Cmd::Watch { no_initial } => Ok(watch::run(cfg, !no_initial)),
|
||||
Cmd::Profile { action } => cmd_profile(&cfg, action),
|
||||
Cmd::Detect { apply } => cmd_detect(&cfg, apply),
|
||||
Cmd::Add {
|
||||
ssid,
|
||||
password,
|
||||
hidden,
|
||||
to,
|
||||
at,
|
||||
} => cmd_add(&mut cfg, ssid, password, hidden, to, at),
|
||||
Cmd::Forget { ssid } => cmd_forget(&mut cfg, &ssid),
|
||||
Cmd::Scan { to } => cmd_scan(&mut cfg, to),
|
||||
Cmd::List { show_passwords } => cmd_list(&cfg, show_passwords),
|
||||
Cmd::Edit => cmd_edit(),
|
||||
Cmd::Doctor { full } => cmd_doctor(&cfg, &cli.profile, full),
|
||||
Cmd::InstallService { no_enable } => cmd_install_service(!no_enable),
|
||||
Cmd::Cd { .. } => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_outcome(profile: &str, o: &flow::Outcome) {
|
||||
match o {
|
||||
flow::Outcome::Connected { ssid, note } => {
|
||||
print!("{C_GREEN}connected{C_RESET} {C_BOLD}{ssid}{C_RESET} ({profile})");
|
||||
match note {
|
||||
Some(n) => println!(" {C_YELLOW}— {n}{C_RESET}"),
|
||||
None => println!(),
|
||||
}
|
||||
}
|
||||
flow::Outcome::TailscaleError { ssid, health } => {
|
||||
println!(
|
||||
"{C_RED}tailscale error{C_RESET}: {} {C_DIM}(on {}){C_RESET}",
|
||||
health.describe(),
|
||||
ssid.clone().unwrap_or_else(|| "—".into())
|
||||
);
|
||||
}
|
||||
flow::Outcome::NoInterface => {
|
||||
println!("{C_RED}no Wi-Fi adapter{C_RESET} — hardware issue")
|
||||
}
|
||||
flow::Outcome::NoNetworks => {
|
||||
println!("{C_RED}no known networks in range{C_RESET} (profile {profile})")
|
||||
}
|
||||
flow::Outcome::UnknownProfile(p) => {
|
||||
println!("{C_RED}unknown profile{C_RESET}: {p}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_status(cfg: &Config, override_p: &Option<String>) -> Result<i32, String> {
|
||||
let p = active_profile(cfg, override_p);
|
||||
let s = status::gather(cfg, &p);
|
||||
|
||||
let dot = |ok: bool| {
|
||||
if ok {
|
||||
format!("{C_GREEN}●{C_RESET}")
|
||||
} else {
|
||||
format!("{C_RED}●{C_RESET}")
|
||||
}
|
||||
};
|
||||
|
||||
println!("{C_BOLD}breadcrumbs{C_RESET}");
|
||||
println!(" profile {C_BOLD}{p}{C_RESET}");
|
||||
println!(
|
||||
" adapter {}",
|
||||
s.iface
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{C_RED}none{C_RESET}"))
|
||||
);
|
||||
println!(
|
||||
" ssid {}",
|
||||
s.ssid
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{C_DIM}—{C_RESET}"))
|
||||
);
|
||||
println!(
|
||||
" ip {}",
|
||||
s.ip.clone().unwrap_or_else(|| format!("{C_DIM}—{C_RESET}"))
|
||||
);
|
||||
println!(
|
||||
" internet {} {}",
|
||||
dot(s.internet),
|
||||
if s.internet { "ok" } else { "down" }
|
||||
);
|
||||
|
||||
match (&s.tailscale, s.tailscale_required) {
|
||||
(Some(h), req) => {
|
||||
let ok = h.is_ok();
|
||||
println!(
|
||||
" tailscale {} {} {C_DIM}(exit: {}{}){C_RESET}",
|
||||
dot(ok || !req),
|
||||
h.describe(),
|
||||
s.exit_node,
|
||||
if req { "" } else { ", optional" }
|
||||
);
|
||||
}
|
||||
(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 {
|
||||
format!("{C_GREEN}healthy{C_RESET}")
|
||||
} else {
|
||||
format!("{C_YELLOW}needs attention{C_RESET} — run `breadcrumbs init`")
|
||||
}
|
||||
);
|
||||
Ok(if healthy { 0 } else { 1 })
|
||||
}
|
||||
|
||||
fn cmd_profile(cfg: &Config, action: Option<ProfileCmd>) -> Result<i32, String> {
|
||||
match action.unwrap_or(ProfileCmd::Get) {
|
||||
ProfileCmd::Get => {
|
||||
println!("{}", State::load(&cfg.settings.default_profile).profile);
|
||||
Ok(0)
|
||||
}
|
||||
ProfileCmd::List => {
|
||||
let cur = State::load(&cfg.settings.default_profile).profile;
|
||||
for name in cfg.profiles.keys() {
|
||||
let mark = if *name == cur { "*" } else { " " };
|
||||
println!("{mark} {name}");
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
ProfileCmd::Set { name, no_apply } => {
|
||||
if !cfg.profiles.contains_key(&name) {
|
||||
let avail: Vec<&String> = cfg.profiles.keys().collect();
|
||||
return Err(format!("unknown profile '{name}'. Available: {avail:?}"));
|
||||
}
|
||||
let st = State {
|
||||
profile: name.clone(),
|
||||
updated: util::timestamp(),
|
||||
};
|
||||
st.save()?;
|
||||
notify::log(&format!("profile set -> {name}"));
|
||||
println!("profile = {C_BOLD}{name}{C_RESET}");
|
||||
if no_apply {
|
||||
return Ok(0);
|
||||
}
|
||||
let outcome = flow::run(cfg, &name);
|
||||
print_outcome(&name, &outcome);
|
||||
Ok(if outcome.ok() { 0 } else { 1 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_profile(cfg: &Config) -> Option<String> {
|
||||
let iface = nm::wifi_interface()?;
|
||||
nm::radio_on();
|
||||
nm::rescan(&iface, &[]);
|
||||
let visible = nm::visible_ssids(&iface);
|
||||
|
||||
// Profiles are stored in a BTreeMap so iteration order is deterministic
|
||||
// (alphabetical). The caller can rely on that for tie-breaking.
|
||||
for (name, profile) in &cfg.profiles {
|
||||
if profile.detect_ssids.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if profile
|
||||
.detect_ssids
|
||||
.iter()
|
||||
.any(|s| visible.contains(s.as_str()))
|
||||
{
|
||||
return Some(name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to the default profile if no markers matched.
|
||||
Some(cfg.settings.default_profile.clone())
|
||||
}
|
||||
|
||||
fn cmd_detect(cfg: &Config, apply: bool) -> Result<i32, String> {
|
||||
match detect_profile(cfg) {
|
||||
Some(p) => {
|
||||
println!("{p}");
|
||||
if apply {
|
||||
State {
|
||||
profile: p.clone(),
|
||||
updated: util::timestamp(),
|
||||
}
|
||||
.save()?;
|
||||
let outcome = flow::run(cfg, &p);
|
||||
print_outcome(&p, &outcome);
|
||||
return Ok(if outcome.ok() { 0 } else { 1 });
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
None => Err("could not detect a profile (no Wi-Fi adapter?)".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_line(msg: &str) -> String {
|
||||
print!("{msg}");
|
||||
let _ = std::io::stdout().flush();
|
||||
let mut s = String::new();
|
||||
let _ = std::io::stdin().lock().read_line(&mut s);
|
||||
s.trim_end_matches(['\n', '\r']).to_string()
|
||||
}
|
||||
|
||||
fn prompt_secret(msg: &str) -> String {
|
||||
// `util::run` redirects child stdin to /dev/null, so plain `stty -echo`
|
||||
// would target the wrong fd and silently leave echo ON (leaking the
|
||||
// password to the screen). `-F /dev/tty` makes stty act on the controlling
|
||||
// terminal directly. If there is no tty we fall back to visible input.
|
||||
let had_tty = run("stty", &["-F", "/dev/tty", "-echo"], Duration::from_secs(2)).success;
|
||||
let val = prompt_line(msg);
|
||||
if had_tty {
|
||||
let _ = run("stty", &["-F", "/dev/tty", "echo"], Duration::from_secs(2));
|
||||
println!();
|
||||
}
|
||||
val
|
||||
}
|
||||
|
||||
fn cmd_add(
|
||||
cfg: &mut Config,
|
||||
ssid: String,
|
||||
password: Option<String>,
|
||||
hidden: bool,
|
||||
to: Option<String>,
|
||||
at: Option<usize>,
|
||||
) -> Result<i32, String> {
|
||||
let password = match password {
|
||||
Some(p) => p,
|
||||
None => prompt_secret(&format!("Password for '{ssid}': ")),
|
||||
};
|
||||
match cfg.networks.iter_mut().find(|n| n.ssid == ssid) {
|
||||
Some(n) => {
|
||||
n.password = password;
|
||||
n.hidden = hidden || n.hidden;
|
||||
}
|
||||
None => cfg.networks.push(NetworkDef {
|
||||
ssid: ssid.clone(),
|
||||
password,
|
||||
hidden,
|
||||
}),
|
||||
}
|
||||
if let Some(prof_name) = to {
|
||||
let prof = cfg
|
||||
.profiles
|
||||
.get_mut(&prof_name)
|
||||
.ok_or_else(|| format!("unknown profile '{prof_name}'"))?;
|
||||
prof.networks.retain(|s| s != &ssid);
|
||||
let idx = at.unwrap_or(prof.networks.len()).min(prof.networks.len());
|
||||
prof.networks.insert(idx, ssid.clone());
|
||||
}
|
||||
cfg.save()?;
|
||||
println!("{C_GREEN}saved{C_RESET} {ssid}");
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn cmd_forget(cfg: &mut Config, ssid: &str) -> Result<i32, String> {
|
||||
let before = cfg.networks.len();
|
||||
cfg.networks.retain(|n| n.ssid != ssid);
|
||||
for p in cfg.profiles.values_mut() {
|
||||
p.networks.retain(|s| s != ssid);
|
||||
if p.bootstrap.as_deref() == Some(ssid) {
|
||||
p.bootstrap = None;
|
||||
}
|
||||
}
|
||||
cfg.save()?;
|
||||
let removed = nm::delete_connections_for_ssid(ssid);
|
||||
println!(
|
||||
"{C_GREEN}forgot{C_RESET} {ssid} (config: {}, NetworkManager: {})",
|
||||
if cfg.networks.len() < before {
|
||||
"removed"
|
||||
} else {
|
||||
"not present"
|
||||
},
|
||||
if removed { "removed" } else { "not present" }
|
||||
);
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn cmd_scan(cfg: &mut Config, to: Option<String>) -> Result<i32, String> {
|
||||
let iface = nm::wifi_interface().ok_or("no Wi-Fi adapter")?;
|
||||
nm::radio_on();
|
||||
nm::rescan(&iface, &[]);
|
||||
let entries = nm::scan_list(&iface);
|
||||
if entries.is_empty() {
|
||||
return Err("no networks found".into());
|
||||
}
|
||||
for (i, e) in entries.iter().enumerate() {
|
||||
println!(
|
||||
"{:>2}. {C_BOLD}{}{C_RESET} {C_DIM}sig {} {}{C_RESET}",
|
||||
i + 1,
|
||||
if e.ssid.is_empty() {
|
||||
"<hidden>"
|
||||
} else {
|
||||
&e.ssid
|
||||
},
|
||||
e.signal,
|
||||
e.security
|
||||
);
|
||||
}
|
||||
let sel = prompt_line("Select number: ");
|
||||
let idx: usize = sel
|
||||
.parse::<usize>()
|
||||
.ok()
|
||||
.filter(|n| *n >= 1 && *n <= entries.len())
|
||||
.ok_or("invalid selection")?;
|
||||
let ssid = entries[idx - 1].ssid.clone();
|
||||
if ssid.is_empty() {
|
||||
return Err("cannot select a hidden SSID here; use `breadcrumbs add`".into());
|
||||
}
|
||||
let password = prompt_secret(&format!("Password for '{ssid}': "));
|
||||
let def = NetworkDef {
|
||||
ssid: ssid.clone(),
|
||||
password: password.clone(),
|
||||
hidden: false,
|
||||
};
|
||||
if !nm::connect(&iface, &def, cfg.settings.nmcli_wait, &cfg.settings.dns) {
|
||||
return Err(format!("failed to connect to {ssid}"));
|
||||
}
|
||||
match cfg.networks.iter_mut().find(|n| n.ssid == ssid) {
|
||||
Some(n) => n.password = password,
|
||||
None => cfg.networks.push(def),
|
||||
}
|
||||
if let Some(prof_name) = to {
|
||||
if let Some(prof) = cfg.profiles.get_mut(&prof_name) {
|
||||
if !prof.networks.contains(&ssid) {
|
||||
prof.networks.push(ssid.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
cfg.save()?;
|
||||
println!("{C_GREEN}connected + saved{C_RESET} {ssid}");
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn mask(p: &str) -> String {
|
||||
if p.len() <= 2 {
|
||||
"••".into()
|
||||
} else {
|
||||
format!("{}{}", &p[..1], "•".repeat(p.len().saturating_sub(1)))
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_list(cfg: &Config, show_pw: bool) -> Result<i32, String> {
|
||||
println!("{C_BOLD}settings{C_RESET}");
|
||||
println!(" dns {}", cfg.settings.dns);
|
||||
println!(" exit_node {}", cfg.settings.exit_node);
|
||||
println!(" default {}", cfg.settings.default_profile);
|
||||
println!(" watch every {}s", cfg.settings.watch_interval);
|
||||
|
||||
println!("\n{C_BOLD}networks{C_RESET}");
|
||||
for n in &cfg.networks {
|
||||
println!(
|
||||
" {C_BOLD}{}{C_RESET} {C_DIM}{}{}{C_RESET}",
|
||||
n.ssid,
|
||||
if show_pw {
|
||||
n.password.clone()
|
||||
} else {
|
||||
mask(&n.password)
|
||||
},
|
||||
if n.hidden { " (hidden)" } else { "" }
|
||||
);
|
||||
}
|
||||
|
||||
println!("\n{C_BOLD}profiles{C_RESET}");
|
||||
let cur = State::load(&cfg.settings.default_profile).profile;
|
||||
for (name, p) in &cfg.profiles {
|
||||
let mark = if *name == cur {
|
||||
format!("{C_GREEN}*{C_RESET}")
|
||||
} else {
|
||||
" ".into()
|
||||
};
|
||||
println!("{mark} {C_BOLD}{name}{C_RESET}");
|
||||
if let Some(b) = &p.bootstrap {
|
||||
println!(" bootstrap {b}");
|
||||
}
|
||||
if p.tailscale {
|
||||
println!(
|
||||
" tailscale required (exit: {})",
|
||||
p.exit_node
|
||||
.clone()
|
||||
.unwrap_or_else(|| cfg.settings.exit_node.clone())
|
||||
);
|
||||
}
|
||||
let mut order: Vec<String> = p.networks.clone();
|
||||
if p.include_all_known {
|
||||
order.push("…all other known networks".into());
|
||||
}
|
||||
println!(" priority {}", order.join(" > "));
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn cmd_edit() -> Result<i32, String> {
|
||||
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".into());
|
||||
let path = config::config_path();
|
||||
let status = Command::new(&editor)
|
||||
.arg(&path)
|
||||
.status()
|
||||
.map_err(|e| format!("launching {editor}: {e}"))?;
|
||||
if !status.success() {
|
||||
return Err("editor exited with error".into());
|
||||
}
|
||||
match Config::load() {
|
||||
Ok(_) => {
|
||||
println!("{C_GREEN}config OK{C_RESET}");
|
||||
Ok(0)
|
||||
}
|
||||
Err(e) => Err(format!("config is now invalid: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_doctor(cfg: &Config, override_p: &Option<String>, full: bool) -> Result<i32, String> {
|
||||
if full {
|
||||
let script = config::config_dir().join("diag.sh");
|
||||
if !script.exists() {
|
||||
return Err(format!(
|
||||
"diag.sh not found (expected at {})",
|
||||
script.display()
|
||||
));
|
||||
}
|
||||
let st = Command::new("bash")
|
||||
.arg(&script)
|
||||
.status()
|
||||
.map_err(|e| format!("running diag: {e}"))?;
|
||||
return Ok(st.code().unwrap_or(1));
|
||||
}
|
||||
|
||||
let p = active_profile(cfg, override_p);
|
||||
let s = status::gather(cfg, &p);
|
||||
println!("{C_BOLD}breadcrumbs doctor{C_RESET} (profile {p})");
|
||||
println!(
|
||||
" nmcli {}",
|
||||
if command_exists("nmcli") {
|
||||
"present"
|
||||
} else {
|
||||
"MISSING"
|
||||
}
|
||||
);
|
||||
println!(
|
||||
" tailscale {}",
|
||||
if command_exists("tailscale") {
|
||||
"present"
|
||||
} else {
|
||||
"absent"
|
||||
}
|
||||
);
|
||||
println!(
|
||||
" adapter {}",
|
||||
s.iface.clone().unwrap_or_else(|| "none".into())
|
||||
);
|
||||
println!(
|
||||
" ssid {}",
|
||||
s.ssid.clone().unwrap_or_else(|| "—".into())
|
||||
);
|
||||
println!(
|
||||
" ip {}",
|
||||
s.ip.clone().unwrap_or_else(|| "—".into())
|
||||
);
|
||||
println!(" internet {}", if s.internet { "ok" } else { "DOWN" });
|
||||
if let Some(h) = &s.tailscale {
|
||||
println!(" tailscale {} (exit {})", h.describe(), s.exit_node);
|
||||
}
|
||||
|
||||
if let Some(iface) = &s.iface {
|
||||
let visible = nm::visible_ssids(iface);
|
||||
let known: Vec<&str> = cfg
|
||||
.networks
|
||||
.iter()
|
||||
.filter(|n| visible.contains(&n.ssid))
|
||||
.map(|n| n.ssid.as_str())
|
||||
.collect();
|
||||
println!(
|
||||
" in range {}",
|
||||
if known.is_empty() {
|
||||
"none of your saved networks".into()
|
||||
} else {
|
||||
known.join(", ")
|
||||
}
|
||||
);
|
||||
}
|
||||
println!("\nFull report: {C_DIM}breadcrumbs doctor --full{C_RESET}");
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn cmd_cd(shell: bool) -> Result<i32, String> {
|
||||
let dir = config::config_dir();
|
||||
if shell {
|
||||
let sh = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into());
|
||||
let err = exec_replace(&sh, &["-lc", &format!("cd {:?} && exec {sh}", dir)]);
|
||||
return Err(err);
|
||||
}
|
||||
println!("{}", dir.display());
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn exec_replace(prog: &str, args: &[&str]) -> String {
|
||||
use std::os::unix::process::CommandExt;
|
||||
let e = Command::new(prog).args(args).exec();
|
||||
format!("exec {prog} failed: {e}")
|
||||
}
|
||||
|
||||
fn cmd_install_service(enable: bool) -> Result<i32, String> {
|
||||
let unit_dir = home_dir().join(".config/systemd/user");
|
||||
std::fs::create_dir_all(&unit_dir)
|
||||
.map_err(|e| format!("creating {}: {e}", unit_dir.display()))?;
|
||||
let bin = std::env::current_exe().map_err(|e| format!("resolving current executable: {e}"))?;
|
||||
// Ordering against graphical-session.target lets the watcher inherit the
|
||||
// session's DISPLAY/WAYLAND_DISPLAY/DBUS so notify-send and the Tailscale
|
||||
// login browser-open actually work. PATH is pinned because systemd --user
|
||||
// units do not get the login shell's PATH, and the watcher shells out to
|
||||
// nmcli/tailscale/sudo/xdg-open by name.
|
||||
let unit = format!(
|
||||
"[Unit]\n\
|
||||
Description=breadcrumbs Wi-Fi state machine watcher\n\
|
||||
After=network.target NetworkManager.service graphical-session.target\n\
|
||||
Wants=network.target graphical-session.target\n\n\
|
||||
[Service]\n\
|
||||
Type=simple\n\
|
||||
Environment=PATH=/usr/local/bin:/usr/bin:/bin\n\
|
||||
ExecStart={bin} watch\n\
|
||||
Restart=always\n\
|
||||
RestartSec=5\n\
|
||||
Nice=5\n\n\
|
||||
[Install]\n\
|
||||
WantedBy=default.target\n",
|
||||
bin = bin.display()
|
||||
);
|
||||
let unit_path = unit_dir.join("breadcrumbs.service");
|
||||
std::fs::write(&unit_path, unit)
|
||||
.map_err(|e| format!("writing {}: {e}", unit_path.display()))?;
|
||||
println!("{C_GREEN}wrote{C_RESET} {}", unit_path.display());
|
||||
|
||||
let _ = run(
|
||||
"systemctl",
|
||||
&["--user", "daemon-reload"],
|
||||
Duration::from_secs(10),
|
||||
);
|
||||
if enable {
|
||||
let o = run(
|
||||
"systemctl",
|
||||
&["--user", "enable", "--now", "breadcrumbs.service"],
|
||||
Duration::from_secs(15),
|
||||
);
|
||||
if o.success {
|
||||
println!("{C_GREEN}enabled + started{C_RESET} breadcrumbs.service");
|
||||
} else {
|
||||
println!(
|
||||
"{C_YELLOW}unit installed{C_RESET}; enable failed: {}",
|
||||
o.stderr.trim()
|
||||
);
|
||||
return Ok(1);
|
||||
}
|
||||
} else {
|
||||
println!("Run: systemctl --user enable --now breadcrumbs.service");
|
||||
}
|
||||
Ok(0)
|
||||
std::process::exit(breadcrumbs::app::run());
|
||||
}
|
||||
|
|
|
|||
116
src/nm.rs
116
src/nm.rs
|
|
@ -22,8 +22,11 @@ fn unescape(s: &str) -> String {
|
|||
}
|
||||
|
||||
/// Split one nmcli `-t` line into fields. Fields are ':'-separated but values
|
||||
/// escape ':' as '\:' and '\' as '\\'.
|
||||
fn parse_scan_line(line: &str) -> Vec<String> {
|
||||
/// escape ':' as '\:' and '\' as '\\' — a plain `splitn(2, ':')` mis-splits
|
||||
/// any field (device name, connection name, SSID, …) that legitimately
|
||||
/// contains a colon, so every terse-output parse in this module goes through
|
||||
/// here rather than splitting on raw bytes. Fields are returned unescaped.
|
||||
fn split_fields(line: &str) -> Vec<String> {
|
||||
let mut fields: Vec<String> = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let mut chars = line.chars().peekable();
|
||||
|
|
@ -55,9 +58,9 @@ pub fn wifi_interface() -> Option<String> {
|
|||
return None;
|
||||
}
|
||||
for line in o.stdout.lines() {
|
||||
let parts: Vec<&str> = line.splitn(2, ':').collect();
|
||||
if parts.len() == 2 && parts[1] == "wifi" {
|
||||
return Some(unescape(parts[0]));
|
||||
let fields = split_fields(line);
|
||||
if fields.len() >= 2 && fields[1] == "wifi" {
|
||||
return Some(fields[0].clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
|
|
@ -132,7 +135,7 @@ pub fn scan_list(iface: &str) -> Vec<ScanEntry> {
|
|||
return out;
|
||||
}
|
||||
for line in o.stdout.lines() {
|
||||
let fields = parse_scan_line(line);
|
||||
let fields = split_fields(line);
|
||||
if fields.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -168,9 +171,9 @@ pub fn active_ssid(iface: &str) -> Option<String> {
|
|||
return None;
|
||||
}
|
||||
for line in o.stdout.lines() {
|
||||
let parts: Vec<&str> = line.splitn(2, ':').collect();
|
||||
if parts.len() == 2 && parts[0] == "yes" {
|
||||
let s = unescape(parts[1].trim());
|
||||
let fields = split_fields(line);
|
||||
if fields.len() >= 2 && fields[0] == "yes" {
|
||||
let s = fields[1].trim().to_string();
|
||||
if !s.is_empty() {
|
||||
return Some(s);
|
||||
}
|
||||
|
|
@ -189,9 +192,9 @@ pub fn device_connected(iface: &str) -> bool {
|
|||
return false;
|
||||
}
|
||||
for line in o.stdout.lines() {
|
||||
let parts: Vec<&str> = line.splitn(2, ':').collect();
|
||||
if parts.len() == 2 && unescape(parts[0]) == iface {
|
||||
return parts[1].starts_with("connected");
|
||||
let fields = split_fields(line);
|
||||
if fields.len() >= 2 && fields[0] == iface {
|
||||
return fields[1].starts_with("connected");
|
||||
}
|
||||
}
|
||||
false
|
||||
|
|
@ -254,11 +257,11 @@ fn first_profile_for_ssid(ssid: &str) -> Option<String> {
|
|||
}
|
||||
let mut fallback: Option<String> = None;
|
||||
for line in o.stdout.lines() {
|
||||
let parts: Vec<&str> = line.splitn(2, ':').collect();
|
||||
if parts.len() < 2 || !parts[1].contains("wireless") {
|
||||
let fields = split_fields(line);
|
||||
if fields.len() < 2 || !fields[1].contains("wireless") {
|
||||
continue;
|
||||
}
|
||||
let name = unescape(parts[0]);
|
||||
let name = fields[0].clone();
|
||||
if name == ssid {
|
||||
return Some(name);
|
||||
}
|
||||
|
|
@ -286,12 +289,33 @@ pub fn connect(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> bool {
|
|||
/// 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.
|
||||
///
|
||||
/// `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
|
||||
/// 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.
|
||||
///
|
||||
/// 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();
|
||||
|
||||
if let Some(profile) = first_profile_for_ssid(&net.ssid) {
|
||||
// Update the saved PSK and, for hidden networks, ensure the flag is set.
|
||||
if !net.password.is_empty() {
|
||||
if let Some(pw) = &net.password {
|
||||
let _ = run(
|
||||
"nmcli",
|
||||
&[
|
||||
|
|
@ -299,7 +323,7 @@ pub fn connect_verbose(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> R
|
|||
"modify",
|
||||
&profile,
|
||||
"802-11-wireless-security.psk",
|
||||
net.password.as_str(),
|
||||
pw.as_str(),
|
||||
],
|
||||
Duration::from_secs(6),
|
||||
);
|
||||
|
|
@ -338,20 +362,28 @@ 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 args = [
|
||||
let mut args: Vec<&str> = vec![
|
||||
"--wait",
|
||||
&wait_s,
|
||||
"device",
|
||||
"wifi",
|
||||
"connect",
|
||||
net.ssid.as_str(),
|
||||
"password",
|
||||
net.password.as_str(),
|
||||
"hidden",
|
||||
hidden,
|
||||
"ifname",
|
||||
iface,
|
||||
];
|
||||
// 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();
|
||||
|
|
@ -380,12 +412,12 @@ pub fn delete_connections_for_ssid(ssid: &str) -> bool {
|
|||
}
|
||||
let mut removed = false;
|
||||
for line in list.stdout.lines() {
|
||||
let parts: Vec<&str> = line.splitn(2, ':').collect();
|
||||
if parts.len() < 2 {
|
||||
let fields = split_fields(line);
|
||||
if fields.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let name = unescape(parts[0]);
|
||||
let typ = parts[1];
|
||||
let name = fields[0].clone();
|
||||
let typ = &fields[1];
|
||||
if !typ.contains("wireless") {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -421,17 +453,37 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn parse_scan_line_splits_and_unescapes() {
|
||||
fn split_fields_splits_and_unescapes() {
|
||||
// SSID:SIGNAL:SECURITY with an escaped ':' inside the SSID.
|
||||
let f = parse_scan_line(r"My\:Net:72:WPA2");
|
||||
let f = split_fields(r"My\:Net:72:WPA2");
|
||||
assert_eq!(f, vec!["My:Net", "72", "WPA2"]);
|
||||
|
||||
// SSID with a space (common in real network names)
|
||||
let f = parse_scan_line("My Network:88:WPA2");
|
||||
let f = split_fields("My Network:88:WPA2");
|
||||
assert_eq!(f, vec!["My Network", "88", "WPA2"]);
|
||||
|
||||
// Empty SSID (hidden) keeps the empty leading field.
|
||||
let f = parse_scan_line(":40:WPA3");
|
||||
let f = split_fields(":40:WPA3");
|
||||
assert_eq!(f, vec!["", "40", "WPA3"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_fields_two_column_with_colon_in_first_field() {
|
||||
// A connection NAME or SSID containing a literal ':' must not be
|
||||
// mis-split into TYPE — this is what a plain `splitn(2, ':')` gets
|
||||
// wrong (e.g. `wifi_interface`/`first_profile_for_ssid` parsing).
|
||||
let f = split_fields(r"Office\:5G:802-11-wireless");
|
||||
assert_eq!(f, vec!["Office:5G", "802-11-wireless"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_fields_empty_line() {
|
||||
assert_eq!(split_fields(""), vec![""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_fields_trailing_backslash_in_field() {
|
||||
let f = split_fields(r"trail\\:wifi");
|
||||
assert_eq!(f, vec![r"trail\", "wifi"]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -381,4 +381,77 @@ mod tests {
|
|||
});
|
||||
assert_eq!(exit_node_state(&v, "exitnode"), (true, true, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_node_lookup_is_case_insensitive() {
|
||||
let v = json!({
|
||||
"Peer": {
|
||||
"k1": { "HostName": "ExitNode", "DNSName": "ExitNode.ts.net.",
|
||||
"Online": true, "ExitNode": true, "ExitNodeOption": true }
|
||||
}
|
||||
});
|
||||
assert_eq!(exit_node_state(&v, "EXITNODE"), (true, true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_node_state_with_no_peers_is_all_false() {
|
||||
let v = json!({ "BackendState": "Running" });
|
||||
assert_eq!(exit_node_state(&v, "exitnode"), (false, false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_node_state_empty_node_name_matches_nothing() {
|
||||
let v = json!({
|
||||
"Peer": {
|
||||
"k1": { "HostName": "exitnode", "DNSName": "exitnode.ts.net.",
|
||||
"Online": true, "ExitNode": true, "ExitNodeOption": true }
|
||||
}
|
||||
});
|
||||
assert_eq!(exit_node_state(&v, ""), (false, false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_node_status_online_only_applies_when_selected() {
|
||||
// ExitNodeStatus.Online reflects the currently *active* exit node —
|
||||
// it must not leak into the reported state of a peer that merely
|
||||
// matches by name but isn't the one actually selected.
|
||||
let v = json!({
|
||||
"ExitNodeStatus": { "Online": true },
|
||||
"Peer": {
|
||||
"k1": { "HostName": "other", "DNSName": "other.ts.net.",
|
||||
"Online": false, "ExitNode": false, "ExitNodeOption": true }
|
||||
}
|
||||
});
|
||||
assert_eq!(exit_node_state(&v, "other"), (true, false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ts_health_is_ok_only_for_ok_variant() {
|
||||
assert!(TsHealth::Ok.is_ok());
|
||||
assert!(!TsHealth::NotInstalled.is_ok());
|
||||
assert!(!TsHealth::NeedsLogin.is_ok());
|
||||
assert!(!TsHealth::Stopped.is_ok());
|
||||
assert!(!TsHealth::ExitNodeMissing.is_ok());
|
||||
assert!(!TsHealth::ExitNodeOffline.is_ok());
|
||||
assert!(!TsHealth::Error("x".into()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ts_health_describe_is_human_readable() {
|
||||
assert_eq!(TsHealth::Ok.describe(), "ok");
|
||||
assert_eq!(
|
||||
TsHealth::NeedsLogin.describe(),
|
||||
"not logged in (run: tailscale up)"
|
||||
);
|
||||
assert_eq!(TsHealth::Error("boom".into()).describe(), "error: boom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_url_finds_https_token_among_others() {
|
||||
assert_eq!(
|
||||
extract_url("To authenticate, visit: https://login.tailscale.com/abc"),
|
||||
Some("https://login.tailscale.com/abc".to_string())
|
||||
);
|
||||
assert_eq!(extract_url("no url on this line"), None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
138
src/util.rs
138
src/util.rs
|
|
@ -1,3 +1,4 @@
|
|||
use std::cell::RefCell;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
|
|
@ -10,17 +11,6 @@ pub fn home_dir() -> PathBuf {
|
|||
.unwrap_or_else(|| PathBuf::from("/root"))
|
||||
}
|
||||
|
||||
pub fn command_exists(name: &str) -> bool {
|
||||
if let Some(paths) = std::env::var_os("PATH") {
|
||||
for dir in std::env::split_paths(&paths) {
|
||||
if dir.join(name).is_file() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Output {
|
||||
pub success: bool,
|
||||
|
|
@ -38,6 +28,65 @@ impl Output {
|
|||
}
|
||||
}
|
||||
|
||||
/// Everything breadcrumbs does to touch the outside world *other than* its
|
||||
/// own file I/O and env var reads: spawning an external program, and
|
||||
/// checking whether one is available at all. Every call site in this crate
|
||||
/// (`nm.rs`, `tailscale.rs`, `status.rs`, `notify.rs`, `app.rs`) goes through
|
||||
/// the free functions below (`run`/`run_with_stdin`/`run_ok`/
|
||||
/// `command_exists`), which are thin wrappers dispatching to whatever
|
||||
/// `Runner` is currently installed in the thread-local slot — `RealRunner` by
|
||||
/// default.
|
||||
///
|
||||
/// Tests swap in a fake implementation via [`with_runner`] so real call
|
||||
/// chains (`flow::run`, `watch::classify`, …) can be driven in-process
|
||||
/// against canned output, with no subprocess ever spawned and full
|
||||
/// visibility into exactly what *would* have been executed — the natural
|
||||
/// mechanism for asserting things like "no password ever reaches nmcli's
|
||||
/// argv on a repeat connect" (see the credential tests under `tests/`).
|
||||
///
|
||||
/// A thread-local (rather than an explicit parameter threaded through every
|
||||
/// function) was chosen so the large existing call surface in `nm.rs` et al.
|
||||
/// didn't need every signature rewritten to carry a `&dyn Runner` — call
|
||||
/// sites are unchanged, only `util`'s internals dispatch differently. It's
|
||||
/// safe across `cargo test`'s parallel test threads because each thread gets
|
||||
/// its own independent slot, defaulting to `RealRunner`, so tests that don't
|
||||
/// install a fake are unaffected by ones that do.
|
||||
pub trait Runner {
|
||||
fn run(&self, prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration) -> Output;
|
||||
fn command_exists(&self, name: &str) -> bool;
|
||||
}
|
||||
|
||||
struct RealRunner;
|
||||
|
||||
impl Runner for RealRunner {
|
||||
fn run(&self, prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration) -> Output {
|
||||
spawn_run(prog, args, stdin, timeout)
|
||||
}
|
||||
|
||||
fn command_exists(&self, name: &str) -> bool {
|
||||
path_lookup_exists(name)
|
||||
}
|
||||
}
|
||||
|
||||
fn path_lookup_exists(name: &str) -> bool {
|
||||
if let Some(paths) = std::env::var_os("PATH") {
|
||||
for dir in std::env::split_paths(&paths) {
|
||||
if dir.join(name).is_file() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static RUNNER: RefCell<Box<dyn Runner>> = RefCell::new(Box::new(RealRunner));
|
||||
}
|
||||
|
||||
pub fn command_exists(name: &str) -> bool {
|
||||
RUNNER.with(|r| r.borrow().command_exists(name))
|
||||
}
|
||||
|
||||
/// Run a command with a hard timeout. The child is killed if it overruns so a
|
||||
/// hung nmcli/tailscale can never wedge the daemon.
|
||||
pub fn run(prog: &str, args: &[&str], timeout: Duration) -> Output {
|
||||
|
|
@ -48,6 +97,32 @@ pub fn run(prog: &str, args: &[&str], timeout: Duration) -> Output {
|
|||
/// secrets (e.g. Wi-Fi PSKs) to `nmcli --ask` without exposing them in argv,
|
||||
/// where any local user could read them via `ps`.
|
||||
pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration) -> Output {
|
||||
RUNNER.with(|r| r.borrow().run(prog, args, stdin, timeout))
|
||||
}
|
||||
|
||||
pub fn run_ok(prog: &str, args: &[&str], timeout: Duration) -> bool {
|
||||
run(prog, args, timeout).success
|
||||
}
|
||||
|
||||
/// Swap the thread-local [`Runner`] for `runner` for the duration of `f`,
|
||||
/// restoring whatever was previously installed afterward — even if `f`
|
||||
/// panics, so a failing assertion inside a test can't leak a fake runner
|
||||
/// into whatever test happens to run next on this thread. This is the seam
|
||||
/// integration tests use to drive real logic without spawning subprocesses.
|
||||
pub fn with_runner<R, T>(runner: R, f: impl FnOnce() -> T) -> T
|
||||
where
|
||||
R: Runner + 'static,
|
||||
{
|
||||
let prev = RUNNER.with(|r| std::mem::replace(&mut *r.borrow_mut(), Box::new(runner)));
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
|
||||
RUNNER.with(|r| *r.borrow_mut() = prev);
|
||||
match result {
|
||||
Ok(v) => v,
|
||||
Err(payload) => std::panic::resume_unwind(payload),
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_run(prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration) -> Output {
|
||||
let stdin_cfg = if stdin.is_some() {
|
||||
Stdio::piped()
|
||||
} else {
|
||||
|
|
@ -115,10 +190,6 @@ pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: D
|
|||
}
|
||||
}
|
||||
|
||||
pub fn run_ok(prog: &str, args: &[&str], timeout: Duration) -> bool {
|
||||
run(prog, args, timeout).success
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
|
|
@ -176,4 +247,41 @@ mod tests {
|
|||
// Leap day 2024-02-29 12:00:00 UTC
|
||||
assert_eq!(fmt_epoch(1_709_208_000), "2024-02-29 12:00:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmt_epoch_pre_1970_is_handled() {
|
||||
// The div_euclid/rem_euclid split must stay correct for negative
|
||||
// epoch seconds (dates before 1970), not just the common positive case.
|
||||
assert_eq!(fmt_epoch(-86_400), "1969-12-31 00:00:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmt_epoch_year_and_month_boundaries() {
|
||||
assert_eq!(fmt_epoch(1_704_067_199), "2023-12-31 23:59:59");
|
||||
assert_eq!(fmt_epoch(1_735_689_600), "2025-01-01 00:00:00");
|
||||
// Last second of October (non-leap-day month boundary).
|
||||
assert_eq!(fmt_epoch(1_730_419_199), "2024-10-31 23:59:59");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_exists_false_for_bogus_binary() {
|
||||
assert!(!command_exists("definitely-not-a-real-binary-xyz123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_exists_true_for_a_real_binary() {
|
||||
// `sh` is guaranteed present on any POSIX system this runs on.
|
||||
assert!(command_exists("sh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_on_missing_binary_fails_cleanly_instead_of_panicking() {
|
||||
let o = run(
|
||||
"definitely-not-a-real-binary-xyz123",
|
||||
&[],
|
||||
Duration::from_secs(1),
|
||||
);
|
||||
assert!(!o.success);
|
||||
assert_eq!(o.stdout, "");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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