Initial commit: breadcrumbs — profile-driven Wi-Fi + Tailscale state machine

This commit is contained in:
Breadway 2026-05-19 11:52:46 +08:00
commit 3422c12379
18 changed files with 3475 additions and 0 deletions

38
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,38 @@
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: "-D warnings"
jobs:
test:
name: fmt · clippy · test · build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Cache cargo
uses: Swatinem/rust-cache@v2
- name: Format check
run: cargo fmt --all -- --check
- name: Clippy
run: cargo clippy --all-targets -- -D warnings
- name: Test
run: cargo test --all --verbose
- name: Release build
run: cargo build --release --verbose

36
.gitignore vendored Normal file
View file

@ -0,0 +1,36 @@
# Build output
/target/
# Secrets: live config holds plaintext Wi-Fi passwords.
# Keep local only; see breadcrumbs.example.toml for the schema.
/breadcrumbs.toml
# Legacy plaintext credential store (migrated into breadcrumbs.toml on first run)
/Networks/
# Local diagnostic scripts (user-specific, not part of the tool)
diag.sh
diag_*.sh
# Editor / IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS artifacts
.DS_Store
Thumbs.db
desktop.ini
# Environment files
.env
.env.local
*.env.*
# Log files
*.log
# Claude Code local state
.claude/

331
Cargo.lock generated Normal file
View file

@ -0,0 +1,331 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "breadcrumbs"
version = "2.0.0"
dependencies = [
"clap",
"serde",
"serde_json",
"toml",
]
[[package]]
name = "clap"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_edit",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime",
"toml_write",
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

22
Cargo.toml Normal file
View file

@ -0,0 +1,22 @@
[package]
name = "breadcrumbs"
version = "2.0.0"
edition = "2021"
description = "Profile-aware Wi-Fi state machine with Tailscale handling and self-healing watch daemon"
license = "MIT"
[[bin]]
name = "breadcrumbs"
path = "src/main.rs"
[dependencies]
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
toml = "0.8"
serde_json = "1"
[profile.release]
opt-level = "s"
lto = true
strip = true
panic = "abort"

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Breadway
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

169
README.md Normal file
View file

