Compare commits

...

7 commits
v2.1.7 ... main

Author SHA1 Message Date
dcf7eb7162 Merge pull request 'NetworkManager D-Bus layer + audit fixes + feature batch' (#2) from feature/nm-rework into main
All checks were successful
dev release / build (push) Successful in 1m27s
2026-08-31 18:14:17 +08:00
Breadway
cc709a4af9 Test the NM layer against an in-process D-Bus fake
All checks were successful
check / check (push) Successful in 1m39s
`tests/common/fake_nm.rs` stands up a fake `org.freedesktop.NetworkManager`
on a private bus so `cli.rs` and `flow_watch.rs` exercise the real
`nm` code paths without a live NetworkManager and without shelling out.
Replaces the previous command-capture scaffolding in those two files;
scenario coverage (captive portal, exit-node failover, 802.1x,
per-network DNS, schedule triggers, Tailscale recovery, SSID
verification) is preserved — 139 tests.
2026-08-31 15:12:32 +08:00
Breadway
b4c1d0b233 Talk to NetworkManager over D-Bus instead of shelling out to nmcli
breadcrumbs now speaks `org.freedesktop.NetworkManager` on the system
bus directly (new `zbus` dependency) — no `nmcli` subprocesses for
connect, scan, status, or the watch loop.

Why:
- Wi-Fi PSKs and 802.1x passwords no longer touch a command line. They
  travel inside `AddAndActivateConnection2` / `Update2` settings
  payloads, so they are never visible to other local users via
  `/proc/<pid>/cmdline`. This fully supersedes the earlier
  "feed the PSK to `nmcli --ask` on stdin" approach.
- The watch loop reacts to real `Device.StateChanged` / connectivity
  signals instead of parsing `nmcli monitor` text.
- Connect waits on the device actually reaching the ACTIVATED state
  rather than trusting `nmcli --wait`.

Config: `settings.nmcli_wait` is renamed to `connect_wait`; the old key
is still accepted via `#[serde(alias)]`. `status.rs` loses its private
`ipv4()` nmcli helper in favour of `nm::ipv4_address`. `util::run_with_stdin`
stays (tailscale still uses it) but no longer carries secrets.
2026-08-31 15:12:21 +08:00
Breadway
13c7743d48 Add feature batch: captive-portal detection, schedules, exit-node failover, 802.1x, per-network DNS
Implements the planned feature sweep: tri-state connectivity with portal
detection, time-based profile schedules, priority exit-node list with
failover, enterprise (802.1x) network support, per-network DNS, signal-
aware selection, auto-learn markers, suspend/resume recovery, prune
command, scored detection, and richer bread events (network.changed,
tailscale.changed). CLI gains --json output, init --wait, and add --dns/
--eap/--identity/--ca-cert. Adds regression coverage for each feature;
141 tests pass and clippy is clean with -D warnings.

Generated with Codebuff 🤖
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-08-31 15:12:08 +08:00
Breadway
c02360a873 Fix bugs from a full audit: Tailscale recovery, SSID verification, captive portals, config races
The watch loop could spin forever on a stopped Tailscale daemon (the
auto-start path was unreachable) while re-notifying every retry, report
"connected" when NM autoconnect won a race onto a different SSID, and
classify captive portals as healthy (200/302 accepted as internet). The
bread-bus subscription thread also read/wrote config and state files
concurrently with the watch loop. Fix all of those plus: EDITOR values
with arguments, scan --to silently ignoring unknown profiles, deleted
core profiles being resurrected, inline-network migration data loss,
login never retried, XDG-unaware install-service, detect persisting a
stale default profile, password length leaking through the mask, a
stdin/stdout pipe deadlock, and several minor UI/robustness issues.

Generated with Codebuff 🤖
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-08-31 15:11:19 +08:00
Breadway
02e96126e0 Feed Wi-Fi PSK to nmcli --ask on stdin, never argv
All checks were successful
check / check (push) Successful in 1m7s
dev release / build (push) Successful in 1m28s
First connect (and reuse-with-password) no longer puts the secret on
nmcli's command line, so it is not visible in /proc/<pid>/cmdline.
networks.toml stays 0600; the local copy is still cleared after first
success.
2026-08-23 14:44:23 +08:00
Breadway
92fb40d69b CI: refuse unsigned bakery index; stop copying missing service unit
All checks were successful
check / check (push) Successful in 1m11s
dev release / build (push) Successful in 3m43s
beta (rc) release / build (push) Has been skipped
release / build (push) Successful in 2m2s
2026-08-16 00:50:22 +08:00
19 changed files with 5219 additions and 1099 deletions

View file

@ -17,7 +17,16 @@ jobs:
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
- name: build - name: build
run: cd src && bash ci/build.sh cargo build --release --locked run: |
set -euo pipefail
if [ ! -f src/ci/build.sh ]; then
echo "::error::ci/build.sh is missing — bakery release builds must go through the shared CI wrapper"
exit 1
fi
cd src && bash ci/build.sh cargo build --release --locked || {
echo "::error::cargo build --release --locked failed. If Cargo.lock drifted, update and commit it; do not drop --locked."
exit 1
}
- name: test - name: test
run: cd src && bash ci/build.sh cargo test --release --locked run: cd src && bash ci/build.sh cargo test --release --locked
@ -35,12 +44,17 @@ jobs:
cp src/breadcrumbs.example.toml "${PKG_DIR}/" cp src/breadcrumbs.example.toml "${PKG_DIR}/"
cp src/LICENSE "${PKG_DIR}/" cp src/LICENSE "${PKG_DIR}/"
cp src/bakery.toml "${PKG_DIR}/bakery.toml" cp src/bakery.toml "${PKG_DIR}/bakery.toml"
cp src/contrib/breadcrumbs.service "${PKG_DIR}/"
ln -sfn "${VERSION}" "/srv/breadway-dl/breadcrumbs/latest" ln -sfn "${VERSION}" "/srv/breadway-dl/breadcrumbs/latest"
- name: regenerate index.json - name: regenerate index.json
env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: | run: |
set -euo pipefail set -euo pipefail
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone)"
exit 1
fi
rm -rf /tmp/bread-ecosystem-ci rm -rf /tmp/bread-ecosystem-ci
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci
bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh

933
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -14,6 +14,7 @@ clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
toml = "0.8" toml = "0.8"
serde_json = "1" serde_json = "1"
zbus = "4"
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] } bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] }
[profile.release] [profile.release]

View file

@ -25,8 +25,10 @@ still switches the profile on disk — there is just nobody listening for
| Event | Data | When | | Event | Data | When |
|-------|------|------| |-------|------|------|
| `bread.crumbs.profile.changed` | `{ "from": "<name>", "to": "<name>" }` | The watch loop observes that the persisted active profile is no longer the one it last acted on (CLI `profile set`, `detect --apply`, or `bread.command.crumbs.set_profile`). Not emitted on watcher start just because a profile is already selected. | | `bread.crumbs.profile.changed` | `{ "from": "<name>", "to": "<name>" }` | The watch loop observes that the persisted active profile is no longer the one it last acted on (CLI `profile set`, `detect --apply`, `bread.command.crumbs.set_profile`, or a time-of-day schedule switch). Not emitted on watcher start just because a profile is already selected. |
| `bread.crumbs.health.changed` | `{ "profile": "<name>", "health": "<variant>", "ssid": <string or null> }` | The watch loop's health classification changes — including the first observation after start, and the forced re-evaluation after a profile change. **Not** emitted on every poll tick while the classification stays the same. | | `bread.crumbs.health.changed` | `{ "profile": "<name>", "health": "<variant>", "ssid": <string or null>, "iface": <string or null>, "ip": <string or null>, "exit_node": "<string>", "tailscale": <variant or null> }` | The watch loop's health classification changes — including the first observation after start, and the forced re-evaluation after a profile change. **Not** emitted on every poll tick while the classification stays the same. |
| `bread.crumbs.network.changed` | `{ "from": <ssid or null>, "to": <ssid or null>, "profile": "<name>" }` | The active SSID changed between watch-loop ticks. `from` is `null` on the first association observed after start (or after a profile switch). |
| `bread.crumbs.tailscale.changed` | `{ "profile": "<name>", "state": <variant or null>, "exit_node": "<string>" }` | The Tailscale health state (or its mere presence) changed between ticks. `state` is the `TsHealth` variant name or `null` when Tailscale isn't installed. |
| `bread.crumbs.set_profile.done` | `{ "profile": "<name>" }` | `bread.command.crumbs.set_profile` persisted the new profile. | | `bread.crumbs.set_profile.done` | `{ "profile": "<name>" }` | `bread.command.crumbs.set_profile` persisted the new profile. |
| `bread.crumbs.set_profile.failed` | `{ "error": "<message>" }` | `bread.command.crumbs.set_profile` was received but rejected (unknown profile, missing `profile` field, config unreadable). | | `bread.crumbs.set_profile.failed` | `{ "error": "<message>" }` | `bread.command.crumbs.set_profile` was received but rejected (unknown profile, missing `profile` field, config unreadable). |
@ -36,6 +38,7 @@ still switches the profile on disk — there is just nobody listening for
|---------|---------| |---------|---------|
| `Up` | Adapter present, internet reachable, Tailscale healthy if the profile requires it. | | `Up` | Adapter present, internet reachable, Tailscale healthy if the profile requires it. |
| `DownNoNet` | No internet. | | `DownNoNet` | No internet. |
| `CaptivePortal` | No internet and an HTTP response arrived that wasn't the 204 generate_204 returns — traffic is being intercepted (captive/guest portal). Needs a browser sign-in, not a reconnect. |
| `DownTailscaleManual` | Tailscale required but needs login / isn't installed — cannot auto-fix. | | `DownTailscaleManual` | Tailscale required but needs login / isn't installed — cannot auto-fix. |
| `DownTailscaleOther` | Tailscale required and unhealthy for some other (usually auto-recoverable) reason. | | `DownTailscaleOther` | Tailscale required and unhealthy for some other (usually auto-recoverable) reason. |
| `NoAdapter` | No Wi-Fi interface. | | `NoAdapter` | No Wi-Fi interface. |

View file

