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:
commit
5b894c4fef
18 changed files with 3475 additions and 0 deletions
277
src/config.rs
Normal file
277
src/config.rs
Normal 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
322
src/flow.rs
Normal 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, ¬e);
|
||||
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, ¬e);
|
||||
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", ¬e, 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
721
src/main.rs
Normal 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
345
src/nm.rs
Normal 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
79
src/notify.rs
Normal 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
34
src/state.rs
Normal 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
92
src/status.rs
Normal 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
384
src/tailscale.rs
Normal 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
179
src/util.rs
Normal 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
223
src/watch.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue