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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Breadway 2026-05-19 11:52:46 +08:00
commit 5b894c4fef
18 changed files with 3475 additions and 0 deletions

277
src/config.rs Normal file
View file

@ -0,0 +1,277 @@
use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::util::home_dir;
fn default_dns() -> String {
"1.1.1.1".to_string()
}
fn default_nmcli_wait() -> u32 {
8
}
fn default_exit_node() -> String {
String::new()
}
fn default_profile_name() -> String {
"away".to_string()
}
fn default_watch_interval() -> u64 {
12
}
fn default_connectivity_url() -> String {
"http://connectivitycheck.gstatic.com/generate_204".to_string()
}
fn default_ping_host() -> String {
"1.1.1.1".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
#[serde(default = "default_dns")]
pub dns: String,
#[serde(default = "default_nmcli_wait")]
pub nmcli_wait: u32,
#[serde(default = "default_exit_node")]
pub exit_node: String,
#[serde(default = "default_profile_name")]
pub default_profile: String,
#[serde(default = "default_watch_interval")]
pub watch_interval: u64,
#[serde(default = "default_connectivity_url")]
pub connectivity_url: String,
#[serde(default = "default_ping_host")]
pub ping_host: String,
}
impl Default for Settings {
fn default() -> Self {
Settings {
dns: default_dns(),
nmcli_wait: default_nmcli_wait(),
exit_node: default_exit_node(),
default_profile: default_profile_name(),
watch_interval: default_watch_interval(),
connectivity_url: default_connectivity_url(),
ping_host: default_ping_host(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkDef {
pub ssid: String,
pub password: String,
#[serde(default)]
pub hidden: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Profile {
/// Optional SSID connected first to bootstrap connectivity (e.g. for Tailscale).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bootstrap: Option<String>,
/// Ordered priority list of SSIDs this profile should end up connected to.
#[serde(default)]
pub networks: Vec<String>,
/// Require a healthy Tailscale + exit node before moving off the bootstrap.
#[serde(default)]
pub tailscale: bool,
/// Per-profile exit node override (falls back to settings.exit_node).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exit_node: Option<String>,
/// After the explicit list, also try every other known network.
#[serde(default)]
pub include_all_known: bool,
/// SSIDs whose presence in a scan indicates this location.
/// Used by `breadcrumbs detect` to guess the active profile.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub detect_ssids: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub settings: Settings,
#[serde(default, rename = "networks")]
pub networks: Vec<NetworkDef>,
#[serde(default)]
pub profiles: BTreeMap<String, Profile>,
}
pub fn config_dir() -> PathBuf {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| home_dir().join(".config"))
.join("breadcrumbs")
}
pub fn config_path() -> PathBuf {
config_dir().join("breadcrumbs.toml")
}
pub fn state_dir() -> PathBuf {
std::env::var_os("XDG_STATE_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| home_dir().join(".local").join("state"))
.join("breadcrumbs")
}
pub fn state_path() -> PathBuf {
state_dir().join("state.toml")
}
pub fn log_path() -> PathBuf {
state_dir().join("breadcrumbs.log")
}
impl Config {
pub fn profile<'a>(&'a self, name: &str) -> Option<&'a Profile> {
self.profiles.get(name)
}
pub fn network<'a>(&'a self, ssid: &str) -> Option<&'a NetworkDef> {
self.networks.iter().find(|n| n.ssid == ssid)
}
/// Load config, creating a skeleton one on first run.
pub fn load() -> Result<Config, String> {
let path = config_path();
if !path.exists() {
let cfg = build_initial_config();
cfg.save()?;
return Ok(cfg);
}
let text =
fs::read_to_string(&path).map_err(|e| format!("reading {}: {e}", path.display()))?;
let mut cfg: Config =
toml::from_str(&text).map_err(|e| format!("parsing {}: {e}", path.display()))?;
// Self-heal: guarantee the three core profiles always exist.
ensure_core_profiles(&mut cfg);
Ok(cfg)
}
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));
}
Ok(())
}
}
/// 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
/// `breadcrumbs edit`, or by copying `breadcrumbs.example.toml`.
fn canonical_networks() -> Vec<NetworkDef> {
Vec::new()
}
/// Starter profiles generated for a brand-new installation.
/// These give users working examples of the three common location patterns:
/// a home profile, a profile requiring Tailscale (e.g. a workplace or school),
/// and an "away" catch-all. All network lists start empty; users populate them
/// via `breadcrumbs add --to <profile>` or `breadcrumbs edit`.
fn core_profiles() -> BTreeMap<String, Profile> {
let mut p = BTreeMap::new();
p.insert(
"home".to_string(),
Profile {
bootstrap: None,
networks: vec![],
tailscale: false,
exit_node: None,
include_all_known: false,
detect_ssids: vec![],
},
);
p.insert(
"work".to_string(),
Profile {
bootstrap: None,
networks: vec![],
tailscale: false,
exit_node: None,
include_all_known: false,
detect_ssids: vec![],
},
);
p.insert(
"away".to_string(),
Profile {
bootstrap: None,
networks: vec![],
tailscale: false,
exit_node: None,
include_all_known: true,
detect_ssids: vec![],
},
);
p
}
fn ensure_core_profiles(cfg: &mut Config) {
for (name, prof) in core_profiles() {
cfg.profiles.entry(name).or_insert(prof);
}
}
/// The config generated on first run: no networks, the three core profiles.
/// Users populate it via `breadcrumbs add` / `edit` / `scan`.
fn build_initial_config() -> Config {
Config {
settings: Settings::default(),
networks: canonical_networks(),
profiles: core_profiles(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn initial_config_is_empty_with_core_profiles() {
let cfg = build_initial_config();
assert!(cfg.networks.is_empty());
assert_eq!(cfg.profiles.len(), 3);
assert!(cfg.profile("home").is_some());
assert!(cfg.profile("work").is_some());
assert!(cfg.profile("away").is_some());
assert!(cfg.profile("away").unwrap().include_all_known);
}
#[test]
fn config_toml_roundtrip() {
let cfg = build_initial_config();
let text = toml::to_string_pretty(&cfg).unwrap();
let back: Config = toml::from_str(&text).unwrap();
assert_eq!(back.networks.len(), cfg.networks.len());
assert_eq!(back.profiles.len(), 3);
assert!(back.profile("home").is_some());
assert!(back.profile("away").is_some());
}
#[test]
fn ensure_core_profiles_backfills_missing() {
let mut cfg = Config {
settings: Settings::default(),
networks: vec![],
profiles: BTreeMap::new(),
};
ensure_core_profiles(&mut cfg);
assert!(cfg.profile("home").is_some());
assert!(cfg.profile("work").is_some());
assert!(cfg.profile("away").is_some());
}
}

322
src/flow.rs Normal file
View file

@ -0,0 +1,322 @@
use crate::config::{Config, NetworkDef};
use crate::nm;
use crate::notify::{log, notify, Urgency};
use crate::status::internet_ok;
use crate::tailscale::{self, TsHealth};
#[derive(Debug)]
pub enum Outcome {
/// Connected to `ssid`; `note` carries any caveat (e.g. on bootstrap only).
Connected {
ssid: String,
note: Option<String>,
},
/// Tailscale required but unhealthy; left on `ssid` (bootstrap if available).
TailscaleError {
ssid: Option<String>,
health: TsHealth,
},
NoInterface,
NoNetworks,
UnknownProfile(String),
}
impl Outcome {
pub fn ok(&self) -> bool {
matches!(self, Outcome::Connected { .. })
}
}
fn resolve_candidates<'a>(cfg: &'a Config, p: &crate::config::Profile) -> Vec<&'a 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);
}
}
}
if p.include_all_known {
for def in &cfg.networks {
if !out.iter().any(|d| d.ssid == def.ssid) {
out.push(def);
}
}
}
out
}
/// Try to connect + confirm it actually carries traffic.
fn connect_and_verify(iface: &str, def: &NetworkDef, cfg: &Config) -> bool {
if !nm::connect(iface, def, cfg.settings.nmcli_wait, &cfg.settings.dns) {
return false;
}
// Associated. Confirm DHCP/route by checking the device is connected.
if !nm::device_connected(iface) {
return false;
}
true
}
/// Run the connection state machine for `profile_name`.
pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
let profile = match cfg.profile(profile_name) {
Some(p) => p.clone(),
None => {
notify(
"breadcrumbs: unknown profile",
&format!("'{profile_name}' is not defined in breadcrumbs.toml"),
Urgency::Critical,
);
return Outcome::UnknownProfile(profile_name.to_string());
}
};
let iface = match nm::wifi_interface() {
Some(i) => i,
None => {
notify(
"breadcrumbs: no Wi-Fi adapter",
"Hardware issue — Wi-Fi device not found. Manual check needed.",
Urgency::Critical,
);
return Outcome::NoInterface;
}
};
nm::radio_on();
let exit_node = profile
.exit_node
.clone()
.unwrap_or_else(|| cfg.settings.exit_node.clone());
let candidates = resolve_candidates(cfg, &profile);
log(&format!(
"flow start: profile={profile_name} iface={iface} tailscale={} candidates=[{}]",
profile.tailscale,
candidates
.iter()
.map(|c| c.ssid.as_str())
.collect::<Vec<_>>()
.join(", ")
));
// Pre-scan for everything we might want, including the bootstrap SSID.
let mut scan_targets: Vec<String> = candidates.iter().map(|c| c.ssid.clone()).collect();
if let Some(bs) = &profile.bootstrap {
scan_targets.push(bs.clone());
}
nm::rescan(&iface, &scan_targets);
let visible = nm::visible_ssids(&iface);
// ---- Tailscale-gated profiles (e.g. school) -------------------------
let mut on_bootstrap = false;
if profile.tailscale {
if let Some(bs_ssid) = profile.bootstrap.clone() {
match cfg.network(&bs_ssid) {
Some(bdef) => {
if visible.contains(&bdef.ssid) || bdef.hidden {
if connect_and_verify(&iface, bdef, cfg) {
on_bootstrap = true;
log(&format!("bootstrap connected: {}", bdef.ssid));
} else {
log(&format!("bootstrap connect failed: {}", bdef.ssid));
}
} else {
log(&format!("bootstrap not in range: {}", bdef.ssid));
}
}
None => log(&format!(
"bootstrap SSID '{bs_ssid}' has no credentials in config"
)),
}
}
let ts = tailscale::ensure_exit_node(&exit_node);
if !ts.is_ok() {
let ssid = nm::active_ssid(&iface).or_else(|| profile.bootstrap.clone());
notify(
"Tailscale Error",
&format!(
"{} — staying on {}",
ts.describe(),
ssid.clone().unwrap_or_else(|| "Wi-Fi".into())
),
Urgency::Critical,
);
return Outcome::TailscaleError { ssid, health: ts };
}
log(&format!("tailscale healthy via exit node {exit_node}"));
// Refresh visibility before moving to the target network.
nm::rescan(&iface, &scan_targets);
}
let visible = nm::visible_ssids(&iface);
// ---- Connect to the priority list ----------------------------------
// Pass 1: visible networks in priority order.
for def in &candidates {
if visible.contains(&def.ssid) {
if connect_and_verify(&iface, def, cfg) {
let note = if internet_ok(cfg) {
None
} else {
Some("associated but no internet yet".to_string())
};
finish_connected(&def.ssid, profile_name, &note);
return Outcome::Connected {
ssid: def.ssid.clone(),
note,
};
}
log(&format!("connect failed (visible): {}", def.ssid));
}
}
// Pass 2: hidden networks we couldn't see in the scan.
for def in &candidates {
if def.hidden && !visible.contains(&def.ssid) {
if connect_and_verify(&iface, def, cfg) {
let note = if internet_ok(cfg) {
None
} else {
Some("associated but no internet yet".to_string())
};
finish_connected(&def.ssid, profile_name, &note);
return Outcome::Connected {
ssid: def.ssid.clone(),
note,
};
}
log(&format!("connect failed (hidden): {}", def.ssid));
}
}
// ---- Nothing in the priority list connected ------------------------
if on_bootstrap {
// We still have working internet via the bootstrap + Tailscale.
let ssid = profile
.bootstrap
.clone()
.unwrap_or_else(|| "bootstrap".into());
let note = format!("target network not in range — staying on {ssid} (Tailscale OK)");
notify("breadcrumbs: using bootstrap", &note, Urgency::Normal);
log(&format!("flow end: on bootstrap {ssid}; {note}"));
return Outcome::Connected {
ssid,
note: Some(note),
};
}
let names = candidates
.iter()
.map(|c| c.ssid.as_str())
.collect::<Vec<_>>()
.join(", ");
notify(
"breadcrumbs: no known networks",
&format!("profile '{profile_name}': none of [{names}] are in range"),
Urgency::Critical,
);
log(&format!(
"flow end: no networks connected (profile={profile_name})"
));
Outcome::NoNetworks
}
fn finish_connected(ssid: &str, profile: &str, note: &Option<String>) {
match note {
None => {
notify(
"breadcrumbs: connected",
&format!("{ssid} ({profile})"),
Urgency::Low,
);
log(&format!("flow end: connected {ssid} (profile={profile})"));
}
Some(n) => {
notify(
"breadcrumbs: connected (degraded)",
&format!("{ssid} ({profile}) — {n}"),
Urgency::Normal,
);
log(&format!(
"flow end: connected {ssid} (profile={profile}) note={n}"
));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{Profile, Settings};
use std::collections::BTreeMap;
fn net(ssid: &str) -> NetworkDef {
NetworkDef {
ssid: ssid.into(),
password: "x".into(),
hidden: false,
}
}
fn cfg() -> Config {
Config {
settings: Settings::default(),
networks: vec![
net("HomeWifi"),
net("WorkNet"),
net("CafeWifi"),
net("FallbackNet"),
],
profiles: BTreeMap::new(),
}
}
#[test]
fn candidates_follow_priority_order() {
let c = cfg();
let p = Profile {
networks: vec!["FallbackNet".into(), "HomeWifi".into()],
..Default::default()
};
let got: Vec<&str> = resolve_candidates(&c, &p)
.iter()
.map(|n| n.ssid.as_str())
.collect();
assert_eq!(got, vec!["FallbackNet", "HomeWifi"]);
}
#[test]
fn include_all_known_appends_remaining_without_dupes() {
let c = cfg();
let p = Profile {
networks: vec!["HomeWifi".into()],
include_all_known: true,
..Default::default()
};
let got: Vec<&str> = resolve_candidates(&c, &p)
.iter()
.map(|n| n.ssid.as_str())
.collect();
assert_eq!(got[0], "HomeWifi");
assert_eq!(got.len(), 4);
assert!(got.contains(&"WorkNet"));
// No duplicate of the explicitly-listed network.
assert_eq!(got.iter().filter(|s| **s == "HomeWifi").count(), 1);
}
#[test]
fn unknown_ssids_in_profile_are_skipped() {
let c = cfg();
let p = Profile {
networks: vec!["Ghost".into(), "WorkNet".into()],
..Default::default()
};
let got: Vec<&str> = resolve_candidates(&c, &p)
.iter()
.map(|n| n.ssid.as_str())
.collect();
assert_eq!(got, vec!["WorkNet"]);
}
}

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)
}