@ -2,21 +2,21 @@
A profile-aware Wi-Fi state machine for Linux with Tailscale exit-node management and a self-healing watch daemon. A profile-aware Wi-Fi state machine for Linux with Tailscale exit-node management and a self-healing watch daemon.
breadcrumbs sits on top of NetworkManager (`nmcli`) and manages your Wi-Fi based on **location profiles**. Switch between home, work, school, or any other context with a single command — it handles scanning, connecting, DNS pinning, and Tailscale setup automatically. breadcrumbs sits on top of NetworkManager's **D-Bus API** (`org.freedesktop.NetworkManager` on the system bus — no `nmcli` subprocesses) and manages your Wi-Fi based on **location profiles**. Switch between home, work, school, or any other context with a single command — it handles scanning, connecting, DNS pinning, and Tailscale setup automatically.
## Features ## Features
- **Profile-based connection management** — define ordered network priority lists per location - **Profile-based connection management** — define ordered network priority lists per location
- **Bootstrap + Tailscale gating** — connect to an interim network first, bring up Tailscale, then move to the target network - **Bootstrap + Tailscale gating** — connect to an interim network first, bring up Tailscale, then move to the target network
- **Self-healing watch daemon** — monitors for drops, auto-recovers, reacts within seconds via `nmcli monitor` - **Self-healing watch daemon** — monitors for drops, auto-recovers, reacts within seconds via NetworkManager D-Bus signals
- **Auto-detection** — scans visible SSIDs and guesses your location from config-defined markers - **Auto-detection** — scans visible SSIDs and guesses your location from config-defined markers
- **Credential handling** — a saved network's password is only needed the *first* time breadcrumbs connects to it. Once that connect succeeds, NetworkManager durably owns the credential (a new connection profile, or an updated PSK on an existing one), so breadcrumbs clears its own local copy and stops writing it to disk. Both config files are `0600` (owner-only); saved networks live in a separate `networks.toml` from settings/profiles (see [Configuration](#configuration)). Note: on that first connect, the PSK is still passed to `nmcli` as a command argument, so it's briefly visible to other local users via `/proc/<pid>/cmdline` for the lifetime of that `nmcli` child — a known limitation (see the note in `src/nm.rs`); a `nmcli --ask`/D-Bus secret-agent path that avoids argv exposure entirely is not yet wired up. In practice this window now only exists once per network, not on every connect. - **Credential handling** — a saved network's password is only needed the *first* time breadcrumbs connects to it. Once that connect succeeds, NetworkManager durably owns the credential (a new connection profile, or an updated PSK on an existing one), so breadcrumbs clears its own local copy and stops writing it to disk. Both config files are `0600` (owner-only); saved networks live in a separate `networks.toml` from settings/profiles (see [Configuration](#configuration)). Secrets are never exposed in a process command line: everything travels inside D-Bus `Update2`/`AddAndActivateConnection2` settings payloads, invisible to other local users via `/proc/<pid>/cmdline`.
- **Desktop notifications** via `notify-send` (optional) - **Desktop notifications** via `notify-send` (optional)
- **systemd user service** generation via `breadcrumbs install-service` - **systemd user service** generation via `breadcrumbs install-service`
## Requirements ## Requirements
- Linux with NetworkManager (`nmcli` in `$PATH`) - Linux with NetworkManager running on the D-Bus system bus
- Rust toolchain (to build from source) - Rust toolchain (to build from source)
- `tailscale` (optional — only needed if any profile sets `tailscale = true`) - `tailscale` (optional — only needed if any profile sets `tailscale = true`)
- `notify-send` (optional — for desktop notifications) - `notify-send` (optional — for desktop notifications)
@ -52,8 +52,15 @@ Settings and location profiles live in `breadcrumbs.toml` — the file people ac
```toml ```toml
[settings] [settings]
dns = "1.1.1.1" # DNS server pinned on every connection dns = "1.1.1.1" # DNS server pinned on every connection
nmcli_wait = 8 # seconds to wait for nmcli connect connect_wait = 8 # seconds to wait for the device to reach the activated state (legacy key: nmcli_wait)
exit_node = "myhostname" # default Tailscale exit node exit_node = "myhostname" # default Tailscale exit node
exit_nodes = ["a", "b"] # optional priority list; tried in order (fallback nodes)
interface = "wlan0" # optional preferred Wi-Fi interface
schedule = [] # optional time-of-day profile switches, e.g.
# [[settings.schedule]]
# profile = "home"
# from = "18:00"
# to = "08:00" # from >= to = overnight window
default_profile = "away" default_profile = "away"
watch_interval = 12 # seconds between health checks (minimum 4) watch_interval = 12 # seconds between health checks (minimum 4)
connectivity_url = "http://connectivitycheck.gstatic.com/generate_204" connectivity_url = "http://connectivitycheck.gstatic.com/generate_204"
@ -80,6 +87,15 @@ Saved networks (SSID + optional local password) live separately, in `networks.to
ssid = "MyHomeNetwork" ssid = "MyHomeNetwork"
password = "hunter2" # optional — see "Credential handling" below password = "hunter2" # optional — see "Credential handling" below
hidden = false hidden = false
dns = "1.1.1.1" # optional per-network DNS override; "" disables pinning
# WPA-Enterprise (802.1x) networks use these instead of a PSK:
# [[networks]]
# ssid = "CorpEAP"
# eap = "peap" # or "tls"
# identity = "user@corp"
# password = "..." # 802.1x password
# ca_cert = "/etc/ssl/certs/corp-ca.pem" # optional
``` ```
`password` is only needed the first time breadcrumbs connects to a network. Once NetworkManager durably saves the credential, breadcrumbs clears its local copy and omits the key on the next save — an existing config with `password = "..."` still loads fine either way, no migration step needed. A config with `[[networks]]` still written inline in `breadcrumbs.toml` (from before this split) also still loads: it's read once, then migrated into `networks.toml` automatically on the next save. `password` is only needed the first time breadcrumbs connects to a network. Once NetworkManager durably saves the credential, breadcrumbs clears its local copy and omits the key on the next save — an existing config with `password = "..."` still loads fine either way, no migration step needed. A config with `[[networks]]` still written inline in `breadcrumbs.toml` (from before this split) also still loads: it's read once, then migrated into `networks.toml` automatically on the next save.
@ -95,7 +111,8 @@ Each profile defines:
| `bootstrap` | SSID to connect to first (e.g. guest Wi-Fi that allows Tailscale traffic). | | `bootstrap` | SSID to connect to first (e.g. guest Wi-Fi that allows Tailscale traffic). |
| `exit_node` | Tailscale exit node for this profile (overrides `settings.exit_node`). | | `exit_node` | Tailscale exit node for this profile (overrides `settings.exit_node`). |
| `include_all_known` | After the priority list, also try every other known network. | | `include_all_known` | After the priority list, also try every other known network. |
| `detect_ssids` | Any visible SSID in this list marks this profile as a candidate for `breadcrumbs detect`. | | `detect_ssids` | Any visible SSID in this list marks this profile as a candidate for `breadcrumbs detect`. Profiles with more matching markers win. |
| `learn` | If `true`, SSIDs this profile successfully connects to are appended to `detect_ssids` (bounded), so `detect` improves without hand-editing. Off by default. |
## Usage ## Usage
@ -105,15 +122,16 @@ breadcrumbs [--profile <name>] <command>
| Command | Description | | Command | Description |
|---------|-------------| |---------|-------------|
| `status` | Show current Wi-Fi / Tailscale health (default) | | `status [--json]` | Show current Wi-Fi / Tailscale health (default) |
| `init` | Run the full connect sequence for the active profile | | `init [--wait <s>]` | Run the full connect sequence; `--wait` retries until connected or the timeout elapses |
| `watch [--no-initial]` | Self-healing daemon: monitors and auto-recovers drops | | `watch [--no-initial]` | Self-healing daemon: monitors and auto-recovers drops |
| `profile get` | Print the active profile | | `profile get` | Print the active profile |
| `profile set <name>` | Switch profile (and apply it, unless `--no-apply`) | | `profile set <name>` | Switch profile (and apply it, unless `--no-apply`) |
| `profile list` | List all profiles | | `profile list` | List all profiles |
| `detect [--apply]` | Guess profile from visible networks; optionally apply it | | `detect [--apply] [--json]` | Guess profile from visible networks; optionally apply it |
| `add <ssid> [password]` | Add or update a saved network | | `add <ssid> [password]` | Add or update a saved network (`--dns`, `--eap`, `--identity`, `--ca-cert`, `--hidden`, `--to`, `--at`) |
| `forget <ssid>` | Remove a network from config and NetworkManager | | `forget <ssid>` | Remove a network from config and NetworkManager |
| `prune [--dry-run]` | Remove NetworkManager wireless profiles whose SSID is no longer in the config |
| `scan [--to <profile>]` | Interactive scan, pick, connect and save | | `scan [--to <profile>]` | Interactive scan, pick, connect and save |
| `list [--show-passwords]` | Show config: settings, networks, profiles | | `list [--show-passwords]` | Show config: settings, networks, profiles |
| `edit` | Open config in `$EDITOR`, validate on exit | | `edit` | Open config in `$EDITOR`, validate on exit |
@ -151,9 +169,20 @@ breadcrumbs install-service
`breadcrumbs watch` is the recommended way to run breadcrumbs for daily use. It: `breadcrumbs watch` is the recommended way to run breadcrumbs for daily use. It:
1. Polls health every `watch_interval` seconds (adaptive backoff on repeated failures) 1. Polls health every `watch_interval` seconds (adaptive backoff on repeated failures)
2. Reacts immediately to link-state changes via `nmcli monitor` 2. Reacts immediately to link-state changes via NetworkManager D-Bus signals (`Device.StateChanged`, `Connectivity` property changes, hotplug events)
3. Runs `flow::run` (the connect state machine) on any detected drop 3. Runs `flow::run` (the connect state machine) on any detected drop
4. Handles profile changes live — re-reads config and state on every tick 4. Handles profile changes live — re-reads config and state on every tick
5. Distinguishes captive portals from plain no-internet (a 200/301/302 instead
of the 204 generate_204 returns) and tells you to sign in instead of
pointlessly reconnecting
6. Applies a `[settings.schedule]` time-of-day profile switch, respecting a
30-minute grace window after a manual `profile set`
7. Detects suspend/resume (a large gap between ticks) and forces an immediate
recovery check instead of waiting out the poll interval
When a Tailscale profile is connected through a bootstrap network and the
connectivity check is intercepted, the watcher stays put and notifies once —
it does not churn reconnects against a portal.
Install as a systemd user service: Install as a systemd user service:

View file

@ -13,7 +13,7 @@
[settings] [settings]
dns = "1.1.1.1" dns = "1.1.1.1"
nmcli_wait = 8 connect_wait = 8
exit_node = "my-exit-node" # Tailscale hostname of your preferred exit node exit_node = "my-exit-node" # Tailscale hostname of your preferred exit node
default_profile = "away" default_profile = "away"
watch_interval = 12 watch_interval = 12

View file

@ -4,6 +4,7 @@
//! consumers (including the integration tests under `tests/`). //! consumers (including the integration tests under `tests/`).
use std::io::{BufRead, Write}; use std::io::{BufRead, Write};
use std::path::PathBuf;
use std::process::Command; use std::process::Command;
use std::time::Duration; use std::time::Duration;
@ -37,13 +38,54 @@ struct Cli {
cmd: Option<Cmd>, cmd: Option<Cmd>,
} }
/// Optional flags for `add`. Flattened into the `Add` subcommand so the
/// CLI surface is unchanged while keeping `cmd_add`'s signature small.
#[derive(clap::Args)]
struct AddOpts {
/// Password (prompted if omitted)
password: Option<String>,
/// Network is hidden (does not broadcast its SSID).
/// `--hidden` sets it; `--hidden=false` clears it on an existing
/// entry; omitted leaves an existing entry's flag untouched.
#[arg(long, num_args = 0..=1, default_missing_value = "true")]
hidden: Option<bool>,
/// Per-network DNS override (empty string disables DNS pinning
/// for this network)
#[arg(long)]
dns: Option<String>,
/// 802.1x EAP method for enterprise networks (e.g. "peap", "tls")
#[arg(long)]
eap: Option<String>,
/// 802.1x identity for enterprise networks
#[arg(long)]
identity: Option<String>,
/// Path to a CA certificate for 802.1x
#[arg(long)]
ca_cert: Option<String>,
/// 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>,
}
#[derive(Subcommand)] #[derive(Subcommand)]
enum Cmd { enum Cmd {
/// Show current Wi-Fi / profile / Tailscale status (default) /// Show current Wi-Fi / profile / Tailscale status (default)
Status, Status {
/// Emit machine-readable JSON
#[arg(long)]
json: bool,
},
/// Run the full connect sequence for the active profile /// Run the full connect sequence for the active profile
#[command(visible_aliases = ["up", "connect", "i"])] #[command(visible_aliases = ["up", "connect", "i"])]
Init, Init {
/// Retry until connected or this many seconds have elapsed
/// (0 = single attempt)
#[arg(long, default_value_t = 0)]
wait: u64,
},
/// Run as a daemon: watch for drops and auto-recover /// Run as a daemon: watch for drops and auto-recover
Watch { Watch {
/// Skip the connect attempt on startup /// Skip the connect attempt on startup
@ -60,24 +102,25 @@ enum Cmd {
/// Set + apply the detected profile /// Set + apply the detected profile
#[arg(long)] #[arg(long)]
apply: bool, apply: bool,
/// Emit machine-readable JSON
#[arg(long)]
json: bool,
}, },
/// Add or update a saved network /// Add or update a saved network
Add { Add {
ssid: String, ssid: String,
/// Password (prompted if omitted) #[command(flatten)]
password: Option<String>, opts: AddOpts,
/// 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) /// Remove a saved network (config + NetworkManager)
Forget { ssid: String }, Forget { ssid: String },
/// Remove NetworkManager wireless profiles whose SSID is no longer in
/// the breadcrumbs config
Prune {
/// Only list what would be removed
#[arg(long)]
dry_run: bool,
},
/// Scan, pick, connect and save a network interactively /// Scan, pick, connect and save a network interactively
Scan { Scan {
/// Attach the saved network to this profile /// Attach the saved network to this profile
@ -144,7 +187,7 @@ fn active_profile(cfg: &Config, override_p: &Option<String>) -> String {
} }
fn real_main(cli: Cli) -> Result<i32, String> { fn real_main(cli: Cli) -> Result<i32, String> {
let cmd = cli.cmd.unwrap_or(Cmd::Status); let cmd = cli.cmd.unwrap_or(Cmd::Status { json: false });
// `cd` and `install-service` don't need a parsed config first. // `cd` and `install-service` don't need a parsed config first.
if let Cmd::Cd { shell } = &cmd { if let Cmd::Cd { shell } = &cmd {
@ -154,24 +197,14 @@ fn real_main(cli: Cli) -> Result<i32, String> {
let mut cfg = Config::load()?; let mut cfg = Config::load()?;
match cmd { match cmd {
Cmd::Status => cmd_status(&cfg, &cli.profile), Cmd::Status { json } => cmd_status(&cfg, &cli.profile, json),
Cmd::Init => { Cmd::Init { wait } => cmd_init(&mut cfg, &cli.profile, wait),
let p = active_profile(&cfg, &cli.profile);
let outcome = flow::run(&mut cfg, &p);
print_outcome(&p, &outcome);
Ok(if outcome.ok() { 0 } else { 1 })
}
Cmd::Watch { no_initial } => Ok(watch::run(cfg, !no_initial)), Cmd::Watch { no_initial } => Ok(watch::run(cfg, !no_initial)),
Cmd::Profile { action } => cmd_profile(&mut cfg, action), Cmd::Profile { action } => cmd_profile(&mut cfg, action),
Cmd::Detect { apply } => cmd_detect(&mut cfg, apply), Cmd::Detect { apply, json } => cmd_detect(&mut cfg, apply, json),
Cmd::Add { Cmd::Add { ssid, opts } => cmd_add(&mut cfg, ssid, opts),
ssid,
password,
hidden,
to,
at,
} => cmd_add(&mut cfg, ssid, password, hidden, to, at),
Cmd::Forget { ssid } => cmd_forget(&mut cfg, &ssid), Cmd::Forget { ssid } => cmd_forget(&mut cfg, &ssid),
Cmd::Prune { dry_run } => cmd_prune(&cfg, dry_run),
Cmd::Scan { to } => cmd_scan(&mut cfg, to), Cmd::Scan { to } => cmd_scan(&mut cfg, to),
Cmd::List { show_passwords } => cmd_list(&cfg, show_passwords), Cmd::List { show_passwords } => cmd_list(&cfg, show_passwords),
Cmd::Edit => cmd_edit(), Cmd::Edit => cmd_edit(),
@ -181,6 +214,32 @@ fn real_main(cli: Cli) -> Result<i32, String> {
} }
} }
fn cmd_init(cfg: &mut Config, override_p: &Option<String>, wait: u64) -> Result<i32, String> {
let p = active_profile(cfg, override_p);
let deadline = std::time::Instant::now() + Duration::from_secs(wait);
let mut attempt = 0;
loop {
// First attempt notifies normally (user-initiated); retries are
// quiet so a long --wait run doesn't spam notifications.
let outcome = if attempt == 0 {
flow::run(cfg, &p)
} else {
flow::run_quiet(cfg, &p)
};
if outcome.ok() {
print_outcome(&p, &outcome);
return Ok(0);
}
if wait == 0 || std::time::Instant::now() >= deadline {
print_outcome(&p, &outcome);
return Ok(1);
}
attempt += 1;
println!("{C_DIM}not connected yet — retrying in 3s…{C_RESET}");
std::thread::sleep(Duration::from_secs(3));
}
}
fn print_outcome(profile: &str, o: &flow::Outcome) { fn print_outcome(profile: &str, o: &flow::Outcome) {
match o { match o {
flow::Outcome::Connected { ssid, note } => { flow::Outcome::Connected { ssid, note } => {
@ -209,10 +268,34 @@ fn print_outcome(profile: &str, o: &flow::Outcome) {
} }
} }
fn cmd_status(cfg: &Config, override_p: &Option<String>) -> Result<i32, String> { fn cmd_status(cfg: &Config, override_p: &Option<String>, json: bool) -> Result<i32, String> {
let p = active_profile(cfg, override_p); let p = active_profile(cfg, override_p);
let s = crate::status::gather(cfg, &p); let s = crate::status::gather(cfg, &p);
let healthy = s.internet
&& s.iface.is_some()
&& (!s.tailscale_required || s.tailscale.as_ref().map(|h| h.is_ok()).unwrap_or(false));
if json {
let tailscale = s.tailscale.as_ref().map(|h| h.state_str());
println!(
"{}",
serde_json::json!({
"profile": p,
"iface": s.iface,
"ssid": s.ssid,
"ip": s.ip,
"internet": s.internet,
"portal": s.portal,
"tailscale_required": s.tailscale_required,
"tailscale": tailscale,
"exit_node": s.exit_node,
"healthy": healthy,
})
);
return Ok(if healthy { 0 } else { 1 });
}
let dot = |ok: bool| { let dot = |ok: bool| {
if ok { if ok {
format!("{C_GREEN}{C_RESET}") format!("{C_GREEN}{C_RESET}")
@ -259,9 +342,6 @@ fn cmd_status(cfg: &Config, override_p: &Option<String>) -> Result<i32, String>
(None, _) => println!(" tailscale {C_DIM}not installed{C_RESET}"), (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!( println!(
" state {}", " state {}",
if healthy { if healthy {
@ -301,47 +381,75 @@ fn cmd_profile(cfg: &mut Config, action: Option<ProfileCmd>) -> Result<i32, Stri
} }
fn detect_profile(cfg: &Config) -> Option<String> { fn detect_profile(cfg: &Config) -> Option<String> {
let iface = nm::wifi_interface()?; let iface = nm::wifi_interface_preferred(cfg.settings.interface.as_deref())?;
nm::radio_on(); nm::radio_on();
nm::rescan(&iface, &[]); nm::rescan(&iface, &[]);
let visible = nm::visible_ssids(&iface); let visible = nm::visible_signals(&iface);
// Profiles are stored in a BTreeMap so iteration order is deterministic // Scored detection: the profile with the most matching markers wins, so
// (alphabetical). The caller can rely on that for tie-breaking. // a 2-marker match beats a 1-marker one. Profiles are stored in a
// BTreeMap, so ties resolve deterministically (alphabetically first).
let mut best: Option<(String, usize)> = None;
for (name, profile) in &cfg.profiles { for (name, profile) in &cfg.profiles {
if profile.detect_ssids.is_empty() { if profile.detect_ssids.is_empty() {
continue; continue;
} }
if profile let count = profile
.detect_ssids .detect_ssids
.iter() .iter()
.any(|s| visible.contains(s.as_str())) .filter(|s| visible.contains_key(s.as_str()))
{ .count();
return Some(name.clone()); if count > 0 {
let better = match &best {
None => true,
Some((_, c)) => count > *c,
};
if better {
best = Some((name.clone(), count));
}
} }
} }
// Fall back to the default profile if no markers matched. best.map(|(p, _)| p).or_else(|| {
// Fall back to the default profile if no markers matched — but only
// if it actually exists: a stale `default_profile` name is a config
// error, not a detection result, and persisting it would wedge the
// watcher in UnknownProfile forever.
if cfg.profiles.contains_key(&cfg.settings.default_profile) {
Some(cfg.settings.default_profile.clone()) Some(cfg.settings.default_profile.clone())
} else {
None
}
})
} }
fn cmd_detect(cfg: &mut Config, apply: bool) -> Result<i32, String> { fn cmd_detect(cfg: &mut Config, apply: bool, json: bool) -> Result<i32, String> {
match detect_profile(cfg) { match detect_profile(cfg) {
Some(p) => { Some(p) => {
println!("{p}"); if json && !apply {
if apply { println!("{}", serde_json::json!({ "profile": p }));
State { return Ok(0);
profile: p.clone(),
updated: crate::util::timestamp(),
} }
.save()?; if apply {
if json {
println!("{}", serde_json::json!({ "profile": p }));
} else {
println!("{p}");
}
// Route through state::set_profile (like the CLI and the
// bread bus do) so an unknown fallback is rejected with a
// proper error instead of being persisted as active.
state::set_profile(cfg, &p)?;
let outcome = flow::run(cfg, &p); let outcome = flow::run(cfg, &p);
print_outcome(&p, &outcome); print_outcome(&p, &outcome);
return Ok(if outcome.ok() { 0 } else { 1 }); return Ok(if outcome.ok() { 0 } else { 1 });
} }
println!("{p}");
Ok(0) Ok(0)
} }
None => Err("could not detect a profile (no Wi-Fi adapter?)".into()), None => Err("could not detect a profile (no Wi-Fi adapter, or no \
profile matches and the default is misconfigured)"
.into()),
} }
} }
@ -357,9 +465,9 @@ fn prompt_line(msg: &str) -> String {
/// response) means "this network has no password" (open Wi-Fi) — normalize /// response) means "this network has no password" (open Wi-Fi) — normalize
/// it to `None` right at the point of entry so it flows the same way a /// it to `None` right at the point of entry so it flows the same way a
/// genuinely absent/cleared password does. Without this, `Some("")` would /// genuinely absent/cleared password does. Without this, `Some("")` would
/// make `nm::connect_verbose` send an empty PSK argument, which nmcli treats /// make `nm::connect_verbose` send an empty PSK in the settings payload,
/// as "secured with a blank password" rather than "open", and the connect /// which NetworkManager treats as "secured with a blank password" rather
/// fails against a real open SSID. /// than "open", and the connect fails against a real open SSID.
fn non_empty(s: String) -> Option<String> { fn non_empty(s: String) -> Option<String> {
if s.is_empty() { if s.is_empty() {
None None
@ -382,28 +490,61 @@ fn prompt_secret(msg: &str) -> String {
val val
} }
fn cmd_add( fn cmd_add(cfg: &mut Config, ssid: String, opts: AddOpts) -> Result<i32, String> {
cfg: &mut Config, let AddOpts {
ssid: String, password,
password: Option<String>, hidden,
hidden: bool, dns,
to: Option<String>, eap,
at: Option<usize>, identity,
) -> Result<i32, String> { ca_cert,
to,
at,
} = opts;
// `--dns ""` is the explicit "don't pin DNS" opt-out; normalize an
// absent flag to None (use the global setting).
let dns = match dns {
Some(s) if s.is_empty() => Some(String::new()),
Some(s) => Some(s),
None => None,
};
// For enterprise networks, the password is the 802.1x password.
let password = match password { let password = match password {
Some(p) => p, Some(p) => p,
None if eap.is_some() => prompt_secret(&format!("802.1x password for '{ssid}': ")),
None => prompt_secret(&format!("Password for '{ssid}': ")), None => prompt_secret(&format!("Password for '{ssid}': ")),
}; };
let password = non_empty(password); let password = non_empty(password);
match cfg.networks.iter_mut().find(|n| n.ssid == ssid) { match cfg.networks.iter_mut().find(|n| n.ssid == ssid) {
Some(n) => { Some(n) => {
n.password = password; n.password = password;
n.hidden = hidden || n.hidden; // `--hidden` / `--hidden=false` set the flag explicitly; when
// the flag is omitted, leave an existing entry's hidden state
// alone (a password-only update must not un-hide a network).
if let Some(h) = hidden {
n.hidden = h;
}
if dns.is_some() {
n.dns = dns;
}
if eap.is_some() {
n.eap = eap;
}
if identity.is_some() {
n.identity = identity;
}
if ca_cert.is_some() {
n.ca_cert = ca_cert;
}
} }
None => cfg.networks.push(NetworkDef { None => cfg.networks.push(NetworkDef {
ssid: ssid.clone(), ssid: ssid.clone(),
password, password,
hidden, dns,
eap,
identity,
ca_cert,
hidden: hidden.unwrap_or(false),
}), }),
} }
if let Some(prof_name) = to { if let Some(prof_name) = to {
@ -443,8 +584,50 @@ fn cmd_forget(cfg: &mut Config, ssid: &str) -> Result<i32, String> {
Ok(0) Ok(0)
} }
/// Remove NetworkManager wireless profiles whose SSID is no longer known to
/// breadcrumbs (config `networks`, or any profile's priority list or
/// bootstrap). `--dry-run` only lists. Returns the number removed.
fn cmd_prune(cfg: &Config, dry_run: bool) -> Result<i32, String> {
let known: Vec<&str> = cfg
.networks
.iter()
.map(|n| n.ssid.as_str())
.chain(
cfg.profiles
.values()
.flat_map(|p| p.networks.iter().map(|s| s.as_str()).chain(p.bootstrap.iter().map(|s| s.as_str()))),
)
.collect();
let stale: Vec<(String, String)> = nm::wireless_profiles()
.into_iter()
.filter(|(_name, ssid)| !known.contains(&ssid.as_str()))
.collect();
if stale.is_empty() {
println!("{C_GREEN}nothing to prune{C_RESET}");
return Ok(0);
}
for (name, ssid) in &stale {
if dry_run {
println!("{C_DIM}would remove{C_RESET} {name} ({ssid})");
} else {
println!("{C_GREEN}removed{C_RESET} {name} ({ssid})");
let _ = nm::delete_connections_for_ssid(ssid);
}
}
Ok(0)
}
fn cmd_scan(cfg: &mut Config, to: Option<String>) -> Result<i32, String> { fn cmd_scan(cfg: &mut Config, to: Option<String>) -> Result<i32, String> {
let iface = nm::wifi_interface().ok_or("no Wi-Fi adapter")?; // Validate `--to` up front, before any side effects (connecting is
// one): `add --to` errors on an unknown profile, so `scan --to` must
// too instead of silently saving a network that never gets attached.
if let Some(prof_name) = &to {
if !cfg.profiles.contains_key(prof_name) {
return Err(format!("unknown profile '{prof_name}'"));
}
}
let iface = nm::wifi_interface_preferred(cfg.settings.interface.as_deref())
.ok_or("no Wi-Fi adapter")?;
nm::radio_on(); nm::radio_on();
nm::rescan(&iface, &[]); nm::rescan(&iface, &[]);
let entries = nm::scan_list(&iface); let entries = nm::scan_list(&iface);
@ -478,9 +661,13 @@ fn cmd_scan(cfg: &mut Config, to: Option<String>) -> Result<i32, String> {
let mut def = NetworkDef { let mut def = NetworkDef {
ssid: ssid.clone(), ssid: ssid.clone(),
password, password,
dns: None,
eap: None,
identity: None,
ca_cert: None,
hidden: false, hidden: false,
}; };
if !nm::connect(&iface, &def, cfg.settings.nmcli_wait, &cfg.settings.dns) { if !nm::connect(&iface, &def, cfg.settings.connect_wait, &cfg.settings.dns) {
return Err(format!("failed to connect to {ssid}")); return Err(format!("failed to connect to {ssid}"));
} }
// A successful connect means NetworkManager now durably holds the PSK // A successful connect means NetworkManager now durably holds the PSK
@ -503,12 +690,13 @@ fn cmd_scan(cfg: &mut Config, to: Option<String>) -> Result<i32, String> {
Ok(0) Ok(0)
} }
/// Mask a secret for display. Operates on chars (not bytes) so multi-byte /// Mask a secret for display. Always renders the same fixed-length
/// UTF-8 passwords don't panic on a mid-character byte slice, and never /// placeholder so the output reveals neither the secret's length nor any
/// echoes back any real character of the secret (previously the first byte /// character of it (a fixed placeholder is what password managers show;
/// was shown unmasked). /// length-hiding also means multi-byte UTF-8 passwords need no special
fn mask(p: &str) -> String { /// handling).
"".repeat(p.chars().count().max(2)) fn mask(_p: &str) -> String {
"".repeat(8)
} }
fn cmd_list(cfg: &Config, show_pw: bool) -> Result<i32, String> { fn cmd_list(cfg: &Config, show_pw: bool) -> Result<i32, String> {
@ -568,7 +756,15 @@ fn cmd_list(cfg: &Config, show_pw: bool) -> Result<i32, String> {
fn cmd_edit() -> Result<i32, String> { fn cmd_edit() -> Result<i32, String> {
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".into()); let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".into());
let path = config::config_path(); let path = config::config_path();
let status = Command::new(&editor) // EDITOR values routinely carry arguments ("code -w", "subl -w"), so
// split on whitespace: the first token is the program, the rest are its
// arguments. The path stays a separate argument — never interpolated
// into a shell string — so it can't be used for injection.
let mut parts = editor.split_whitespace();
let prog = parts.next().unwrap_or("nano");
let mut cmd = Command::new(prog);
cmd.args(parts);
let status = cmd
.arg(&path) .arg(&path)
.status() .status()
.map_err(|e| format!("launching {editor}: {e}"))?; .map_err(|e| format!("launching {editor}: {e}"))?;
@ -604,9 +800,9 @@ fn cmd_doctor(cfg: &Config, override_p: &Option<String>, full: bool) -> Result<i
let s = crate::status::gather(cfg, &p); let s = crate::status::gather(cfg, &p);
println!("{C_BOLD}breadcrumbs doctor{C_RESET} (profile {p})"); println!("{C_BOLD}breadcrumbs doctor{C_RESET} (profile {p})");
println!( println!(
" nmcli {}", " network-manager {}",
if command_exists("nmcli") { if nm::available() {
"present" "present (D-Bus)"
} else { } else {
"MISSING" "MISSING"
} }
@ -686,7 +882,13 @@ fn exec_replace(prog: &str, dir: &std::path::Path) -> String {
} }
fn cmd_install_service(enable: bool) -> Result<i32, String> { fn cmd_install_service(enable: bool) -> Result<i32, String> {
let unit_dir = home_dir().join(".config/systemd/user"); // Honor XDG_CONFIG_HOME like the rest of the app: systemd --user units
// live in $XDG_CONFIG_HOME/systemd/user (default ~/.config/systemd/user).
let unit_dir = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| home_dir().join(".config"))
.join("systemd")
.join("user");
std::fs::create_dir_all(&unit_dir) std::fs::create_dir_all(&unit_dir)
.map_err(|e| format!("creating {}: {e}", unit_dir.display()))?; .map_err(|e| format!("creating {}: {e}", unit_dir.display()))?;
let bin = std::env::current_exe().map_err(|e| format!("resolving current executable: {e}"))?; let bin = std::env::current_exe().map_err(|e| format!("resolving current executable: {e}"))?;
@ -694,7 +896,8 @@ fn cmd_install_service(enable: bool) -> Result<i32, String> {
// session's DISPLAY/WAYLAND_DISPLAY/DBUS so notify-send and the Tailscale // session's DISPLAY/WAYLAND_DISPLAY/DBUS so notify-send and the Tailscale
// login browser-open actually work. PATH is pinned because systemd --user // 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 // units do not get the login shell's PATH, and the watcher shells out to
// nmcli/tailscale/sudo/xdg-open by name. // tailscale/sudo/xdg-open by name (NetworkManager is reached over D-Bus,
// so no nmcli is needed).
let unit = format!( let unit = format!(
"[Unit]\n\ "[Unit]\n\
Description=breadcrumbs Wi-Fi state machine watcher\n\ Description=breadcrumbs Wi-Fi state machine watcher\n\
@ -747,24 +950,15 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn mask_empty_password() { fn mask_is_fixed_length_regardless_of_secret() {
// len() == 0 <= 2 branch: still at least 2 dots so an empty saved // Fixed-length masking: the output must reveal neither the secret's
// password doesn't visually collapse to nothing in `list`. // length nor any character of it — for empty, short, and long
assert_eq!(mask(""), "••"); // secrets alike.
} assert_eq!(mask(""), "".repeat(8));
assert_eq!(mask("a"), "".repeat(8));
#[test] assert_eq!(mask("ab"), "".repeat(8));
fn mask_short_passwords_reveal_nothing() { assert_eq!(mask("hunter2"), "".repeat(8));
assert_eq!(mask("a"), "••"); assert!(!mask("hunter2").contains('h'));
assert_eq!(mask("ab"), "••");
}
#[test]
fn mask_never_echoes_a_real_character() {
let pw = "hunter2";
let masked = mask(pw);
assert_eq!(masked, "".repeat(pw.len()));
assert!(!masked.contains('h'), "masked output leaked the first char");
} }
#[test] #[test]
@ -774,14 +968,13 @@ mod tests {
// emoji or accented character), since byte index 1 can land mid-char. // emoji or accented character), since byte index 1 can land mid-char.
let pw = "日本語パスワード"; let pw = "日本語パスワード";
let masked = mask(pw); let masked = mask(pw);
assert_eq!(masked.chars().count(), pw.chars().count()); assert_eq!(masked, "".repeat(8));
assert!(masked.chars().all(|c| c == '•')); assert!(masked.chars().all(|c| c == '•'));
} }
#[test] #[test]
fn mask_emoji_first_character_does_not_panic() { fn mask_emoji_first_character_does_not_panic() {
let pw = "🔒password123"; let pw = "🔒password123";
let masked = mask(pw); assert_eq!(mask(pw), "".repeat(8));
assert_eq!(masked.chars().count(), pw.chars().count());
} }
} }

View file

@ -26,48 +26,109 @@ pub fn emit_profile_changed(client: &BreadClient, from: &str, to: &str) {
); );
} }
pub fn emit_health_changed(client: &BreadClient, profile: &str, health: &str, ssid: Option<&str>) { /// Payload for `bread.crumbs.health.changed`. Constructed by the watch
/// loop from a classification and passed as a unit so the emit functions
/// stay small.
pub struct HealthChanged<'a> {
pub profile: &'a str,
pub health: &'a str,
pub ssid: Option<&'a str>,
pub iface: Option<&'a str>,
pub ip: Option<&'a str>,
pub exit_node: &'a str,
pub tailscale: Option<&'a str>,
}
pub fn emit_health_changed(client: &BreadClient, ev: HealthChanged<'_>) {
client.emit( client.emit(
"bread.crumbs.health.changed", "bread.crumbs.health.changed",
serde_json::json!({ serde_json::json!({
"profile": profile, "profile": ev.profile,
"health": health, "health": ev.health,
"ssid": ssid, "ssid": ev.ssid,
"iface": ev.iface,
"ip": ev.ip,
"exit_node": ev.exit_node,
"tailscale": ev.tailscale,
}), }),
); );
} }
/// `bread.crumbs.network.changed` — the watch loop observed the active SSID
/// transition. `from` is `null` when there was no previous association.
pub fn emit_network_changed(client: &BreadClient, from: Option<&str>, to: Option<&str>, profile: &str) {
client.emit(
"bread.crumbs.network.changed",
serde_json::json!({ "from": from, "to": to, "profile": profile }),
);
}
/// `bread.crumbs.tailscale.changed` — the Tailscale health state (or its
/// mere presence) changed between watch-loop ticks.
pub fn emit_tailscale_changed(
client: &BreadClient,
profile: &str,
state: Option<&str>,
exit_node: &str,
) {
client.emit(
"bread.crumbs.tailscale.changed",
serde_json::json!({
"profile": profile,
"state": state,
"exit_node": exit_node,
}),
);
}
/// What a `bread.command.crumbs.*` event asks the watch loop to do.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommandAction {
/// Persist this profile via [`state::set_profile`]. Applied on the
/// watch loop thread — the single owner of config/state file access —
/// never on the subscription thread, which would race the loop's own
/// `Config::load`/`save`.
SetProfile(String),
/// Nothing to do: unknown verb, or a validation failure already
/// reported via `bread.crumbs.set_profile.failed`.
Ignore,
}
/// Reacts to `bread.command.crumbs.*` verbs. Only `set_profile` maps to /// Reacts to `bread.command.crumbs.*` verbs. Only `set_profile` maps to
/// real, existing breadcrumbs functionality today — there is no pin/select /// real, existing breadcrumbs functionality today — there is no pin/select
/// (or other) verb because breadcrumbs has no such concept. Unrecognized /// (or other) verb because breadcrumbs has no such concept. Unrecognized
/// verbs are ignored, not stubbed as no-ops that pretend to succeed. /// verbs are ignored, not stubbed as no-ops that pretend to succeed.
/// ///
/// Returns `true` when a profile was actually persisted, so the watch loop /// This only *parses and validates* the command — it performs no file I/O
/// can wake immediately and re-evaluate instead of waiting out the current /// (that would race the watch loop's own config access from a second
/// poll interval. /// thread). The returned [`CommandAction`] is forwarded to the loop, which
/// /// applies it via [`apply_set_profile`].
/// Emits `bread.crumbs.set_profile.done`/`.failed` per the confirmation pub fn handle_command(event: &BreadEvent) -> CommandAction {
/// convention in bread's Documentation.md.
pub fn handle_command(event: &BreadEvent) -> bool {
let Some(verb) = event.event.strip_prefix("bread.command.crumbs.") else { let Some(verb) = event.event.strip_prefix("bread.command.crumbs.") else {
return false; return CommandAction::Ignore;
}; };
match verb { match verb {
"set_profile" => handle_set_profile(event), "set_profile" => match event.data.get("profile").and_then(|v| v.as_str()) {
Some(name) if !name.trim().is_empty() => CommandAction::SetProfile(name.to_string()),
_ => {
emit_set_profile_failed("missing string \"profile\" in command data");
CommandAction::Ignore
}
},
other => { other => {
crate::notify::log(&format!( crate::notify::log(&format!(
"watch: ignoring unrecognized bread.command.crumbs.{other}" "watch: ignoring unrecognized bread.command.crumbs.{other}"
)); ));
false CommandAction::Ignore
} }
} }
} }
fn handle_set_profile(event: &BreadEvent) -> bool { /// Apply a `set_profile` command on the watch loop thread and emit the
let Some(name) = event.data.get("profile").and_then(|v| v.as_str()) else { /// `done`/`failed` confirmation. Kept separate from [`handle_command`] so
emit_set_profile_failed("missing string \"profile\" in command data"); /// the bread subscription thread never touches config/state files
return false; /// concurrently with the loop.
}; pub fn apply_set_profile(name: &str) {
match Config::load().and_then(|cfg| state::set_profile(&cfg, name)) { match Config::load().and_then(|cfg| state::set_profile(&cfg, name)) {
Ok(()) => { Ok(()) => {
crate::notify::log(&format!( crate::notify::log(&format!(
@ -77,11 +138,9 @@ fn handle_set_profile(event: &BreadEvent) -> bool {
"bread.crumbs.set_profile.done", "bread.crumbs.set_profile.done",
serde_json::json!({ "profile": name }), serde_json::json!({ "profile": name }),
); );
true
} }
Err(e) => { Err(e) => {
emit_set_profile_failed(&e); emit_set_profile_failed(&e);
false
} }
} }
} }

View file

@ -9,7 +9,7 @@ use crate::util::home_dir;
fn default_dns() -> String { fn default_dns() -> String {
"1.1.1.1".to_string() "1.1.1.1".to_string()
} }
fn default_nmcli_wait() -> u32 { fn default_connect_wait() -> u32 {
8 8
} }
fn default_exit_node() -> String { fn default_exit_node() -> String {
@ -28,12 +28,59 @@ fn default_ping_host() -> String {
"1.1.1.1".to_string() "1.1.1.1".to_string()
} }
/// Parse "HH:MM" (24h) into minutes since midnight; `None` if malformed.
pub fn hhmm_to_minutes(s: &str) -> Option<u32> {
let (h, m) = s.trim().split_once(':')?;
let h: u32 = h.parse().ok()?;
let m: u32 = m.parse().ok()?;
if h > 23 || m > 59 {
return None;
}
Some(h * 60 + m)
}
/// Does the window `[from, to)` (minutes since midnight) contain `now`?
/// `from >= to` means an overnight window (e.g. 22:0007:00).
pub fn window_contains(from: u32, to: u32, now: u32) -> bool {
if from < to {
now >= from && now < to
} else {
now >= from || now < to
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ScheduleEntry {
/// Profile to switch to while the window is active.
pub profile: String,
/// "HH:MM", inclusive start.
pub from: String,
/// "HH:MM", exclusive end (`from >= to` means overnight).
pub to: String,
}
impl ScheduleEntry {
/// Whether the window contains `now_minutes` (minutes since midnight).
pub fn contains(&self, now_minutes: u32) -> bool {
match (hhmm_to_minutes(&self.from), hhmm_to_minutes(&self.to)) {
(Some(f), Some(t)) => window_contains(f, t, now_minutes),
_ => false,
}
}
}
fn is_false(b: &bool) -> bool {
!b
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings { pub struct Settings {
#[serde(default = "default_dns")] #[serde(default = "default_dns")]
pub dns: String, pub dns: String,
#[serde(default = "default_nmcli_wait")] /// Seconds to wait for a connect to reach the ACTIVATED device state.
pub nmcli_wait: u32, /// `nmcli_wait` is accepted as a legacy alias.
#[serde(default = "default_connect_wait", alias = "nmcli_wait")]
pub connect_wait: u32,
#[serde(default = "default_exit_node")] #[serde(default = "default_exit_node")]
pub exit_node: String, pub exit_node: String,
#[serde(default = "default_profile_name")] #[serde(default = "default_profile_name")]
@ -44,22 +91,60 @@ pub struct Settings {
pub connectivity_url: String, pub connectivity_url: String,
#[serde(default = "default_ping_host")] #[serde(default = "default_ping_host")]
pub ping_host: String, pub ping_host: String,
/// Set the first time the config is saved. Core profiles (`home` /
/// `work` / `away`) are only backfilled for genuinely fresh or legacy
/// configs; once the user owns the file, a profile they deliberately
/// deleted stays deleted instead of being silently resurrected on the
/// next load. Omits itself from the TOML until set, so existing
/// configs keep parsing exactly as before.
#[serde(default, skip_serializing_if = "is_false")]
pub core_profiles_initialized: bool,
/// Preferred Wi-Fi interface (e.g. "wlan0"). When set, this exact
/// device is used if present; otherwise the first Wi-Fi device wins.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interface: Option<String>,
/// Priority-ordered fallback exit nodes. Tried in order by the flow;
/// the first healthy one is selected. Falls back to `exit_node` when
/// empty.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exit_nodes: Vec<String>,
/// Optional time-of-day schedule: at a given time, switch to the listed
/// profile automatically (respecting a manual-override grace window;
/// see the watch loop). First matching rule wins; outside every window
/// nothing is switched.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub schedule: Vec<ScheduleEntry>,
} }
impl Default for Settings { impl Default for Settings {
fn default() -> Self { fn default() -> Self {
Settings { Settings {
dns: default_dns(), dns: default_dns(),
nmcli_wait: default_nmcli_wait(), connect_wait: default_connect_wait(),
exit_node: default_exit_node(), exit_node: default_exit_node(),
default_profile: default_profile_name(), default_profile: default_profile_name(),
watch_interval: default_watch_interval(), watch_interval: default_watch_interval(),
connectivity_url: default_connectivity_url(), connectivity_url: default_connectivity_url(),
ping_host: default_ping_host(), ping_host: default_ping_host(),
core_profiles_initialized: false,
interface: None,
exit_nodes: Vec::new(),
schedule: Vec::new(),
} }
} }
} }
impl Settings {
/// The profile a time-of-day schedule picks for `now_minutes` (minutes
/// since midnight), if any — first matching rule wins.
pub fn scheduled_profile(&self, now_minutes: u32) -> Option<String> {
self.schedule
.iter()
.find(|e| e.contains(now_minutes))
.map(|e| e.profile.clone())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkDef { pub struct NetworkDef {
pub ssid: String, pub ssid: String,
@ -70,13 +155,37 @@ pub struct NetworkDef {
/// rather than writing a plaintext copy that's no longer needed. `None` /// rather than writing a plaintext copy that's no longer needed. `None`
/// means either "NetworkManager already owns this secret" or "this is /// means either "NetworkManager already owns this secret" or "this is
/// an open (unsecured) network" — both cases behave the same way on /// an open (unsecured) network" — both cases behave the same way on
/// connect: no password argument is ever sent. /// connect: no PSK is sent to nmcli at all. When `Some`, the PSK is
/// fed to `nmcli --ask` on stdin, never as an argv element.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub password: Option<String>, pub password: Option<String>,
/// Per-network DNS override. `None` falls back to `settings.dns`;
/// an explicitly empty string disables DNS pinning for this network.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dns: Option<String>,
/// WPA-Enterprise (802.1x). When `eap` is set the network is treated as
/// enterprise: `identity` + `password` (reused) + optional `ca_cert`
/// path. `eap` is e.g. "peap" or "tls".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub eap: Option<String>,
/// 802.1x identity (e.g. `user@corp`) for enterprise networks.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub identity: Option<String>,
/// Path to a CA certificate for 802.1x (optional).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ca_cert: Option<String>,
#[serde(default)] #[serde(default)]
pub hidden: bool, pub hidden: bool,
} }
impl NetworkDef {
/// The DNS to pin for this network: the per-network override if set,
/// otherwise the global setting.
pub fn effective_dns<'a>(&'a self, fallback: &'a str) -> &'a str {
self.dns.as_deref().unwrap_or(fallback)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Profile { pub struct Profile {
/// Optional SSID connected first to bootstrap connectivity (e.g. for Tailscale). /// Optional SSID connected first to bootstrap connectivity (e.g. for Tailscale).
@ -98,6 +207,11 @@ pub struct Profile {
/// Used by `breadcrumbs detect` to guess the active profile. /// Used by `breadcrumbs detect` to guess the active profile.
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub detect_ssids: Vec<String>, pub detect_ssids: Vec<String>,
/// Opt-in learning: on a successful connect, the SSID is appended to
/// `detect_ssids` (bounded) so `breadcrumbs detect` improves without
/// hand-editing. Off by default to keep detect predictable.
#[serde(default, skip_serializing_if = "is_false")]
pub learn: bool,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -170,11 +284,29 @@ impl Config {
self.networks.iter().find(|n| n.ssid == ssid) self.networks.iter().find(|n| n.ssid == ssid)
} }
/// The effective exit-node list for a profile, in priority order:
/// per-profile `exit_node`, else `settings.exit_nodes`, else
/// `settings.exit_node`. Empty entries are filtered out.
pub fn exit_nodes_for(&self, profile: &str) -> Vec<String> {
if let Some(p) = self.profiles.get(profile).and_then(|p| p.exit_node.clone()) {
return vec![p];
}
let list = if self.settings.exit_nodes.is_empty() {
vec![self.settings.exit_node.clone()]
} else {
self.settings.exit_nodes.clone()
};
list.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
/// Load config, creating a skeleton one on first run. /// Load config, creating a skeleton one on first run.
pub fn load() -> Result<Config, String> { pub fn load() -> Result<Config, String> {
let path = config_path(); let path = config_path();
if !path.exists() { if !path.exists() {
let cfg = build_initial_config(); let mut cfg = build_initial_config();
cfg.save()?; cfg.save()?;
return Ok(cfg); return Ok(cfg);
} }
@ -191,14 +323,31 @@ impl Config {
.map_err(|e| format!("reading {}: {e}", net_path.display()))?; .map_err(|e| format!("reading {}: {e}", net_path.display()))?;
let nf: NetworksFile = toml::from_str(&net_text) let nf: NetworksFile = toml::from_str(&net_text)
.map_err(|e| format!("parsing {}: {e}", net_path.display()))?; .map_err(|e| format!("parsing {}: {e}", net_path.display()))?;
cfg.networks = nf.networks; // Merge, don't overwrite: a legacy config can still carry an
// inline `[[networks]]` block, and those entries must survive
// even when networks.toml already exists — otherwise the next
// save() (which writes only networks.toml) would silently drop
// hand-added inline networks. networks.toml wins on SSID
// conflicts; inline-only entries are appended and migrated.
let mut merged = nf.networks;
for def in std::mem::take(&mut cfg.networks) {
if !merged.iter().any(|n| n.ssid == def.ssid) {
merged.push(def);
}
}
cfg.networks = merged;
} }
// else: no networks.toml yet — keep whatever legacy inline networks // else: no networks.toml yet — keep whatever legacy inline networks
// were read from breadcrumbs.toml above (or none, on a genuinely // were read from breadcrumbs.toml above (or none, on a genuinely
// fresh config). The next `save()` writes them to networks.toml and // fresh config). The next `save()` writes them to networks.toml and
// stops writing them into breadcrumbs.toml, completing the migration. // stops writing them into breadcrumbs.toml, completing the migration.
// Self-heal: guarantee the three core profiles always exist. // Enforce the documented minimum so `list` and the watch loop agree
// on the poll interval (watch silently clamps to 4 otherwise).
if cfg.settings.watch_interval < 4 {
cfg.settings.watch_interval = 4;
}
ensure_core_profiles(&mut cfg); ensure_core_profiles(&mut cfg);
Ok(cfg) Ok(cfg)
} }
@ -208,7 +357,12 @@ impl Config {
/// `forget`, `scan`, `profile set`, and `flow::run`'s own credential /// `forget`, `scan`, `profile set`, and `flow::run`'s own credential
/// clearing) goes through this single method so the two files never /// clearing) goes through this single method so the two files never
/// drift out of sync with each other. /// drift out of sync with each other.
pub fn save(&self) -> Result<(), String> { pub fn save(&mut self) -> Result<(), String> {
// The first save marks the config as user-owned: core profiles are
// backfilled only for genuinely fresh/legacy configs, never
// resurrected after the user has edited (or deleted) them.
self.settings.core_profiles_initialized = true;
let dir = config_dir(); let dir = config_dir();
fs::create_dir_all(&dir).map_err(|e| format!("creating {}: {e}", dir.display()))?; fs::create_dir_all(&dir).map_err(|e| format!("creating {}: {e}", dir.display()))?;
@ -267,6 +421,7 @@ fn core_profiles() -> BTreeMap<String, Profile> {
exit_node: None, exit_node: None,
include_all_known: false, include_all_known: false,
detect_ssids: vec![], detect_ssids: vec![],
learn: false,
}, },
); );
p.insert( p.insert(
@ -278,6 +433,7 @@ fn core_profiles() -> BTreeMap<String, Profile> {
exit_node: None, exit_node: None,
include_all_known: false, include_all_known: false,
detect_ssids: vec![], detect_ssids: vec![],
learn: false,
}, },
); );
p.insert( p.insert(
@ -289,12 +445,20 @@ fn core_profiles() -> BTreeMap<String, Profile> {
exit_node: None, exit_node: None,
include_all_known: true, include_all_known: true,
detect_ssids: vec![], detect_ssids: vec![],
learn: false,
}, },
); );
p p
} }
fn ensure_core_profiles(cfg: &mut Config) { fn ensure_core_profiles(cfg: &mut Config) {
// Backfill missing core profiles only until the user has taken
// ownership of the config (`core_profiles_initialized` is set by the
// first save). After that, a profile the user deliberately deleted
// stays deleted.
if cfg.settings.core_profiles_initialized {
return;
}
for (name, prof) in core_profiles() { for (name, prof) in core_profiles() {
cfg.profiles.entry(name).or_insert(prof); cfg.profiles.entry(name).or_insert(prof);
} }
@ -349,6 +513,23 @@ mod tests {
assert!(cfg.profile("away").is_some()); assert!(cfg.profile("away").is_some());
} }
#[test]
fn ensure_core_profiles_skips_backfill_once_initialized() {
// After the first save the config is user-owned: a deliberately
// deleted core profile must stay deleted instead of being
// resurrected on every load.
let mut cfg = Config {
settings: Settings {
core_profiles_initialized: true,
..Default::default()
},
networks: vec![],
profiles: BTreeMap::new(),
};
ensure_core_profiles(&mut cfg);
assert!(cfg.profiles.is_empty(), "no backfill once user-owned");
}
#[test] #[test]
fn ensure_core_profiles_preserves_user_customized_core_profile() { fn ensure_core_profiles_preserves_user_customized_core_profile() {
// A user-edited "home" (custom SSIDs) must not be clobbered by the // A user-edited "home" (custom SSIDs) must not be clobbered by the
@ -381,7 +562,7 @@ mod tests {
fn settings_default_matches_documented_defaults() { fn settings_default_matches_documented_defaults() {
let s = Settings::default(); let s = Settings::default();
assert_eq!(s.dns, "1.1.1.1"); assert_eq!(s.dns, "1.1.1.1");
assert_eq!(s.nmcli_wait, 8); assert_eq!(s.connect_wait, 8);
assert_eq!(s.default_profile, "away"); assert_eq!(s.default_profile, "away");
assert_eq!(s.watch_interval, 12); assert_eq!(s.watch_interval, 12);
assert_eq!(s.ping_host, "1.1.1.1"); assert_eq!(s.ping_host, "1.1.1.1");
@ -414,6 +595,10 @@ hidden = false"#;
let n = NetworkDef { let n = NetworkDef {
ssid: "Cafe".into(), ssid: "Cafe".into(),
password: None, password: None,
dns: None,
eap: None,
identity: None,
ca_cert: None,
hidden: false, hidden: false,
}; };
let text = toml::to_string_pretty(&n).unwrap(); let text = toml::to_string_pretty(&n).unwrap();
@ -425,6 +610,10 @@ hidden = false"#;
let n = NetworkDef { let n = NetworkDef {
ssid: "Cafe".into(), ssid: "Cafe".into(),
password: Some("hunter2".into()), password: Some("hunter2".into()),
dns: None,
eap: None,
identity: None,
ca_cert: None,
hidden: false, hidden: false,
}; };
let text = toml::to_string_pretty(&n).unwrap(); let text = toml::to_string_pretty(&n).unwrap();
@ -469,4 +658,135 @@ dns = "9.9.9.9""#;
assert!(cfg.networks.is_empty()); assert!(cfg.networks.is_empty());
assert!(cfg.profiles.is_empty()); assert!(cfg.profiles.is_empty());
} }
#[test]
fn exit_nodes_for_prioritizes_profile_then_list_then_single_node() {
let mut cfg = build_initial_config();
cfg.settings.exit_node = "global".into();
cfg.settings.exit_nodes = vec!["listA".into(), "listB".into()];
cfg.profiles.get_mut("home").unwrap().exit_node = Some("profile".into());
// Per-profile override wins outright.
assert_eq!(cfg.exit_nodes_for("home"), vec!["profile".to_string()]);
// Otherwise the priority list is used verbatim.
cfg.profiles.get_mut("home").unwrap().exit_node = None;
assert_eq!(
cfg.exit_nodes_for("home"),
vec!["listA".to_string(), "listB".to_string()]
);
// Without a list, the single setting is the (one-element) fallback.
cfg.settings.exit_nodes = vec![];
assert_eq!(cfg.exit_nodes_for("home"), vec!["global".to_string()]);
}
#[test]
fn exit_nodes_for_filters_empty_and_whitespace_entries() {
let mut cfg = build_initial_config();
cfg.settings.exit_nodes = vec![
" ".into(),
"nodeA".into(),
"".into(),
" nodeB ".into(),
];
assert_eq!(
cfg.exit_nodes_for("home"),
vec!["nodeA".to_string(), "nodeB".to_string()]
);
}
#[test]
fn hhmm_to_minutes_parses_and_rejects_malformed() {
assert_eq!(hhmm_to_minutes("09:30"), Some(570));
assert_eq!(hhmm_to_minutes("00:00"), Some(0));
assert_eq!(hhmm_to_minutes("23:59"), Some(1439));
assert_eq!(hhmm_to_minutes("9:30"), Some(570)); // lenient about padding
assert_eq!(hhmm_to_minutes("24:00"), None); // hour out of range
assert_eq!(hhmm_to_minutes("12:60"), None); // minute out of range
assert_eq!(hhmm_to_minutes("0930"), None); // no colon
assert_eq!(hhmm_to_minutes(""), None);
}
#[test]
fn window_contains_handles_same_day_and_overnight() {
// Same-day window 09:0017:00 (end exclusive).
assert!(window_contains(540, 1020, 600));
assert!(!window_contains(540, 1020, 1020));
assert!(!window_contains(540, 1020, 500));
// Overnight window 22:0007:00.
assert!(window_contains(1320, 420, 1380)); // 23:00
assert!(window_contains(1320, 420, 60)); // 01:00
assert!(!window_contains(1320, 420, 720)); // 12:00
}
#[test]
fn scheduled_profile_returns_first_matching_rule() {
let mut cfg = build_initial_config();
cfg.settings.schedule = vec![
ScheduleEntry {
profile: "work".into(),
from: "09:00".into(),
to: "17:00".into(),
},
ScheduleEntry {
profile: "home".into(),
from: "09:30".into(),
to: "18:00".into(),
},
];
// 10:00 matches both — the first rule (work) wins.
assert_eq!(cfg.settings.scheduled_profile(600), Some("work".into()));
// Outside every window → no schedule applies.
assert_eq!(cfg.settings.scheduled_profile(60), None);
}
#[test]
fn effective_dns_uses_per_network_override_then_global_fallback() {
let n = NetworkDef {
ssid: "x".into(),
password: None,
dns: Some("9.9.9.9".into()),
eap: None,
identity: None,
ca_cert: None,
hidden: false,
};
assert_eq!(n.effective_dns("1.1.1.1"), "9.9.9.9");
let n2 = NetworkDef { dns: None, ..n.clone() };
assert_eq!(n2.effective_dns("1.1.1.1"), "1.1.1.1");
// An explicit empty string is a valid per-network opt-out.
let n3 = NetworkDef { dns: Some(String::new()), ..n.clone() };
assert_eq!(n3.effective_dns("1.1.1.1"), "");
}
#[test]
fn enterprise_fields_round_trip_and_omit_when_none() {
let n = NetworkDef {
ssid: "Corp".into(),
password: Some("pw".into()),
dns: None,
eap: Some("peap".into()),
identity: Some("user@corp".into()),
ca_cert: Some("/etc/ca.pem".into()),
hidden: false,
};
let text = toml::to_string_pretty(&n).unwrap();
assert!(text.contains("eap") && text.contains("identity") && text.contains("ca_cert"));
let back: NetworkDef = toml::from_str(&text).unwrap();
assert_eq!(back.eap.as_deref(), Some("peap"));
assert_eq!(back.identity.as_deref(), Some("user@corp"));
assert_eq!(back.ca_cert.as_deref(), Some("/etc/ca.pem"));
let plain = NetworkDef {
eap: None,
identity: None,
ca_cert: None,
..n
};
let t2 = toml::to_string_pretty(&plain).unwrap();
assert!(!t2.contains("eap") && !t2.contains("identity") && !t2.contains("ca_cert"));
}
} }

View file

@ -1,3 +1,6 @@
use std::thread;
use std::time::Duration;
use crate::config::{Config, NetworkDef}; use crate::config::{Config, NetworkDef};
use crate::nm; use crate::nm;
use crate::notify::{log, notify, Urgency}; use crate::notify::{log, notify, Urgency};
@ -54,12 +57,11 @@ fn resolve_candidates(cfg: &Config, p: &crate::config::Profile) -> Vec<NetworkDe
/// A network was just connected to using a local password, and NetworkManager /// A network was just connected to using a local password, and NetworkManager
/// now durably holds that secret — either in a freshly created connection /// now durably holds that secret — either in a freshly created connection
/// profile (`device wifi connect`) or an existing one whose PSK we just /// profile (`device wifi connect --ask`) or an existing one whose PSK we just
/// updated (`connection modify`). Either way breadcrumbs no longer needs its /// supplied via `--ask connection up`. Either way breadcrumbs no longer needs
/// own plaintext copy: clear it and persist immediately, so it can't be /// its own plaintext copy: clear it and persist immediately so it doesn't sit
/// re-sent as an argv argument on the next connect and doesn't sit on disk /// on disk any longer than necessary. A no-op (no save) if the network has no
/// any longer than necessary. A no-op (no save) if the network has no local /// local password to begin with.
/// password to begin with.
fn clear_password_if_used(cfg: &mut Config, ssid: &str) { fn clear_password_if_used(cfg: &mut Config, ssid: &str) {
let Some(def) = cfg.networks.iter_mut().find(|n| n.ssid == ssid) else { let Some(def) = cfg.networks.iter_mut().find(|n| n.ssid == ssid) else {
return; return;
@ -75,14 +77,62 @@ fn clear_password_if_used(cfg: &mut Config, ssid: &str) {
} }
} }
/// Try to connect + confirm it actually carries traffic. /// Opt-in learning (`profiles.<name>.learn = true`): remember the SSIDs a
/// Returns Ok(()) on success, Err(reason) on failure. /// profile successfully connects to so `breadcrumbs detect` improves without
fn connect_and_verify(iface: &str, def: &NetworkDef, cfg: &Config) -> Result<(), String> { /// hand-editing. Bounded to keep the list sane; never touches an existing
nm::connect_verbose(iface, def, cfg.settings.nmcli_wait, &cfg.settings.dns)?; /// marker.
if !nm::device_connected(iface) { fn learn_ssid(cfg: &mut Config, profile: &str, ssid: &str) {
return Err("device not connected after nmcli success".into()); let Some(p) = cfg.profiles.get_mut(profile) else {
return;
};
if !p.learn || p.detect_ssids.len() >= 8 || p.detect_ssids.iter().any(|s| s == ssid) {
return;
} }
Ok(()) p.detect_ssids.push(ssid.to_string());
if let Err(e) = cfg.save() {
log(&format!("failed to persist learned SSID {ssid} for {profile}: {e}"));
}
}
/// Try to connect + confirm the device actually landed on the *requested*
/// SSID. 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.connect_wait, def.effective_dns(&cfg.settings.dns))?;
// Confirm the SSID, not just "device connected": NM autoconnect can win
// a race and leave the device on a different network, and the wifi list
// can lag activation by a moment — so poll briefly before giving up.
for _ in 0..8 {
match nm::active_ssid(iface) {
// Explicitly on the requested network — success.
Some(active) if active == def.ssid => return Ok(()),
// Associated with a *different* network — the failure this
// check exists to catch.
Some(_) => break,
// Scan list stale right after activation — keep polling while
// the device is at least connected.
None => {
if !nm::device_connected(iface) {
break;
}
}
}
thread::sleep(Duration::from_millis(250));
}
Err(format!("not associated with '{}' after connect", def.ssid))
}
/// Run the connection state machine for `profile_name`, with desktop
/// notifications enabled. See [`run_quiet`] for the daemon-facing variant.
pub fn run(cfg: &mut Config, profile_name: &str) -> Outcome {
run_inner(cfg, profile_name, true)
}
/// Same state machine, but suppresses desktop notifications. Used by the
/// watch loop, which does its own transition-gated notifications — without
/// this, a persistent failure (e.g. a stopped Tailscale daemon) would
/// re-notify on every recovery retry instead of once per state change.
pub fn run_quiet(cfg: &mut Config, profile_name: &str) -> Outcome {
run_inner(cfg, profile_name, false)
} }
/// Run the connection state machine for `profile_name`. /// Run the connection state machine for `profile_name`.
@ -92,36 +142,38 @@ fn connect_and_verify(iface: &str, def: &NetworkDef, cfg: &Config) -> Result<(),
/// immediately (see [`clear_password_if_used`]) — this is the only way that /// immediately (see [`clear_password_if_used`]) — this is the only way that
/// clearing happens for the `init` / `profile set --apply` / `detect --apply` /// clearing happens for the `init` / `profile set --apply` / `detect --apply`
/// commands and the watch loop, all of which route through here. /// commands and the watch loop, all of which route through here.
pub fn run(cfg: &mut Config, profile_name: &str) -> Outcome { fn run_inner(cfg: &mut Config, profile_name: &str, notify_user: bool) -> Outcome {
let profile = match cfg.profile(profile_name) { let profile = match cfg.profile(profile_name) {
Some(p) => p.clone(), Some(p) => p.clone(),
None => { None => {
if notify_user {
notify( notify(
"breadcrumbs: unknown profile", "breadcrumbs: unknown profile",
&format!("'{profile_name}' is not defined in breadcrumbs.toml"), &format!("'{profile_name}' is not defined in breadcrumbs.toml"),
Urgency::Critical, Urgency::Critical,
); );
}
return Outcome::UnknownProfile(profile_name.to_string()); return Outcome::UnknownProfile(profile_name.to_string());
} }
}; };
let iface = match nm::wifi_interface() { let iface = match nm::wifi_interface_preferred(cfg.settings.interface.as_deref()) {
Some(i) => i, Some(i) => i,
None => { None => {
if notify_user {
notify( notify(
"breadcrumbs: no Wi-Fi adapter", "breadcrumbs: no Wi-Fi adapter",
"Hardware issue — Wi-Fi device not found. Manual check needed.", "Hardware issue — Wi-Fi device not found. Manual check needed.",
Urgency::Critical, Urgency::Critical,
); );
}
return Outcome::NoInterface; return Outcome::NoInterface;
} }
}; };
nm::radio_on(); nm::radio_on();
let exit_node = profile let exit_nodes = cfg.exit_nodes_for(profile_name);
.exit_node let exit_node = exit_nodes.first().cloned().unwrap_or_default();
.clone()
.unwrap_or_else(|| cfg.settings.exit_node.clone());
let candidates = resolve_candidates(cfg, &profile); let candidates = resolve_candidates(cfg, &profile);
log(&format!( log(&format!(
@ -157,7 +209,9 @@ pub fn run(cfg: &mut Config, profile_name: &str) -> Outcome {
log(&format!("bootstrap connected: {}", bdef.ssid)); log(&format!("bootstrap connected: {}", bdef.ssid));
clear_password_if_used(cfg, &bdef.ssid); clear_password_if_used(cfg, &bdef.ssid);
} }
Err(e) => log(&format!("bootstrap connect failed: {}{e}", 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));
@ -169,9 +223,10 @@ pub fn run(cfg: &mut Config, profile_name: &str) -> Outcome {
} }
} }
let ts = tailscale::ensure_exit_node(&exit_node); let ts = tailscale::ensure_exit_node(&exit_nodes);
if !ts.is_ok() { if !ts.is_ok() {
let ssid = nm::active_ssid(&iface).or_else(|| profile.bootstrap.clone()); let ssid = nm::active_ssid(&iface).or_else(|| profile.bootstrap.clone());
if notify_user {
notify( notify(
"Tailscale Error", "Tailscale Error",
&format!( &format!(
@ -181,6 +236,7 @@ pub fn run(cfg: &mut Config, profile_name: &str) -> Outcome {
), ),
Urgency::Critical, Urgency::Critical,
); );
}
return Outcome::TailscaleError { ssid, health: ts }; return Outcome::TailscaleError { ssid, health: ts };
} }
log(&format!("tailscale healthy via exit node {exit_node}")); log(&format!("tailscale healthy via exit node {exit_node}"));
@ -188,23 +244,35 @@ pub fn run(cfg: &mut Config, profile_name: &str) -> Outcome {
nm::rescan(&iface, &scan_targets); nm::rescan(&iface, &scan_targets);
} }
let visible = nm::visible_ssids(&iface); // Signals are re-read after the Tailscale gate so pass 1 can prefer the
// strongest AP among a profile's visible networks.
let visible_sig = nm::visible_signals(&iface);
// ---- Connect to the priority list ---------------------------------- // ---- Connect to the priority list ----------------------------------
// Pass 1: visible networks in priority order. // Pass 1: visible networks, strongest signal first (priority order is
// the stable tiebreaker for equal signals).
let mut visible_candidates: Vec<&NetworkDef> = candidates
.iter()
.filter(|d| visible_sig.contains_key(&d.ssid))
.collect();
visible_candidates.sort_by(|a, b| {
visible_sig
.get(&b.ssid)
.cmp(&visible_sig.get(&a.ssid))
});
let mut any_attempted = false; let mut any_attempted = false;
for def in &candidates { for def in &visible_candidates {
if visible.contains(&def.ssid) {
any_attempted = true; any_attempted = true;
match connect_and_verify(&iface, def, cfg) { match connect_and_verify(&iface, def, cfg) {
Ok(()) => { Ok(()) => {
clear_password_if_used(cfg, &def.ssid); clear_password_if_used(cfg, &def.ssid);
learn_ssid(cfg, profile_name, &def.ssid);
let note = if internet_ok(cfg) { let note = if internet_ok(cfg) {
None None
} else { } else {
Some("associated but no internet yet".to_string()) Some("associated but no internet yet".to_string())
}; };
finish_connected(&def.ssid, profile_name, &note); finish_connected(&def.ssid, profile_name, &note, notify_user);
return Outcome::Connected { return Outcome::Connected {
ssid: def.ssid.clone(), ssid: def.ssid.clone(),
note, note,
@ -213,7 +281,6 @@ pub fn run(cfg: &mut Config, profile_name: &str) -> Outcome {
Err(e) => log(&format!("connect failed (visible): {}{e}", def.ssid)), Err(e) => log(&format!("connect failed (visible): {}{e}", 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) {
@ -221,12 +288,13 @@ pub fn run(cfg: &mut Config, profile_name: &str) -> Outcome {
match connect_and_verify(&iface, def, cfg) { match connect_and_verify(&iface, def, cfg) {
Ok(()) => { Ok(()) => {
clear_password_if_used(cfg, &def.ssid); clear_password_if_used(cfg, &def.ssid);
learn_ssid(cfg, profile_name, &def.ssid);
let note = if internet_ok(cfg) { let note = if internet_ok(cfg) {
None None
} else { } else {
Some("associated but no internet yet".to_string()) Some("associated but no internet yet".to_string())
}; };
finish_connected(&def.ssid, profile_name, &note); finish_connected(&def.ssid, profile_name, &note, notify_user);
return Outcome::Connected { return Outcome::Connected {
ssid: def.ssid.clone(), ssid: def.ssid.clone(),
note, note,
@ -248,7 +316,10 @@ pub fn run(cfg: &mut Config, profile_name: &str) -> Outcome {
if !nm::device_connected(&iface) { if !nm::device_connected(&iface) {
// Owned clone (not a borrow of `cfg`) so a successful reconnect // Owned clone (not a borrow of `cfg`) so a successful reconnect
// is free to mutate `cfg` to clear the used password. // is free to mutate `cfg` to clear the used password.
if let Some(bdef) = profile.bootstrap.as_deref().and_then(|s| cfg.network(s).cloned()) if let Some(bdef) = profile
.bootstrap
.as_deref()
.and_then(|s| cfg.network(s).cloned())
{ {
match connect_and_verify(&iface, &bdef, cfg) { match connect_and_verify(&iface, &bdef, cfg) {
Ok(()) => { Ok(()) => {
@ -268,7 +339,9 @@ pub fn run(cfg: &mut Config, profile_name: &str) -> Outcome {
} else { } else {
format!("target network not in range — staying on {bs_ssid} (Tailscale OK)") format!("target network not in range — staying on {bs_ssid} (Tailscale OK)")
}; };
if notify_user {
notify("breadcrumbs: using bootstrap", &reason, Urgency::Normal); notify("breadcrumbs: using bootstrap", &reason, Urgency::Normal);
}
log(&format!("flow end: on bootstrap {bs_ssid}; {reason}")); log(&format!("flow end: on bootstrap {bs_ssid}; {reason}"));
return Outcome::Connected { return Outcome::Connected {
ssid: bs_ssid, ssid: bs_ssid,
@ -282,33 +355,40 @@ pub fn run(cfg: &mut Config, profile_name: &str) -> Outcome {
.map(|c| c.ssid.as_str()) .map(|c| c.ssid.as_str())
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", "); .join(", ");
notify( let msg = if candidates.is_empty() {
"breadcrumbs: no known networks", format!("profile '{profile_name}' has no networks configured")
&format!("profile '{profile_name}': none of [{names}] are in range"), } else {
Urgency::Critical, format!("profile '{profile_name}': none of [{names}] are in range")
); };
if notify_user {
notify("breadcrumbs: no known networks", &msg, Urgency::Critical);
}
log(&format!( log(&format!(
"flow end: no networks connected (profile={profile_name})" "flow end: no networks connected (profile={profile_name})"
)); ));
Outcome::NoNetworks Outcome::NoNetworks
} }
fn finish_connected(ssid: &str, profile: &str, note: &Option<String>) { fn finish_connected(ssid: &str, profile: &str, note: &Option<String>, notify_user: bool) {
match note { match note {
None => { None => {
if notify_user {
notify( notify(
"breadcrumbs: connected", "breadcrumbs: connected",
&format!("{ssid} ({profile})"), &format!("{ssid} ({profile})"),
Urgency::Low, Urgency::Low,
); );
}
log(&format!("flow end: connected {ssid} (profile={profile})")); log(&format!("flow end: connected {ssid} (profile={profile})"));
} }
Some(n) => { Some(n) => {
if notify_user {
notify( notify(
"breadcrumbs: connected (degraded)", "breadcrumbs: connected (degraded)",
&format!("{ssid} ({profile}) — {n}"), &format!("{ssid} ({profile}) — {n}"),
Urgency::Normal, Urgency::Normal,
); );
}
log(&format!( log(&format!(
"flow end: connected {ssid} (profile={profile}) note={n}" "flow end: connected {ssid} (profile={profile}) note={n}"
)); ));
@ -326,6 +406,10 @@ mod tests {
NetworkDef { NetworkDef {
ssid: ssid.into(), ssid: ssid.into(),
password: Some("x".into()), password: Some("x".into()),
dns: None,
eap: None,
identity: None,
ca_cert: None,
hidden: false, hidden: false,
} }
} }

1083
src/nm.rs

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,19 @@ use crate::nm;
use crate::tailscale::{self, TsHealth}; use crate::tailscale::{self, TsHealth};
use crate::util::{command_exists, run}; use crate::util::{command_exists, run};
pub fn internet_ok(cfg: &Config) -> bool { /// Connectivity verdict. `Portal` is the interesting case: an HTTP response
/// arrived (200/301/302) but it wasn't the 204 the generate_204 endpoint
/// returns for genuine internet — the classic captive/guest-portal
/// signature, and the reason `classify` can tell "no internet at all" from
/// "internet but intercepted".
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Connectivity {
Online,
Portal,
NoNet,
}
pub fn connectivity(cfg: &Config) -> Connectivity {
if command_exists("curl") { if command_exists("curl") {
let o = run( let o = run(
"curl", "curl",
@ -21,35 +33,36 @@ pub fn internet_ok(cfg: &Config) -> bool {
], ],
Duration::from_secs(6), Duration::from_secs(6),
); );
// Only a 204 counts as real internet. Captive/guest portals answer
// 200 (a login page) or 302 (a redirect to it). The default endpoint
// is generate_204, which returns 204 precisely when traffic isn't
// being intercepted.
let code = o.stdout.trim(); let code = o.stdout.trim();
if code == "204" || code == "200" || code == "301" || code == "302" { if code == "204" {
return true; return Connectivity::Online;
}
if code == "200" || code == "301" || code == "302" {
return Connectivity::Portal;
} }
} }
// Fallback: ICMP to the configured host. // Fallback: ICMP to the configured host. A working ping overrides a
run( // non-204 curl answer that wasn't portal-shaped (e.g. a 403 from an
// overzealous firewall); a portal usually blocks ICMP too, so this
// stays Portal for the genuine case.
let ping = run(
"ping", "ping",
&["-c", "1", "-W", "2", &cfg.settings.ping_host], &["-c", "1", "-W", "2", &cfg.settings.ping_host],
Duration::from_secs(4), Duration::from_secs(4),
) );
.success if ping.success {
Connectivity::Online
} else {
Connectivity::NoNet
}
} }
fn ipv4(iface: &str) -> Option<String> { pub fn internet_ok(cfg: &Config) -> bool {
let o = run( matches!(connectivity(cfg), Connectivity::Online)
"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 struct Status {
@ -57,25 +70,40 @@ pub struct Status {
pub ssid: Option<String>, pub ssid: Option<String>,
pub ip: Option<String>, pub ip: Option<String>,
pub internet: bool, pub internet: bool,
/// True when traffic is being intercepted (captive/guest portal).
pub portal: bool,
pub tailscale_required: bool, pub tailscale_required: bool,
pub tailscale: Option<TsHealth>, pub tailscale: Option<TsHealth>,
pub exit_node: String, pub exit_node: String,
} }
pub fn gather(cfg: &Config, profile_name: &str) -> Status { pub fn gather(cfg: &Config, profile_name: &str) -> Status {
let iface = nm::wifi_interface(); let iface = nm::wifi_interface_preferred(cfg.settings.interface.as_deref());
let ssid = iface.as_deref().and_then(nm::active_ssid); let ssid = iface.as_deref().and_then(nm::active_ssid);
let ip = iface.as_deref().and_then(ipv4); let ip = iface.as_deref().and_then(nm::ipv4_address);
let internet = internet_ok(cfg); // Skip the (potentially 4s-blocking) connectivity probe when there's no
// Wi-Fi interface at all: the watch loop classifies NoAdapter and would
// otherwise burn a network round-trip (curl/ping) every tick for nothing.
let (internet, portal) = if iface.is_some() {
match connectivity(cfg) {
Connectivity::Online => (true, false),
Connectivity::Portal => (false, true),
Connectivity::NoNet => (false, false),
}
} else {
(false, false)
};
let prof = cfg.profile(profile_name); let prof = cfg.profile(profile_name);
let ts_required = prof.map(|p| p.tailscale).unwrap_or(false); let ts_required = prof.map(|p| p.tailscale).unwrap_or(false);
let exit_node = prof let exit_nodes = cfg.exit_nodes_for(profile_name);
.and_then(|p| p.exit_node.clone()) let exit_node = exit_nodes.first().cloned().unwrap_or_default();
.unwrap_or_else(|| cfg.settings.exit_node.clone());
// Checked whenever tailscale is installed so `status`/`doctor` can show
// it even for non-required profiles; classify only consults it when the
// profile requires Tailscale.
let tailscale = if tailscale::installed() { let tailscale = if tailscale::installed() {
Some(tailscale::check(&exit_node)) Some(tailscale::check(&exit_nodes))
} else { } else {
None None
}; };
@ -85,6 +113,7 @@ pub fn gather(cfg: &Config, profile_name: &str) -> Status {
ssid, ssid,
ip, ip,
internet, internet,
portal,
tailscale_required: ts_required, tailscale_required: ts_required,
tailscale, tailscale,
exit_node, exit_node,

View file

@ -21,6 +21,10 @@ pub enum TsHealth {
ExitNodeMissing, ExitNodeMissing,
/// The exit node exists but is offline. /// The exit node exists but is offline.
ExitNodeOffline, ExitNodeOffline,
/// The profile requires an exit node but none is configured
/// (`settings.exit_node` / per-profile `exit_node` empty). Cannot be
/// auto-fixed — the user must configure one.
NoExitNode,
Error(String), Error(String),
} }
@ -29,6 +33,21 @@ impl TsHealth {
matches!(self, TsHealth::Ok) matches!(self, TsHealth::Ok)
} }
/// Wire name for `bread.crumbs.*` event payloads (variant name, like
/// `Health::as_str`).
pub fn state_str(&self) -> &'static str {
match self {
TsHealth::Ok => "Ok",
TsHealth::NotInstalled => "NotInstalled",
TsHealth::NeedsLogin => "NeedsLogin",
TsHealth::Stopped => "Stopped",
TsHealth::ExitNodeMissing => "ExitNodeMissing",
TsHealth::ExitNodeOffline => "ExitNodeOffline",
TsHealth::NoExitNode => "NoExitNode",
TsHealth::Error(_) => "Error",
}
}
pub fn describe(&self) -> String { pub fn describe(&self) -> String {
match self { match self {
TsHealth::Ok => "ok".into(), TsHealth::Ok => "ok".into(),
@ -37,6 +56,7 @@ impl TsHealth {
TsHealth::Stopped => "backend stopped".into(), TsHealth::Stopped => "backend stopped".into(),
TsHealth::ExitNodeMissing => "exit node not found in tailnet".into(), TsHealth::ExitNodeMissing => "exit node not found in tailnet".into(),
TsHealth::ExitNodeOffline => "exit node is offline".into(), TsHealth::ExitNodeOffline => "exit node is offline".into(),
TsHealth::NoExitNode => "no exit node configured".into(),
TsHealth::Error(e) => format!("error: {e}"), TsHealth::Error(e) => format!("error: {e}"),
} }
} }
@ -219,16 +239,82 @@ fn run_login() {
} }
} }
/// Bring Tailscale to a state where `node` is the active, online exit node. /// Strip whitespace and drop empty entries from the acceptable-node list.
/// Performs at most one bring-up/login and one `tailscale set` attempt. fn effective_nodes(nodes: &[String]) -> Vec<String> {
pub fn ensure_exit_node(node: &str) -> TsHealth { nodes
.iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
/// Given a status JSON and the acceptable exit nodes, report `Ok` when the
/// active selection is one of them and online; otherwise the closest
/// actionable failure: not-selected (present + online, flow must select),
/// offline, or missing.
fn exit_node_health(nodes: &[String], v: &Value) -> TsHealth {
let mut any_exists = false;
let mut any_online = false;
let mut any_selected = false;
for node in nodes {
let (exists, online, selected) = exit_node_state(v, node);
if exists {
any_exists = true;
if online {
any_online = true;
}
if selected {
any_selected = true;
}
}
}
if any_selected && any_online {
TsHealth::Ok
} else if any_selected {
// The active exit node is one of ours but offline.
TsHealth::ExitNodeOffline
} else if any_online {
// Present + online but not selected — the flow will select it.
TsHealth::Error("exit node not selected".into())
} else if any_exists {
TsHealth::ExitNodeOffline
} else {
TsHealth::ExitNodeMissing
}
}
/// Bring Tailscale to a state where one of `nodes` (priority order) is the
/// active, online exit node. Performs at most one bring-up/login, then one
/// `tailscale set` per node until one takes.
pub fn ensure_exit_node(nodes: &[String]) -> TsHealth {
if !installed() { if !installed() {
return TsHealth::NotInstalled; return TsHealth::NotInstalled;
} }
let eff = effective_nodes(nodes);
if eff.is_empty() {
// Never run `tailscale set --exit-node=` with an empty node — that
// would clear the user's current exit-node selection. This is a
// config error, surfaced as its own health state.
return TsHealth::NoExitNode;
}
let v = match status_json() { let v = match status_json() {
Some(v) => v, Some(v) => v,
None => {
// Daemon unreachable — usually *not running*: a stopped daemon
// prints its error to stderr and leaves stdout empty, which is
// exactly why this branch used to be dead code (the
// BackendState "Stopped" case below only fires when the daemon
// is up but the backend is stopped). Try to bring it up before
// giving up; `tailscale up` is idempotent when already running
// and fails fast (no sudo prompt — stdin is /dev/null) when
// the caller lacks permission to manage the daemon.
let _ = run("tailscale", &["up"], Duration::from_secs(20));
match status_json() {
Some(v2) => v2,
None => return TsHealth::Error("could not read tailscale status".into()), None => return TsHealth::Error("could not read tailscale status".into()),
}
}
}; };
match backend_state(&v).as_str() { match backend_state(&v).as_str() {
@ -249,43 +335,45 @@ pub fn ensure_exit_node(node: &str) -> TsHealth {
_ => {} _ => {}
} }
// Select the exit node (idempotent). // Failover: try each acceptable node in priority order until one is
// selected and online.
for node in &eff {
let _ = run( let _ = run(
"tailscale", "tailscale",
&["set", &format!("--exit-node={node}")], &["set", &format!("--exit-node={node}")],
Duration::from_secs(10), Duration::from_secs(10),
); );
if let Some(v2) = status_json() {
if matches!(exit_node_health(std::slice::from_ref(node), &v2), TsHealth::Ok) {
return TsHealth::Ok;
}
}
}
let v = match status_json() { let v = match status_json() {
Some(v) => v, Some(v) => v,
None => return TsHealth::Error("could not re-read tailscale status".into()), None => return TsHealth::Error("could not re-read tailscale status".into()),
}; };
match backend_state(&v).as_str() { match backend_state(&v).as_str() {
"Running" => {} "Running" => {}
"NeedsLogin" | "NoState" => return TsHealth::NeedsLogin, "NeedsLogin" | "NoState" => return TsHealth::NeedsLogin,
"Stopped" => return TsHealth::Stopped, "Stopped" => return TsHealth::Stopped,
other => return TsHealth::Error(format!("backend state: {other}")), other => return TsHealth::Error(format!("backend state: {other}")),
} }
exit_node_health(&eff, &v)
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. /// Lightweight health check without trying to (re)configure anything.
pub fn check(node: &str) -> TsHealth { pub fn check(nodes: &[String]) -> TsHealth {
if !installed() { if !installed() {
return TsHealth::NotInstalled; return TsHealth::NotInstalled;
} }
let eff = effective_nodes(nodes);
if eff.is_empty() {
// Read-only check, so never runs `tailscale set` — an empty node is
// a config error, not something this probe can fix.
return TsHealth::NoExitNode;
}
let v = match status_json() { let v = match status_json() {
Some(v) => v, Some(v) => v,
None => return TsHealth::Error("status unavailable".into()), None => return TsHealth::Error("status unavailable".into()),
@ -296,16 +384,7 @@ pub fn check(node: &str) -> TsHealth {
"Stopped" => return TsHealth::Stopped, "Stopped" => return TsHealth::Stopped,
other => return TsHealth::Error(format!("backend state: {other}")), other => return TsHealth::Error(format!("backend state: {other}")),
} }
let (exists, online, selected) = exit_node_state(&v, node); exit_node_health(&eff, &v)
if !exists {
TsHealth::ExitNodeMissing
} else if !online {
TsHealth::ExitNodeOffline
} else if !selected {
TsHealth::Error("exit node not selected".into())
} else {
TsHealth::Ok
}
} }
#[cfg(test)] #[cfg(test)]
@ -433,6 +512,7 @@ mod tests {
assert!(!TsHealth::Stopped.is_ok()); assert!(!TsHealth::Stopped.is_ok());
assert!(!TsHealth::ExitNodeMissing.is_ok()); assert!(!TsHealth::ExitNodeMissing.is_ok());
assert!(!TsHealth::ExitNodeOffline.is_ok()); assert!(!TsHealth::ExitNodeOffline.is_ok());
assert!(!TsHealth::NoExitNode.is_ok());
assert!(!TsHealth::Error("x".into()).is_ok()); assert!(!TsHealth::Error("x".into()).is_ok());
} }
@ -443,6 +523,7 @@ mod tests {
TsHealth::NeedsLogin.describe(), TsHealth::NeedsLogin.describe(),
"not logged in (run: tailscale up)" "not logged in (run: tailscale up)"
); );
assert_eq!(TsHealth::NoExitNode.describe(), "no exit node configured");
assert_eq!(TsHealth::Error("boom".into()).describe(), "error: boom"); assert_eq!(TsHealth::Error("boom".into()).describe(), "error: boom");
} }
@ -454,4 +535,76 @@ mod tests {
); );
assert_eq!(extract_url("no url on this line"), None); assert_eq!(extract_url("no url on this line"), None);
} }
#[test]
fn effective_nodes_trims_and_drops_empties() {
assert_eq!(
effective_nodes(&[" a ".into(), "".into(), "b".into()]),
vec!["a".to_string(), "b".to_string()]
);
}
#[test]
fn exit_node_health_ok_when_any_node_selected_and_online() {
let v = json!({
"BackendState": "Running",
"Peer": {
"k1": { "HostName": "nodeB", "DNSName": "nodeB.ts.net.",
"Online": true, "ExitNode": true, "ExitNodeOption": true }
}
});
assert_eq!(
exit_node_health(&["nodeA".into(), "nodeB".into()], &v),
TsHealth::Ok
);
}
#[test]
fn exit_node_health_reports_not_selected_when_a_node_is_online_but_unselected() {
let v = json!({
"Peer": {
"k1": { "HostName": "nodeA", "DNSName": "nodeA.ts.net.",
"Online": true, "ExitNode": false, "ExitNodeOption": true }
}
});
assert_eq!(
exit_node_health(&["nodeA".into()], &v),
TsHealth::Error("exit node not selected".into())
);
}
#[test]
fn exit_node_health_reports_offline_when_all_exist_but_none_online() {
let v = json!({
"Peer": {
"k1": { "HostName": "nodeA", "DNSName": "nodeA.ts.net.",
"Online": false, "ExitNode": false, "ExitNodeOption": true }
}
});
assert_eq!(
exit_node_health(&["nodeA".into()], &v),
TsHealth::ExitNodeOffline
);
}
#[test]
fn exit_node_health_reports_missing_when_no_node_present() {
let v = json!({ "BackendState": "Running" });
assert_eq!(
exit_node_health(&["nodeA".into()], &v),
TsHealth::ExitNodeMissing
);
}
#[test]
fn state_str_is_the_variant_name() {
assert_eq!(TsHealth::Ok.state_str(), "Ok");
assert_eq!(TsHealth::NotInstalled.state_str(), "NotInstalled");
assert_eq!(TsHealth::NeedsLogin.state_str(), "NeedsLogin");
assert_eq!(TsHealth::Stopped.state_str(), "Stopped");
assert_eq!(TsHealth::ExitNodeMissing.state_str(), "ExitNodeMissing");
assert_eq!(TsHealth::ExitNodeOffline.state_str(), "ExitNodeOffline");
assert_eq!(TsHealth::NoExitNode.state_str(), "NoExitNode");
assert_eq!(TsHealth::Error("x".into()).state_str(), "Error");
}
} }

View file

@ -88,14 +88,14 @@ pub fn command_exists(name: &str) -> bool {
} }
/// Run a command with a hard timeout. The child is killed if it overruns so a /// Run a command with a hard timeout. The child is killed if it overruns so a
/// hung nmcli/tailscale can never wedge the daemon. /// hung subprocess can never wedge the daemon.
pub fn run(prog: &str, args: &[&str], timeout: Duration) -> Output { pub fn run(prog: &str, args: &[&str], timeout: Duration) -> Output {
run_with_stdin(prog, args, None, timeout) run_with_stdin(prog, args, None, timeout)
} }
/// Like [`run`], but feeds `stdin` to the child's standard input. Used to hand /// Like [`run`], but feeds `stdin` to the child's standard input.
/// secrets (e.g. Wi-Fi PSKs) to `nmcli --ask` without exposing them in argv, /// (Wi-Fi secrets no longer go through here: `nm` sends them inside D-Bus
/// where any local user could read them via `ps`. /// payloads, never on a command line.)
pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration) -> Output { pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration) -> Output {
RUNNER.with(|r| r.borrow().run(prog, args, stdin, timeout)) RUNNER.with(|r| r.borrow().run(prog, args, stdin, timeout))
} }
@ -139,13 +139,6 @@ fn spawn_run(prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration)
Err(_) => return Output::failed(), 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 stdout_pipe = child.stdout.take();
let mut stderr_pipe = child.stderr.take(); let mut stderr_pipe = child.stderr.take();
@ -164,6 +157,16 @@ fn spawn_run(prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration)
buf buf
}); });
// Feed stdin only now that the reader threads are draining stdout and
// stderr: a chatty child could otherwise fill its stdout pipe while we
// block writing stdin, deadlocking both sides.
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 start = Instant::now(); let start = Instant::now();
let status = loop { let status = loop {
match child.try_wait() { match child.try_wait() {
@ -190,6 +193,19 @@ fn spawn_run(prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration)
} }
} }
/// Current local "HH:MM" (24h), for the time-of-day schedule. `None` if the
/// clock can't be read — the schedule is skipped, never guessed.
pub fn local_hhmm() -> Option<String> {
let o = run("date", &["+%H:%M"], Duration::from_secs(2));
if o.success {
let t = o.stdout.trim().to_string();
if t.len() == 5 && t.as_bytes()[2] == b':' {
return Some(t);
}
}
None
}
/// Local "YYYY-MM-DD HH:MM:SS". Uses `date` for correct local time, falling /// 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. /// back to a dependency-free UTC computation if it is unavailable.
pub fn timestamp() -> String { pub fn timestamp() -> String {

View file

@ -1,19 +1,27 @@
use std::io::{BufRead, BufReader}; use std::collections::HashMap;
use std::process::{Command, Stdio};
use std::sync::mpsc::{self, Receiver}; use std::sync::mpsc::{self, Receiver};
use std::thread; use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use bread_utils::bread_client::BreadClient; use bread_utils::bread_client::BreadClient;
use zbus::blocking::{Connection, Proxy};
use zbus::zvariant::OwnedValue;
use crate::bread_events; use crate::bread_events;
use crate::config::Config; use crate::config::Config;
use crate::flow; use crate::flow;
use crate::nm;
use crate::notify::{log, notify, Urgency}; use crate::notify::{log, notify, Urgency};
use crate::state::State; use crate::state::{self, State};
use crate::status::{self}; use crate::status::{self};
use crate::tailscale::TsHealth; use crate::tailscale::TsHealth;
const NM_DEST: &str = "org.freedesktop.NetworkManager";
const NM_PATH: &str = "/org/freedesktop/NetworkManager";
const NM_IFACE: &str = "org.freedesktop.NetworkManager";
const DEV_IFACE: &str = "org.freedesktop.NetworkManager.Device";
const PROPS_IFACE: &str = "org.freedesktop.DBus.Properties";
/// Coarse health classification the watch loop reacts to each tick. `pub` /// Coarse health classification the watch loop reacts to each tick. `pub`
/// (and so is [`classify`]) purely so integration tests can drive the real /// (and so is [`classify`]) purely so integration tests can drive the real
/// classification logic in-process against a faked [`crate::util::Runner`], /// classification logic in-process against a faked [`crate::util::Runner`],
@ -23,6 +31,10 @@ use crate::tailscale::TsHealth;
pub enum Health { pub enum Health {
Up, Up,
DownNoNet, DownNoNet,
/// Traffic is being intercepted — a captive/guest portal answered the
/// connectivity check with 200/301/302 instead of 204. Not something
/// reconnecting fixes; the user must sign in.
CaptivePortal,
DownTailscaleManual, DownTailscaleManual,
DownTailscaleOther, DownTailscaleOther,
NoAdapter, NoAdapter,
@ -38,6 +50,7 @@ impl Health {
match self { match self {
Health::Up => "Up", Health::Up => "Up",
Health::DownNoNet => "DownNoNet", Health::DownNoNet => "DownNoNet",
Health::CaptivePortal => "CaptivePortal",
Health::DownTailscaleManual => "DownTailscaleManual", Health::DownTailscaleManual => "DownTailscaleManual",
Health::DownTailscaleOther => "DownTailscaleOther", Health::DownTailscaleOther => "DownTailscaleOther",
Health::NoAdapter => "NoAdapter", Health::NoAdapter => "NoAdapter",
@ -46,32 +59,71 @@ impl Health {
} }
} }
pub fn classify(cfg: &Config, profile: &str) -> (Health, Option<String>) { /// Everything the watch loop needs to know about one health observation:
/// the classification plus the context used for events and notifications.
#[derive(Debug, Clone)]
pub struct Classification {
pub health: Health,
pub ssid: Option<String>,
pub iface: Option<String>,
pub ip: Option<String>,
pub tailscale: Option<TsHealth>,
pub exit_node: String,
}
pub fn classify(cfg: &Config, profile: &str) -> Classification {
// Checked before gather(): a profile missing from config would otherwise // Checked before gather(): a profile missing from config would otherwise
// silently fall back to "tailscale not required" and read as healthy off // silently fall back to "tailscale not required" and read as healthy off
// of nothing but a bare internet check, never surfacing the misconfig. // of nothing but a bare internet check, never surfacing the misconfig.
if cfg.profile(profile).is_none() { if cfg.profile(profile).is_none() {
return (Health::UnknownProfile, None); return Classification {
health: Health::UnknownProfile,
ssid: None,
iface: None,
ip: None,
tailscale: None,
exit_node: String::new(),
};
} }
let s = status::gather(cfg, profile); let s = status::gather(cfg, profile);
if s.iface.is_none() { if s.iface.is_none() {
return (Health::NoAdapter, None); return Classification {
health: Health::NoAdapter,
ssid: None,
iface: None,
ip: None,
tailscale: None,
exit_node: s.exit_node,
};
} }
let ssid = s.ssid.clone(); let ssid = s.ssid.clone();
if !s.internet { let health = if !s.internet {
return (Health::DownNoNet, ssid); if s.portal {
Health::CaptivePortal
} else {
Health::DownNoNet
} }
if s.tailscale_required { } else if s.tailscale_required {
match s.tailscale { match s.tailscale {
Some(TsHealth::Ok) => (Health::Up, ssid), Some(TsHealth::Ok) => Health::Up,
Some(TsHealth::NeedsLogin) | Some(TsHealth::NotInstalled) => { // NeedsLogin / NotInstalled / NoExitNode all need human action:
(Health::DownTailscaleManual, ssid) // a missing exit-node config can't be auto-fixed either.
} Some(TsHealth::NeedsLogin)
Some(_) => (Health::DownTailscaleOther, ssid), | Some(TsHealth::NotInstalled)
None => (Health::DownTailscaleManual, ssid), | Some(TsHealth::NoExitNode) => Health::DownTailscaleManual,
Some(_) => Health::DownTailscaleOther,
None => Health::DownTailscaleManual,
} }
} else { } else {
(Health::Up, ssid) Health::Up
};
Classification {
health,
ssid,
iface: s.iface,
ip: s.ip,
tailscale: s.tailscale,
exit_node: s.exit_node,
} }
} }
@ -84,61 +136,131 @@ fn debounce_ready(last: Option<Instant>, gap: Duration) -> bool {
last.map(|t| t.elapsed() > gap).unwrap_or(true) last.map(|t| t.elapsed() > gap).unwrap_or(true)
} }
/// Tail `nmcli monitor` and ping the channel on link-state churn so we react /// Whether the flow-recovery cooldown has elapsed since the last `flow::run`
/// to drops within a second instead of waiting out the poll interval. /// (or one never ran). Pure so the recovery pacing is unit-testable.
fn spawn_nm_monitor(tx: mpsc::Sender<()>) { fn recovery_due(last_flow_at: Option<Instant>, now: Instant, cooldown_secs: u64) -> bool {
last_flow_at
.map(|t| now.duration_since(t).as_secs())
.unwrap_or(u64::MAX)
>= cooldown_secs
}
/// A wake signal for the watch loop. `SetProfile` is an *action* (applied on
/// the loop thread), `LinkChurn` is just "go look" — the distinction keeps
/// every config/state file access on the single loop thread, so the bread
/// subscription thread can never race the loop's own `Config::load`/`save`.
enum Wake {
LinkChurn,
SetProfile(String),
}
/// Whether a `PropertiesChanged` message on the NM root changes the
/// `Connectivity` property of the NetworkManager interface — the signal
/// that catches "still connected but lost the internet" (portal, DHCP
/// failure) without waiting out the poll interval.
fn props_changed_connectivity(msg: &zbus::Message) -> bool {
let Ok(body) = msg.body().deserialize::<(String, HashMap<String, OwnedValue>, Vec<String>)>() else {
return false;
};
body.0 == NM_IFACE && body.1.contains_key("Connectivity")
}
/// Subscribe to a D-Bus signal from NetworkManager and ping the channel for
/// each matching message, reconnecting on bus/NM restarts. One thread per
/// subscription (a handful at most); each owns its own connection so a dead
/// bus can't wedge the others.
fn spawn_signal_watcher<F>(tx: mpsc::Sender<Wake>, path: String, iface: &'static str, signal: &'static str, mut on_msg: F)
where
F: FnMut(&zbus::Message) -> bool + Send + 'static,
{
thread::spawn(move || loop { thread::spawn(move || loop {
let child = Command::new("nmcli") let Ok(conn) = Connection::system() else {
.arg("monitor") thread::sleep(Duration::from_secs(10));
.stdin(Stdio::null()) continue;
.stdout(Stdio::piped()) };
.stderr(Stdio::null()) let Ok(proxy) = Proxy::new(&conn, NM_DEST, path.as_str(), iface) else {
.spawn(); thread::sleep(Duration::from_secs(10));
let mut child = match child { continue;
Ok(c) => c, };
Err(_) => { let Ok(mut iter) = proxy.receive_signal(signal) else {
thread::sleep(Duration::from_secs(10)); thread::sleep(Duration::from_secs(10));
continue; continue;
}
}; };
if let Some(out) = child.stdout.take() {
let reader = BufReader::new(out);
// `None` means "haven't fired yet, so fire on the first interesting // `None` means "haven't fired yet, so fire on the first interesting
// line". Storing an `Option` instead of seeding with // signal". Storing an `Option` instead of seeding with
// `Instant::now() - 10s` avoids a panic: `Instant - Duration` // `Instant::now() - 10s` avoids a panic: `Instant - Duration`
// underflows (and panics) when the monotonic clock is younger than // underflows (and panics) when the monotonic clock is younger than
// the offset, which happens if `watch` starts within ~10s of boot — // the offset, which happens if `watch` starts within ~10s of boot —
// exactly when the systemd unit (ordered after graphical-session) // exactly when the systemd unit (ordered after graphical-session)
// tends to launch. // tends to launch.
let mut last: Option<Instant> = None; let mut last: Option<Instant> = None;
for line in reader.lines().map_while(Result::ok) { for msg in iter.by_ref() {
let l = line.to_lowercase(); if on_msg(&msg) && debounce_ready(last, Duration::from_millis(1500)) {
let interesting = l.contains("disconnect")
|| l.contains("unavailable")
|| l.contains("failed");
if interesting && debounce_ready(last, Duration::from_millis(1500)) {
last = Some(Instant::now()); last = Some(Instant::now());
let _ = tx.send(()); let _ = tx.send(Wake::LinkChurn);
} }
} }
} // Subscription died (NM or bus restart) — back off and resubscribe.
let _ = child.wait();
// monitor died (NM restart?) — back off and respawn.
thread::sleep(Duration::from_secs(5)); thread::sleep(Duration::from_secs(5));
}); });
} }
/// Sleep up to `dur`, but wake early if `nmcli monitor` signals link churn. /// Subscribe to NetworkManager D-Bus signals and ping the channel on
fn wait_for_tick(rx: &Receiver<()>, dur: Duration) { /// link-state churn so we react to drops within a second instead of waiting
match rx.recv_timeout(dur) { /// out the poll interval. Replaces the old `nmcli monitor` subprocess: the
Ok(()) => { /// same events are observed, but as structured D-Bus signals.
// Drain any burst of events so we don't re-fire immediately. ///
while rx.try_recv().is_ok() {} /// Watched signals:
/// - `PropertiesChanged` on the NM root object, filtered to the
/// `Connectivity` property — catches captive portals / DHCP failures that
/// keep the device "connected" while losing the internet;
/// - `DeviceAdded` / `DeviceRemoved` — hotplug;
/// - `Device.StateChanged` on every Wi-Fi device — drops and reconnects.
fn spawn_nm_monitor(tx: mpsc::Sender<Wake>) {
spawn_signal_watcher(
tx.clone(),
NM_PATH.to_string(),
PROPS_IFACE,
"PropertiesChanged",
props_changed_connectivity,
);
spawn_signal_watcher(tx.clone(), NM_PATH.to_string(), NM_IFACE, "DeviceAdded", |_| true);
spawn_signal_watcher(tx.clone(), NM_PATH.to_string(), NM_IFACE, "DeviceRemoved", |_| true);
// StateChanged on each Wi-Fi device. Devices added later (USB dongle
// hotplug) are caught by the DeviceAdded watcher waking the loop; the
// poll interval covers anything else.
for path in nm::wifi_device_paths() {
spawn_signal_watcher(tx.clone(), path, DEV_IFACE, "StateChanged", |_| true);
} }
Err(mpsc::RecvTimeoutError::Timeout) => {} }
/// Sleep up to `dur`, but wake early if the D-Bus signal monitor signals
/// link churn or a `set_profile` command arrives. Returns the pending
/// action, if any.
fn wait_for_tick(rx: &Receiver<Wake>, dur: Duration) -> Option<Wake> {
match rx.recv_timeout(dur) {
Ok(first) => {
// Drain any burst of churn signals so we don't re-fire
// immediately, but never drop a queued set_profile — it's an
// action, not a signal, and the earliest one wins.
let mut pending = match &first {
Wake::SetProfile(_) => Some(first),
Wake::LinkChurn => None,
};
while let Ok(w) = rx.try_recv() {
if pending.is_none() && matches!(&w, Wake::SetProfile(_)) {
pending = Some(w);
}
}
pending
}
Err(mpsc::RecvTimeoutError::Timeout) => None,
// Monitor thread gone (shouldn't happen: we hold the sender) — fall // Monitor thread gone (shouldn't happen: we hold the sender) — fall
// back to a plain sleep so we don't busy-spin. // back to a plain sleep so we don't busy-spin.
Err(mpsc::RecvTimeoutError::Disconnected) => thread::sleep(dur), Err(mpsc::RecvTimeoutError::Disconnected) => {
thread::sleep(dur);
None
}
} }
} }
@ -151,57 +273,112 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
); );
log("watch: started"); log("watch: started");
let (tx, rx) = mpsc::channel::<()>(); let (tx, rx) = mpsc::channel::<Wake>();
spawn_nm_monitor(tx.clone()); spawn_nm_monitor(tx.clone());
// Long-lived, so this uses BreadClient::subscribe (a persistent // Long-lived, so this uses BreadClient::subscribe (a persistent
// background thread with its own reconnect/backoff loop). breadd being // background thread with its own reconnect/backoff loop). breadd being
// absent or restarting is transparent: the subscription just quietly // absent or restarting is transparent: the subscription just quietly
// stops delivering commands until it reconnects. A successful // stops delivering commands until it reconnects. The callback only
// `set_profile` wakes this loop the same way `nmcli monitor` does, so // *validates* the command and forwards an action through the channel —
// the new profile is applied on the next tick instead of waiting out // it never touches config/state files itself (that would race this
// the current poll interval. // loop's own Config::load/save), so all file access stays on this one
// thread.
let bread = BreadClient::connect(bread_events::APP_ID); let bread = BreadClient::connect(bread_events::APP_ID);
let wake = tx; let wake = tx;
let _commands = bread.subscribe("bread.command.crumbs.**", move |event| { let _commands = bread.subscribe("bread.command.crumbs.**", move |event| {
if bread_events::handle_command(&event) { match bread_events::handle_command(&event) {
let _ = wake.send(()); bread_events::CommandAction::SetProfile(name) => {
let _ = wake.send(Wake::SetProfile(name));
}
bread_events::CommandAction::Ignore => {}
} }
}); });
let mut profile = State::load(&cfg.settings.default_profile).profile; let mut profile = State::load(&cfg.settings.default_profile).profile;
if run_initial { if run_initial {
// Don't churn an already-working connection on (re)start. // Don't churn an already-working connection on (re)start.
let (h, _) = classify(&cfg, &profile); let class = classify(&cfg, &profile);
if h == Health::Up { if class.health == Health::Up {
log(&format!( log(&format!(
"watch: already healthy on start (profile={profile}); skipping initial flow" "watch: already healthy on start (profile={profile}); skipping initial flow"
)); ));
} else { } else {
log(&format!("watch: initial flow for profile={profile}")); log(&format!("watch: initial flow for profile={profile}"));
let _ = flow::run(&mut cfg, &profile); let _ = flow::run_quiet(&mut cfg, &profile);
} }
} }
let mut prev_health: Option<Health> = None; let mut prev_health: Option<Health> = None;
let mut prev_profile = profile.clone(); let mut prev_profile = profile.clone();
let mut prev_ssid: Option<String> = None;
let mut prev_ts: Option<&'static str> = None;
let mut fail_streak: u32 = 0; let mut fail_streak: u32 = 0;
let mut last_flow_at: Option<Instant> = None; let mut last_flow_at: Option<Instant> = None;
const FLOW_COOLDOWN: u64 = 20; const FLOW_COOLDOWN: u64 = 20;
const RESUME_SLACK: Duration = Duration::from_secs(60);
const SCHEDULE_GRACE: Duration = Duration::from_secs(30 * 60);
let mut prev_wait = Duration::from_secs(base);
let mut last_tick_at = Instant::now();
// Tracks what the *schedule* last applied, so the loop can tell a
// manual `profile set` (CLI or bus) apart from its own switch and give
// manual changes a grace window before the schedule overrides them.
let mut last_schedule_applied: Option<String> = Some(profile.clone());
let mut manual_set_at: Option<Instant> = None;
loop { loop {
// Reload config + state so edits and `profile set` take effect live. // Reload config + state so edits and `profile set` take effect live.
// This always runs *before* `flow::run` below (never after, within // This always runs *before* `flow::run` below (never after, within
// the same tick), so a password `flow::run` clears-and-saves this // the same tick), so a password `flow::run` clears-and-saves this
// iteration is durably on disk by the time the *next* iteration's // iteration is durably on disk by the time the *next* iteration's
// reload runs — there's no window where a stale (still-has-password) // reload runs. All config/state file access happens on this loop
// reload could clobber the save, since the two never race on // thread: `set_profile` commands from the bread bus are queued as
// different threads: everything here is sequential on this loop. // [`Wake::SetProfile`] and applied here (see the bottom of the
// loop), never on the subscription thread.
if let Ok(fresh) = Config::load() { if let Ok(fresh) = Config::load() {
cfg = fresh; cfg = fresh;
} }
profile = State::load(&cfg.settings.default_profile).profile; profile = State::load(&cfg.settings.default_profile).profile;
// Suspend/resume: the D-Bus signal monitor sees nothing while the
// machine sleeps, so a large wall-clock gap means the network state
// may have changed underneath us — allow an immediate recovery run
// instead of waiting out any remaining flow cooldown.
if last_tick_at.elapsed() > prev_wait + RESUME_SLACK {
log("watch: large gap since last tick (suspend/resume?) — forcing recovery check");
last_flow_at = None;
}
// Time-of-day schedule: switch to the scheduled profile when its
// window is active, unless the user manually set the profile within
// the grace window.
if last_schedule_applied.as_deref() != Some(profile.as_str()) {
// The persisted profile changed and it wasn't our own schedule
// switch — a manual set (CLI or bus). Start the grace window.
if last_schedule_applied.is_some() {
manual_set_at = Some(Instant::now());
}
last_schedule_applied = Some(profile.clone());
}
if let Some(sched) = scheduled_profile_now(&cfg) {
if sched != profile {
let grace_ok = manual_set_at
.map(|t| t.elapsed() >= SCHEDULE_GRACE)
.unwrap_or(true);
if grace_ok {
if state::set_profile(&cfg, &sched).is_ok() {
log(&format!("watch: schedule applied profile {sched}"));
last_schedule_applied = Some(sched.clone());
manual_set_at = None;
}
} else {
log(&format!(
"watch: schedule would switch to {sched}, but a manual set is still in grace"
));
}
}
}
let profile_changed = profile != prev_profile; let profile_changed = profile != prev_profile;
if profile_changed { if profile_changed {
log(&format!( log(&format!(
@ -215,13 +392,42 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
bread_events::emit_profile_changed(&bread, &prev_profile, &profile); bread_events::emit_profile_changed(&bread, &prev_profile, &profile);
prev_profile = profile.clone(); prev_profile = profile.clone();
prev_health = None; // force re-evaluation/recovery for new profile prev_health = None; // force re-evaluation/recovery for new profile
prev_ssid = None; // a profile switch is a fresh network context
prev_ts = None;
last_flow_at = None; // allow immediate recovery on profile change last_flow_at = None; // allow immediate recovery on profile change
} }
let (health, ssid) = classify(&cfg, &profile); let class = classify(&cfg, &profile);
let health = class.health.clone();
let ssid = class.ssid.clone();
let transition = prev_health.as_ref() != Some(&health); let transition = prev_health.as_ref() != Some(&health);
if transition { if transition {
bread_events::emit_health_changed(&bread, &profile, health.as_str(), ssid.as_deref()); bread_events::emit_health_changed(
&bread,
bread_events::HealthChanged {
profile: &profile,
health: health.as_str(),
ssid: ssid.as_deref(),
iface: class.iface.as_deref(),
ip: class.ip.as_deref(),
exit_node: &class.exit_node,
tailscale: class.tailscale.as_ref().map(|t| t.state_str()),
},
);
}
if class.ssid != prev_ssid {
bread_events::emit_network_changed(
&bread,
prev_ssid.as_deref(),
class.ssid.as_deref(),
&profile,
);
prev_ssid = class.ssid.clone();
}
let ts_state = class.tailscale.as_ref().map(|t| t.state_str());
if ts_state != prev_ts {
bread_events::emit_tailscale_changed(&bread, &profile, ts_state, &class.exit_node);
prev_ts = ts_state;
} }
match &health { match &health {
@ -249,30 +455,56 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
fail_streak = fail_streak.saturating_add(1); fail_streak = fail_streak.saturating_add(1);
} }
Health::UnknownProfile => { Health::UnknownProfile => {
// flow::run() already notifies + logs the "unknown profile" // flow::run() is quiet from here, so surface the misconfig
// critical error; re-running it here would just spam that on // ourselves — once per transition/change, not every tick.
// every tick, so only surface it once per transition.
if transition || profile_changed { if transition || profile_changed {
let _ = flow::run(&mut cfg, &profile);
}
fail_streak = fail_streak.saturating_add(1);
}
Health::DownTailscaleManual => {
// Can't be auto-fixed (login / not installed). Notify once.
if transition {
notify( notify(
"Tailscale Error", "breadcrumbs: unknown profile",
"Tailscale needs manual attention (login / install). \ &format!("'{profile}' is not defined in breadcrumbs.toml"),
Other Wi-Fi automation paused until resolved.",
Urgency::Critical, Urgency::Critical,
); );
} }
// Re-run flow only on transition so we land on the bootstrap net.
if transition || profile_changed {
let _ = flow::run(&mut cfg, &profile);
}
fail_streak = fail_streak.saturating_add(1); fail_streak = fail_streak.saturating_add(1);
} }
Health::CaptivePortal => {
if transition {
notify(
"breadcrumbs: captive portal detected",
"Traffic is being intercepted — open a browser and sign in.",
Urgency::Normal,
);
}
// Reconnecting won't fix a portal; keep the poll fast (don't
// count it as a failure) so a successful sign-in is noticed
// promptly and the state flips back to Up.
fail_streak = 0;
}
Health::DownTailscaleManual => {
// Can't be auto-fixed (login / install / exit-node config).
// Notify once per transition.
if transition {
notify(
"Tailscale Error",
"Tailscale needs manual attention (login / install / \
exit node config). Other Wi-Fi automation paused \
until resolved.",
Urgency::Critical,
);
}
// Re-attempt periodically and on the transition into this
// state: login may have completed since the last attempt, or
// the user may have missed the browser window. Quiet — a
// still-broken state must not re-notify on every retry.
if recovery_due(last_flow_at, Instant::now(), FLOW_COOLDOWN) {
let outcome = flow::run_quiet(&mut cfg, &profile);
last_flow_at = Some(Instant::now());
fail_streak = if outcome.ok() {
0
} else {
fail_streak.saturating_add(1)
};
}
}
Health::DownNoNet | Health::DownTailscaleOther => { Health::DownNoNet | Health::DownTailscaleOther => {
if transition { if transition {
notify( notify(
@ -281,13 +513,12 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
Urgency::Normal, Urgency::Normal,
); );
} }
let elapsed = last_flow_at.map(|t| t.elapsed().as_secs()).unwrap_or(u64::MAX); if recovery_due(last_flow_at, Instant::now(), FLOW_COOLDOWN) {
if elapsed >= FLOW_COOLDOWN {
log(&format!( log(&format!(
"watch: down ({:?}) profile={profile} ssid={:?} — running flow", "watch: down ({:?}) profile={profile} ssid={:?} — running flow",
health, ssid health, ssid
)); ));
let outcome = flow::run(&mut cfg, &profile); let outcome = flow::run_quiet(&mut cfg, &profile);
log(&format!("watch: recovery outcome = {:?}", outcome)); log(&format!("watch: recovery outcome = {:?}", outcome));
last_flow_at = Some(Instant::now()); last_flow_at = Some(Instant::now());
fail_streak = if outcome.ok() { fail_streak = if outcome.ok() {
@ -297,7 +528,7 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
}; };
} else { } else {
log(&format!( log(&format!(
"watch: down ({:?}) — cooldown ({elapsed}s/{FLOW_COOLDOWN}s), skipping flow", "watch: down ({:?}) — cooldown, skipping flow",
health health
)); ));
} }
@ -309,8 +540,23 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
// Adaptive backoff: healthy -> base; failing -> grow up to ~6x. // Adaptive backoff: healthy -> base; failing -> grow up to ~6x.
let mult = 1 + fail_streak.min(5); let mult = 1 + fail_streak.min(5);
let dur = Duration::from_secs(base * mult as u64); let dur = Duration::from_secs(base * mult as u64);
wait_for_tick(&rx, dur); prev_wait = dur;
last_tick_at = Instant::now();
// Apply a queued set_profile on this thread — the single owner of
// config/state file access — and emit the confirmation. The next
// iteration's reload sees the new profile and recovers accordingly.
if let Some(Wake::SetProfile(name)) = wait_for_tick(&rx, dur) {
bread_events::apply_set_profile(&name);
} }
}
}
/// The profile a time-of-day schedule wants right now, if any. Returns
/// `None` when no schedule is configured or the local time can't be read.
fn scheduled_profile_now(cfg: &Config) -> Option<String> {
let hhmm = crate::util::local_hhmm()?;
let mins = crate::config::hhmm_to_minutes(&hhmm)?;
cfg.settings.scheduled_profile(mins)
} }
#[cfg(test)] #[cfg(test)]
@ -347,4 +593,48 @@ mod tests {
assert_eq!(Health::NoAdapter.as_str(), "NoAdapter"); assert_eq!(Health::NoAdapter.as_str(), "NoAdapter");
assert_eq!(Health::UnknownProfile.as_str(), "UnknownProfile"); assert_eq!(Health::UnknownProfile.as_str(), "UnknownProfile");
} }
#[test]
fn wait_for_tick_returns_pending_set_profile_and_drains_churn() {
// A queued set_profile is an action, not a signal: it must survive
// the churn-burst drain and be returned to the loop.
let (tx, rx) = mpsc::channel::<Wake>();
let _ = tx.send(Wake::LinkChurn);
let _ = tx.send(Wake::SetProfile("home".into()));
let _ = tx.send(Wake::LinkChurn);
let wake = wait_for_tick(&rx, Duration::from_millis(10));
assert!(matches!(wake, Some(Wake::SetProfile(n)) if n == "home"));
// The burst was fully drained.
assert!(rx.try_recv().is_err());
}
#[test]
fn wait_for_tick_drains_churn_burst_without_action() {
// A burst of monitor signals collapses to one wake with no action.
let (tx, rx) = mpsc::channel::<Wake>();
let _ = tx.send(Wake::LinkChurn);
let _ = tx.send(Wake::LinkChurn);
let _ = tx.send(Wake::LinkChurn);
assert!(wait_for_tick(&rx, Duration::from_millis(10)).is_none());
assert!(rx.try_recv().is_err());
}
#[test]
fn wait_for_tick_times_out_with_no_signal() {
let (_tx, rx) = mpsc::channel::<Wake>();
assert!(wait_for_tick(&rx, Duration::from_millis(10)).is_none());
}
#[test]
fn recovery_due_fires_when_never_run_and_after_cooldown() {
// Never run → due immediately (the map() to u64::MAX path).
assert!(recovery_due(None, Instant::now(), 20));
// Just ran → not due again yet.
let now = Instant::now();
assert!(!recovery_due(Some(now), now, 20));
// A zero cooldown is always already-elapsed.
assert!(recovery_due(Some(now), now, 0));
}
} }

View file

@ -1,6 +1,10 @@
//! End-to-end CLI tests. Each run is fully isolated: HOME / XDG dirs point at a //! End-to-end CLI tests. Each run is fully isolated: HOME / XDG dirs point at a
//! throwaway tempdir and PATH is emptied so no real `nmcli`/`tailscale`/`date` //! throwaway tempdir, PATH is emptied so no real `tailscale`/`curl`/`date` is
//! is ever invoked and the host system is never touched. //! ever invoked, and a private `dbus-daemon` (optionally hosting a fake
//! NetworkManager service — see `tests/common::fake_nm`) stands in for the
//! system bus so the binary's D-Bus calls never touch the host.
mod common;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
@ -8,12 +12,15 @@ use std::process::Command;
use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::atomic::{AtomicU32, Ordering};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use common::fake_nm::{self, Security};
const BIN: &str = env!("CARGO_BIN_EXE_breadcrumbs"); const BIN: &str = env!("CARGO_BIN_EXE_breadcrumbs");
static COUNTER: AtomicU32 = AtomicU32::new(0); static COUNTER: AtomicU32 = AtomicU32::new(0);
struct Sandbox { struct Sandbox {
root: PathBuf, root: PathBuf,
_bus: fake_nm::Daemon,
} }
impl Sandbox { impl Sandbox {
@ -30,7 +37,16 @@ impl Sandbox {
nanos nanos
)); ));
fs::create_dir_all(root.join("bin")).unwrap(); fs::create_dir_all(root.join("bin")).unwrap();
Sandbox { root } Sandbox {
root,
_bus: fake_nm::launch_daemon(),
}
}
/// Attach the fake NetworkManager service to this sandbox's bus and
/// return a handle for driving its state.
fn nm(&self) -> fake_nm::FakeNmBus {
fake_nm::serve_on(&self._bus.addr)
} }
/// Binary invocation with an isolated, side-effect-free environment. /// Binary invocation with an isolated, side-effect-free environment.
@ -48,7 +64,10 @@ impl Sandbox {
.env("XDG_CONFIG_HOME", self.root.join("config")) .env("XDG_CONFIG_HOME", self.root.join("config"))
.env("XDG_STATE_HOME", self.root.join("state")) .env("XDG_STATE_HOME", self.root.join("state"))
// Empty bin dir => no external commands resolve. // Empty bin dir => no external commands resolve.
.env("PATH", self.root.join("bin")); .env("PATH", self.root.join("bin"))
// Point the binary's `Connection::system()` at this test's
// private bus so it never touches a real system bus.
.env("DBUS_SYSTEM_BUS_ADDRESS", &self._bus.addr);
for (k, v) in extra { for (k, v) in extra {
c.env(k, v); c.env(k, v);
} }
@ -66,7 +85,7 @@ impl Sandbox {
} }
/// Write an executable shell script into the sandbox's PATH dir so a /// Write an executable shell script into the sandbox's PATH dir so a
/// test can stand in for an external command (e.g. `$EDITOR`). /// test can stand in for an external command (e.g. `$EDITOR`, `curl`).
fn write_fake_bin(&self, name: &str, script: &str) -> PathBuf { fn write_fake_bin(&self, name: &str, script: &str) -> PathBuf {
let path = self.root.join("bin").join(name); let path = self.root.join("bin").join(name);
fs::write(&path, script).unwrap(); fs::write(&path, script).unwrap();
@ -151,7 +170,7 @@ fn profile_defaults_to_away_then_persists_set() {
assert!(o.status.success()); assert!(o.status.success());
assert_eq!(stdout(&o).trim(), "away"); assert_eq!(stdout(&o).trim(), "away");
// `set --no-apply` must not touch the network (no nmcli available anyway). // `set --no-apply` must not touch the network (no NM on the bus anyway).
let o = sb.cmd(&["profile", "set", "home", "--no-apply"]); let o = sb.cmd(&["profile", "set", "home", "--no-apply"]);
assert!( assert!(
o.status.success(), o.status.success(),
@ -274,7 +293,9 @@ fn detect_without_wifi_adapter_errors() {
} }
#[test] #[test]
fn doctor_reports_missing_nmcli_in_sandbox() { fn doctor_reports_missing_network_manager_on_private_bus() {
// The sandbox bus has no NetworkManager service on it, so doctor must
// report it missing rather than assuming presence.
let sb = Sandbox::new(); let sb = Sandbox::new();
let o = sb.cmd(&["doctor"]); let o = sb.cmd(&["doctor"]);
assert!(o.status.success(), "stderr: {}", stderr(&o)); assert!(o.status.success(), "stderr: {}", stderr(&o));
@ -308,7 +329,10 @@ fn install_service_no_enable_writes_valid_unit_file() {
let o = sb.cmd(&["install-service", "--no-enable"]); let o = sb.cmd(&["install-service", "--no-enable"]);
assert!(o.status.success(), "stderr: {}", stderr(&o)); assert!(o.status.success(), "stderr: {}", stderr(&o));
let unit_path = sb.root.join(".config/systemd/user/breadcrumbs.service"); // The sandbox sets XDG_CONFIG_HOME=$root/config, so the unit lands
// under $XDG_CONFIG_HOME/systemd/user — the whole point of the fix is
// honoring XDG rather than hardcoding ~/.config.
let unit_path = sb.root.join("config/systemd/user/breadcrumbs.service");
assert!(unit_path.exists()); assert!(unit_path.exists());
let text = fs::read_to_string(unit_path).unwrap(); let text = fs::read_to_string(unit_path).unwrap();
assert!(text.contains("ExecStart=")); assert!(text.contains("ExecStart="));
@ -392,68 +416,25 @@ fn add_with_empty_password_is_stored_as_no_password() {
// NM-owned credentials (item 4): a password is only ever needed once. // NM-owned credentials (item 4): a password is only ever needed once.
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/// A fake `nmcli` that behaves statefully enough to exercise the "first
/// connect creates a profile with a password, second connect reuses it
/// without one" path: it records every invocation's argv (one per line) to
/// `$HOME/.nmcli-calls`, and remembers — via a marker file, also under
/// `$HOME` — that "device wifi connect TestNet" has already run, so a
/// following `connection show` reports a saved profile exists.
const FAKE_NMCLI_STATEFUL: &str = r#"#!/bin/sh
record="$HOME/.nmcli-calls"
marker="$HOME/.nmcli-profile-created"
echo "$@" >> "$record"
args="$*"
case "$args" in
"-t -f DEVICE,TYPE device status")
echo "wlan0:wifi" ;;
"radio wifi on") ;;
"device wifi rescan"*) ;;
"-t -f SSID device wifi list ifname wlan0")
echo "TestNet" ;;
"-t -f NAME,TYPE connection show")
if [ -f "$marker" ]; then
echo "TestNet:802-11-wireless"
fi
;;
*"device wifi connect TestNet"*)
# `: > file` (truncate-or-create via a shell builtin + redirection)
# not `touch`, which is an external binary and the sandbox's PATH
# deliberately contains nothing but this fake nmcli itself.
: > "$marker" ;;
*"connection up TestNet"*) ;;
*"802-11-wireless-security.psk"*) ;;
"-g GENERAL.CON-UUID device show wlan0")
echo "uuid-1" ;;
*"ipv4.ignore-auto-dns"*) ;;
"device reapply wlan0") ;;
"-t -f DEVICE,STATE device status")
echo "wlan0:connected" ;;
*) ;;
esac
exit 0
"#;
#[test] #[test]
fn password_is_cleared_after_first_connect_and_never_sent_again() { fn password_is_cleared_after_first_connect_and_never_sent_again() {
// The first connect creates an NM profile carrying the PSK (over D-Bus,
// never in argv); the second connect reuses that profile and must not
// create a duplicate or resend the password.
let sb = Sandbox::new(); let sb = Sandbox::new();
sb.write_fake_bin("nmcli", FAKE_NMCLI_STATEFUL); let nm = sb.nm();
let dev = nm.add_wifi_device("wlan0", 100);
nm.add_ap(&dev, "TestNet", 80, Security::Wpa2);
let add = sb.cmd(&["add", "TestNet", "hunter2"]); let add = sb.cmd(&["add", "TestNet", "hunter2"]);
assert!(add.status.success(), "stderr: {}", stderr(&add)); assert!(add.status.success(), "stderr: {}", stderr(&add));
// "away" is the default profile and defaults to include_all_known, so // "away" is the default profile and defaults to include_all_known, so
// TestNet is already a connect candidate with no `--to` needed. // TestNet is already a connect candidate with no `--to` needed.
let record = sb.root.join(".nmcli-calls"); // First connect: no saved NM profile yet, so breadcrumbs creates one
// with the password.
// First connect: no saved NM profile yet, so breadcrumbs creates one via
// `device wifi connect ... password hunter2 ...`.
let first = sb.cmd(&["init"]); let first = sb.cmd(&["init"]);
assert!(first.status.success(), "stderr: {}", stderr(&first)); assert!(first.status.success(), "stderr: {}", stderr(&first));
let first_calls = fs::read_to_string(&record).unwrap_or_default();
assert!(
first_calls.contains("device wifi connect TestNet") && first_calls.contains("hunter2"),
"first connect should create a new NM profile with the password: {first_calls}"
);
// The local copy is gone from disk immediately after. // The local copy is gone from disk immediately after.
let networks = fs::read_to_string(sb.networks_file()).unwrap(); let networks = fs::read_to_string(sb.networks_file()).unwrap();
@ -463,49 +444,41 @@ fn password_is_cleared_after_first_connect_and_never_sent_again() {
); );
assert!(networks.contains("TestNet"), "network entry itself should remain"); assert!(networks.contains("TestNet"), "network entry itself should remain");
// Reset the recording so the second run's argv can be checked in isolation. // The NM profile durably holds the secret.
fs::write(&record, "").unwrap(); let psk = {
let st = nm.state.lock().unwrap();
st.connections
.values()
.filter_map(|s| {
let sec = s.get("802-11-wireless-security")?;
sec.get("psk").and_then(|v| v.downcast_ref::<String>().ok())
})
.next()
};
assert_eq!(psk.as_deref(), Some("hunter2"), "NM profile must hold the PSK");
// Second connect: a saved profile now exists (per the fake nmcli's own // Second connect: breadcrumbs has no local password anymore, so it must
// bookkeeping) and breadcrumbs has no local password anymore, so it must // reuse the existing profile without creating a duplicate.
// reuse the profile via `connection up` and never send a PSK argument.
let second = sb.cmd(&["init"]); let second = sb.cmd(&["init"]);
assert!(second.status.success(), "stderr: {}", stderr(&second)); assert!(second.status.success(), "stderr: {}", stderr(&second));
let second_calls = fs::read_to_string(&record).unwrap_or_default(); assert_eq!(
assert!( nm.connection_count(),
second_calls.contains("connection up TestNet"), 1,
"second connect should reuse the existing NM profile: {second_calls}" "reuse must not accumulate duplicate NM profiles"
);
assert!(
!second_calls.to_lowercase().contains("hunter2")
&& !second_calls.contains("psk")
&& !second_calls.contains("password"),
"second connect must never send a password argument: {second_calls}"
); );
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// CLI-level coverage through fake nmcli/tailscale (item 3) // CLI-level coverage through fake NM + tailscale (item 3)
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
const FAKE_NMCLI_HEALTHY: &str = r#"#!/bin/sh
args="$*"
case "$args" in
"-t -f DEVICE,TYPE device status")
echo "wlan0:wifi" ;;
"-t -f ACTIVE,SSID device wifi list ifname wlan0")
echo "yes:HomeWifi" ;;
"-g IP4.ADDRESS device show wlan0")
echo "192.168.1.50/24" ;;
*) ;;
esac
exit 0
"#;
#[test] #[test]
fn status_reports_healthy_through_fake_nmcli_and_curl() { fn status_reports_healthy_through_fake_nm_and_curl() {
let sb = Sandbox::new(); let sb = Sandbox::new();
sb.write_fake_bin("nmcli", FAKE_NMCLI_HEALTHY); let nm = sb.nm();
let dev = nm.add_wifi_device("wlan0", 100);
let ap = nm.add_ap(&dev, "HomeWifi", 80, Security::Wpa2);
nm.set_active_ap(&dev, &ap);
sb.write_fake_bin("curl", "#!/bin/sh\necho -n 204\nexit 0\n"); sb.write_fake_bin("curl", "#!/bin/sh\necho -n 204\nexit 0\n");
let o = sb.cmd(&["status"]); let o = sb.cmd(&["status"]);
@ -516,36 +489,27 @@ fn status_reports_healthy_through_fake_nmcli_and_curl() {
} }
#[test] #[test]
fn doctor_reports_present_when_nmcli_and_tailscale_are_on_path() { fn doctor_reports_present_when_nm_and_tailscale_are_available() {
let sb = Sandbox::new(); let sb = Sandbox::new();
sb.write_fake_bin("nmcli", FAKE_NMCLI_HEALTHY); let _nm = sb.nm(); // attach the fake NM; keep the handle alive for the run
sb.write_fake_bin("tailscale", "#!/bin/sh\nexit 0\n"); sb.write_fake_bin("tailscale", "#!/bin/sh\nexit 0\n");
let o = sb.cmd(&["doctor"]); let o = sb.cmd(&["doctor"]);
assert!(o.status.success(), "stderr: {}", stderr(&o)); assert!(o.status.success(), "stderr: {}", stderr(&o));
let out = stdout(&o); let out = stdout(&o);
assert!(out.contains("nmcli") && out.contains("present"), "out: {out}"); assert!(
out.contains("network-manager") && out.contains("present"),
"out: {out}"
);
assert!(!out.contains("MISSING"), "out: {out}"); assert!(!out.contains("MISSING"), "out: {out}");
} }
const FAKE_NMCLI_DETECT: &str = r#"#!/bin/sh
args="$*"
case "$args" in
"-t -f DEVICE,TYPE device status")
echo "wlan0:wifi" ;;
"radio wifi on") ;;
"device wifi rescan"*) ;;
"-t -f SSID device wifi list ifname wlan0")
echo "CorpWifi" ;;
*) ;;
esac
exit 0
"#;
#[test] #[test]
fn detect_picks_profile_whose_detect_ssids_are_visible() { fn detect_picks_profile_whose_detect_ssids_are_visible() {
let sb = Sandbox::new(); let sb = Sandbox::new();
sb.write_fake_bin("nmcli", FAKE_NMCLI_DETECT); let nm = sb.nm();
let dev = nm.add_wifi_device("wlan0", 100);
nm.add_ap(&dev, "CorpWifi", 80, Security::Wpa2);
sb.cmd(&["list"]); // bootstrap the default config (home/work/away) sb.cmd(&["list"]); // bootstrap the default config (home/work/away)
// Attach a marker SSID to "work" so detection has something to match — // Attach a marker SSID to "work" so detection has something to match —
@ -561,3 +525,302 @@ fn detect_picks_profile_whose_detect_ssids_are_visible() {
assert!(o.status.success(), "stderr: {}", stderr(&o)); assert!(o.status.success(), "stderr: {}", stderr(&o));
assert_eq!(stdout(&o).trim(), "work"); assert_eq!(stdout(&o).trim(), "work");
} }
// -----------------------------------------------------------------------
// Regression tests for the audit fixes (XDG paths, EDITOR args, config
// merging/clamping, core-profile ownership, scan validation).
// -----------------------------------------------------------------------
#[test]
fn edit_splits_editor_arguments() {
// EDITOR="code -w" style values must be split into program + args
// instead of being treated as one (nonexistent) binary path.
let sb = Sandbox::new();
sb.write_fake_bin(
"fake-editor",
"#!/bin/sh\necho \"$@\" > \"$HOME/editor-args\"\nexit 0\n",
);
let o = sb.cmd_env(&["edit"], &[("EDITOR", "fake-editor --wait")]);
assert!(o.status.success(), "stderr: {}", stderr(&o));
assert!(stdout(&o).contains("config OK"));
let args = fs::read_to_string(sb.root.join("editor-args")).unwrap();
assert!(args.contains("--wait"), "editor args must be split off: {args}");
assert!(
args.contains("breadcrumbs.toml"),
"config path must be appended as its own argument: {args}"
);
}
#[test]
fn scan_to_unknown_profile_errors_like_add() {
// `scan --to bogus` must fail up front, matching `add --to`, instead of
// silently saving a network that never gets attached.
let sb = Sandbox::new();
sb.cmd(&["list"]); // bootstrap the config
let o = sb.cmd(&["scan", "--to", "bogus"]);
assert!(!o.status.success());
assert!(
stderr(&o).contains("unknown profile 'bogus'"),
"stderr: {}",
stderr(&o)
);
}
#[test]
fn watch_interval_below_minimum_is_clamped_to_four() {
// `list` and the watch loop must agree on the poll interval: a value
// below the documented minimum of 4 is clamped at load, not just
// silently clamped inside the watch loop.
let sb = Sandbox::new();
fs::create_dir_all(sb.root.join("config/breadcrumbs")).unwrap();
fs::write(sb.config_file(), "[settings]\nwatch_interval = 1\n").unwrap();
let o = sb.cmd(&["list"]);
assert!(o.status.success(), "stderr: {}", stderr(&o));
assert!(
stdout(&o).contains("watch every 4s"),
"watch interval must clamp to the minimum: {}",
stdout(&o)
);
}
#[test]
fn legacy_inline_networks_merge_with_networks_toml_instead_of_dropping() {
// A breadcrumbs.toml still carrying a legacy inline `[[networks]]` block
// must keep those networks even when networks.toml already exists — the
// merge is completed (and the inline block dropped) on the next save.
let sb = Sandbox::new();
fs::create_dir_all(sb.root.join("config/breadcrumbs")).unwrap();
fs::write(
sb.config_file(),
"[settings]\ndefault_profile = \"away\"\n\n[[networks]]\nssid = \"InlineNet\"\npassword = \"pw-inline\"\n",
)
.unwrap();
fs::write(
sb.networks_file(),
"[[networks]]\nssid = \"FileNet\"\npassword = \"pw-file\"\n",
)
.unwrap();
let o = sb.cmd(&["list"]);
assert!(o.status.success(), "stderr: {}", stderr(&o));
let out = stdout(&o);
assert!(out.contains("InlineNet"), "inline network must survive the merge: {out}");
assert!(out.contains("FileNet"), "networks.toml network must be present: {out}");
// A later save migrates the merged set into networks.toml and drops the
// inline block from breadcrumbs.toml.
let o2 = sb.cmd(&["add", "OtherNet", "pw3"]);
assert!(o2.status.success(), "stderr: {}", stderr(&o2));
let networks = fs::read_to_string(sb.networks_file()).unwrap();
assert!(
networks.contains("InlineNet")
&& networks.contains("FileNet")
&& networks.contains("OtherNet"),
"save must persist the merged set: {networks}"
);
let config_text = fs::read_to_string(sb.config_file()).unwrap();
assert!(
!config_text.contains("[[networks]]"),
"inline block should be gone after migration: {config_text}"
);
}
#[test]
fn core_profiles_are_not_resurrected_once_config_is_user_owned() {
// After the first save the config is user-owned: a deliberately deleted
// core profile must stay deleted.
let sb = Sandbox::new();
fs::create_dir_all(sb.root.join("config/breadcrumbs")).unwrap();
fs::write(
sb.config_file(),
"[settings]\ncore_profiles_initialized = true\n",
)
.unwrap();
let o = sb.cmd(&["profile", "list"]);
assert!(o.status.success(), "stderr: {}", stderr(&o));
let out = stdout(&o);
assert!(
!out.contains("home") && !out.contains("work") && !out.contains("away"),
"deleted core profiles must stay deleted: {out}"
);
}
#[test]
fn legacy_config_without_profiles_gets_core_profiles_backfilled() {
// Pre-ownership configs (no flag yet) still get the core profiles
// backfilled once — the self-heal that makes bare `[settings]` configs
// usable.
let sb = Sandbox::new();
fs::create_dir_all(sb.root.join("config/breadcrumbs")).unwrap();
fs::write(sb.config_file(), "[settings]\n").unwrap();
let o = sb.cmd(&["profile", "list"]);
assert!(o.status.success(), "stderr: {}", stderr(&o));
let out = stdout(&o);
assert!(
out.contains("home") && out.contains("work") && out.contains("away"),
"legacy configs get the core profiles backfilled once: {out}"
);
}
// -----------------------------------------------------------------------
// New features: per-network DNS, enterprise (802.1x) networks, --json
// output, prune, scored detection, and init --wait retry.
// -----------------------------------------------------------------------
#[test]
fn add_with_dns_persists_per_network_override() {
let sb = Sandbox::new();
let o = sb.cmd(&["add", "CafeWifi", "pw", "--dns", "9.9.9.9"]);
assert!(o.status.success(), "stderr: {}", stderr(&o));
let networks = fs::read_to_string(sb.networks_file()).unwrap();
assert!(
networks.contains("dns = \"9.9.9.9\""),
"per-network DNS override must be persisted: {networks}"
);
}
#[test]
fn add_enterprise_fields_persist() {
let sb = Sandbox::new();
let o = sb.cmd(&[
"add",
"CorpWifi",
"pw",
"--eap",
"peap",
"--identity",
"user@corp",
"--ca-cert",
"/etc/ca.pem",
]);
assert!(o.status.success(), "stderr: {}", stderr(&o));
let networks = fs::read_to_string(sb.networks_file()).unwrap();
assert!(networks.contains("eap = \"peap\""), "networks: {networks}");
assert!(networks.contains("identity = \"user@corp\""));
assert!(networks.contains("ca_cert = \"/etc/ca.pem\""));
}
#[test]
fn status_json_emits_machine_readable_output() {
let sb = Sandbox::new();
let o = sb.cmd(&["status", "--json"]);
// No adapter in the sandbox → unhealthy (exit 1), but still valid JSON.
assert_eq!(o.status.code(), Some(1));
let v: serde_json::Value =
serde_json::from_str(&stdout(&o)).expect("status --json must emit valid JSON");
assert_eq!(v["profile"].as_str(), Some("away"));
assert_eq!(v["healthy"].as_bool(), Some(false));
assert_eq!(v["internet"].as_bool(), Some(false));
}
#[test]
fn detect_json_emits_machine_readable_output() {
let sb = Sandbox::new();
let nm = sb.nm();
let dev = nm.add_wifi_device("wlan0", 100);
nm.add_ap(&dev, "CorpWifi", 80, Security::Wpa2);
sb.cmd(&["list"]); // bootstrap the default config
let text = fs::read_to_string(sb.config_file()).unwrap();
let patched = text.replace(
"[profiles.work]",
"[profiles.work]\ndetect_ssids = [\"CorpWifi\"]",
);
fs::write(sb.config_file(), patched).unwrap();
let o = sb.cmd(&["detect", "--json"]);
assert!(o.status.success(), "stderr: {}", stderr(&o));
let v: serde_json::Value =
serde_json::from_str(&stdout(&o)).expect("detect --json must emit valid JSON");
assert_eq!(v["profile"].as_str(), Some("work"));
}
#[test]
fn detect_prefers_profile_with_more_matching_markers() {
let sb = Sandbox::new();
let nm = sb.nm();
let dev = nm.add_wifi_device("wlan0", 100);
nm.add_ap(&dev, "CorpWifi", 80, Security::Wpa2);
nm.add_ap(&dev, "CafeWifi", 70, Security::Wpa2);
sb.cmd(&["list"]); // bootstrap
// home matches 1 marker (CorpWifi); work matches 2 (CorpWifi + CafeWifi).
let text = fs::read_to_string(sb.config_file()).unwrap();
let patched = text
.replace(
"[profiles.home]",
"[profiles.home]\ndetect_ssids = [\"CorpWifi\"]",
)
.replace(
"[profiles.work]",
"[profiles.work]\ndetect_ssids = [\"CorpWifi\", \"CafeWifi\"]",
);
fs::write(sb.config_file(), patched).unwrap();
let o = sb.cmd(&["detect"]);
assert!(o.status.success(), "stderr: {}", stderr(&o));
assert_eq!(
stdout(&o).trim(),
"work",
"the profile with more matching markers must win"
);
}
#[test]
fn prune_dry_run_lists_stale_nm_profiles() {
let sb = Sandbox::new();
let nm = sb.nm();
nm.save_connection("OldCafe", None);
sb.cmd(&["list"]); // bootstrap (no saved networks → everything is stale)
let o = sb.cmd(&["prune", "--dry-run"]);
assert!(o.status.success(), "stderr: {}", stderr(&o));
let out = stdout(&o);
assert!(
out.contains("would remove") && out.contains("OldCafe"),
"out: {out}"
);
}
#[test]
fn prune_removes_stale_nm_profiles() {
let sb = Sandbox::new();
let nm = sb.nm();
nm.save_connection("OldCafe", None);
sb.cmd(&["list"]);
let o = sb.cmd(&["prune"]);
assert!(o.status.success(), "stderr: {}", stderr(&o));
let out = stdout(&o);
assert!(
out.contains("removed") && out.contains("OldCafe"),
"out: {out}"
);
assert_eq!(
nm.connection_count(),
0,
"prune must actually delete the stale profile"
);
}
#[test]
fn init_wait_retries_until_connect_succeeds() {
let sb = Sandbox::new();
let nm = sb.nm();
let dev = nm.add_wifi_device("wlan0", 100);
nm.add_ap(&dev, "HomeWifi", 80, Security::Wpa2);
// "away" defaults to include_all_known, so HomeWifi is a candidate.
let add = sb.cmd(&["add", "HomeWifi", "hunter2"]);
assert!(add.status.success(), "stderr: {}", stderr(&add));
// The fake's first activation fails; the retry succeeds. `--wait` must
// keep going past the first failure rather than bailing.
nm.fail_next_activations(1);
let o = sb.cmd(&["init", "--wait", "5"]);
assert!(o.status.success(), "stderr: {}", stderr(&o));
assert!(stdout(&o).contains("connected"), "out: {}", stdout(&o));
}

1007
tests/common/fake_nm.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -9,7 +9,7 @@
//! rules ("if the program+args match this predicate, return this canned //! rules ("if the program+args match this predicate, return this canned
//! `Output`"), which also records every invocation so a test can assert //! `Output`"), which also records every invocation so a test can assert
//! exactly what was — or, just as importantly, was *not* — passed (e.g. //! exactly what was — or, just as importantly, was *not* — passed (e.g.
//! that a password argument never reaches a fake `nmcli`). //! that a password argument never reaches a fake subprocess).
//! - [`EnvSandbox`]: real logic (`flow::run`, `watch::classify`) still does //! - [`EnvSandbox`]: real logic (`flow::run`, `watch::classify`) still does
//! its own best-effort file logging via `notify::log`, which resolves a //! its own best-effort file logging via `notify::log`, which resolves a
//! path from `$HOME`/`$XDG_STATE_HOME`. `EnvSandbox` points those at a //! path from `$HOME`/`$XDG_STATE_HOME`. `EnvSandbox` points those at a
@ -18,9 +18,15 @@
//! inherently cross-test-within-this-binary racy, so it's guarded by a //! inherently cross-test-within-this-binary racy, so it's guarded by a
//! process-wide mutex — tests using it serialize against each other but //! process-wide mutex — tests using it serialize against each other but
//! not against unrelated tests (each `tests/*.rs` file is its own binary). //! not against unrelated tests (each `tests/*.rs` file is its own binary).
//! - [`fake_nm`]: a real fake NetworkManager D-Bus service on a private
//! `dbus-daemon`. The production `nm` module talks to it over real D-Bus
//! marshalling (`Connection::system()` honors `DBUS_SYSTEM_BUS_ADDRESS`),
//! replacing the old fake-`nmcli`-argv rules.
#![allow(dead_code)] // not every test file uses every helper here #![allow(dead_code)] // not every test file uses every helper here
pub mod fake_nm;
use std::cell::RefCell; use std::cell::RefCell;
use std::collections::HashSet; use std::collections::HashSet;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@ -50,6 +56,7 @@ impl RecordedCall {
} }
type Matcher = Box<dyn Fn(&str, &[&str]) -> bool>; type Matcher = Box<dyn Fn(&str, &[&str]) -> bool>;
type DynamicRule = (Matcher, Box<dyn Fn(&str, &[&str]) -> Output>);
/// A canned, rule-based [`Runner`]. Rules are tried in registration order; /// A canned, rule-based [`Runner`]. Rules are tried in registration order;
/// the first whose matcher returns `true` supplies the response. No rule /// the first whose matcher returns `true` supplies the response. No rule
@ -58,6 +65,7 @@ type Matcher = Box<dyn Fn(&str, &[&str]) -> bool>;
/// (a wrong exit code) rather than silently returning success. /// (a wrong exit code) rather than silently returning success.
pub struct FakeRunner { pub struct FakeRunner {
rules: Vec<(Matcher, Output)>, rules: Vec<(Matcher, Output)>,
dynamic_rules: Vec<DynamicRule>,
commands: HashSet<String>, commands: HashSet<String>,
calls: Rc<RefCell<Vec<RecordedCall>>>, calls: Rc<RefCell<Vec<RecordedCall>>>,
} }
@ -66,6 +74,7 @@ impl FakeRunner {
pub fn new() -> Self { pub fn new() -> Self {
FakeRunner { FakeRunner {
rules: Vec::new(), rules: Vec::new(),
dynamic_rules: Vec::new(),
commands: HashSet::new(), commands: HashSet::new(),
calls: Rc::new(RefCell::new(Vec::new())), calls: Rc::new(RefCell::new(Vec::new())),
} }
@ -91,7 +100,7 @@ impl FakeRunner {
} }
/// Shorthand for matching on `prog` plus a whitespace-joined view of /// Shorthand for matching on `prog` plus a whitespace-joined view of
/// `args` containing `substr` (handy for `nmcli`/`tailscale` calls, whose /// `args` containing `substr` (handy for `tailscale`/`curl` calls, whose
/// interesting bit is usually a subcommand somewhere in the middle). /// interesting bit is usually a subcommand somewhere in the middle).
pub fn on_contains(self, prog: &'static str, substr: &'static str, output: Output) -> Self { pub fn on_contains(self, prog: &'static str, substr: &'static str, output: Output) -> Self {
self.on( self.on(
@ -99,6 +108,20 @@ impl FakeRunner {
output, output,
) )
} }
/// Register a rule whose *response* is computed at call time (rather
/// than canned), enabling stateful fakes — e.g. answering "which SSID is
/// active?" with the SSID of the most recently dialed connection.
/// Dynamic rules are tried after the static ones.
pub fn on_dynamic(
mut self,
matcher: impl Fn(&str, &[&str]) -> bool + 'static,
out: impl Fn(&str, &[&str]) -> Output + 'static,
) -> Self {
self.dynamic_rules
.push((Box::new(matcher), Box::new(out)));
self
}
} }
impl Default for FakeRunner { impl Default for FakeRunner {
@ -119,6 +142,11 @@ impl Runner for FakeRunner {
return out.clone(); return out.clone();
} }
} }
for (matcher, out) in &self.dynamic_rules {
if matcher(prog, args) {
return out(prog, args);
}
}
Output::failed() Output::failed()
} }

File diff suppressed because it is too large Load diff