Compare commits

..

2 commits

Author SHA1 Message Date
Breadway
0fdac8e07c Add join, networks, and scan-list commands; bump to v2.1.1
All checks were successful
Mirror to GitHub / mirror (push) Successful in 3s
2026-06-24 07:04:31 +08:00
Breadway
d3c1e19ba3 Release v2.1.0: backend test seam, captive-portal detection, JSON status, robustness
Features:
- Introduce a Backend trait + System impl so flow/status/watch can be unit
  tested against a fake; add 11 connect-state-machine tests.
- Captive-portal detection: status::connectivity returns Online/Portal/Offline;
  surfaced in status, JSON, connect notes, and a dedicated watch state.
- `status --json` for bars/scripts; `profile add`/`profile remove`; detect now
  scores by number of in-range markers.

Robustness:
- Pin LC_ALL=C/LANG=C on child processes for locale-independent parsing.
- Atomic config/state writes (temp + rename); 0600 config never world-readable.
- Transient PSK file written to $XDG_RUNTIME_DIR when available.

Fixes (from prior audit):
- Feed Wi-Fi PSK to nmcli via stdin/passwd-file, never argv.
- mask() no longer panics on multi-byte passwords.
- Connectivity check requires HTTP 204 (no captive-portal false positives).
- nmcli NAME,TYPE parsing handles escaped colons.
- Strip CIDR suffix from displayed IP; PKGBUILD/Cargo version aligned (2.1.0).
2026-06-23 12:13:34 +08:00
3 changed files with 78 additions and 2 deletions

2
Cargo.lock generated
View file

@ -54,7 +54,7 @@ dependencies = [
[[package]]
name = "breadcrumbs"
version = "2.1.0"
version = "2.1.1"
dependencies = [
"clap",
"serde",

View file

@ -1,6 +1,6 @@
[package]
name = "breadcrumbs"
version = "2.1.0"
version = "2.1.1"
edition = "2021"
description = "Profile-aware Wi-Fi state machine with Tailscale handling and self-healing watch daemon"
license = "MIT"

View file

@ -99,6 +99,20 @@ enum Cmd {
#[arg(long)]
show_passwords: bool,
},
/// Connect to a specific saved network by SSID, bypassing profile routing
Join { ssid: String },
/// List saved network SSIDs
Networks {
/// Emit a JSON array instead of one-per-line
#[arg(long)]
json: bool,
},
/// Scan for visible networks and list them with signal strength
ScanList {
/// Emit JSON instead of human-readable output
#[arg(long)]
json: bool,
},
/// Open the config file in $EDITOR
Edit,
/// Quick connectivity / Tailscale diagnostics
@ -192,6 +206,9 @@ fn real_main(cli: Cli) -> Result<i32, String> {
at,
} => cmd_add(&mut cfg, ssid, password, hidden, to, at),
Cmd::Forget { ssid } => cmd_forget(&mut cfg, &ssid),
Cmd::Join { ssid } => cmd_join(&be, &cfg, &ssid),
Cmd::Networks { json } => cmd_networks(&cfg, json),
Cmd::ScanList { json } => cmd_scan_list(&cfg, json),
Cmd::Scan { to } => cmd_scan(&mut cfg, to),
Cmd::List { show_passwords } => cmd_list(&cfg, show_passwords),
Cmd::Edit => cmd_edit(),
@ -514,6 +531,65 @@ fn cmd_add(
Ok(0)
}
fn cmd_join(be: &dyn Backend, cfg: &Config, ssid: &str) -> Result<i32, String> {
let net = cfg
.network(ssid)
.ok_or_else(|| format!("no saved network '{ssid}' — add it first with `breadcrumbs add {ssid}`"))?;
let iface = be
.wifi_interface()
.ok_or_else(|| "no Wi-Fi adapter found".to_string())?;
be.radio_on();
match nm::connect_verbose(&iface, net, cfg.settings.nmcli_wait, &cfg.settings.dns) {
Ok(()) => {
println!("{C_GREEN}connected{C_RESET} {C_BOLD}{ssid}{C_RESET}");
Ok(0)
}
Err(e) => {
eprintln!("{C_RED}connect failed{C_RESET}: {e}");
Ok(1)
}
}
}
fn cmd_scan_list(cfg: &Config, json: bool) -> Result<i32, String> {
let iface = nm::wifi_interface().ok_or("no Wi-Fi adapter found")?;
let entries = nm::scan_list(&iface);
let saved: std::collections::HashSet<&str> =
cfg.networks.iter().map(|n| n.ssid.as_str()).collect();
if json {
let v: Vec<serde_json::Value> = entries
.iter()
.map(|e| {
serde_json::json!({
"ssid": e.ssid,
"signal": e.signal,
"security": e.security,
"saved": saved.contains(e.ssid.as_str()),
})
})
.collect();
println!("{}", serde_json::to_string(&v).unwrap_or_else(|_| "[]".into()));
} else {
for e in &entries {
let mark = if saved.contains(e.ssid.as_str()) { "*" } else { " " };
println!("{mark} {:>3}% {} {}", e.signal, e.ssid, e.security);
}
}
Ok(0)
}
fn cmd_networks(cfg: &Config, json: bool) -> Result<i32, String> {
let ssids: Vec<&str> = cfg.networks.iter().map(|n| n.ssid.as_str()).collect();
if json {
println!("{}", serde_json::to_string(&ssids).unwrap_or_else(|_| "[]".into()));
} else {
for ssid in &ssids {
println!("{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);