@ -0,0 +1,169 @@
# 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
- **Secure credential handling** — passwords fed to `nmcli` via stdin (never in argv/`ps`), config stored at 0600
- **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` with default profiles. Copy `breadcrumbs.example.toml` as a starting point and fill in your real network credentials:
```bash
cp breadcrumbs.example.toml ~/.config/breadcrumbs/breadcrumbs.toml
breadcrumbs edit # opens in $EDITOR
```
Config paths respect `$XDG_CONFIG_HOME` and `$XDG_STATE_HOME`.
### Config structure
```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"
[[networks]]
ssid = "MyHomeNetwork"
password = "hunter2"
hidden = false
[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"]
```
### 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

58
breadcrumbs.example.toml Normal file
View file

@ -0,0 +1,58 @@
# breadcrumbs configuration template.
#
# Copy to ~/.config/breadcrumbs/breadcrumbs.toml and fill in real values, OR
# just run breadcrumbs once (it generates a skeleton) and then use
# `breadcrumbs add` / `breadcrumbs edit` to fill in your networks.
# The real breadcrumbs.toml is gitignored and never committed.
[settings]
dns = "1.1.1.1"
nmcli_wait = 8
exit_node = "my-exit-node" # Tailscale hostname of your preferred exit node
default_profile = "away"
watch_interval = 12
connectivity_url = "http://connectivitycheck.gstatic.com/generate_204"
ping_host = "1.1.1.1"
[[networks]]
ssid = "HomeWifi"
password = "REPLACE_ME"
hidden = false
[[networks]]
ssid = "WorkGuest"
password = "REPLACE_ME"
hidden = false
[[networks]]
ssid = "CorpWifi"
password = "REPLACE_ME"
hidden = false
# Location state machine. Switch with: breadcrumbs profile set <name>
#
# detect_ssids: list any SSIDs that reliably indicate you are at this location.
# `breadcrumbs detect` scans for visible networks and switches to the first
# profile whose detect_ssids list contains a match. Profiles without
# detect_ssids are skipped during detection; the default_profile is used as
# the final fallback.
[profiles.away]
networks = ["HomeWifi"]
tailscale = false
include_all_known = true
# No detect_ssids: "away" is the catch-all fallback (set as default_profile).
[profiles.home]
networks = ["HomeWifi"]
tailscale = false
include_all_known = false
detect_ssids = ["HomeWifi"]
[profiles.work]
bootstrap = "WorkGuest" # connect here first so Tailscale can come up
networks = ["CorpWifi"]
tailscale = true
exit_node = "my-exit-node"
include_all_known = false
detect_ssids = ["CorpWifi", "WorkGuest"]

277
src/config.rs Normal file
View file

@ -0,0 +1,277 @@
use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::util::home_dir;
fn default_dns() -> String {
"1.1.1.1".to_string()
}
fn default_nmcli_wait() -> u32 {
8
}
fn default_exit_node() -> String {
String::new()
}
fn default_profile_name() -> String {
"away".to_string()
}
fn default_watch_interval() -> u64 {
12
}
fn default_connectivity_url() -> String {
"http://connectivitycheck.gstatic.com/generate_204".to_string()
}
fn default_ping_host() -> String {
"1.1.1.1".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
#[serde(default = "default_dns")]
pub dns: String,
#[serde(default = "default_nmcli_wait")]
pub nmcli_wait: u32,
#[serde(default = "default_exit_node")]
pub exit_node: String,
#[serde(default = "default_profile_name")]
pub default_profile: String,
#[serde(default = "default_watch_interval")]
pub watch_interval: u64,
#[serde(default = "default_connectivity_url")]
pub connectivity_url: String,
#[serde(default = "default_ping_host")]
pub ping_host: String,
}
impl Default for Settings {
fn default() -> Self {
Settings {
dns: default_dns(),
nmcli_wait: default_nmcli_wait(),
exit_node: default_exit_node(),
default_profile: default_profile_name(),
watch_interval: default_watch_interval(),
connectivity_url: default_connectivity_url(),
ping_host: default_ping_host(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkDef {
pub ssid: String,
pub password: String,
#[serde(default)]
pub hidden: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Profile {
/// Optional SSID connected first to bootstrap connectivity (e.g. for Tailscale).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bootstrap: Option<String>,
/// Ordered priority list of SSIDs this profile should end up connected to.
#[serde(default)]
pub networks: Vec<String>,
/// Require a healthy Tailscale + exit node before moving off the bootstrap.
#[serde(default)]
pub tailscale: bool,
/// Per-profile exit node override (falls back to settings.exit_node).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exit_node: Option<String>,
/// After the explicit list, also try every other known network.
#[serde(default)]
pub include_all_known: bool,
/// SSIDs whose presence in a scan indicates this location.
/// Used by `breadcrumbs detect` to guess the active profile.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub detect_ssids: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub settings: Settings,
#[serde(default, rename = "networks")]
pub networks: Vec<NetworkDef>,
#[serde(default)]
pub profiles: BTreeMap<String, Profile>,
}
pub fn config_dir() -> PathBuf {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| home_dir().join(".config"))
.join("breadcrumbs")
}
pub fn config_path() -> PathBuf {
config_dir().join("breadcrumbs.toml")
}
pub fn state_dir() -> PathBuf {
std::env::var_os("XDG_STATE_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| home_dir().join(".local").join("state"))
.join("breadcrumbs")
}
pub fn state_path() -> PathBuf {
state_dir().join("state.toml")
}
pub fn log_path() -> PathBuf {
state_dir().join("breadcrumbs.log")
}
impl Config {
pub fn profile<'a>(&'a self, name: &str) -> Option<&'a Profile> {
self.profiles.get(name)
}
pub fn network<'a>(&'a self, ssid: &str) -> Option<&'a NetworkDef> {
self.networks.iter().find(|n| n.ssid == ssid)
}
/// Load config, creating a skeleton one on first run.
pub fn load() -> Result<Config, String> {
let path = config_path();
if !path.exists() {
let cfg = build_initial_config();
cfg.save()?;
return Ok(cfg);
}
let text =
fs::read_to_string(&path).map_err(|e| format!("reading {}: {e}", path.display()))?;
let mut cfg: Config =
toml::from_str(&text).map_err(|e| format!("parsing {}: {e}", path.display()))?;
// Self-heal: guarantee the three core profiles always exist.
ensure_core_profiles(&mut cfg);
Ok(cfg)
}
pub fn save(&self) -> Result<(), String> {
let dir = config_dir();
fs::create_dir_all(&dir).map_err(|e| format!("creating {}: {e}", dir.display()))?;
let text = toml::to_string_pretty(self).map_err(|e| format!("serializing config: {e}"))?;
let path = config_path();
fs::write(&path, text).map_err(|e| format!("writing {}: {e}", path.display()))?;
// Plaintext Wi-Fi passwords live here — keep it owner-only.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
}
Ok(())
}
}
/// Initial skeleton networks generated for a brand-new installation.
/// Passwords are intentionally blank — secrets never live in source.
/// Users fill them via `breadcrumbs add`, `breadcrumbs scan`, or
/// `breadcrumbs edit`, or by copying `breadcrumbs.example.toml`.
fn canonical_networks() -> Vec<NetworkDef> {
Vec::new()
}
/// Starter profiles generated for a brand-new installation.
/// These give users working examples of the three common location patterns:
/// a home profile, a profile requiring Tailscale (e.g. a workplace or school),
/// and an "away" catch-all. All network lists start empty; users populate them
/// via `breadcrumbs add --to <profile>` or `breadcrumbs edit`.
fn core_profiles() -> BTreeMap<String, Profile> {
let mut p = BTreeMap::new();
p.insert(
"home".to_string(),
Profile {
bootstrap: None,
networks: vec![],
tailscale: false,
exit_node: None,
include_all_known: false,
detect_ssids: vec![],
},
);
p.insert(
"work".to_string(),
Profile {
bootstrap: None,
networks: vec![],
tailscale: false,
exit_node: None,
include_all_known: false,
detect_ssids: vec![],
},
);
p.insert(
"away".to_string(),
Profile {
bootstrap: None,
networks: vec![],
tailscale: false,
exit_node: None,
include_all_known: true,
detect_ssids: vec![],
},
);
p
}
fn ensure_core_profiles(cfg: &mut Config) {
for (name, prof) in core_profiles() {
cfg.profiles.entry(name).or_insert(prof);
}
}
/// The config generated on first run: no networks, the three core profiles.
/// Users populate it via `breadcrumbs add` / `edit` / `scan`.
fn build_initial_config() -> Config {
Config {
settings: Settings::default(),
networks: canonical_networks(),
profiles: core_profiles(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn initial_config_is_empty_with_core_profiles() {
let cfg = build_initial_config();
assert!(cfg.networks.is_empty());
assert_eq!(cfg.profiles.len(), 3);
assert!(cfg.profile("home").is_some());
assert!(cfg.profile("work").is_some());
assert!(cfg.profile("away").is_some());
assert!(cfg.profile("away").unwrap().include_all_known);
}
#[test]
fn config_toml_roundtrip() {
let cfg = build_initial_config();
let text = toml::to_string_pretty(&cfg).unwrap();
let back: Config = toml::from_str(&text).unwrap();
assert_eq!(back.networks.len(), cfg.networks.len());
assert_eq!(back.profiles.len(), 3);
assert!(back.profile("home").is_some());
assert!(back.profile("away").is_some());
}
#[test]
fn ensure_core_profiles_backfills_missing() {
let mut cfg = Config {
settings: Settings::default(),
networks: vec![],
profiles: BTreeMap::new(),
};
ensure_core_profiles(&mut cfg);
assert!(cfg.profile("home").is_some());
assert!(cfg.profile("work").is_some());
assert!(cfg.profile("away").is_some());
}
}

322
src/flow.rs Normal file
View file

@ -0,0 +1,322 @@
use crate::config::{Config, NetworkDef};
use crate::nm;
use crate::notify::{log, notify, Urgency};
use crate::status::internet_ok;
use crate::tailscale::{self, TsHealth};
#[derive(Debug)]
pub enum Outcome {
/// Connected to `ssid`; `note` carries any caveat (e.g. on bootstrap only).
Connected {
ssid: String,
note: Option<String>,
},
/// Tailscale required but unhealthy; left on `ssid` (bootstrap if available).
TailscaleError {
ssid: Option<String>,
health: TsHealth,
},
NoInterface,
NoNetworks,
UnknownProfile(String),
}
impl Outcome {
pub fn ok(&self) -> bool {
matches!(self, Outcome::Connected { .. })
}
}
fn resolve_candidates<'a>(cfg: &'a Config, p: &crate::config::Profile) -> Vec<&'a NetworkDef> {
let mut out: Vec<&NetworkDef> = Vec::new();
for ssid in &p.networks {
if let Some(def) = cfg.network(ssid) {
if !out.iter().any(|d| d.ssid == def.ssid) {
out.push(def);
}
}
}
if p.include_all_known {
for def in &cfg.networks {
if !out.iter().any(|d| d.ssid == def.ssid) {
out.push(def);
}
}
}
out
}
/// Try to connect + confirm it actually carries traffic.
fn connect_and_verify(iface: &str, def: &NetworkDef, cfg: &Config) -> bool {
if !nm::connect(iface, def, cfg.settings.nmcli_wait, &cfg.settings.dns) {
return false;
}
// Associated. Confirm DHCP/route by checking the device is connected.
if !nm::device_connected(iface) {
return false;
}
true
}
/// Run the connection state machine for `profile_name`.
pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
let profile = match cfg.profile(profile_name) {
Some(p) => p.clone(),
None => {
notify(
"breadcrumbs: unknown profile",
&format!("'{profile_name}' is not defined in breadcrumbs.toml"),
Urgency::Critical,
);
return Outcome::UnknownProfile(profile_name.to_string());
}
};
let iface = match nm::wifi_interface() {
Some(i) => i,
None => {
notify(
"breadcrumbs: no Wi-Fi adapter",
"Hardware issue — Wi-Fi device not found. Manual check needed.",
Urgency::Critical,
);
return Outcome::NoInterface;
}
};
nm::radio_on();
let exit_node = profile
.exit_node
.clone()
.unwrap_or_else(|| cfg.settings.exit_node.clone());
let candidates = resolve_candidates(cfg, &profile);
log(&format!(
"flow start: profile={profile_name} iface={iface} tailscale={} candidates=[{}]",
profile.tailscale,
candidates
.iter()
.map(|c| c.ssid.as_str())
.collect::<Vec<_>>()
.join(", ")
));
// Pre-scan for everything we might want, including the bootstrap SSID.
let mut scan_targets: Vec<String> = candidates.iter().map(|c| c.ssid.clone()).collect();
if let Some(bs) = &profile.bootstrap {
scan_targets.push(bs.clone());
}
nm::rescan(&iface, &scan_targets);
let visible = nm::visible_ssids(&iface);
// ---- Tailscale-gated profiles (e.g. school) -------------------------
let mut on_bootstrap = false;
if profile.tailscale {
if let Some(bs_ssid) = profile.bootstrap.clone() {
match cfg.network(&bs_ssid) {
Some(bdef) => {
if visible.contains(&bdef.ssid) || bdef.hidden {
if connect_and_verify(&iface, bdef, cfg) {
on_bootstrap = true;
log(&format!("bootstrap connected: {}", bdef.ssid));
} else {
log(&format!("bootstrap connect failed: {}", bdef.ssid));
}
} else {
log(&format!("bootstrap not in range: {}", bdef.ssid));
}
}
None => log(&format!(
"bootstrap SSID '{bs_ssid}' has no credentials in config"
)),
}
}
let ts = tailscale::ensure_exit_node(&exit_node);
if !ts.is_ok() {
let ssid = nm::active_ssid(&iface).or_else(|| profile.bootstrap.clone());
notify(
"Tailscale Error",
&format!(
"{} — staying on {}",
ts.describe(),
ssid.clone().unwrap_or_else(|| "Wi-Fi".into())
),
Urgency::Critical,
);
return Outcome::TailscaleError { ssid, health: ts };
}
log(&format!("tailscale healthy via exit node {exit_node}"));
// Refresh visibility before moving to the target network.
nm::rescan(&iface, &scan_targets);
}
let visible = nm::visible_ssids(&iface);
// ---- Connect to the priority list ----------------------------------
// Pass 1: visible networks in priority order.
for def in &candidates {
if visible.contains(&def.ssid) {
if connect_and_verify(&iface, def, cfg) {
let note = if internet_ok(cfg) {
None
} else {
Some("associated but no internet yet".to_string())
};
finish_connected(&def.ssid, profile_name, &note);
return Outcome::Connected {
ssid: def.ssid.clone(),
note,
};
}
log(&format!("connect failed (visible): {}", def.ssid));
}
}
// Pass 2: hidden networks we couldn't see in the scan.
for def in &candidates {
if def.hidden && !visible.contains(&def.ssid) {
if connect_and_verify(&iface, def, cfg) {
let note = if internet_ok(cfg) {
None
} else {
Some("associated but no internet yet".to_string())
};
finish_connected(&def.ssid, profile_name, &note);
return Outcome::Connected {
ssid: def.ssid.clone(),
note,
};
}
log(&format!("connect failed (hidden): {}", def.ssid));
}
}
// ---- Nothing in the priority list connected ------------------------
if on_bootstrap {
// We still have working internet via the bootstrap + Tailscale.
let ssid = profile
.bootstrap
.clone()
.unwrap_or_else(|| "bootstrap".into());
let note = format!("target network not in range — staying on {ssid} (Tailscale OK)");
notify("breadcrumbs: using bootstrap", &note, Urgency::Normal);
log(&format!("flow end: on bootstrap {ssid}; {note}"));
return Outcome::Connected {
ssid,
note: Some(note),
};
}
let names = candidates
.iter()
.map(|c| c.ssid.as_str())
.collect::<Vec<_>>()
.join(", ");
notify(
"breadcrumbs: no known networks",
&format!("profile '{profile_name}': none of [{names}] are in range"),
Urgency::Critical,
);
log(&format!(
"flow end: no networks connected (profile={profile_name})"
));
Outcome::NoNetworks
}
fn finish_connected(ssid: &str, profile: &str, note: &Option<String>) {
match note {
None => {
notify(
"breadcrumbs: connected",
&format!("{ssid} ({profile})"),
Urgency::Low,
);
log(&format!("flow end: connected {ssid} (profile={profile})"));
}
Some(n) => {
notify(
"breadcrumbs: connected (degraded)",
&format!("{ssid} ({profile}) — {n}"),
Urgency::Normal,
);
log(&format!(
"flow end: connected {ssid} (profile={profile}) note={n}"
));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{Profile, Settings};
use std::collections::BTreeMap;
fn net(ssid: &str) -> NetworkDef {
NetworkDef {
ssid: ssid.into(),
password: "x".into(),
hidden: false,
}
}
fn cfg() -> Config {
Config {
settings: Settings::default(),
networks: vec![
net("HomeWifi"),
net("WorkNet"),
net("CafeWifi"),
net("FallbackNet"),
],
profiles: BTreeMap::new(),
}
}
#[test]
fn candidates_follow_priority_order() {
let c = cfg();
let p = Profile {
networks: vec!["FallbackNet".into(), "HomeWifi".into()],
..Default::default()
};
let got: Vec<&str> = resolve_candidates(&c, &p)
.iter()
.map(|n| n.ssid.as_str())
.collect();
assert_eq!(got, vec!["FallbackNet", "HomeWifi"]);
}
#[test]
fn include_all_known_appends_remaining_without_dupes() {
let c = cfg();
let p = Profile {
networks: vec!["HomeWifi".into()],
include_all_known: true,
..Default::default()
};
let got: Vec<&str> = resolve_candidates(&c, &p)
.iter()
.map(|n| n.ssid.as_str())
.collect();
assert_eq!(got[0], "HomeWifi");
assert_eq!(got.len(), 4);
assert!(got.contains(&"WorkNet"));
// No duplicate of the explicitly-listed network.
assert_eq!(got.iter().filter(|s| **s == "HomeWifi").count(), 1);
}
#[test]
fn unknown_ssids_in_profile_are_skipped() {
let c = cfg();
let p = Profile {
networks: vec!["Ghost".into(), "WorkNet".into()],
..Default::default()
};
let got: Vec<&str> = resolve_candidates(&c, &p)
.iter()
.map(|n| n.ssid.as_str())
.collect();
assert_eq!(got, vec!["WorkNet"]);
}
}

721
src/main.rs Normal file
View file

@ -0,0 +1,721 @@
mod config;
mod flow;
mod nm;
mod notify;
mod state;
mod status;
mod tailscale;
mod util;
mod watch;
use std::io::{BufRead, Write};
use std::process::Command;
use std::time::Duration;
use clap::{Parser, Subcommand};
use config::{Config, NetworkDef};
use state::State;
use util::{command_exists, home_dir, run};
const C_RESET: &str = "\x1b[0m";
const C_BOLD: &str = "\x1b[1m";
const C_GREEN: &str = "\x1b[32m";
const C_RED: &str = "\x1b[31m";
const C_YELLOW: &str = "\x1b[33m";
const C_DIM: &str = "\x1b[2m";
#[derive(Parser)]
#[command(
name = "breadcrumbs",
version,
about = "Profile-aware Wi-Fi state machine with Tailscale handling",
disable_help_subcommand = true
)]
struct Cli {
/// Override the active profile for this run only (does not persist)
#[arg(long, short, global = true)]
profile: Option<String>,
#[command(subcommand)]
cmd: Option<Cmd>,
}
#[derive(Subcommand)]
enum Cmd {
/// Show current Wi-Fi / profile / Tailscale status (default)
Status,
/// Run the full connect sequence for the active profile
#[command(visible_aliases = ["up", "connect", "i"])]
Init,
/// Run as a daemon: watch for drops and auto-recover
Watch {
/// Skip the connect attempt on startup
#[arg(long)]
no_initial: bool,
},
/// Get / set / list location profiles (the state machine)
Profile {
#[command(subcommand)]
action: Option<ProfileCmd>,
},
/// Guess the profile from visible networks
Detect {
/// Set + apply the detected profile
#[arg(long)]
apply: bool,
},
/// Add or update a saved network
Add {
ssid: String,
/// Password (prompted if omitted)
password: Option<String>,
/// 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)
Forget { ssid: String },
/// Scan, pick, connect and save a network interactively
Scan {
/// Attach the saved network to this profile
#[arg(long)]
to: Option<String>,
},
/// List configured networks and profiles
List {
#[arg(long)]
show_passwords: bool,
},
/// Open the config file in $EDITOR
Edit,
/// Quick connectivity / Tailscale diagnostics
Doctor {
/// Run the full diag.sh report from the config directory
#[arg(long)]
full: bool,
},
/// Print the breadcrumbs config directory
Cd {
#[arg(long)]
shell: bool,
},
/// Install + enable the systemd user watcher service
InstallService {
/// Install the unit but do not enable/start it
#[arg(long)]
no_enable: bool,
},
}
#[derive(Subcommand)]
enum ProfileCmd {
/// Print the active profile
Get,
/// Set the active profile (and apply it unless --no-apply)
Set {
name: String,
#[arg(long)]
no_apply: bool,
},
/// List available profiles
List,
}
fn main() {
let cli = Cli::parse();
let code = match real_main(cli) {
Ok(c) => c,
Err(e) => {
eprintln!("{C_RED}error:{C_RESET} {e}");
1
}
};
std::process::exit(code);
}
fn active_profile(cfg: &Config, override_p: &Option<String>) -> String {
if let Some(p) = override_p {
return p.clone();
}
State::load(&cfg.settings.default_profile).profile
}
fn real_main(cli: Cli) -> Result<i32, String> {
let cmd = cli.cmd.unwrap_or(Cmd::Status);
// `cd` and `install-service` don't need a parsed config first.
if let Cmd::Cd { shell } = &cmd {
return cmd_cd(*shell);
}
let mut cfg = Config::load()?;
match cmd {
Cmd::Status => cmd_status(&cfg, &cli.profile),
Cmd::Init => {
let p = active_profile(&cfg, &cli.profile);
let outcome = flow::run(&cfg, &p);
print_outcome(&p, &outcome);
Ok(if outcome.ok() { 0 } else { 1 })
}
Cmd::Watch { no_initial } => Ok(watch::run(cfg, !no_initial)),
Cmd::Profile { action } => cmd_profile(&cfg, action),
Cmd::Detect { apply } => cmd_detect(&cfg, apply),
Cmd::Add {
ssid,
password,
hidden,
to,
at,
} => cmd_add(&mut cfg, ssid, password, hidden, to, at),
Cmd::Forget { ssid } => cmd_forget(&mut cfg, &ssid),
Cmd::Scan { to } => cmd_scan(&mut cfg, to),
Cmd::List { show_passwords } => cmd_list(&cfg, show_passwords),
Cmd::Edit => cmd_edit(),
Cmd::Doctor { full } => cmd_doctor(&cfg, &cli.profile, full),
Cmd::InstallService { no_enable } => cmd_install_service(!no_enable),
Cmd::Cd { .. } => unreachable!(),
}
}
fn print_outcome(profile: &str, o: &flow::Outcome) {
match o {
flow::Outcome::Connected { ssid, note } => {
print!("{C_GREEN}connected{C_RESET} {C_BOLD}{ssid}{C_RESET} ({profile})");
match note {
Some(n) => println!(" {C_YELLOW}{n}{C_RESET}"),
None => println!(),
}
}
flow::Outcome::TailscaleError { ssid, health } => {
println!(
"{C_RED}tailscale error{C_RESET}: {} {C_DIM}(on {}){C_RESET}",
health.describe(),
ssid.clone().unwrap_or_else(|| "".into())
);
}
flow::Outcome::NoInterface => {
println!("{C_RED}no Wi-Fi adapter{C_RESET} — hardware issue")
}
flow::Outcome::NoNetworks => {
println!("{C_RED}no known networks in range{C_RESET} (profile {profile})")
}
flow::Outcome::UnknownProfile(p) => {
println!("{C_RED}unknown profile{C_RESET}: {p}")
}
}
}
fn cmd_status(cfg: &Config, override_p: &Option<String>) -> Result<i32, String> {
let p = active_profile(cfg, override_p);
let s = status::gather(cfg, &p);
let dot = |ok: bool| {
if ok {
format!("{C_GREEN}{C_RESET}")
} else {
format!("{C_RED}{C_RESET}")
}
};
println!("{C_BOLD}breadcrumbs{C_RESET}");
println!(" profile {C_BOLD}{p}{C_RESET}");
println!(
" adapter {}",
s.iface
.clone()
.unwrap_or_else(|| format!("{C_RED}none{C_RESET}"))
);
println!(
" ssid {}",
s.ssid
.clone()
.unwrap_or_else(|| format!("{C_DIM}{C_RESET}"))
);
println!(
" ip {}",
s.ip.clone().unwrap_or_else(|| format!("{C_DIM}{C_RESET}"))
);
println!(
" internet {} {}",
dot(s.internet),
if s.internet { "ok" } else { "down" }
);
match (&s.tailscale, s.tailscale_required) {
(Some(h), req) => {
let ok = h.is_ok();
println!(
" tailscale {} {} {C_DIM}(exit: {}{}){C_RESET}",
dot(ok || !req),
h.describe(),
s.exit_node,
if req { "" } else { ", optional" }
);
}
(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!(
" state {}",
if healthy {
format!("{C_GREEN}healthy{C_RESET}")
} else {
format!("{C_YELLOW}needs attention{C_RESET} — run `breadcrumbs init`")
}
);
Ok(if healthy { 0 } else { 1 })
}
fn cmd_profile(cfg: &Config, action: Option<ProfileCmd>) -> Result<i32, String> {
match action.unwrap_or(ProfileCmd::Get) {
ProfileCmd::Get => {
println!("{}", State::load(&cfg.settings.default_profile).profile);
Ok(0)
}
ProfileCmd::List => {
let cur = State::load(&cfg.settings.default_profile).profile;
for name in cfg.profiles.keys() {
let mark = if *name == cur { "*" } else { " " };
println!("{mark} {name}");
}
Ok(0)
}
ProfileCmd::Set { name, no_apply } => {
if !cfg.profiles.contains_key(&name) {
let avail: Vec<&String> = cfg.profiles.keys().collect();
return Err(format!("unknown profile '{name}'. Available: {avail:?}"));
}
let st = State {
profile: name.clone(),
updated: util::timestamp(),
};
st.save()?;
notify::log(&format!("profile set -> {name}"));
println!("profile = {C_BOLD}{name}{C_RESET}");
if no_apply {
return Ok(0);
}
let outcome = flow::run(cfg, &name);
print_outcome(&name, &outcome);
Ok(if outcome.ok() { 0 } else { 1 })
}
}
}
fn detect_profile(cfg: &Config) -> Option<String> {
let iface = nm::wifi_interface()?;
nm::radio_on();
nm::rescan(&iface, &[]);
let visible = nm::visible_ssids(&iface);
// Profiles are stored in a BTreeMap so iteration order is deterministic
// (alphabetical). The caller can rely on that for tie-breaking.
for (name, profile) in &cfg.profiles {
if profile.detect_ssids.is_empty() {
continue;
}
if profile
.detect_ssids
.iter()
.any(|s| visible.contains(s.as_str()))
{
return Some(name.clone());
}
}
// Fall back to the default profile if no markers matched.
Some(cfg.settings.default_profile.clone())
}
fn cmd_detect(cfg: &Config, apply: bool) -> Result<i32, String> {
match detect_profile(cfg) {
Some(p) => {
println!("{p}");
if apply {
State {
profile: p.clone(),
updated: util::timestamp(),
}
.save()?;
let outcome = flow::run(cfg, &p);
print_outcome(&p, &outcome);
return Ok(if outcome.ok() { 0 } else { 1 });
}
Ok(0)
}
None => Err("could not detect a profile (no Wi-Fi adapter?)".into()),
}
}
fn prompt_line(msg: &str) -> String {
print!("{msg}");
let _ = std::io::stdout().flush();
let mut s = String::new();
let _ = std::io::stdin().lock().read_line(&mut s);
s.trim_end_matches(['\n', '\r']).to_string()
}
fn prompt_secret(msg: &str) -> String {
// `util::run` redirects child stdin to /dev/null, so plain `stty -echo`
// would target the wrong fd and silently leave echo ON (leaking the
// password to the screen). `-F /dev/tty` makes stty act on the controlling
// terminal directly. If there is no tty we fall back to visible input.
let had_tty = run("stty", &["-F", "/dev/tty", "-echo"], Duration::from_secs(2)).success;
let val = prompt_line(msg);
if had_tty {
let _ = run("stty", &["-F", "/dev/tty", "echo"], Duration::from_secs(2));
println!();
}
val
}
fn cmd_add(
cfg: &mut Config,
ssid: String,
password: Option<String>,
hidden: bool,
to: Option<String>,
at: Option<usize>,
) -> Result<i32, String> {
let password = match password {
Some(p) => p,
None => prompt_secret(&format!("Password for '{ssid}': ")),
};
match cfg.networks.iter_mut().find(|n| n.ssid == ssid) {
Some(n) => {
n.password = password;
n.hidden = hidden || n.hidden;
}
None => cfg.networks.push(NetworkDef {
ssid: ssid.clone(),
password,
hidden,
}),
}
if let Some(prof_name) = to {
let prof = cfg
.profiles
.get_mut(&prof_name)
.ok_or_else(|| format!("unknown profile '{prof_name}'"))?;
prof.networks.retain(|s| s != &ssid);
let idx = at.unwrap_or(prof.networks.len()).min(prof.networks.len());
prof.networks.insert(idx, ssid.clone());
}
cfg.save()?;
println!("{C_GREEN}saved{C_RESET} {ssid}");
Ok(0)
}
fn cmd_forget(cfg: &mut Config, ssid: &str) -> Result<i32, String> {
let before = cfg.networks.len();
cfg.networks.retain(|n| n.ssid != ssid);
for p in cfg.profiles.values_mut() {
p.networks.retain(|s| s != ssid);
if p.bootstrap.as_deref() == Some(ssid) {
p.bootstrap = None;
}
}
cfg.save()?;
let removed = nm::delete_connections_for_ssid(ssid);
println!(
"{C_GREEN}forgot{C_RESET} {ssid} (config: {}, NetworkManager: {})",
if cfg.networks.len() < before {
"removed"
} else {
"not present"
},
if removed { "removed" } else { "not present" }
);
Ok(0)
}
fn cmd_scan(cfg: &mut Config, to: Option<String>) -> Result<i32, String> {
let iface = nm::wifi_interface().ok_or("no Wi-Fi adapter")?;
nm::radio_on();
nm::rescan(&iface, &[]);
let entries = nm::scan_list(&iface);
if entries.is_empty() {
return Err("no networks found".into());
}
for (i, e) in entries.iter().enumerate() {
println!(
"{:>2}. {C_BOLD}{}{C_RESET} {C_DIM}sig {} {}{C_RESET}",
i + 1,
if e.ssid.is_empty() {
"<hidden>"
} else {
&e.ssid
},
e.signal,
e.security
);
}
let sel = prompt_line("Select number: ");
let idx: usize = sel
.parse::<usize>()
.ok()
.filter(|n| *n >= 1 && *n <= entries.len())
.ok_or("invalid selection")?;
let ssid = entries[idx - 1].ssid.clone();
if ssid.is_empty() {
return Err("cannot select a hidden SSID here; use `breadcrumbs add`".into());
}
let password = prompt_secret(&format!("Password for '{ssid}': "));
let def = NetworkDef {
ssid: ssid.clone(),
password: password.clone(),
hidden: false,
};
if !nm::connect(&iface, &def, cfg.settings.nmcli_wait, &cfg.settings.dns) {
return Err(format!("failed to connect to {ssid}"));
}
match cfg.networks.iter_mut().find(|n| n.ssid == ssid) {
Some(n) => n.password = password,
None => cfg.networks.push(def),
}
if let Some(prof_name) = to {
if let Some(prof) = cfg.profiles.get_mut(&prof_name) {
if !prof.networks.contains(&ssid) {
prof.networks.push(ssid.clone());
}
}
}
cfg.save()?;
println!("{C_GREEN}connected + saved{C_RESET} {ssid}");
Ok(0)
}
fn mask(p: &str) -> String {
if p.len() <= 2 {
"••".into()
} else {
format!("{}{}", &p[..1], "".repeat(p.len().saturating_sub(1)))
}
}
fn cmd_list(cfg: &Config, show_pw: bool) -> Result<i32, String> {
println!("{C_BOLD}settings{C_RESET}");
println!(" dns {}", cfg.settings.dns);
println!(" exit_node {}", cfg.settings.exit_node);
println!(" default {}", cfg.settings.default_profile);
println!(" watch every {}s", cfg.settings.watch_interval);
println!("\n{C_BOLD}networks{C_RESET}");
for n in &cfg.networks {
println!(
" {C_BOLD}{}{C_RESET} {C_DIM}{}{}{C_RESET}",
n.ssid,
if show_pw {
n.password.clone()
} else {
mask(&n.password)
},
if n.hidden { " (hidden)" } else { "" }
);
}
println!("\n{C_BOLD}profiles{C_RESET}");
let cur = State::load(&cfg.settings.default_profile).profile;
for (name, p) in &cfg.profiles {
let mark = if *name == cur {
format!("{C_GREEN}*{C_RESET}")
} else {
" ".into()
};
println!("{mark} {C_BOLD}{name}{C_RESET}");
if let Some(b) = &p.bootstrap {
println!(" bootstrap {b}");
}
if p.tailscale {
println!(
" tailscale required (exit: {})",
p.exit_node
.clone()
.unwrap_or_else(|| cfg.settings.exit_node.clone())
);
}
let mut order: Vec<String> = p.networks.clone();
if p.include_all_known {
order.push("…all other known networks".into());
}
println!(" priority {}", order.join(" > "));
}
Ok(0)
}
fn cmd_edit() -> Result<i32, String> {
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".into());
let path = config::config_path();
let status = Command::new(&editor)
.arg(&path)
.status()
.map_err(|e| format!("launching {editor}: {e}"))?;
if !status.success() {
return Err("editor exited with error".into());
}
match Config::load() {
Ok(_) => {
println!("{C_GREEN}config OK{C_RESET}");
Ok(0)
}
Err(e) => Err(format!("config is now invalid: {e}")),
}
}
fn cmd_doctor(cfg: &Config, override_p: &Option<String>, full: bool) -> Result<i32, String> {
if full {
let script = config::config_dir().join("diag.sh");
if !script.exists() {
return Err(format!(
"diag.sh not found (expected at {})",
script.display()
));
}
let st = Command::new("bash")
.arg(&script)
.status()
.map_err(|e| format!("running diag: {e}"))?;
return Ok(st.code().unwrap_or(1));
}
let p = active_profile(cfg, override_p);
let s = status::gather(cfg, &p);
println!("{C_BOLD}breadcrumbs doctor{C_RESET} (profile {p})");
println!(
" nmcli {}",
if command_exists("nmcli") {
"present"
} else {
"MISSING"
}
);
println!(
" tailscale {}",
if command_exists("tailscale") {
"present"
} else {
"absent"
}
);
println!(
" adapter {}",
s.iface.clone().unwrap_or_else(|| "none".into())
);
println!(
" ssid {}",
s.ssid.clone().unwrap_or_else(|| "".into())
);
println!(
" ip {}",
s.ip.clone().unwrap_or_else(|| "".into())
);
println!(" internet {}", if s.internet { "ok" } else { "DOWN" });
if let Some(h) = &s.tailscale {
println!(" tailscale {} (exit {})", h.describe(), s.exit_node);
}
if let Some(iface) = &s.iface {
let visible = nm::visible_ssids(iface);
let known: Vec<&str> = cfg
.networks
.iter()
.filter(|n| visible.contains(&n.ssid))
.map(|n| n.ssid.as_str())
.collect();
println!(
" in range {}",
if known.is_empty() {
"none of your saved networks".into()
} else {
known.join(", ")
}
);
}
println!("\nFull report: {C_DIM}breadcrumbs doctor --full{C_RESET}");
Ok(0)
}
fn cmd_cd(shell: bool) -> Result<i32, String> {
let dir = config::config_dir();
if shell {
let sh = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into());
let err = exec_replace(&sh, &["-lc", &format!("cd {:?} && exec {sh}", dir)]);
return Err(err);
}
println!("{}", dir.display());
Ok(0)
}
fn exec_replace(prog: &str, args: &[&str]) -> String {
use std::os::unix::process::CommandExt;
let e = Command::new(prog).args(args).exec();
format!("exec {prog} failed: {e}")
}
fn cmd_install_service(enable: bool) -> Result<i32, String> {
let unit_dir = home_dir().join(".config/systemd/user");
std::fs::create_dir_all(&unit_dir)
.map_err(|e| format!("creating {}: {e}", unit_dir.display()))?;
let bin = std::env::current_exe().map_err(|e| format!("resolving current executable: {e}"))?;
// Ordering against graphical-session.target lets the watcher inherit the
// session's DISPLAY/WAYLAND_DISPLAY/DBUS so notify-send and the Tailscale
// 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
// nmcli/tailscale/sudo/xdg-open by name.
let unit = format!(
"[Unit]\n\
Description=breadcrumbs Wi-Fi state machine watcher\n\
After=network.target NetworkManager.service graphical-session.target\n\
Wants=network.target graphical-session.target\n\n\
[Service]\n\
Type=simple\n\
Environment=PATH=/usr/local/bin:/usr/bin:/bin\n\
ExecStart={bin} watch\n\
Restart=always\n\
RestartSec=5\n\
Nice=5\n\n\
[Install]\n\
WantedBy=default.target\n",
bin = bin.display()
);
let unit_path = unit_dir.join("breadcrumbs.service");
std::fs::write(&unit_path, unit)
.map_err(|e| format!("writing {}: {e}", unit_path.display()))?;
println!("{C_GREEN}wrote{C_RESET} {}", unit_path.display());
let _ = run(
"systemctl",
&["--user", "daemon-reload"],
Duration::from_secs(10),
);
if enable {
let o = run(
"systemctl",
&["--user", "enable", "--now", "breadcrumbs.service"],
Duration::from_secs(15),
);
if o.success {
println!("{C_GREEN}enabled + started{C_RESET} breadcrumbs.service");
} else {
println!(
"{C_YELLOW}unit installed{C_RESET}; enable failed: {}",
o.stderr.trim()
);
return Ok(1);
}
} else {
println!("Run: systemctl --user enable --now breadcrumbs.service");
}
Ok(0)
}

345
src/nm.rs Normal file
View file

@ -0,0 +1,345 @@
use std::collections::HashSet;
use std::time::Duration;
use crate::config::NetworkDef;
use crate::util::{run, run_ok, run_with_stdin};
/// nmcli `-t` escapes `:` and `\` in field values; undo that.
fn unescape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' {
if let Some(&n) = chars.peek() {
out.push(n);
chars.next();
continue;
}
}
out.push(c);
}
out
}
/// Split one nmcli `-t` line into fields. Fields are ':'-separated but values
/// escape ':' as '\:' and '\' as '\\'.
fn parse_scan_line(line: &str) -> Vec<String> {
let mut fields: Vec<String> = Vec::new();
let mut cur = String::new();
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' {
if let Some(&n) = chars.peek() {
cur.push(n);
chars.next();
continue;
}
}
if c == ':' {
fields.push(std::mem::take(&mut cur));
} else {
cur.push(c);
}
}
fields.push(cur);
fields
}
pub fn wifi_interface() -> Option<String> {
let o = run(
"nmcli",
&["-t", "-f", "DEVICE,TYPE", "device", "status"],
Duration::from_secs(8),
);
if !o.success {
return None;
}
for line in o.stdout.lines() {
let parts: Vec<&str> = line.splitn(2, ':').collect();
if parts.len() == 2 && parts[1] == "wifi" {
return Some(unescape(parts[0]));
}
}
None
}
pub fn radio_on() {
let _ = run("nmcli", &["radio", "wifi", "on"], Duration::from_secs(6));
}
pub fn rescan(iface: &str, ssids: &[String]) {
let mut args: Vec<String> = vec![
"device".into(),
"wifi".into(),
"rescan".into(),
"ifname".into(),
iface.into(),
];
for s in ssids {
args.push("ssid".into());
args.push(s.clone());
}
let argv: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
let _ = run("nmcli", &argv, Duration::from_secs(20));
}
pub fn visible_ssids(iface: &str) -> HashSet<String> {
let o = run(
"nmcli",
&[
"-t", "-f", "SSID", "device", "wifi", "list", "ifname", iface,
],
Duration::from_secs(12),
);
let mut set = HashSet::new();
if !o.success {
return set;
}
for line in o.stdout.lines() {
let ssid = unescape(line.trim());
if !ssid.is_empty() {
set.insert(ssid);
}
}
set
}
#[derive(Debug, Clone)]
pub struct ScanEntry {
pub ssid: String,
pub signal: String,
pub security: String,
}
pub fn scan_list(iface: &str) -> Vec<ScanEntry> {
let o = run(
"nmcli",
&[
"-t",
"-f",
"SSID,SIGNAL,SECURITY",
"device",
"wifi",
"list",
"ifname",
iface,
],
Duration::from_secs(12),
);
let mut seen = HashSet::new();
let mut out = Vec::new();
if !o.success {
return out;
}
for line in o.stdout.lines() {
let fields = parse_scan_line(line);
if fields.is_empty() {
continue;
}
let ssid = fields[0].trim().to_string();
if ssid.is_empty() || !seen.insert(ssid.clone()) {
continue;
}
out.push(ScanEntry {
ssid,
signal: fields.get(1).cloned().unwrap_or_default(),
security: fields.get(2).cloned().unwrap_or_default(),
});
}
out
}
pub fn active_ssid(iface: &str) -> Option<String> {
let o = run(
"nmcli",
&[
"-t",
"-f",
"ACTIVE,SSID",
"device",
"wifi",
"list",
"ifname",
iface,
],
Duration::from_secs(8),
);
if !o.success {
return None;
}
for line in o.stdout.lines() {
let parts: Vec<&str> = line.splitn(2, ':').collect();
if parts.len() == 2 && parts[0] == "yes" {
let s = unescape(parts[1].trim());
if !s.is_empty() {
return Some(s);
}
}
}
None
}
pub fn device_connected(iface: &str) -> bool {
let o = run(
"nmcli",
&["-t", "-f", "DEVICE,STATE", "device", "status"],
Duration::from_secs(6),
);
if !o.success {
return false;
}
for line in o.stdout.lines() {
let parts: Vec<&str> = line.splitn(2, ':').collect();
if parts.len() == 2 && unescape(parts[0]) == iface {
return parts[1].starts_with("connected");
}
}
false
}
fn active_uuid(iface: &str) -> Option<String> {
let o = run(
"nmcli",
&["-g", "GENERAL.CON-UUID", "device", "show", iface],
Duration::from_secs(6),
);
if !o.success {
return None;
}
let u = o.stdout.trim().to_string();
if u.is_empty() {
None
} else {
Some(u)
}
}
fn enforce_dns(uuid: &str, iface: &str, dns: &str) {
if dns.trim().is_empty() {
return;
}
let ok = run_ok(
"nmcli",
&[
"connection",
"modify",
uuid,
"ipv4.ignore-auto-dns",
"yes",
"ipv4.dns",
dns,
],
Duration::from_secs(8),
);
if ok {
let _ = run(
"nmcli",
&["device", "reapply", iface],
Duration::from_secs(8),
);
}
}
/// Connect to a network and pin DNS. Returns true only if associated.
pub fn connect(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> bool {
let wait_s = wait.to_string();
let hidden = if net.hidden { "yes" } else { "no" };
// `--ask` makes nmcli read the PSK from stdin instead of taking it on the
// command line, so the password never appears in `ps`/`/proc`.
let args = [
"--wait",
&wait_s,
"--ask",
"device",
"wifi",
"connect",
net.ssid.as_str(),
"hidden",
hidden,
"ifname",
iface,
];
let secret = format!("{}\n", net.password);
let o = run_with_stdin(
"nmcli",
&args,
Some(&secret),
Duration::from_secs(wait as u64 + 15),
);
if !o.success {
return false;
}
if let Some(uuid) = active_uuid(iface) {
enforce_dns(&uuid, iface, dns);
}
true
}
/// Delete every saved connection profile whose name or 802-11-wireless SSID
/// matches `ssid` (used by `breadcrumbs forget` to purge stale entries).
pub fn delete_connections_for_ssid(ssid: &str) -> bool {
let list = run(
"nmcli",
&["-t", "-f", "NAME,TYPE", "connection", "show"],
Duration::from_secs(8),
);
if !list.success {
return false;
}
let mut removed = false;
for line in list.stdout.lines() {
let parts: Vec<&str> = line.splitn(2, ':').collect();
if parts.len() < 2 {
continue;
}
let name = unescape(parts[0]);
let typ = parts[1];
if !typ.contains("wireless") {
continue;
}
let conn_ssid = run(
"nmcli",
&["-g", "802-11-wireless.ssid", "connection", "show", &name],
Duration::from_secs(6),
);
let conn_ssid = conn_ssid.stdout.trim();
if (name == ssid || conn_ssid == ssid)
&& run_ok(
"nmcli",
&["connection", "delete", "id", &name],
Duration::from_secs(8),
)
{
removed = true;
}
}
removed
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unescape_handles_nmcli_escaping() {
assert_eq!(unescape("plain"), "plain");
assert_eq!(unescape(r"a\:b"), "a:b");
assert_eq!(unescape(r"back\\slash"), r"back\slash");
assert_eq!(unescape("trailing\\"), "trailing\\");
}
#[test]
fn parse_scan_line_splits_and_unescapes() {
// SSID:SIGNAL:SECURITY with an escaped ':' inside the SSID.
let f = parse_scan_line(r"My\:Net:72:WPA2");
assert_eq!(f, vec!["My:Net", "72", "WPA2"]);
// SSID with a space (common in real network names)
let f = parse_scan_line("My Network:88:WPA2");
assert_eq!(f, vec!["My Network", "88", "WPA2"]);
// Empty SSID (hidden) keeps the empty leading field.
let f = parse_scan_line(":40:WPA3");
assert_eq!(f, vec!["", "40", "WPA3"]);
}
}

79
src/notify.rs Normal file
View file

@ -0,0 +1,79 @@
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::time::Duration;
use crate::config::{log_path, state_dir};
use crate::util::{command_exists, run, timestamp};
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Urgency {
Low,
Normal,
Critical,
}
impl Urgency {
fn as_str(self) -> &'static str {
match self {
Urgency::Low => "low",
Urgency::Normal => "normal",
Urgency::Critical => "critical",
}
}
}
const MAX_LOG_BYTES: u64 = 512 * 1024;
pub fn log(line: &str) {
let _ = fs::create_dir_all(state_dir());
let path = log_path();
if let Ok(meta) = fs::metadata(&path) {
if meta.len() > MAX_LOG_BYTES {
if let Ok(text) = fs::read_to_string(&path) {
let tail: Vec<&str> = text.lines().rev().take(300).collect();
let kept: String = tail.into_iter().rev().collect::<Vec<_>>().join("\n");
let _ = fs::write(&path, kept + "\n");
}
}
}
if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(&path) {
let _ = writeln!(f, "{} {}", timestamp(), line);
}
}
/// Desktop notification (best effort) + log entry.
pub fn notify(summary: &str, body: &str, urgency: Urgency) {
log(&format!(
"[notify/{}] {} {}",
urgency.as_str(),
summary,
if body.is_empty() {
String::new()
} else {
format!("- {body}")
}
));
if !command_exists("notify-send") {
return;
}
let timeout_ms = match urgency {
Urgency::Critical => "0",
Urgency::Normal => "6000",
Urgency::Low => "3500",
};
let mut args = vec![
"-a",
"breadcrumbs",
"-u",
urgency.as_str(),
"-t",
timeout_ms,
"-h",
"string:x-canonical-private-synchronous:breadcrumbs",
summary,
];
if !body.is_empty() {
args.push(body);
}
let _ = run("notify-send", &args, Duration::from_secs(5));
}

34
src/state.rs Normal file
View file

@ -0,0 +1,34 @@
use std::fs;
use serde::{Deserialize, Serialize};
use crate::config::{state_dir, state_path};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct State {
pub profile: String,
#[serde(default)]
pub updated: String,
}
impl State {
pub fn load(default_profile: &str) -> State {
if let Ok(text) = fs::read_to_string(state_path()) {
if let Ok(s) = toml::from_str::<State>(&text) {
if !s.profile.is_empty() {
return s;
}
}
}
State {
profile: default_profile.to_string(),
updated: String::new(),
}
}
pub fn save(&self) -> Result<(), String> {
fs::create_dir_all(state_dir()).map_err(|e| format!("creating state dir: {e}"))?;
let text = toml::to_string_pretty(self).map_err(|e| format!("serializing state: {e}"))?;
fs::write(state_path(), text).map_err(|e| format!("writing state: {e}"))
}
}

92
src/status.rs Normal file
View file

@ -0,0 +1,92 @@
use std::time::Duration;
use crate::config::Config;
use crate::nm;
use crate::tailscale::{self, TsHealth};
use crate::util::{command_exists, run};
pub fn internet_ok(cfg: &Config) -> bool {
if command_exists("curl") {
let o = run(
"curl",
&[
"-s",
"-o",
"/dev/null",
"-w",
"%{http_code}",
"--max-time",
"4",
&cfg.settings.connectivity_url,
],
Duration::from_secs(6),
);
let code = o.stdout.trim();
if code == "204" || code == "200" || code == "301" || code == "302" {
return true;
}
}
// Fallback: ICMP to the configured host.
run(
"ping",
&["-c", "1", "-W", "2", &cfg.settings.ping_host],
Duration::from_secs(4),
)
.success
}
fn ipv4(iface: &str) -> Option<String> {
let o = run(
"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 iface: Option<String>,
pub ssid: Option<String>,
pub ip: Option<String>,
pub internet: bool,
pub tailscale_required: bool,
pub tailscale: Option<TsHealth>,
pub exit_node: String,
}
pub fn gather(cfg: &Config, profile_name: &str) -> Status {
let iface = nm::wifi_interface();
let ssid = iface.as_deref().and_then(nm::active_ssid);
let ip = iface.as_deref().and_then(ipv4);
let internet = internet_ok(cfg);
let prof = cfg.profile(profile_name);
let ts_required = prof.map(|p| p.tailscale).unwrap_or(false);
let exit_node = prof
.and_then(|p| p.exit_node.clone())
.unwrap_or_else(|| cfg.settings.exit_node.clone());
let tailscale = if tailscale::installed() {
Some(tailscale::check(&exit_node))
} else {
None
};
Status {
iface,
ssid,
ip,
internet,
tailscale_required: ts_required,
tailscale,
exit_node,
}
}

384
src/tailscale.rs Normal file
View file

@ -0,0 +1,384 @@
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};
use serde_json::Value;
use crate::notify::{log, notify, Urgency};
use crate::util::{command_exists, run};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TsHealth {
/// Backend Running and the requested exit node is selected + online.
Ok,
NotInstalled,
NeedsLogin,
Stopped,
/// The exit node host is not present / not advertising as an exit node.
ExitNodeMissing,
/// The exit node exists but is offline.
ExitNodeOffline,
Error(String),
}
impl TsHealth {
pub fn is_ok(&self) -> bool {
matches!(self, TsHealth::Ok)
}
pub fn describe(&self) -> String {
match self {
TsHealth::Ok => "ok".into(),
TsHealth::NotInstalled => "tailscale not installed".into(),
TsHealth::NeedsLogin => "not logged in (run: tailscale up)".into(),
TsHealth::Stopped => "backend stopped".into(),
TsHealth::ExitNodeMissing => "exit node not found in tailnet".into(),
TsHealth::ExitNodeOffline => "exit node is offline".into(),
TsHealth::Error(e) => format!("error: {e}"),
}
}
}
pub fn installed() -> bool {
command_exists("tailscale")
}
fn status_json() -> Option<Value> {
let o = run("tailscale", &["status", "--json"], Duration::from_secs(8));
if o.stdout.trim().is_empty() {
return None;
}
serde_json::from_str(&o.stdout).ok()
}
fn backend_state(v: &Value) -> String {
v.get("BackendState")
.and_then(Value::as_str)
.unwrap_or("")
.to_string()
}
/// Does any peer named `node` advertise as an exit node, and is it online /
/// currently selected?
fn exit_node_state(
v: &Value,
node: &str,
) -> (
bool, /*exists*/
bool, /*online*/
bool, /*selected*/
) {
// Strong signal: ExitNodeStatus is populated when an exit node is active.
let ens_online = v
.get("ExitNodeStatus")
.and_then(|e| e.get("Online"))
.and_then(Value::as_bool);
let want = node.trim().to_lowercase();
let mut exists = false;
let mut online = false;
let mut selected = false;
if let Some(peers) = v.get("Peer").and_then(Value::as_object) {
for (_k, p) in peers {
let host = p
.get("HostName")
.and_then(Value::as_str)
.unwrap_or("")
.to_lowercase();
let dns = p
.get("DNSName")
.and_then(Value::as_str)
.unwrap_or("")
.to_lowercase();
let matches = host == want || dns.split('.').next() == Some(want.as_str());
let advertises = p
.get("ExitNodeOption")
.and_then(Value::as_bool)
.unwrap_or(false);
if matches && advertises {
exists = true;
online = p.get("Online").and_then(Value::as_bool).unwrap_or(false);
selected = p.get("ExitNode").and_then(Value::as_bool).unwrap_or(false);
break;
}
}
}
if selected {
if let Some(o) = ens_online {
online = o;
}
}
(exists, online, selected)
}
fn extract_url(line: &str) -> Option<String> {
line.split_whitespace()
.find(|s| s.starts_with("https://"))
.map(str::to_string)
}
static LOGIN_INFLIGHT: AtomicBool = AtomicBool::new(false);
/// Kick off browser-based Tailscale login in the background and return
/// immediately. The watch loop must never block on interactive auth, so the
/// actual `sudo tailscale login` + browser flow runs on its own thread; the
/// caller just keeps reporting `NeedsLogin` (→ stay on the bootstrap network)
/// until a later poll observes the backend come up. The guard collapses
/// repeated triggers into a single in-flight attempt.
fn login_and_open() {
if LOGIN_INFLIGHT.swap(true, Ordering::SeqCst) {
return;
}
thread::spawn(|| {
run_login();
LOGIN_INFLIGHT.store(false, Ordering::SeqCst);
});
}
/// Run `sudo tailscale login`, open the auth URL in the browser, and block
/// until login completes (up to 5 minutes). Always called on a worker thread.
fn run_login() {
let mut child = match Command::new("sudo")
.args(["tailscale", "login"])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
log(&format!("tailscale login: spawn failed: {e}"));
return;
}
};
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let (tx, rx) = mpsc::channel::<String>();
let tx2 = tx.clone();
thread::spawn(move || {
if let Some(r) = stdout {
for line in BufReader::new(r).lines().map_while(Result::ok) {
if let Some(url) = extract_url(&line) {
let _ = tx.send(url);
}
}
}
});
thread::spawn(move || {
if let Some(r) = stderr {
for line in BufReader::new(r).lines().map_while(Result::ok) {
if let Some(url) = extract_url(&line) {
let _ = tx2.send(url);
}
}
}
});
if let Ok(url) = rx.recv_timeout(Duration::from_secs(30)) {
log(&format!("tailscale login: opening {url}"));
notify(
"Tailscale: login required",
"Opening browser to authenticate.",
Urgency::Normal,
);
let _ = Command::new("xdg-open")
.arg(&url)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
} else {
log("tailscale login: no URL received within 30s");
}
// Wait up to 5 min for the user to complete browser auth.
let start = Instant::now();
let timeout = Duration::from_secs(300);
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if start.elapsed() >= timeout {
log("tailscale login: timed out waiting for auth");
let _ = child.kill();
let _ = child.wait();
break;
}
thread::sleep(Duration::from_millis(500));
}
Err(_) => break,
}
}
}
/// Bring Tailscale to a state where `node` is the active, online exit node.
/// Performs at most one bring-up/login and one `tailscale set` attempt.
pub fn ensure_exit_node(node: &str) -> TsHealth {
if !installed() {
return TsHealth::NotInstalled;
}
let v = match status_json() {
Some(v) => v,
None => return TsHealth::Error("could not read tailscale status".into()),
};
match backend_state(&v).as_str() {
"NeedsLogin" | "NoState" => {
login_and_open();
}
"Stopped" => {
// Daemon not running — bring it up, then check if it needs auth.
let _ = run("tailscale", &["up"], Duration::from_secs(20));
if let Some(v2) = status_json() {
if matches!(backend_state(&v2).as_str(), "NeedsLogin" | "NoState") {
login_and_open();
}
}
}
"Running" => {}
"" => return TsHealth::Error("empty backend state".into()),
_ => {}
}
// Select the exit node (idempotent).
let _ = run(
"tailscale",
&["set", &format!("--exit-node={node}")],
Duration::from_secs(10),
);
let v = match status_json() {
Some(v) => v,
None => return TsHealth::Error("could not re-read tailscale status".into()),
};
match backend_state(&v).as_str() {
"Running" => {}
"NeedsLogin" | "NoState" => return TsHealth::NeedsLogin,
"Stopped" => return TsHealth::Stopped,
other => return TsHealth::Error(format!("backend state: {other}")),
}
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.
pub fn check(node: &str) -> TsHealth {
if !installed() {
return TsHealth::NotInstalled;
}
let v = match status_json() {
Some(v) => v,
None => return TsHealth::Error("status unavailable".into()),
};
match backend_state(&v).as_str() {
"Running" => {}
"NeedsLogin" | "NoState" => return TsHealth::NeedsLogin,
"Stopped" => return TsHealth::Stopped,
other => return TsHealth::Error(format!("backend state: {other}")),
}
let (exists, online, selected) = exit_node_state(&v, node);
if !exists {
TsHealth::ExitNodeMissing
} else if !online {
TsHealth::ExitNodeOffline
} else if !selected {
TsHealth::Error("exit node not selected".into())
} else {
TsHealth::Ok
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn backend_state_extraction() {
assert_eq!(
backend_state(&json!({"BackendState": "Running"})),
"Running"
);
assert_eq!(backend_state(&json!({})), "");
}
#[test]
fn exit_node_healthy_and_selected() {
let v = json!({
"BackendState": "Running",
"ExitNodeStatus": { "Online": true },
"Peer": {
"k1": { "HostName": "exitnode", "DNSName": "exitnode.ts.net.",
"Online": true, "ExitNode": true, "ExitNodeOption": true }
}
});
assert_eq!(exit_node_state(&v, "exitnode"), (true, true, true));
}
#[test]
fn exit_node_present_but_offline() {
let v = json!({
"BackendState": "Running",
"Peer": {
"k1": { "HostName": "exitnode", "DNSName": "exitnode.ts.net.",
"Online": false, "ExitNode": false, "ExitNodeOption": true }
}
});
assert_eq!(exit_node_state(&v, "exitnode"), (true, false, false));
}
#[test]
fn exit_node_missing_when_not_advertised() {
let v = json!({
"BackendState": "Running",
"Peer": {
"k1": { "HostName": "exitnode", "DNSName": "exitnode.ts.net.",
"Online": true, "ExitNode": false, "ExitNodeOption": false }
}
});
assert_eq!(exit_node_state(&v, "exitnode"), (false, false, false));
}
#[test]
fn exit_node_matches_via_dns_first_label() {
let v = json!({
"Peer": {
"k1": { "HostName": "box-1", "DNSName": "exitnode.example.ts.net.",
"Online": true, "ExitNode": true, "ExitNodeOption": true }
}
});
let (exists, online, selected) = exit_node_state(&v, "exitnode");
assert!(exists && online && selected);
}
#[test]
fn exit_node_present_online_but_not_selected() {
let v = json!({
"Peer": {
"k1": { "HostName": "exitnode", "DNSName": "exitnode.ts.net.",
"Online": true, "ExitNode": false, "ExitNodeOption": true }
}
});
assert_eq!(exit_node_state(&v, "exitnode"), (true, true, false));
}
}

179
src/util.rs Normal file
View file

@ -0,0 +1,179 @@
use std::io::{Read, Write};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
pub fn home_dir() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/root"))
}
pub fn command_exists(name: &str) -> bool {
if let Some(paths) = std::env::var_os("PATH") {
for dir in std::env::split_paths(&paths) {
if dir.join(name).is_file() {
return true;
}
}
}
false
}
#[derive(Debug, Clone)]
pub struct Output {
pub success: bool,
pub stdout: String,
pub stderr: String,
}
impl Output {
pub fn failed() -> Output {
Output {
success: false,
stdout: String::new(),
stderr: String::new(),
}
}
}
/// Run a command with a hard timeout. The child is killed if it overruns so a
/// hung nmcli/tailscale can never wedge the daemon.
pub fn run(prog: &str, args: &[&str], timeout: Duration) -> Output {
run_with_stdin(prog, args, None, timeout)
}
/// Like [`run`], but feeds `stdin` to the child's standard input. Used to hand
/// secrets (e.g. Wi-Fi PSKs) to `nmcli --ask` without exposing them in argv,
/// where any local user could read them via `ps`.
pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration) -> Output {
let stdin_cfg = if stdin.is_some() {
Stdio::piped()
} else {
Stdio::null()
};
let mut child = match Command::new(prog)
.args(args)
.stdin(stdin_cfg)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
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 stderr_pipe = child.stderr.take();
let out_handle = thread::spawn(move || {
let mut buf = String::new();
if let Some(ref mut p) = stdout_pipe {
let _ = p.read_to_string(&mut buf);
}
buf
});
let err_handle = thread::spawn(move || {
let mut buf = String::new();
if let Some(ref mut p) = stderr_pipe {
let _ = p.read_to_string(&mut buf);
}
buf
});
let start = Instant::now();
let status = loop {
match child.try_wait() {
Ok(Some(s)) => break Some(s),
Ok(None) => {
if start.elapsed() >= timeout {
let _ = child.kill();
let _ = child.wait();
break None;
}
thread::sleep(Duration::from_millis(50));
}
Err(_) => break None,
}
};
let stdout = out_handle.join().unwrap_or_default();
let stderr = err_handle.join().unwrap_or_default();
Output {
success: status.map(|s| s.success()).unwrap_or(false),
stdout,
stderr,
}
}
pub fn run_ok(prog: &str, args: &[&str], timeout: Duration) -> bool {
run(prog, args, timeout).success
}
/// 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.
pub fn timestamp() -> String {
let o = run("date", &["+%Y-%m-%d %H:%M:%S"], Duration::from_secs(2));
if o.success {
let t = o.stdout.trim();
if !t.is_empty() {
return t.to_string();
}
}
timestamp_utc()
}
/// epoch seconds -> "YYYY-MM-DD HH:MM:SS" (UTC), no external deps.
fn timestamp_utc() -> String {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0) as i64;
fmt_epoch(secs)
}
/// Format UTC epoch seconds as "YYYY-MM-DD HH:MM:SS" (pure / testable).
fn fmt_epoch(secs: i64) -> String {
let days = secs.div_euclid(86_400);
let tod = secs.rem_euclid(86_400);
let (h, mi, s) = (tod / 3600, (tod % 3600) / 60, tod % 60);
// civil_from_days (Howard Hinnant's algorithm)
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!("{y:04}-{m:02}-{d:02} {h:02}:{mi:02}:{s:02}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fmt_epoch_known_values() {
assert_eq!(fmt_epoch(0), "1970-01-01 00:00:00");
// 2001-09-09 01:46:40 UTC
assert_eq!(fmt_epoch(1_000_000_000), "2001-09-09 01:46:40");
// 2021-01-01 00:00:00 UTC
assert_eq!(fmt_epoch(1_609_459_200), "2021-01-01 00:00:00");
// Leap day 2024-02-29 12:00:00 UTC
assert_eq!(fmt_epoch(1_709_208_000), "2024-02-29 12:00:00");
}
}

223
src/watch.rs Normal file
View file

@ -0,0 +1,223 @@
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};
use std::sync::mpsc::{self, Receiver};
use std::thread;
use std::time::{Duration, Instant};
use crate::config::Config;
use crate::flow;
use crate::notify::{log, notify, Urgency};
use crate::state::State;
use crate::status::{self};
use crate::tailscale::TsHealth;
#[derive(PartialEq, Eq, Clone, Debug)]
enum Health {
Up,
DownNoNet,
DownTailscaleManual,
DownTailscaleOther,
NoAdapter,
}
fn classify(cfg: &Config, profile: &str) -> (Health, Option<String>) {
let s = status::gather(cfg, profile);
if s.iface.is_none() {
return (Health::NoAdapter, None);
}
let ssid = s.ssid.clone();
if !s.internet {
return (Health::DownNoNet, ssid);
}
if s.tailscale_required {
match s.tailscale {
Some(TsHealth::Ok) => (Health::Up, ssid),
Some(TsHealth::NeedsLogin) | Some(TsHealth::NotInstalled) => {
(Health::DownTailscaleManual, ssid)
}
Some(_) => (Health::DownTailscaleOther, ssid),
None => (Health::DownTailscaleManual, ssid),
}
} else {
(Health::Up, ssid)
}
}
/// Tail `nmcli monitor` and ping the channel on link-state churn so we react
/// to drops within a second instead of waiting out the poll interval.
fn spawn_nm_monitor(tx: mpsc::Sender<()>) {
thread::spawn(move || loop {
let child = Command::new("nmcli")
.arg("monitor")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn();
let mut child = match child {
Ok(c) => c,
Err(_) => {
thread::sleep(Duration::from_secs(10));
continue;
}
};
if let Some(out) = child.stdout.take() {
let reader = BufReader::new(out);
let mut last = Instant::now() - Duration::from_secs(10);
for line in reader.lines().map_while(Result::ok) {
let l = line.to_lowercase();
let interesting = l.contains("disconnect")
|| l.contains("unavailable")
|| l.contains("connected")
|| l.contains("connection")
|| l.contains("now")
|| l.contains("state");
if interesting && last.elapsed() > Duration::from_millis(1500) {
last = Instant::now();
let _ = tx.send(());
}
}
}
let _ = child.wait();
// monitor died (NM restart?) — back off and respawn.
thread::sleep(Duration::from_secs(5));
});
}
/// Sleep up to `dur`, but wake early if `nmcli monitor` signals link churn.
fn wait_for_tick(rx: &Receiver<()>, dur: Duration) {
match rx.recv_timeout(dur) {
Ok(()) => {
// Drain any burst of events so we don't re-fire immediately.
while rx.try_recv().is_ok() {}
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
// Monitor thread gone (shouldn't happen: we hold the sender) — fall
// back to a plain sleep so we don't busy-spin.
Err(mpsc::RecvTimeoutError::Disconnected) => thread::sleep(dur),
}
}
pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
let base = cfg.settings.watch_interval.max(4);
notify(
"breadcrumbs watcher started",
"Monitoring Wi-Fi; will auto-recover drops.",
Urgency::Low,
);
log("watch: started");
let (tx, rx) = mpsc::channel::<()>();
spawn_nm_monitor(tx);
let mut profile = State::load(&cfg.settings.default_profile).profile;
if run_initial {
// Don't churn an already-working connection on (re)start.
let (h, _) = classify(&cfg, &profile);
if h == Health::Up {
log(&format!(
"watch: already healthy on start (profile={profile}); skipping initial flow"
));
} else {
log(&format!("watch: initial flow for profile={profile}"));
let _ = flow::run(&cfg, &profile);
}
}
let mut prev_health: Option<Health> = None;
let mut prev_profile = profile.clone();
let mut fail_streak: u32 = 0;
loop {
// Reload config + state so edits and `profile set` take effect live.
if let Ok(fresh) = Config::load() {
cfg = fresh;
}
profile = State::load(&cfg.settings.default_profile).profile;
let profile_changed = profile != prev_profile;
if profile_changed {
log(&format!(
"watch: profile changed {prev_profile} -> {profile}"
));
notify(
"breadcrumbs: profile changed",
&format!("{prev_profile} -> {profile}"),
Urgency::Low,
);
prev_profile = profile.clone();
prev_health = None; // force re-evaluation/recovery for new profile
}
let (health, ssid) = classify(&cfg, &profile);
let transition = prev_health.as_ref() != Some(&health);
match &health {
Health::Up => {
if transition && prev_health.is_some() {
notify(
"breadcrumbs: back online",
&format!(
"{} ({profile})",
ssid.clone().unwrap_or_else(|| "Wi-Fi".into())
),
Urgency::Low,
);
}
fail_streak = 0;
}
Health::NoAdapter => {
if transition {
notify(
"breadcrumbs: no Wi-Fi adapter",
"Hardware issue — manual check needed.",
Urgency::Critical,
);
}
fail_streak = fail_streak.saturating_add(1);
}
Health::DownTailscaleManual => {
// Can't be auto-fixed (login / not installed). Notify once.
if transition {
notify(
"Tailscale Error",
"Tailscale needs manual attention (login / install). \
Other Wi-Fi automation paused until resolved.",
Urgency::Critical,
);
}
// Re-run flow only on transition so we land on the bootstrap net.
if transition || profile_changed {
let _ = flow::run(&cfg, &profile);
}
fail_streak = fail_streak.saturating_add(1);
}
Health::DownNoNet | Health::DownTailscaleOther => {
if transition {
notify(
"breadcrumbs: connection lost",
&format!("Recovering ({profile})…"),
Urgency::Normal,
);
}
log(&format!(
"watch: down ({:?}) profile={profile} ssid={:?} — running flow",
health, ssid
));
let outcome = flow::run(&cfg, &profile);
log(&format!("watch: recovery outcome = {:?}", outcome));
fail_streak = if outcome.ok() {
0
} else {
fail_streak.saturating_add(1)
};
}
}
prev_health = Some(health);
// Adaptive backoff: healthy -> base; failing -> grow up to ~6x.
let mult = 1 + fail_streak.min(5);
let dur = Duration::from_secs(base * mult as u64);
wait_for_tick(&rx, dur);
}
}

144
tests/cli.rs Normal file
View file

@ -0,0 +1,144 @@
//! 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`
//! is ever invoked and the host system is never touched.
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
const BIN: &str = env!("CARGO_BIN_EXE_breadcrumbs");
static COUNTER: AtomicU32 = AtomicU32::new(0);
struct Sandbox {
root: PathBuf,
}
impl Sandbox {
fn new() -> Sandbox {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!(
"breadcrumbs-it-{}-{}-{}",
std::process::id(),
n,
nanos
));
fs::create_dir_all(root.join("bin")).unwrap();
Sandbox { root }
}
/// Binary invocation with an isolated, side-effect-free environment.
fn cmd(&self, args: &[&str]) -> std::process::Output {
Command::new(BIN)
.args(args)
.env_clear()
.env("HOME", &self.root)
.env("XDG_CONFIG_HOME", self.root.join("config"))
.env("XDG_STATE_HOME", self.root.join("state"))
// Empty bin dir => no external commands resolve.
.env("PATH", self.root.join("bin"))
.output()
.expect("failed to spawn breadcrumbs")
}
fn config_file(&self) -> PathBuf {
self.root.join("config/breadcrumbs/breadcrumbs.toml")
}
}
impl Drop for Sandbox {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.root);
}
}
fn stdout(o: &std::process::Output) -> String {
String::from_utf8_lossy(&o.stdout).to_string()
}
#[test]
fn help_lists_all_commands() {
let sb = Sandbox::new();
let o = sb.cmd(&["--help"]);
assert!(o.status.success());
let out = stdout(&o);
assert!(out.contains("Profile-aware Wi-Fi state machine"));
for c in [
"status",
"init",
"watch",
"profile",
"doctor",
"install-service",
] {
assert!(out.contains(c), "help missing `{c}`");
}
}
#[test]
fn version_prints_crate_version() {
let sb = Sandbox::new();
let o = sb.cmd(&["--version"]);
assert!(o.status.success());
assert!(stdout(&o).contains("breadcrumbs"));
}
#[test]
fn list_bootstraps_config_with_core_profiles() {
let sb = Sandbox::new();
let o = sb.cmd(&["list"]);
assert!(
o.status.success(),
"stderr: {}",
String::from_utf8_lossy(&o.stderr)
);
let out = stdout(&o);
// All three core profiles must be present on a fresh install.
assert!(out.contains("home") && out.contains("work") && out.contains("away"));
// Config was materialised on disk and is valid TOML.
let cfg = sb.config_file();
assert!(cfg.exists(), "config not created at {}", cfg.display());
let text = fs::read_to_string(&cfg).unwrap();
assert!(text.contains("[profiles.home]"));
assert!(text.contains("[profiles.work]"));
assert!(text.contains("[profiles.away]"));
}
#[test]
fn profile_defaults_to_away_then_persists_set() {
let sb = Sandbox::new();
let o = sb.cmd(&["profile", "get"]);
assert!(o.status.success());
assert_eq!(stdout(&o).trim(), "away");
// `set --no-apply` must not touch the network (no nmcli available anyway).
let o = sb.cmd(&["profile", "set", "home", "--no-apply"]);
assert!(
o.status.success(),
"stderr: {}",
String::from_utf8_lossy(&o.stderr)
);
let o = sb.cmd(&["profile", "get"]);
assert_eq!(stdout(&o).trim(), "home");
// Unknown profile is rejected.
let o = sb.cmd(&["profile", "set", "bogus"]);
assert!(!o.status.success());
}
#[test]
fn unknown_profile_override_is_reported() {
let sb = Sandbox::new();
let o = sb.cmd(&["--profile", "nope", "init"]);
assert!(!o.status.success());
}