345
src/nm.rs Normal file
View file

@ -0,0 +1,345 @@
use std::collections::HashSet;
use std::time::Duration;
use crate::config::NetworkDef;
use crate::util::{run, run_ok, run_with_stdin};
/// nmcli `-t` escapes `:` and `\` in field values; undo that.
fn unescape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' {
if let Some(&n) = chars.peek() {
out.push(n);
chars.next();
continue;
}
}
out.push(c);
}
out
}
/// Split one nmcli `-t` line into fields. Fields are ':'-separated but values
/// escape ':' as '\:' and '\' as '\\'.
fn parse_scan_line(line: &str) -> Vec<String> {
let mut fields: Vec<String> = Vec::new();
let mut cur = String::new();
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' {
if let Some(&n) = chars.peek() {
cur.push(n);
chars.next();
continue;
}
}
if c == ':' {
fields.push(std::mem::take(&mut cur));
} else {
cur.push(c);
}
}
fields.push(cur);
fields
}
pub fn wifi_interface() -> Option<String> {
let o = run(
"nmcli",
&["-t", "-f", "DEVICE,TYPE", "device", "status"],
Duration::from_secs(8),
);
if !o.success {
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]));
}
}
None
}
pub fn radio_on() {
let _ = run("nmcli", &["radio", "wifi", "on"], Duration::from_secs(6));
}
pub fn rescan(iface: &str, ssids: &[String]) {
let mut args: Vec<String> = vec![
"device".into(),
"wifi".into(),
"rescan".into(),
"ifname".into(),
iface.into(),
];
for s in ssids {
args.push("ssid".into());
args.push(s.clone());
}
let argv: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
let _ = run("nmcli", &argv, Duration::from_secs(20));
}
pub fn visible_ssids(iface: &str) -> HashSet<String> {
let o = run(
"nmcli",
&[
"-t", "-f", "SSID", "device", "wifi", "list", "ifname", iface,
],
Duration::from_secs(12),
);
let mut set = HashSet::new();
if !o.success {
return set;
}
for line in o.stdout.lines() {
let ssid = unescape(line.trim());
if !ssid.is_empty() {
set.insert(ssid);
}
}
set
}
#[derive(Debug, Clone)]
pub struct ScanEntry {
pub ssid: String,
pub signal: String,
pub security: String,
}
pub fn scan_list(iface: &str) -> Vec<ScanEntry> {
let o = run(
"nmcli",
&[
"-t",
"-f",
"SSID,SIGNAL,SECURITY",
"device",
"wifi",
"list",
"ifname",
iface,
],
Duration::from_secs(12),
);
let mut seen = HashSet::new();
let mut out = Vec::new();
if !o.success {
return out;
}
for line in o.stdout.lines() {
let fields = parse_scan_line(line);
if fields.is_empty() {
continue;
}
let ssid = fields[0].trim().to_string();
if ssid.is_empty() || !seen.insert(ssid.clone()) {
continue;
}
out.push(ScanEntry {
ssid,
signal: fields.get(1).cloned().unwrap_or_default(),
security: fields.get(2).cloned().unwrap_or_default(),
});
}
out
}
pub fn active_ssid(iface: &str) -> Option<String> {
let o = run(
"nmcli",
&[
"-t",
"-f",
"ACTIVE,SSID",
"device",
"wifi",
"list",
"ifname",
iface,
],
Duration::from_secs(8),
);
if !o.success {
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());
if !s.is_empty() {
return Some(s);
}
}
}
None
}
pub fn device_connected(iface: &str) -> bool {
let o = run(
"nmcli",
&["-t", "-f", "DEVICE,STATE", "device", "status"],
Duration::from_secs(6),
);
if !o.success {
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");
}
}
false
}
fn active_uuid(iface: &str) -> Option<String> {
let o = run(
"nmcli",
&["-g", "GENERAL.CON-UUID", "device", "show", iface],
Duration::from_secs(6),
);
if !o.success {
return None;
}
let u = o.stdout.trim().to_string();
if u.is_empty() {
None
} else {
Some(u)
}
}
fn enforce_dns(uuid: &str, iface: &str, dns: &str) {
if dns.trim().is_empty() {
return;
}
let ok = run_ok(
"nmcli",
&[
"connection",
"modify",
uuid,
"ipv4.ignore-auto-dns",
"yes",
"ipv4.dns",
dns,
],
Duration::from_secs(8),
);
if ok {
let _ = run(
"nmcli",
&["device", "reapply", iface],
Duration::from_secs(8),
);
}
}
/// Connect to a network and pin DNS. Returns true only if associated.
pub fn connect(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> bool {
let wait_s = wait.to_string();
let hidden = if net.hidden { "yes" } else { "no" };
// `--ask` makes nmcli read the PSK from stdin instead of taking it on the
// command line, so the password never appears in `ps`/`/proc`.
let args = [
"--wait",
&wait_s,
"--ask",
"device",
"wifi",
"connect",
net.ssid.as_str(),
"hidden",
hidden,
"ifname",
iface,
];
let secret = format!("{}\n", net.password);
let o = run_with_stdin(
"nmcli",
&args,
Some(&secret),
Duration::from_secs(wait as u64 + 15),
);
if !o.success {
return false;
}
if let Some(uuid) = active_uuid(iface) {
enforce_dns(&uuid, iface, dns);
}
true
}
/// Delete every saved connection profile whose name or 802-11-wireless SSID
/// matches `ssid` (used by `breadcrumbs forget` to purge stale entries).
pub fn delete_connections_for_ssid(ssid: &str) -> bool {
let list = run(
"nmcli",
&["-t", "-f", "NAME,TYPE", "connection", "show"],
Duration::from_secs(8),
);
if !list.success {
return false;
}
let mut removed = false;
for line in list.stdout.lines() {
let parts: Vec<&str> = line.splitn(2, ':').collect();
if parts.len() < 2 {
continue;
}
let name = unescape(parts[0]);
let typ = parts[1];
if !typ.contains("wireless") {
continue;
}
let conn_ssid = run(
"nmcli",
&["-g", "802-11-wireless.ssid", "connection", "show", &name],
Duration::from_secs(6),
);
let conn_ssid = conn_ssid.stdout.trim();
if (name == ssid || conn_ssid == ssid)
&& run_ok(
"nmcli",
&["connection", "delete", "id", &name],
Duration::from_secs(8),
)
{
removed = true;
}
}
removed
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unescape_handles_nmcli_escaping() {
assert_eq!(unescape("plain"), "plain");
assert_eq!(unescape(r"a\:b"), "a:b");
assert_eq!(unescape(r"back\\slash"), r"back\slash");
assert_eq!(unescape("trailing\\"), "trailing\\");
}
#[test]
fn parse_scan_line_splits_and_unescapes() {
// SSID:SIGNAL:SECURITY with an escaped ':' inside the SSID.
let f = parse_scan_line(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");
assert_eq!(f, vec!["My Network", "88", "WPA2"]);
// Empty SSID (hidden) keeps the empty leading field.
let f = parse_scan_line(":40:WPA3");
assert_eq!(f, vec!["", "40", "WPA3"]);
}
}

79
src/notify.rs Normal file
View file

@ -0,0 +1,79 @@
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::time::Duration;
use crate::config::{log_path, state_dir};
use crate::util::{command_exists, run, timestamp};
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Urgency {
Low,
Normal,
Critical,
}
impl Urgency {
fn as_str(self) -> &'static str {
match self {
Urgency::Low => "low",
Urgency::Normal => "normal",
Urgency::Critical => "critical",
}
}
}
const MAX_LOG_BYTES: u64 = 512 * 1024;
pub fn log(line: &str) {
let _ = fs::create_dir_all(state_dir());
let path = log_path();
if let Ok(meta) = fs::metadata(&path) {
if meta.len() > MAX_LOG_BYTES {
if let Ok(text) = fs::read_to_string(&path) {
let tail: Vec<&str> = text.lines().rev().take(300).collect();
let kept: String = tail.into_iter().rev().collect::<Vec<_>>().join("\n");
let _ = fs::write(&path, kept + "\n");
}
}
}
if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(&path) {
let _ = writeln!(f, "{} {}", timestamp(), line);
}
}
/// Desktop notification (best effort) + log entry.
pub fn notify(summary: &str, body: &str, urgency: Urgency) {
log(&format!(
"[notify/{}] {} {}",
urgency.as_str(),
summary,
if body.is_empty() {
String::new()
} else {
format!("- {body}")
}
));
if !command_exists("notify-send") {
return;
}
let timeout_ms = match urgency {
Urgency::Critical => "0",
Urgency::Normal => "6000",
Urgency::Low => "3500",
};
let mut args = vec![
"-a",
"breadcrumbs",
"-u",
urgency.as_str(),
"-t",
timeout_ms,
"-h",
"string:x-canonical-private-synchronous:breadcrumbs",
summary,
];
if !body.is_empty() {
args.push(body);
}
let _ = run("notify-send", &args, Duration::from_secs(5));
}

34
src/state.rs Normal file
View file

@ -0,0 +1,34 @@
use std::fs;
use serde::{Deserialize, Serialize};
use crate::config::{state_dir, state_path};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct State {
pub profile: String,
#[serde(default)]
pub updated: String,
}
impl State {
pub fn load(default_profile: &str) -> State {
if let Ok(text) = fs::read_to_string(state_path()) {
if let Ok(s) = toml::from_str::<State>(&text) {
if !s.profile.is_empty() {
return s;
}
}
}
State {
profile: default_profile.to_string(),
updated: String::new(),
}
}
pub fn save(&self) -> Result<(), String> {
fs::create_dir_all(state_dir()).map_err(|e| format!("creating state dir: {e}"))?;
let text = toml::to_string_pretty(self).map_err(|e| format!("serializing state: {e}"))?;
fs::write(state_path(), text).map_err(|e| format!("writing state: {e}"))
}
}

92
src/status.rs Normal file
View file

@ -0,0 +1,92 @@
use std::time::Duration;
use crate::config::Config;
use crate::nm;
use crate::tailscale::{self, TsHealth};
use crate::util::{command_exists, run};
pub fn internet_ok(cfg: &Config) -> bool {
if command_exists("curl") {
let o = run(
"curl",
&[
"-s",
"-o",
"/dev/null",
"-w",
"%{http_code}",
"--max-time",
"4",
&cfg.settings.connectivity_url,
],
Duration::from_secs(6),
);
let code = o.stdout.trim();
if code == "204" || code == "200" || code == "301" || code == "302" {
return true;
}
}
// Fallback: ICMP to the configured host.
run(
"ping",
&["-c", "1", "-W", "2", &cfg.settings.ping_host],
Duration::from_secs(4),
)
.success
}
fn ipv4(iface: &str) -> Option<String> {
let o = run(
"nmcli",
&["-g", "IP4.ADDRESS", "device", "show", iface],
Duration::from_secs(6),
);
if !o.success {
return None;
}
let s = o.stdout.trim();
if s.is_empty() {
None
} else {
Some(s.lines().next().unwrap_or(s).trim().to_string())
}
}
pub struct Status {
pub iface: Option<String>,
pub ssid: Option<String>,
pub ip: Option<String>,
pub internet: bool,
pub tailscale_required: bool,
pub tailscale: Option<TsHealth>,
pub exit_node: String,
}
pub fn gather(cfg: &Config, profile_name: &str) -> Status {
let iface = nm::wifi_interface();
let ssid = iface.as_deref().and_then(nm::active_ssid);
let ip = iface.as_deref().and_then(ipv4);
let internet = internet_ok(cfg);
let prof = cfg.profile(profile_name);
let ts_required = prof.map(|p| p.tailscale).unwrap_or(false);
let exit_node = prof
.and_then(|p| p.exit_node.clone())
.unwrap_or_else(|| cfg.settings.exit_node.clone());
let tailscale = if tailscale::installed() {
Some(tailscale::check(&exit_node))
} else {
None
};
Status {
iface,
ssid,
ip,
internet,
tailscale_required: ts_required,
tailscale,
exit_node,
}
}

384
src/tailscale.rs Normal file
View file

@ -0,0 +1,384 @@
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};
use serde_json::Value;
use crate::notify::{log, notify, Urgency};
use crate::util::{command_exists, run};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TsHealth {
/// Backend Running and the requested exit node is selected + online.
Ok,
NotInstalled,
NeedsLogin,
Stopped,
/// The exit node host is not present / not advertising as an exit node.
ExitNodeMissing,
/// The exit node exists but is offline.
ExitNodeOffline,
Error(String),
}
impl TsHealth {
pub fn is_ok(&self) -> bool {
matches!(self, TsHealth::Ok)
}
pub fn describe(&self) -> String {
match self {
TsHealth::Ok => "ok".into(),
TsHealth::NotInstalled => "tailscale not installed".into(),
TsHealth::NeedsLogin => "not logged in (run: tailscale up)".into(),
TsHealth::Stopped => "backend stopped".into(),
TsHealth::ExitNodeMissing => "exit node not found in tailnet".into(),
TsHealth::ExitNodeOffline => "exit node is offline".into(),
TsHealth::Error(e) => format!("error: {e}"),
}
}
}
pub fn installed() -> bool {
command_exists("tailscale")
}
fn status_json() -> Option<Value> {
let o = run("tailscale", &["status", "--json"], Duration::from_secs(8));
if o.stdout.trim().is_empty() {
return None;
}
serde_json::from_str(&o.stdout).ok()
}
fn backend_state(v: &Value) -> String {
v.get("BackendState")
.and_then(Value::as_str)
.unwrap_or("")
.to_string()
}
/// Does any peer named `node` advertise as an exit node, and is it online /
/// currently selected?
fn exit_node_state(
v: &Value,
node: &str,
) -> (
bool, /*exists*/
bool, /*online*/
bool, /*selected*/
) {
// Strong signal: ExitNodeStatus is populated when an exit node is active.
let ens_online = v
.get("ExitNodeStatus")
.and_then(|e| e.get("Online"))
.and_then(Value::as_bool);
let want = node.trim().to_lowercase();
let mut exists = false;
let mut online = false;
let mut selected = false;
if let Some(peers) = v.get("Peer").and_then(Value::as_object) {
for (_k, p) in peers {
let host = p
.get("HostName")
.and_then(Value::as_str)
.unwrap_or("")
.to_lowercase();
let dns = p
.get("DNSName")
.and_then(Value::as_str)
.unwrap_or("")
.to_lowercase();
let matches = host == want || dns.split('.').next() == Some(want.as_str());
let advertises = p
.get("ExitNodeOption")
.and_then(Value::as_bool)
.unwrap_or(false);
if matches && advertises {
exists = true;
online = p.get("Online").and_then(Value::as_bool).unwrap_or(false);
selected = p.get("ExitNode").and_then(Value::as_bool).unwrap_or(false);
break;
}
}
}
if selected {
if let Some(o) = ens_online {
online = o;
}
}
(exists, online, selected)
}
fn extract_url(line: &str) -> Option<String> {
line.split_whitespace()
.find(|s| s.starts_with("https://"))
.map(str::to_string)
}
static LOGIN_INFLIGHT: AtomicBool = AtomicBool::new(false);
/// Kick off browser-based Tailscale login in the background and return
/// immediately. The watch loop must never block on interactive auth, so the
/// actual `sudo tailscale login` + browser flow runs on its own thread; the
/// caller just keeps reporting `NeedsLogin` (→ stay on the bootstrap network)
/// until a later poll observes the backend come up. The guard collapses
/// repeated triggers into a single in-flight attempt.
fn login_and_open() {
if LOGIN_INFLIGHT.swap(true, Ordering::SeqCst) {
return;
}
thread::spawn(|| {
run_login();
LOGIN_INFLIGHT.store(false, Ordering::SeqCst);
});
}
/// Run `sudo tailscale login`, open the auth URL in the browser, and block
/// until login completes (up to 5 minutes). Always called on a worker thread.
fn run_login() {
let mut child = match Command::new("sudo")
.args(["tailscale", "login"])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
log(&format!("tailscale login: spawn failed: {e}"));
return;
}
};
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let (tx, rx) = mpsc::channel::<String>();
let tx2 = tx.clone();
thread::spawn(move || {
if let Some(r) = stdout {
for line in BufReader::new(r).lines().map_while(Result::ok) {
if let Some(url) = extract_url(&line) {
let _ = tx.send(url);
}
}
}
});
thread::spawn(move || {
if let Some(r) = stderr {
for line in BufReader::new(r).lines().map_while(Result::ok) {
if let Some(url) = extract_url(&line) {
let _ = tx2.send(url);
}
}
}
});
if let Ok(url) = rx.recv_timeout(Duration::from_secs(30)) {
log(&format!("tailscale login: opening {url}"));
notify(
"Tailscale: login required",
"Opening browser to authenticate.",
Urgency::Normal,
);
let _ = Command::new("xdg-open")
.arg(&url)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
} else {
log("tailscale login: no URL received within 30s");
}
// Wait up to 5 min for the user to complete browser auth.
let start = Instant::now();
let timeout = Duration::from_secs(300);
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if start.elapsed() >= timeout {
log("tailscale login: timed out waiting for auth");
let _ = child.kill();
let _ = child.wait();
break;
}
thread::sleep(Duration::from_millis(500));
}
Err(_) => break,
}
}
}
/// Bring Tailscale to a state where `node` is the active, online exit node.
/// Performs at most one bring-up/login and one `tailscale set` attempt.
pub fn ensure_exit_node(node: &str) -> TsHealth {
if !installed() {
return TsHealth::NotInstalled;
}
let v = match status_json() {
Some(v) => v,
None => return TsHealth::Error("could not read tailscale status".into()),
};
match backend_state(&v).as_str() {
"NeedsLogin" | "NoState" => {
login_and_open();
}
"Stopped" => {
// Daemon not running — bring it up, then check if it needs auth.
let _ = run("tailscale", &["up"], Duration::from_secs(20));
if let Some(v2) = status_json() {
if matches!(backend_state(&v2).as_str(), "NeedsLogin" | "NoState") {
login_and_open();
}
}
}
"Running" => {}
"" => return TsHealth::Error("empty backend state".into()),
_ => {}
}
// Select the exit node (idempotent).
let _ = run(
"tailscale",
&["set", &format!("--exit-node={node}")],
Duration::from_secs(10),
);
let v = match status_json() {
Some(v) => v,
None => return TsHealth::Error("could not re-read tailscale status".into()),
};
match backend_state(&v).as_str() {
"Running" => {}
"NeedsLogin" | "NoState" => return TsHealth::NeedsLogin,
"Stopped" => return TsHealth::Stopped,
other => return TsHealth::Error(format!("backend state: {other}")),
}
let (exists, online, selected) = exit_node_state(&v, node);
if !exists {
TsHealth::ExitNodeMissing
} else if !online {
TsHealth::ExitNodeOffline
} else if !selected {
// Online and present but our set didn't take — treat as missing/selectable error.
TsHealth::Error("exit node not selected".into())
} else {
TsHealth::Ok
}
}
/// Lightweight health check without trying to (re)configure anything.
pub fn check(node: &str) -> TsHealth {
if !installed() {
return TsHealth::NotInstalled;
}
let v = match status_json() {
Some(v) => v,
None => return TsHealth::Error("status unavailable".into()),
};
match backend_state(&v).as_str() {
"Running" => {}
"NeedsLogin" | "NoState" => return TsHealth::NeedsLogin,
"Stopped" => return TsHealth::Stopped,
other => return TsHealth::Error(format!("backend state: {other}")),
}
let (exists, online, selected) = exit_node_state(&v, node);
if !exists {
TsHealth::ExitNodeMissing
} else if !online {
TsHealth::ExitNodeOffline
} else if !selected {
TsHealth::Error("exit node not selected".into())
} else {
TsHealth::Ok
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn backend_state_extraction() {
assert_eq!(
backend_state(&json!({"BackendState": "Running"})),
"Running"
);
assert_eq!(backend_state(&json!({})), "");
}
#[test]
fn exit_node_healthy_and_selected() {
let v = json!({
"BackendState": "Running",
"ExitNodeStatus": { "Online": true },
"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_present_but_offline() {
let v = json!({
"BackendState": "Running",
"Peer": {
"k1": { "HostName": "exitnode", "DNSName": "exitnode.ts.net.",
"Online": false, "ExitNode": false, "ExitNodeOption": true }
}
});
assert_eq!(exit_node_state(&v, "exitnode"), (true, false, false));
}
#[test]
fn exit_node_missing_when_not_advertised() {
let v = json!({
"BackendState": "Running",
"Peer": {
"k1": { "HostName": "exitnode", "DNSName": "exitnode.ts.net.",
"Online": true, "ExitNode": false, "ExitNodeOption": false }
}
});
assert_eq!(exit_node_state(&v, "exitnode"), (false, false, false));
}
#[test]
fn exit_node_matches_via_dns_first_label() {
let v = json!({
"Peer": {
"k1": { "HostName": "box-1", "DNSName": "exitnode.example.ts.net.",
"Online": true, "ExitNode": true, "ExitNodeOption": true }
}
});
let (exists, online, selected) = exit_node_state(&v, "exitnode");
assert!(exists && online && selected);
}
#[test]
fn exit_node_present_online_but_not_selected() {
let v = json!({
"Peer": {
"k1": { "HostName": "exitnode", "DNSName": "exitnode.ts.net.",
"Online": true, "ExitNode": false, "ExitNodeOption": true }
}
});
assert_eq!(exit_node_state(&v, "exitnode"), (true, true, false));
}
}

179
src/util.rs Normal file
View file

@ -0,0 +1,179 @@
use std::io::{Read, Write};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
pub fn home_dir() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.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,
pub stdout: String,
pub stderr: String,
}
impl Output {
pub fn failed() -> Output {
Output {
success: false,
stdout: String::new(),
stderr: String::new(),
}
}
}
/// 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 {
run_with_stdin(prog, args, None, timeout)
}
/// Like [`run`], but feeds `stdin` to the child's standard input. Used to hand
/// 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 {
let stdin_cfg = if stdin.is_some() {
Stdio::piped()
} else {
Stdio::null()
};
let mut child = match Command::new(prog)
.args(args)
.stdin(stdin_cfg)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(_) => return Output::failed(),
};
if let Some(data) = stdin {
if let Some(mut sink) = child.stdin.take() {
let _ = sink.write_all(data.as_bytes());
// Drop closes the pipe so the child's read sees EOF.
}
}
let mut stdout_pipe = child.stdout.take();
let mut stderr_pipe = child.stderr.take();
let out_handle = thread::spawn(move || {
let mut buf = String::new();
if let Some(ref mut p) = stdout_pipe {
let _ = p.read_to_string(&mut buf);
}
buf
});
let err_handle = thread::spawn(move || {
let mut buf = String::new();
if let Some(ref mut p) = stderr_pipe {
let _ = p.read_to_string(&mut buf);
}
buf
});
let start = Instant::now();
let status = loop {
match child.try_wait() {
Ok(Some(s)) => break Some(s),
Ok(None) => {
if start.elapsed() >= timeout {
let _ = child.kill();
let _ = child.wait();
break None;
}
thread::sleep(Duration::from_millis(50));
}
Err(_) => break None,
}
};
let stdout = out_handle.join().unwrap_or_default();
let stderr = err_handle.join().unwrap_or_default();
Output {
success: status.map(|s| s.success()).unwrap_or(false),
stdout,
stderr,
}
}
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 {
let o = run("date", &["+%Y-%m-%d %H:%M:%S"], Duration::from_secs(2));
if o.success {
let t = o.stdout.trim();
if !t.is_empty() {
return t.to_string();
}
}
timestamp_utc()
}
/// epoch seconds -> "YYYY-MM-DD HH:MM:SS" (UTC), no external deps.
fn timestamp_utc() -> String {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0) as i64;
fmt_epoch(secs)
}
/// Format UTC epoch seconds as "YYYY-MM-DD HH:MM:SS" (pure / testable).
fn fmt_epoch(secs: i64) -> String {
let days = secs.div_euclid(86_400);
let tod = secs.rem_euclid(86_400);
let (h, mi, s) = (tod / 3600, (tod % 3600) / 60, tod % 60);
// civil_from_days (Howard Hinnant's algorithm)
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!("{y:04}-{m:02}-{d:02} {h:02}:{mi:02}:{s:02}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fmt_epoch_known_values() {
assert_eq!(fmt_epoch(0), "1970-01-01 00:00:00");
// 2001-09-09 01:46:40 UTC
assert_eq!(fmt_epoch(1_000_000_000), "2001-09-09 01:46:40");
// 2021-01-01 00:00:00 UTC
assert_eq!(fmt_epoch(1_609_459_200), "2021-01-01 00:00:00");
// Leap day 2024-02-29 12:00:00 UTC
assert_eq!(fmt_epoch(1_709_208_000), "2024-02-29 12:00:00");
}
}

