No description
  • Rust 99.5%
  • Shell 0.5%
Find a file
Breadway 939d9edcfe
All checks were successful
dev release / build (push) Successful in 45s
Merge pull request #45: only claim bootstrap when the active SSID is bootstrap
2026-10-20 12:02:51 +08:00
.forgejo/workflows CI: isolate beta and stable index generation clones 2026-09-30 02:20:31 +08:00
.github/workflows chore: remove internal runner hostname from public repo 2026-09-04 11:04:26 +08:00
ci ci: race-free clone in ci/build.sh; bump ci/bread-ecosystem.rev 2026-09-29 20:31:20 +08:00
src fix(flow): only claim bootstrap when the active SSID is bootstrap 2026-10-20 11:57:18 +08:00
tests fix: validate effective DNS before network mutations 2026-10-18 09:05:57 +08:00
.gitignore ci: build dev track and PR checks against dependency main 2026-09-28 07:02:48 +08:00
AGENTS.md fix: address the 2026-09 diagnostic audit 2026-09-26 08:38:56 +08:00
bakery.toml Drop pacman packaging, bakery-only distribution 2026-07-23 10:25:12 +08:00
breadcrumbs.example.toml docs: remove em dashes from code and docs 2026-09-09 13:59:36 +08:00
Cargo.lock fix: address the 2026-09 diagnostic audit 2026-09-26 08:38:56 +08:00
Cargo.toml fix: address the 2026-09 diagnostic audit 2026-09-26 08:38:56 +08:00
CONTRIBUTING.md ci: build dev track and PR checks against dependency main 2026-09-28 07:02:48 +08:00
EVENTS.md docs: remove em dashes from code and docs 2026-09-09 13:59:36 +08:00
LICENSE Initial commit: breadcrumbs — profile-driven Wi-Fi + Tailscale state machine 2026-05-19 11:52:46 +08:00
networks.example.toml docs: remove em dashes from code and docs 2026-09-09 13:59:36 +08:00
README.md fix: address the 2026-09 diagnostic audit 2026-09-26 08:38:56 +08:00

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'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

  • 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 NetworkManager D-Bus signals
  • 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). Secrets are never exposed on the NetworkManager side of things: everything travels inside D-Bus Update2/AddAndActivateConnection2 settings payloads, invisible to other local users via /proc/<pid>/cmdline. breadcrumbs add itself prompts interactively (no echo) by default, or reads the password from stdin with --password-stdin; the one remaining way to leak a password onto argv/shell history is the deprecated positional add <ssid> <password> form, kept only for backward compatibility and no longer documented here.
  • Desktop notifications via notify-send (optional)
  • systemd user service generation via breadcrumbs install-service

Requirements

  • Linux with NetworkManager running on the D-Bus system bus
  • 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

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:

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:

[settings]
dns = "1.1.1.1"          # DNS pinned on every connection (IPv4 and IPv6; comma-separate
                         # several servers); "" disables pinning. See "DNS pinning" below
connect_wait = 8         # seconds to wait for a connection to activate before cancelling it
                         # (legacy key: nmcli_wait); raise it for slow enterprise/WPA3 networks
exit_node = "myhostname" # default Tailscale exit node (hostname, MagicDNS name or Tailscale IP)
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, from == to = all day
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:

[[networks]]
ssid = "MyHomeNetwork"
password = "hunter2"  # optional, see "Credential handling" below
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"          # "peap", "ttls" or "tls"
# phase2 = "mschapv2"   # inner auth for peap/ttls (default mschapv2)
# identity = "user@corp"
# password = "..."       # 802.1x password (for tls: the private key passphrase)
# ca_cert = "/etc/ssl/certs/corp-ca.pem"   # optional
# client_cert = "/path/client.pem"         # tls only
# private_key = "/path/client.key"         # tls only

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. 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

breadcrumbs [--profile <name>] <command>
Command Description
status [--json] Show current Wi-Fi / Tailscale health (default)
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
profile get Print the active profile
profile set <name> Switch profile (and apply it, unless --no-apply)
profile list List all profiles
detect [--apply] [--json] Guess profile from visible networks; optionally apply it
add <ssid> [--password-stdin] Add or update a saved network; password is prompted for interactively (no echo) unless --password-stdin is given (--dns, --eap, --identity, --ca-cert, --phase2, --client-cert, --private-key, --hidden, --to, --at)
join <ssid> Connect to an already-saved network right now, outside any profile. Takes no password, for GUI callers that already ran add
forget <ssid> Remove a network from config and NetworkManager
prune [--dry-run] [--yes] Remove NetworkManager Wi-Fi client profiles whose SSID is no longer in the config (lists them and asks first; --yes for scripts; hotspot profiles are never touched)
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, plus config problems (dangling profile/network references, bad schedule times)
cd [--shell] Print (or cd into) the config directory
install-service [--no-enable] Install and optionally enable systemd user unit

Examples

# 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 (prompts for the
# password interactively, with echo off)
breadcrumbs add "CoffeeShop5G" --to away

# Add a network non-interactively, e.g. from a script (reads the
# password as a single line from stdin, never put it on argv)
echo "hunter2" | breadcrumbs add "CoffeeShop5G" --password-stdin

# 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 NetworkManager D-Bus signals (Device.StateChanged, Connectivity property changes, hotplug events, including Wi-Fi devices plugged in after it started)
  3. Runs flow::run (the connect state machine) on a confirmed drop. While still associated, one failed connectivity probe is not enough: it re-checks a few seconds later and only recovers after two failures in a row. Recovery never re-activates the network you are already on (that drops a working link); it only moves you to a network you are not on
  4. Backs off hidden networks that keep failing (1 min, doubling, up to 30 min) instead of dialing them blind on every recovery run
  5. Handles profile changes live: re-reads config and state on every tick
  6. Distinguishes captive portals from plain no-internet (a 200, a redirect, or 511 instead of the 204 generate_204 returns) and tells you to sign in instead of pointlessly reconnecting
  7. Applies a [settings.schedule] time-of-day profile switch, respecting a 30-minute grace window after a manual profile set
  8. Detects suspend/resume (measured on a clock that keeps running while suspended) and forces an immediate recovery check

No breadcrumbs command holds a lock while it waits on the network or on you: reads (status, list, profile list, ...) take no lock at all, and each change to the config takes the lock only for its own quick load, modify, save.

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:

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. Selects an exit node with tailscale set --exit-node=<node>: the first online node in exit_nodes priority order. A node that is already selected and healthy is left alone, and an offline node is never selected
  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. Login runs tailscale login as you, which needs you to be the Tailscale operator (sudo tailscale set --operator=$USER, once); failing that it tries sudo -n tailscale login (passwordless sudo only, since the daemon has no terminal to ask for a password).

DNS pinning

With dns set (the default is 1.1.1.1), breadcrumbs pins those servers on the NetworkManager profile of every network it connects to, for both IPv4 and IPv6, and ignores the DNS servers the network hands out. The pin is saved in the profile, so it stays in effect even when breadcrumbs is not running, and the profile is marked so breadcrumbs knows it made the pin. Setting dns = "" for a network (or globally) removes a pin breadcrumbs made, restoring the network's own DNS, and never touches DNS you set yourself.

Some networks (schools, hotels, corporate networks) block outside DNS servers or rely on their own DNS for a captive portal. Use dns = "" for those networks.

status --json

breadcrumbs status --json prints one object and exits 0 when healthy, 1 otherwise. Other tools (e.g. breadbar) read it:

Field Type Meaning
profile string Active profile
iface string or null Wi-Fi interface
ssid string or null Associated SSID
ip string or null IPv4 address
internet bool A 204 from the connectivity check (or a working ping)
portal bool A captive portal intercepted the check
tailscale_required bool The profile requires Tailscale
tailscale string or null Tailscale health: Ok, NeedsLogin, Stopped, ExitNodeMissing, ExitNodeOffline, NoExitNode, NotInstalled, Error; null when Tailscale is not installed
exit_node string First configured exit node
healthy bool Internet up and, if required, Tailscale Ok

License

MIT