breadcrumbs/README.md
Breadway 037c6e54c9 Harden breadcrumbs: fix real bugs, restructure as lib, stop storing PSKs twice
Bug fixes:
- mask() panicked on multi-byte UTF-8 passwords (byte-slicing a char
  boundary); now masks by char count and never echoes a real character
- `cd --shell` interpolated the config path into a shell -c string via
  Debug formatting, which doesn't neutralize shell metacharacters; now
  passed as a positional shell argument instead
- connecting to open (no-password) networks failed because an empty PSK
  was always sent to nmcli, which nmcli treats as secured-with-no-password
  instead of open; the password arg is now omitted entirely when empty
- five nmcli terse-output parse sites used a raw splitn(2, ':'), which
  mis-splits any device/connection name containing a literal ':'; unified
  on the existing escape-aware field splitter
- watch's health classifier silently read a config-deleted profile as
  "healthy" off a bare internet check instead of surfacing the misconfig
- the nmcli-monitor thread seeded its debounce clock with
  `Instant::now() - 10s`, which panics on the monotonic clock near boot —
  exactly when the generated systemd unit tends to start the watcher

Architecture:
- extracted src/lib.rs + src/app.rs so command logic can be exercised
  in-process by tests instead of only by spawning the compiled binary
- added a Runner trait (src/util.rs) so subprocess calls can be faked in
  tests; flow::run and watch::classify are now covered by real in-process
  tests of the connect state machine and health transitions, not just
  their pure helpers
- Wi-Fi passwords are no longer kept in breadcrumbs' config once
  NetworkManager durably holds them: NetworkDef.password is now optional,
  and a successful password-based connect clears + persists it
  immediately, so it's never sent again on subsequent connects
- saved networks (SSID + optional local password) moved out of
  breadcrumbs.toml into a separate networks.toml; old configs with
  inline [[networks]] still load and migrate automatically on next save
- corrected a false README claim that passwords are never in nmcli argv

Test count: 20 -> 89 (52 unit, 24 CLI integration, 13 in-process
state-machine tests). Full clean run: cargo build/build --release/
test/clippy --all-targets, verified from a `cargo clean` rebuild.
2026-07-22 06:58:47 +08:00

179 lines
7.9 KiB
Markdown

# breadcrumbs
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.
## Features
- **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
- **Self-healing watch daemon** — monitors for drops, auto-recovers, reacts within seconds via `nmcli monitor`
- **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.
- **Desktop notifications** via `notify-send` (optional)
- **systemd user service** generation via `breadcrumbs install-service`
## Requirements
- Linux with NetworkManager (`nmcli` in `$PATH`)
- Rust toolchain (to build from source)
- `tailscale` (optional — only needed if any profile sets `tailscale = true`)
- `notify-send` (optional — for desktop notifications)
- `curl` (optional — used for connectivity checks, falls back to `ping`)
## Installation
```bash
git clone https://github.com/breadway/breadcrumbs
cd breadcrumbs
cargo build --release
# Copy to somewhere on your PATH:
cp target/release/breadcrumbs ~/.local/bin/
```
## Configuration
On first run, breadcrumbs creates `~/.config/breadcrumbs/breadcrumbs.toml` (settings + profiles) and `~/.config/breadcrumbs/networks.toml` (saved networks) with default profiles. Copy `breadcrumbs.example.toml` as a starting point for the former:
```bash
cp breadcrumbs.example.toml ~/.config/breadcrumbs/breadcrumbs.toml
breadcrumbs edit # opens breadcrumbs.toml in $EDITOR
```
...then add your real networks with `breadcrumbs add`/`scan` rather than hand-editing `networks.toml` (see `networks.example.toml` if you want to see its shape or write it by hand anyway).
Config paths respect `$XDG_CONFIG_HOME` and `$XDG_STATE_HOME`.
### Config structure
Settings and location profiles live in `breadcrumbs.toml` — the file people actually hand-edit or dotfile:
```toml
[settings]
dns = "1.1.1.1" # DNS server pinned on every connection
nmcli_wait = 8 # seconds to wait for nmcli connect
exit_node = "myhostname" # default Tailscale exit node
default_profile = "away"
watch_interval = 12 # seconds between health checks (minimum 4)
connectivity_url = "http://connectivitycheck.gstatic.com/generate_204"
ping_host = "1.1.1.1"
[profiles.home]
networks = ["MyHomeNetwork"] # priority-ordered SSIDs
tailscale = false
include_all_known = false
detect_ssids = ["MyHomeNetwork"] # used by `breadcrumbs detect`
[profiles.work]
bootstrap = "GuestWifi" # connect here first before requiring Tailscale
networks = ["CorpWifi"]
tailscale = true
exit_node = "jump-host" # per-profile override
detect_ssids = ["CorpWifi", "Corp-5G"]
```
Saved networks (SSID + optional local password) live separately, in `networks.toml`, managed via `add`/`scan`/`forget`:
```toml
[[networks]]
ssid = "MyHomeNetwork"
password = "hunter2" # optional — see "Credential handling" below
hidden = false
```
`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.
### Profiles
Each profile defines:
| Key | Description |
|-----|-------------|
| `networks` | Ordered list of SSIDs to try. First available wins. |
| `tailscale` | If `true`, Tailscale must be healthy before moving to a target network. |
| `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`). |
| `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`. |
## Usage
```
breadcrumbs [--profile <name>] <command>
```
| Command | Description |
|---------|-------------|
| `status` | Show current Wi-Fi / Tailscale health (default) |
| `init` | Run the full connect sequence for the active profile |
| `watch [--no-initial]` | Self-healing daemon: monitors and auto-recovers drops |
| `profile get` | Print the active profile |
| `profile set <name>` | Switch profile (and apply it, unless `--no-apply`) |
| `profile list` | List all profiles |
| `detect [--apply]` | Guess profile from visible networks; optionally apply it |
| `add <ssid> [password]` | Add or update a saved network |
| `forget <ssid>` | Remove a network from config and NetworkManager |
| `scan [--to <profile>]` | Interactive scan, pick, connect and save |
| `list [--show-passwords]` | Show config: settings, networks, profiles |
| `edit` | Open config in `$EDITOR`, validate on exit |
| `doctor [--full]` | Quick connectivity and Tailscale diagnostics |
| `cd [--shell]` | Print (or `cd` into) the config directory |
| `install-service [--no-enable]` | Install and optionally enable systemd user unit |
### Examples
```bash
# Check current state
breadcrumbs
# Switch to the "work" profile and connect
breadcrumbs profile set work
# Run as a daemon in the foreground (use install-service for persistent use)
breadcrumbs watch
# Override profile for one run without persisting
breadcrumbs --profile home init
# Add a new network and attach it to a profile
breadcrumbs add "CoffeeShop5G" --to away
# Detect and switch profile based on visible networks
breadcrumbs detect --apply
# Install and start the systemd watcher service
breadcrumbs install-service
```
## Watch daemon
`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)
2. Reacts immediately to link-state changes via `nmcli monitor`
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
Install as a systemd user service:
```bash
breadcrumbs install-service
# or manually:
systemctl --user enable --now breadcrumbs.service
```
## Tailscale integration
For profiles with `tailscale = true`:
1. Connects to the `bootstrap` SSID (if configured)
2. Ensures the Tailscale daemon is running; opens a browser login if needed
3. Sets the configured exit node with `tailscale set --exit-node=<node>`
4. Only moves to the target network once Tailscale is healthy
If Tailscale needs interactive login, the auth URL is opened automatically and the watch daemon stays on the bootstrap network until authentication completes.
## License
MIT