223
src/watch.rs Normal file
View file

@ -0,0 +1,223 @@
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};
use std::sync::mpsc::{self, Receiver};
use std::thread;
use std::time::{Duration, Instant};
use crate::config::Config;
use crate::flow;
use crate::notify::{log, notify, Urgency};
use crate::state::State;
use crate::status::{self};
use crate::tailscale::TsHealth;
#[derive(PartialEq, Eq, Clone, Debug)]
enum Health {
Up,
DownNoNet,
DownTailscaleManual,
DownTailscaleOther,
NoAdapter,
}
fn classify(cfg: &Config, profile: &str) -> (Health, Option<String>) {
let s = status::gather(cfg, profile);
if s.iface.is_none() {
return (Health::NoAdapter, None);
}
let ssid = s.ssid.clone();
if !s.internet {
return (Health::DownNoNet, ssid);
}
if s.tailscale_required {
match s.tailscale {
Some(TsHealth::Ok) => (Health::Up, ssid),
Some(TsHealth::NeedsLogin) | Some(TsHealth::NotInstalled) => {
(Health::DownTailscaleManual, ssid)
}
Some(_) => (Health::DownTailscaleOther, ssid),
None => (Health::DownTailscaleManual, ssid),
}
} else {
(Health::Up, ssid)
}
}
/// 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<()>) {
thread::spawn(move || loop {
let child = Command::new("nmcli")
.arg("monitor")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn();
let mut child = match child {
Ok(c) => c,
Err(_) => {
thread::sleep(Duration::from_secs(10));
continue;
}
};
if let Some(out) = child.stdout.take() {
let reader = BufReader::new(out);
let mut last = Instant::now() - Duration::from_secs(10);
for line in reader.lines().map_while(Result::ok) {
let l = line.to_lowercase();
let interesting = l.contains("disconnect")
|| l.contains("unavailable")
|| l.contains("connected")
|| l.contains("connection")
|| l.contains("now")
|| l.contains("state");
if interesting && last.elapsed() > Duration::from_millis(1500) {
last = Instant::now();
let _ = tx.send(());
}
}
}
let _ = child.wait();
// monitor died (NM restart?) — back off and respawn.
thread::sleep(Duration::from_secs(5));
});
}
/// Sleep up to `dur`, but wake early if `nmcli monitor` signals link churn.
fn wait_for_tick(rx: &Receiver<()>, dur: Duration) {
match rx.recv_timeout(dur) {
Ok(()) => {
// Drain any burst of events so we don't re-fire immediately.
while rx.try_recv().is_ok() {}
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
// Monitor thread gone (shouldn't happen: we hold the sender) — fall
// back to a plain sleep so we don't busy-spin.
Err(mpsc::RecvTimeoutError::Disconnected) => thread::sleep(dur),
}
}
pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
let base = cfg.settings.watch_interval.max(4);
notify(
"breadcrumbs watcher started",
"Monitoring Wi-Fi; will auto-recover drops.",
Urgency::Low,
);
log("watch: started");
let (tx, rx) = mpsc::channel::<()>();
spawn_nm_monitor(tx);
let mut profile = State::load(&cfg.settings.default_profile).profile;
if run_initial {
// Don't churn an already-working connection on (re)start.
let (h, _) = classify(&cfg, &profile);
if h == Health::Up {
log(&format!(
"watch: already healthy on start (profile={profile}); skipping initial flow"
));
} else {
log(&format!("watch: initial flow for profile={profile}"));
let _ = flow::run(&cfg, &profile);
}
}
let mut prev_health: Option<Health> = None;
let mut prev_profile = profile.clone();
let mut fail_streak: u32 = 0;
loop {
// Reload config + state so edits and `profile set` take effect live.
if let Ok(fresh) = Config::load() {
cfg = fresh;
}
profile = State::load(&cfg.settings.default_profile).profile;
let profile_changed = profile != prev_profile;
if profile_changed {
log(&format!(
"watch: profile changed {prev_profile} -> {profile}"
));
notify(
"breadcrumbs: profile changed",
&format!("{prev_profile} -> {profile}"),
Urgency::Low,
);
prev_profile = profile.clone();
prev_health = None; // force re-evaluation/recovery for new profile
}
let (health, ssid) = classify(&cfg, &profile);
let transition = prev_health.as_ref() != Some(&health);
match &health {
Health::Up => {
if transition && prev_health.is_some() {
notify(
"breadcrumbs: back online",
&format!(
"{} ({profile})",
ssid.clone().unwrap_or_else(|| "Wi-Fi".into())
),
Urgency::Low,
);
}
fail_streak = 0;
}
Health::NoAdapter => {
if transition {
notify(
"breadcrumbs: no Wi-Fi adapter",
"Hardware issue — manual check needed.",
Urgency::Critical,
);
}
fail_streak = fail_streak.saturating_add(1);
}
Health::DownTailscaleManual => {
// Can't be auto-fixed (login / not installed). Notify once.
if transition {
notify(
"Tailscale Error",
"Tailscale needs manual attention (login / install). \
Other Wi-Fi automation paused until resolved.",
Urgency::Critical,
);
}
// Re-run flow only on transition so we land on the bootstrap net.
if transition || profile_changed {
let _ = flow::run(&cfg, &profile);
}
fail_streak = fail_streak.saturating_add(1);
}
Health::DownNoNet | Health::DownTailscaleOther => {
if transition {
notify(
"breadcrumbs: connection lost",
&format!("Recovering ({profile})…"),
Urgency::Normal,
);
}
log(&format!(
"watch: down ({:?}) profile={profile} ssid={:?} — running flow",
health, ssid
));
let outcome = flow::run(&cfg, &profile);
log(&format!("watch: recovery outcome = {:?}", outcome));
fail_streak = if outcome.ok() {
0
} else {
fail_streak.saturating_add(1)
};
}
}
prev_health = Some(health);
// Adaptive backoff: healthy -> base; failing -> grow up to ~6x.
let mult = 1 + fail_streak.min(5);
let dur = Duration::from_secs(base * mult as u64);
wait_for_tick(&rx, dur);
}
}