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 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5b894c4fef
commit
60b883913a
4 changed files with 159 additions and 58 deletions
57
.github/workflows/release.yml
vendored
Normal file
57
.github/workflows/release.yml
vendored
Normal file
|
|
@ -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
|
||||||
12
bakery.toml
Normal file
12
bakery.toml
Normal file
|
|
@ -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 = []
|
||||||
114
src/flow.rs
114
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.
|
/// Try to connect + confirm it actually carries traffic.
|
||||||
fn connect_and_verify(iface: &str, def: &NetworkDef, cfg: &Config) -> bool {
|
/// Returns Ok(()) on success, Err(reason) on failure.
|
||||||
if !nm::connect(iface, def, cfg.settings.nmcli_wait, &cfg.settings.dns) {
|
fn connect_and_verify(iface: &str, def: &NetworkDef, cfg: &Config) -> Result<(), String> {
|
||||||
return false;
|
nm::connect_verbose(iface, def, cfg.settings.nmcli_wait, &cfg.settings.dns)?;
|
||||||
}
|
|
||||||
// Associated. Confirm DHCP/route by checking the device is connected.
|
|
||||||
if !nm::device_connected(iface) {
|
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`.
|
/// 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) {
|
match cfg.network(&bs_ssid) {
|
||||||
Some(bdef) => {
|
Some(bdef) => {
|
||||||
if visible.contains(&bdef.ssid) || bdef.hidden {
|
if visible.contains(&bdef.ssid) || bdef.hidden {
|
||||||
if connect_and_verify(&iface, bdef, cfg) {
|
match connect_and_verify(&iface, bdef, cfg) {
|
||||||
on_bootstrap = true;
|
Ok(()) => {
|
||||||
log(&format!("bootstrap connected: {}", bdef.ssid));
|
on_bootstrap = true;
|
||||||
} else {
|
log(&format!("bootstrap connected: {}", bdef.ssid));
|
||||||
log(&format!("bootstrap connect failed: {}", bdef.ssid));
|
}
|
||||||
|
Err(e) => log(&format!("bootstrap connect failed: {} — {e}", bdef.ssid)),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log(&format!("bootstrap not in range: {}", bdef.ssid));
|
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 ----------------------------------
|
// ---- Connect to the priority list ----------------------------------
|
||||||
// Pass 1: visible networks in priority order.
|
// Pass 1: visible networks in priority order.
|
||||||
|
let mut any_attempted = false;
|
||||||
for def in &candidates {
|
for def in &candidates {
|
||||||
if visible.contains(&def.ssid) {
|
if visible.contains(&def.ssid) {
|
||||||
if connect_and_verify(&iface, def, cfg) {
|
any_attempted = true;
|
||||||
let note = if internet_ok(cfg) {
|
match connect_and_verify(&iface, def, cfg) {
|
||||||
None
|
Ok(()) => {
|
||||||
} else {
|
let note = if internet_ok(cfg) {
|
||||||
Some("associated but no internet yet".to_string())
|
None
|
||||||
};
|
} else {
|
||||||
finish_connected(&def.ssid, profile_name, ¬e);
|
Some("associated but no internet yet".to_string())
|
||||||
return Outcome::Connected {
|
};
|
||||||
ssid: def.ssid.clone(),
|
finish_connected(&def.ssid, profile_name, ¬e);
|
||||||
note,
|
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.
|
// Pass 2: hidden networks we couldn't see in the scan.
|
||||||
for def in &candidates {
|
for def in &candidates {
|
||||||
if def.hidden && !visible.contains(&def.ssid) {
|
if def.hidden && !visible.contains(&def.ssid) {
|
||||||
if connect_and_verify(&iface, def, cfg) {
|
any_attempted = true;
|
||||||
let note = if internet_ok(cfg) {
|
match connect_and_verify(&iface, def, cfg) {
|
||||||
None
|
Ok(()) => {
|
||||||
} else {
|
let note = if internet_ok(cfg) {
|
||||||
Some("associated but no internet yet".to_string())
|
None
|
||||||
};
|
} else {
|
||||||
finish_connected(&def.ssid, profile_name, ¬e);
|
Some("associated but no internet yet".to_string())
|
||||||
return Outcome::Connected {
|
};
|
||||||
ssid: def.ssid.clone(),
|
finish_connected(&def.ssid, profile_name, ¬e);
|
||||||
note,
|
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 ------------------------
|
// ---- Nothing in the priority list connected ------------------------
|
||||||
if on_bootstrap {
|
if on_bootstrap {
|
||||||
// We still have working internet via the bootstrap + Tailscale.
|
// The failed candidate connect attempt disconnected us from bootstrap.
|
||||||
let ssid = profile
|
// Re-establish it before returning so the claimed state is real.
|
||||||
|
let bs_ssid = profile
|
||||||
.bootstrap
|
.bootstrap
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| "bootstrap".into());
|
.unwrap_or_else(|| "bootstrap".into());
|
||||||
let note = format!("target network not in range — staying on {ssid} (Tailscale OK)");
|
if !nm::device_connected(&iface) {
|
||||||
notify("breadcrumbs: using bootstrap", ¬e, Urgency::Normal);
|
if let Some(bdef) = profile.bootstrap.as_deref().and_then(|s| cfg.network(s)) {
|
||||||
log(&format!("flow end: on bootstrap {ssid}; {note}"));
|
match connect_and_verify(&iface, bdef, cfg) {
|
||||||
return Outcome::Connected {
|
Ok(()) => log(&format!("bootstrap reconnected: {}", bdef.ssid)),
|
||||||
ssid,
|
Err(e) => {
|
||||||
note: Some(note),
|
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
|
let names = candidates
|
||||||
|
|
|
||||||
34
src/nm.rs
34
src/nm.rs
|
|
@ -2,7 +2,7 @@ use std::collections::HashSet;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::config::NetworkDef;
|
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.
|
/// nmcli `-t` escapes `:` and `\` in field values; undo that.
|
||||||
fn unescape(s: &str) -> String {
|
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.
|
/// Connect to a network and pin DNS. Returns true only if associated.
|
||||||
pub fn connect(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> bool {
|
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 <psk>` 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/<pid>/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 wait_s = wait.to_string();
|
||||||
let hidden = if net.hidden { "yes" } else { "no" };
|
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 = [
|
let args = [
|
||||||
"--wait",
|
"--wait",
|
||||||
&wait_s,
|
&wait_s,
|
||||||
"--ask",
|
|
||||||
"device",
|
"device",
|
||||||
"wifi",
|
"wifi",
|
||||||
"connect",
|
"connect",
|
||||||
net.ssid.as_str(),
|
net.ssid.as_str(),
|
||||||
|
"password",
|
||||||
|
net.password.as_str(),
|
||||||
"hidden",
|
"hidden",
|
||||||
hidden,
|
hidden,
|
||||||
"ifname",
|
"ifname",
|
||||||
iface,
|
iface,
|
||||||
];
|
];
|
||||||
let secret = format!("{}\n", net.password);
|
let o = run("nmcli", &args, Duration::from_secs(wait as u64 + 15));
|
||||||
let o = run_with_stdin(
|
|
||||||
"nmcli",
|
|
||||||
&args,
|
|
||||||
Some(&secret),
|
|
||||||
Duration::from_secs(wait as u64 + 15),
|
|
||||||
);
|
|
||||||
if !o.success {
|
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) {
|
if let Some(uuid) = active_uuid(iface) {
|
||||||
enforce_dns(&uuid, iface, dns);
|
enforce_dns(&uuid, iface, dns);
|
||||||
}
|
}
|
||||||
true
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete every saved connection profile whose name or 802-11-wireless SSID
|
/// Delete every saved connection profile whose name or 802-11-wireless SSID
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue