breadcrumbs/src/flow.rs
Breadway 248b97c92f Add bakery.toml and release workflow; improve connect error reporting
- bakery.toml: describes breadcrumbs for bakery install
- release.yml: builds on hestia self-hosted runner, publishes binary to
  dl.breadway.dev and GitHub Releases on v* tags
- flow.rs/nm.rs: connect_and_verify now returns Result<(), String> with
  a descriptive error message instead of a bare bool
2026-06-06 22:31:29 +08:00

346 lines
11 KiB
Rust

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.
/// Returns Ok(()) on success, Err(reason) on failure.
fn connect_and_verify(iface: &str, def: &NetworkDef, cfg: &Config) -> Result<(), String> {
nm::connect_verbose(iface, def, cfg.settings.nmcli_wait, &cfg.settings.dns)?;
if !nm::device_connected(iface) {
return Err("device not connected after nmcli success".into());
}
Ok(())
}
/// 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 {
match connect_and_verify(&iface, bdef, cfg) {
Ok(()) => {
on_bootstrap = true;
log(&format!("bootstrap connected: {}", bdef.ssid));
}
Err(e) => log(&format!("bootstrap connect failed: {}{e}", 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.
let mut any_attempted = false;
for def in &candidates {
if visible.contains(&def.ssid) {
any_attempted = true;
match connect_and_verify(&iface, def, cfg) {
Ok(()) => {
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,
};
}
Err(e) => log(&format!("connect failed (visible): {}{e}", def.ssid)),
}
}
}
// Pass 2: hidden networks we couldn't see in the scan.
for def in &candidates {
if def.hidden && !visible.contains(&def.ssid) {
any_attempted = true;
match connect_and_verify(&iface, def, cfg) {
Ok(()) => {
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,
};
}
Err(e) => log(&format!("connect failed (hidden): {}{e}", def.ssid)),
}
}
}
// ---- Nothing in the priority list connected ------------------------
if on_bootstrap {
// The failed candidate connect attempt disconnected us from bootstrap.
// Re-establish it before returning so the claimed state is real.
let bs_ssid = profile
.bootstrap
.clone()
.unwrap_or_else(|| "bootstrap".into());
if !nm::device_connected(&iface) {
if let Some(bdef) = profile.bootstrap.as_deref().and_then(|s| cfg.network(s)) {
match connect_and_verify(&iface, bdef, cfg) {
Ok(()) => log(&format!("bootstrap reconnected: {}", bdef.ssid)),
Err(e) => {
log(&format!("bootstrap reconnect failed: {}{e}", bdef.ssid));
on_bootstrap = false;
}
}
}
}
if on_bootstrap {
let reason = if any_attempted {
format!("target network connect failed — staying on {bs_ssid} (Tailscale OK)")
} else {
format!("target network not in range — staying on {bs_ssid} (Tailscale OK)")
};
notify("breadcrumbs: using bootstrap", &reason, Urgency::Normal);
log(&format!("flow end: on bootstrap {bs_ssid}; {reason}"));
return Outcome::Connected {
ssid: bs_ssid,
note: Some(reason),
};
}
}
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"]);
}
}