Initial commit: breadcrumbs — profile-driven Wi-Fi + Tailscale state machine

This commit is contained in:
Breadway 2026-05-19 11:52:46 +08:00
commit 3422c12379
18 changed files with 3475 additions and 0 deletions

721
src/main.rs Normal file
View file

@ -0,0 +1,721 @@
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,
}
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)
}