- Rust 99.5%
- Shell 0.5%
|
|
||
|---|---|---|
| .forgejo/workflows | ||
| .github/workflows | ||
| ci | ||
| src | ||
| tests | ||
| .gitignore | ||
| AGENTS.md | ||
| bakery.toml | ||
| breadcrumbs.example.toml | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CONTRIBUTING.md | ||
| EVENTS.md | ||
| LICENSE | ||
| networks.example.toml | ||
| README.md | ||
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 separatenetworks.tomlfrom settings/profiles (see Configuration). Secrets are never exposed on the NetworkManager side of things: everything travels inside D-BusUpdate2/AddAndActivateConnection2settings payloads, invisible to other local users via/proc/<pid>/cmdline.breadcrumbs additself 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 positionaladd <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 setstailscale = true)notify-send(optional, for desktop notifications)curl(optional, used for connectivity checks, falls back toping)
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:
- Polls health every
watch_intervalseconds (adaptive backoff on repeated failures) - Reacts immediately to link-state changes via NetworkManager D-Bus signals (
Device.StateChanged,Connectivityproperty changes, hotplug events, including Wi-Fi devices plugged in after it started) - 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 - Backs off hidden networks that keep failing (1 min, doubling, up to 30 min) instead of dialing them blind on every recovery run
- Handles profile changes live: re-reads config and state on every tick
- 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
- Applies a
[settings.schedule]time-of-day profile switch, respecting a 30-minute grace window after a manualprofile set - 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:
- Connects to the
bootstrapSSID (if configured) - Ensures the Tailscale daemon is running; opens a browser login if needed
- Selects an exit node with
tailscale set --exit-node=<node>: the first online node inexit_nodespriority order. A node that is already selected and healthy is left alone, and an offline node is never selected - 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