From 248b97c92fb82222084af9f6aea6e39a12c65aff Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 22:31:29 +0800 Subject: [PATCH] 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 --- .github/workflows/release.yml | 57 +++++++++++++++++ bakery.toml | 12 ++++ src/flow.rs | 114 ++++++++++++++++++++-------------- src/nm.rs | 34 ++++++---- 4 files changed, 159 insertions(+), 58 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 bakery.toml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2eed5b6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,57 @@ +name: release + +on: + push: + tags: ["v*"] + +env: + DL_DIR: /srv/breadway-dl + ECOSYSTEM_DIR: /home/breadway/Projects/bread-ecosystem + +jobs: + build: + runs-on: [self-hosted, hestia] + steps: + - uses: actions/checkout@v4 + + - name: build + run: cargo build --release --locked + + - name: test + run: cargo test --release --locked + + - name: prepare artifacts + run: | + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="${DL_DIR}/breadcrumbs/${VERSION}" + mkdir -p "${PKG_DIR}" + cp target/release/breadcrumbs "${PKG_DIR}/breadcrumbs-x86_64" + strip "${PKG_DIR}/breadcrumbs-x86_64" + sha256sum "${PKG_DIR}/breadcrumbs-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/breadcrumbs-x86_64.sha256" + cp breadcrumbs.example.toml "${PKG_DIR}/" + cp bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${PKG_DIR}" "${DL_DIR}/breadcrumbs/latest" + + - name: ensure bread-ecosystem + run: | + if [[ -d "${ECOSYSTEM_DIR}/.git" ]]; then + git -C "${ECOSYSTEM_DIR}" pull --ff-only + else + mkdir -p "$(dirname "${ECOSYSTEM_DIR}")" + git clone https://github.com/Breadway/bread-ecosystem.git "${ECOSYSTEM_DIR}" + fi + + - name: regenerate index.json + run: bash "${ECOSYSTEM_DIR}/scripts/gen-index.sh" + + - name: upload to GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="${DL_DIR}/breadcrumbs/${VERSION}" + gh release upload "${GITHUB_REF_NAME}" \ + "${PKG_DIR}/breadcrumbs-x86_64" \ + "${PKG_DIR}/breadcrumbs-x86_64.sha256" \ + --clobber diff --git a/bakery.toml b/bakery.toml new file mode 100644 index 0000000..70c256f --- /dev/null +++ b/bakery.toml @@ -0,0 +1,12 @@ +name = "breadcrumbs" +description = "Profile-aware Wi-Fi state machine with Tailscale integration" +binaries = ["breadcrumbs"] +system_deps = ["networkmanager"] +bread_deps = [] + +[config] +dir = "~/.config/breadcrumbs" +example = "breadcrumbs.example.toml" + +[install] +post_install = [] diff --git a/src/flow.rs b/src/flow.rs index 15c15e0..71f33f6 100644 --- a/src/flow.rs +++ b/src/flow.rs @@ -47,15 +47,13 @@ fn resolve_candidates<'a>(cfg: &'a Config, p: &crate::config::Profile) -> Vec<&' } /// 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. +/// 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 false; + return Err("device not connected after nmcli success".into()); } - true + Ok(()) } /// Run the connection state machine for `profile_name`. @@ -116,11 +114,12 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome { 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)); + 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)); @@ -155,56 +154,81 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome { // ---- 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) { - 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, - }; + 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, ¬e); + return Outcome::Connected { + ssid: def.ssid.clone(), + note, + }; + } + Err(e) => log(&format!("connect failed (visible): {} — {e}", def.ssid)), } - 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, - }; + 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, ¬e); + return Outcome::Connected { + ssid: def.ssid.clone(), + note, + }; + } + Err(e) => log(&format!("connect failed (hidden): {} — {e}", def.ssid)), } - 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 + // 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()); - 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), - }; + 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 diff --git a/src/nm.rs b/src/nm.rs index c0f32b5..0c37754 100644 --- a/src/nm.rs +++ b/src/nm.rs @@ -2,7 +2,7 @@ use std::collections::HashSet; use std::time::Duration; use crate::config::NetworkDef; -use crate::util::{run, run_ok, run_with_stdin}; +use crate::util::{run, run_ok}; /// nmcli `-t` escapes `:` and `\` in field values; undo that. fn unescape(s: &str) -> String { @@ -242,37 +242,45 @@ fn enforce_dns(uuid: &str, iface: &str, dns: &str) { /// Connect to a network and pin DNS. Returns true only if associated. pub fn connect(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> bool { + connect_verbose(iface, net, wait, dns).is_ok() +} + +/// Connect to a network and pin DNS. Returns the nmcli error on failure. +/// +/// Uses `nmcli device wifi connect ... password ` so the provided PSK +/// always takes effect, even when NM already has a stale saved profile for +/// the same SSID. The PSK is briefly visible in /proc//cmdline, which is +/// an acceptable trade-off for a personal desktop tool. +pub fn connect_verbose(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> Result<(), String> { 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(), + "password", + net.password.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), - ); + let o = run("nmcli", &args, Duration::from_secs(wait as u64 + 15)); if !o.success { - return false; + let detail = o.stderr.trim().to_string(); + return Err(if detail.is_empty() { + o.stdout.trim().to_string() + } else { + detail + }); } if let Some(uuid) = active_uuid(iface) { enforce_dns(&uuid, iface, dns); } - true + Ok(()) } /// Delete every saved connection profile whose name or 802-11-wireless SSID