commit 5b894c4fef00d6463d4823d812d44da4b2aaae7e Author: Breadway Date: Tue May 19 11:52:46 2026 +0800 Initial commit: breadcrumbs — profile-driven Wi-Fi + Tailscale state machine Co-Authored-By: Claude Opus 4.7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5abaed1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..21ec4d6 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..06c815b --- /dev/null +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..16d3717 --- /dev/null +++ b/Cargo.toml @@ -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" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..373e4ee --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f55369 --- /dev/null +++ b/README.md @@ -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 ] +``` + +| 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 ` | Switch profile (and apply it, unless `--no-apply`) | +| `profile list` | List all profiles | +| `detect [--apply]` | Guess profile from visible networks; optionally apply it | +| `add [password]` | Add or update a saved network | +| `forget ` | Remove a network from config and NetworkManager | +| `scan [--to ]` | 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=` +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 diff --git a/breadcrumbs.example.toml b/breadcrumbs.example.toml new file mode 100644 index 0000000..ead73f0 --- /dev/null +++ b/breadcrumbs.example.toml @@ -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 +# +# 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"] diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..c258d31 --- /dev/null +++ b/src/config.rs @@ -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, + /// Ordered priority list of SSIDs this profile should end up connected to. + #[serde(default)] + pub networks: Vec, + /// 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, + /// 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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + #[serde(default)] + pub settings: Settings, + #[serde(default, rename = "networks")] + pub networks: Vec, + #[serde(default)] + pub profiles: BTreeMap, +} + +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 { + 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 { + 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 ` or `breadcrumbs edit`. +fn core_profiles() -> BTreeMap { + 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()); + } +} diff --git a/src/flow.rs b/src/flow.rs new file mode 100644 index 0000000..15c15e0 --- /dev/null +++ b/src/flow.rs @@ -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, + }, + /// Tailscale required but unhealthy; left on `ssid` (bootstrap if available). + TailscaleError { + ssid: Option, + 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::>() + .join(", ") + )); + + // Pre-scan for everything we might want, including the bootstrap SSID. + let mut scan_targets: Vec = 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, ¬e); + 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, ¬e); + 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", ¬e, 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::>() + .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) { + 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"]); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..548da4b --- /dev/null +++ b/src/main.rs @@ -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, + + #[command(subcommand)] + cmd: Option, +} + +#[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, + }, + /// 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, + /// 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, + /// Position in the profile list (0 = highest priority) + #[arg(long)] + at: Option, + }, + /// 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, + }, + /// 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 { + if let Some(p) = override_p { + return p.clone(); + } + State::load(&cfg.settings.default_profile).profile +} + +fn real_main(cli: Cli) -> Result { + 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) -> Result { + 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) -> Result { + 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 { + 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 { + 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, + hidden: bool, + to: Option, + at: Option, +) -> Result { + 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 { + 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) -> Result { + 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() { + "" + } else { + &e.ssid + }, + e.signal, + e.security + ); + } + let sel = prompt_line("Select number: "); + let idx: usize = sel + .parse::() + .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 { + 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 = 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 { + 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, full: bool) -> Result { + 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 { + 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 { + 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) +} diff --git a/src/nm.rs b/src/nm.rs new file mode 100644 index 0000000..c0f32b5 --- /dev/null +++ b/src/nm.rs @@ -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 { + let mut fields: Vec = 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 { + 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 = 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 { + 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 { + 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 { + 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 { + 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"]); + } +} diff --git a/src/notify.rs b/src/notify.rs new file mode 100644 index 0000000..6915de2 --- /dev/null +++ b/src/notify.rs @@ -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::>().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)); +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..0366959 --- /dev/null +++ b/src/state.rs @@ -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::(&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}")) + } +} diff --git a/src/status.rs b/src/status.rs new file mode 100644 index 0000000..b06dea9 --- /dev/null +++ b/src/status.rs @@ -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 { + 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, + pub ssid: Option, + pub ip: Option, + pub internet: bool, + pub tailscale_required: bool, + pub tailscale: Option, + 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, + } +} diff --git a/src/tailscale.rs b/src/tailscale.rs new file mode 100644 index 0000000..a5aa49a --- /dev/null +++ b/src/tailscale.rs @@ -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 { + 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 { + 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::(); + 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)); + } +} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..04f3740 --- /dev/null +++ b/src/util.rs @@ -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"); + } +} diff --git a/src/watch.rs b/src/watch.rs new file mode 100644 index 0000000..b95ffbf --- /dev/null +++ b/src/watch.rs @@ -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) { + 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 = 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); + } +} diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..297173a --- /dev/null +++ b/tests/cli.rs @@ -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()); +}