Initial commit: breadcrumbs — profile-driven Wi-Fi + Tailscale state machine
This commit is contained in:
commit
3422c12379
18 changed files with 3475 additions and 0 deletions
38
.github/workflows/ci.yml
vendored
Normal file
38
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
RUSTFLAGS: "-D warnings"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: fmt · clippy · test · build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: rustfmt, clippy
|
||||||
|
|
||||||
|
- name: Cache cargo
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
|
||||||
|
- name: Format check
|
||||||
|
run: cargo fmt --all -- --check
|
||||||
|
|
||||||
|
- name: Clippy
|
||||||
|
run: cargo clippy --all-targets -- -D warnings
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: cargo test --all --verbose
|
||||||
|
|
||||||
|
- name: Release build
|
||||||
|
run: cargo build --release --verbose
|
||||||
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
# Build output
|
||||||
|
/target/
|
||||||
|
|
||||||
|
# Secrets: live config holds plaintext Wi-Fi passwords.
|
||||||
|
# Keep local only; see breadcrumbs.example.toml for the schema.
|
||||||
|
/breadcrumbs.toml
|
||||||
|
|
||||||
|
# Legacy plaintext credential store (migrated into breadcrumbs.toml on first run)
|
||||||
|
/Networks/
|
||||||
|
|
||||||
|
# Local diagnostic scripts (user-specific, not part of the tool)
|
||||||
|
diag.sh
|
||||||
|
diag_*.sh
|
||||||
|
|
||||||
|
# Editor / IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS artifacts
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.env.*
|
||||||
|
|
||||||
|
# Log files
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Claude Code local state
|
||||||
|
.claude/
|
||||||
331
Cargo.lock
generated
Normal file
331
Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,331 @@
|
||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstream"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"anstyle-parse",
|
||||||
|
"anstyle-query",
|
||||||
|
"anstyle-wincon",
|
||||||
|
"colorchoice",
|
||||||
|
"is_terminal_polyfill",
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle"
|
||||||
|
version = "1.0.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-parse"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||||
|
dependencies = [
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-query"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-wincon"
|
||||||
|
version = "3.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"once_cell_polyfill",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "breadcrumbs"
|
||||||
|
version = "2.0.0"
|
||||||
|
dependencies = [
|
||||||
|
"clap",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"toml",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap"
|
||||||
|
version = "4.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
|
||||||
|
dependencies = [
|
||||||
|
"clap_builder",
|
||||||
|
"clap_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_builder"
|
||||||
|
version = "4.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
|
||||||
|
dependencies = [
|
||||||
|
"anstream",
|
||||||
|
"anstyle",
|
||||||
|
"clap_lex",
|
||||||
|
"strsim",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_derive"
|
||||||
|
version = "4.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_lex"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorchoice"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equivalent"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.17.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heck"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indexmap"
|
||||||
|
version = "2.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||||
|
dependencies = [
|
||||||
|
"equivalent",
|
||||||
|
"hashbrown",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is_terminal_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memchr"
|
||||||
|
version = "2.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.106"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.228"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.149"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "0.6.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strsim"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.117"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "0.8.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_edit",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_datetime"
|
||||||
|
version = "0.6.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_edit"
|
||||||
|
version = "0.22.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"serde",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_write",
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_write"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8parse"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "0.7.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||||
22
Cargo.toml
Normal file
22
Cargo.toml
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
[package]
|
||||||
|
name = "breadcrumbs"
|
||||||
|
version = "2.0.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Profile-aware Wi-Fi state machine with Tailscale handling and self-healing watch daemon"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "breadcrumbs"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
toml = "0.8"
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = "s"
|
||||||
|
lto = true
|
||||||
|
strip = true
|
||||||
|
panic = "abort"
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Breadway
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
169
README.md
Normal file
169
README.md
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
# breadcrumbs
|
||||||
|
|
||||||
|
A profile-aware Wi-Fi state machine for Linux with Tailscale exit-node management and a self-healing watch daemon.
|
||||||
|
|
||||||
|
breadcrumbs sits on top of NetworkManager (`nmcli`) and manages your Wi-Fi based on **location profiles**. Switch between home, work, school, or any other context with a single command — it handles scanning, connecting, DNS pinning, and Tailscale setup automatically.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Profile-based connection management** — define ordered network priority lists per location
|
||||||
|
- **Bootstrap + Tailscale gating** — connect to an interim network first, bring up Tailscale, then move to the target network
|
||||||
|
- **Self-healing watch daemon** — monitors for drops, auto-recovers, reacts within seconds via `nmcli monitor`
|
||||||
|
- **Auto-detection** — scans visible SSIDs and guesses your location from config-defined markers
|
||||||
|
- **Secure credential handling** — passwords fed to `nmcli` via stdin (never in argv/`ps`), config stored at 0600
|
||||||
|
- **Desktop notifications** via `notify-send` (optional)
|
||||||
|
- **systemd user service** generation via `breadcrumbs install-service`
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Linux with NetworkManager (`nmcli` in `$PATH`)
|
||||||
|
- Rust toolchain (to build from source)
|
||||||
|
- `tailscale` (optional — only needed if any profile sets `tailscale = true`)
|
||||||
|
- `notify-send` (optional — for desktop notifications)
|
||||||
|
- `curl` (optional — used for connectivity checks, falls back to `ping`)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/breadway/breadcrumbs
|
||||||
|
cd breadcrumbs
|
||||||
|
cargo build --release
|
||||||
|
# Copy to somewhere on your PATH:
|
||||||
|
cp target/release/breadcrumbs ~/.local/bin/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
On first run, breadcrumbs creates `~/.config/breadcrumbs/breadcrumbs.toml` with default profiles. Copy `breadcrumbs.example.toml` as a starting point and fill in your real network credentials:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp breadcrumbs.example.toml ~/.config/breadcrumbs/breadcrumbs.toml
|
||||||
|
breadcrumbs edit # opens in $EDITOR
|
||||||
|
```
|
||||||
|
|
||||||
|
Config paths respect `$XDG_CONFIG_HOME` and `$XDG_STATE_HOME`.
|
||||||
|
|
||||||
|
### Config structure
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[settings]
|
||||||
|
dns = "1.1.1.1" # DNS server pinned on every connection
|
||||||
|
nmcli_wait = 8 # seconds to wait for nmcli connect
|
||||||
|
exit_node = "myhostname" # default Tailscale exit node
|
||||||
|
default_profile = "away"
|
||||||
|
watch_interval = 12 # seconds between health checks (minimum 4)
|
||||||
|
connectivity_url = "http://connectivitycheck.gstatic.com/generate_204"
|
||||||
|
ping_host = "1.1.1.1"
|
||||||
|
|
||||||
|
[[networks]]
|
||||||
|
ssid = "MyHomeNetwork"
|
||||||
|
password = "hunter2"
|
||||||
|
hidden = false
|
||||||
|
|
||||||
|
[profiles.home]
|
||||||
|
networks = ["MyHomeNetwork"] # priority-ordered SSIDs
|
||||||
|
tailscale = false
|
||||||
|
include_all_known = false
|
||||||
|
detect_ssids = ["MyHomeNetwork"] # used by `breadcrumbs detect`
|
||||||
|
|
||||||
|
[profiles.work]
|
||||||
|
bootstrap = "GuestWifi" # connect here first before requiring Tailscale
|
||||||
|
networks = ["CorpWifi"]
|
||||||
|
tailscale = true
|
||||||
|
exit_node = "jump-host" # per-profile override
|
||||||
|
detect_ssids = ["CorpWifi", "Corp-5G"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Profiles
|
||||||
|
|
||||||
|
Each profile defines:
|
||||||
|
|
||||||
|
| Key | Description |
|
||||||
|
|-----|-------------|
|
||||||
|
| `networks` | Ordered list of SSIDs to try. First available wins. |
|
||||||
|
| `tailscale` | If `true`, Tailscale must be healthy before moving to a target network. |
|
||||||
|
| `bootstrap` | SSID to connect to first (e.g. guest Wi-Fi that allows Tailscale traffic). |
|
||||||
|
| `exit_node` | Tailscale exit node for this profile (overrides `settings.exit_node`). |
|
||||||
|
| `include_all_known` | After the priority list, also try every other known network. |
|
||||||
|
| `detect_ssids` | Any visible SSID in this list marks this profile as a candidate for `breadcrumbs detect`. |
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
breadcrumbs [--profile <name>] <command>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `status` | Show current Wi-Fi / Tailscale health (default) |
|
||||||
|
| `init` | Run the full connect sequence for the active profile |
|
||||||
|
| `watch [--no-initial]` | Self-healing daemon: monitors and auto-recovers drops |
|
||||||
|
| `profile get` | Print the active profile |
|
||||||
|
| `profile set <name>` | Switch profile (and apply it, unless `--no-apply`) |
|
||||||
|
| `profile list` | List all profiles |
|
||||||
|
| `detect [--apply]` | Guess profile from visible networks; optionally apply it |
|
||||||
|
| `add <ssid> [password]` | Add or update a saved network |
|
||||||
|
| `forget <ssid>` | Remove a network from config and NetworkManager |
|
||||||
|
| `scan [--to <profile>]` | Interactive scan, pick, connect and save |
|
||||||
|
| `list [--show-passwords]` | Show config: settings, networks, profiles |
|
||||||
|
| `edit` | Open config in `$EDITOR`, validate on exit |
|
||||||
|
| `doctor [--full]` | Quick connectivity and Tailscale diagnostics |
|
||||||
|
| `cd [--shell]` | Print (or `cd` into) the config directory |
|
||||||
|
| `install-service [--no-enable]` | Install and optionally enable systemd user unit |
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check current state
|
||||||
|
breadcrumbs
|
||||||
|
|
||||||
|
# Switch to the "work" profile and connect
|
||||||
|
breadcrumbs profile set work
|
||||||
|
|
||||||
|
# Run as a daemon in the foreground (use install-service for persistent use)
|
||||||
|
breadcrumbs watch
|
||||||
|
|
||||||
|
# Override profile for one run without persisting
|
||||||
|
breadcrumbs --profile home init
|
||||||
|
|
||||||
|
# Add a new network and attach it to a profile
|
||||||
|
breadcrumbs add "CoffeeShop5G" --to away
|
||||||
|
|
||||||
|
# Detect and switch profile based on visible networks
|
||||||
|
breadcrumbs detect --apply
|
||||||
|
|
||||||
|
# Install and start the systemd watcher service
|
||||||
|
breadcrumbs install-service
|
||||||
|
```
|
||||||
|
|
||||||
|
## Watch daemon
|
||||||
|
|
||||||
|
`breadcrumbs watch` is the recommended way to run breadcrumbs for daily use. It:
|
||||||
|
|
||||||
|
1. Polls health every `watch_interval` seconds (adaptive backoff on repeated failures)
|
||||||
|
2. Reacts immediately to link-state changes via `nmcli monitor`
|
||||||
|
3. Runs `flow::run` (the connect state machine) on any detected drop
|
||||||
|
4. Handles profile changes live — re-reads config and state on every tick
|
||||||
|
|
||||||
|
Install as a systemd user service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
breadcrumbs install-service
|
||||||
|
# or manually:
|
||||||
|
systemctl --user enable --now breadcrumbs.service
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tailscale integration
|
||||||
|
|
||||||
|
For profiles with `tailscale = true`:
|
||||||
|
|
||||||
|
1. Connects to the `bootstrap` SSID (if configured)
|
||||||
|
2. Ensures the Tailscale daemon is running; opens a browser login if needed
|
||||||
|
3. Sets the configured exit node with `tailscale set --exit-node=<node>`
|
||||||
|
4. Only moves to the target network once Tailscale is healthy
|
||||||
|
|
||||||
|
If Tailscale needs interactive login, the auth URL is opened automatically and the watch daemon stays on the bootstrap network until authentication completes.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
58
breadcrumbs.example.toml
Normal file
58
breadcrumbs.example.toml
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
# breadcrumbs configuration template.
|
||||||
|
#
|
||||||
|
# Copy to ~/.config/breadcrumbs/breadcrumbs.toml and fill in real values, OR
|
||||||
|
# just run breadcrumbs once (it generates a skeleton) and then use
|
||||||
|
# `breadcrumbs add` / `breadcrumbs edit` to fill in your networks.
|
||||||
|
# The real breadcrumbs.toml is gitignored and never committed.
|
||||||
|
|
||||||
|
[settings]
|
||||||
|
dns = "1.1.1.1"
|
||||||
|
nmcli_wait = 8
|
||||||
|
exit_node = "my-exit-node" # Tailscale hostname of your preferred exit node
|
||||||
|
default_profile = "away"
|
||||||
|
watch_interval = 12
|
||||||
|
connectivity_url = "http://connectivitycheck.gstatic.com/generate_204"
|
||||||
|
ping_host = "1.1.1.1"
|
||||||
|
|
||||||
|
[[networks]]
|
||||||
|
ssid = "HomeWifi"
|
||||||
|
password = "REPLACE_ME"
|
||||||
|
hidden = false
|
||||||
|
|
||||||
|
[[networks]]
|
||||||
|
ssid = "WorkGuest"
|
||||||
|
password = "REPLACE_ME"
|
||||||
|
hidden = false
|
||||||
|
|
||||||
|
[[networks]]
|
||||||
|
ssid = "CorpWifi"
|
||||||
|
password = "REPLACE_ME"
|
||||||
|
hidden = false
|
||||||
|
|
||||||
|
# Location state machine. Switch with: breadcrumbs profile set <name>
|
||||||
|
#
|
||||||
|
# detect_ssids: list any SSIDs that reliably indicate you are at this location.
|
||||||
|
# `breadcrumbs detect` scans for visible networks and switches to the first
|
||||||
|
# profile whose detect_ssids list contains a match. Profiles without
|
||||||
|
# detect_ssids are skipped during detection; the default_profile is used as
|
||||||
|
# the final fallback.
|
||||||
|
|
||||||
|
[profiles.away]
|
||||||
|
networks = ["HomeWifi"]
|
||||||
|
tailscale = false
|
||||||
|
include_all_known = true
|
||||||
|
# No detect_ssids: "away" is the catch-all fallback (set as default_profile).
|
||||||
|
|
||||||
|
[profiles.home]
|
||||||
|
networks = ["HomeWifi"]
|
||||||
|
tailscale = false
|
||||||
|
include_all_known = false
|
||||||
|
detect_ssids = ["HomeWifi"]
|
||||||
|
|
||||||
|
[profiles.work]
|
||||||
|
bootstrap = "WorkGuest" # connect here first so Tailscale can come up
|
||||||
|
networks = ["CorpWifi"]
|
||||||
|
tailscale = true
|
||||||
|
exit_node = "my-exit-node"
|
||||||
|
include_all_known = false
|
||||||
|
detect_ssids = ["CorpWifi", "WorkGuest"]
|
||||||
277
src/config.rs
Normal file
277
src/config.rs
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::util::home_dir;
|
||||||
|
|
||||||
|
fn default_dns() -> String {
|
||||||
|
"1.1.1.1".to_string()
|
||||||
|
}
|
||||||
|
fn default_nmcli_wait() -> u32 {
|
||||||
|
8
|
||||||
|
}
|
||||||
|
fn default_exit_node() -> String {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
fn default_profile_name() -> String {
|
||||||
|
"away".to_string()
|
||||||
|
}
|
||||||
|
fn default_watch_interval() -> u64 {
|
||||||
|
12
|
||||||
|
}
|
||||||
|
fn default_connectivity_url() -> String {
|
||||||
|
"http://connectivitycheck.gstatic.com/generate_204".to_string()
|
||||||
|
}
|
||||||
|
fn default_ping_host() -> String {
|
||||||
|
"1.1.1.1".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Settings {
|
||||||
|
#[serde(default = "default_dns")]
|
||||||
|
pub dns: String,
|
||||||
|
#[serde(default = "default_nmcli_wait")]
|
||||||
|
pub nmcli_wait: u32,
|
||||||
|
#[serde(default = "default_exit_node")]
|
||||||
|
pub exit_node: String,
|
||||||
|
#[serde(default = "default_profile_name")]
|
||||||
|
pub default_profile: String,
|
||||||
|
#[serde(default = "default_watch_interval")]
|
||||||
|
pub watch_interval: u64,
|
||||||
|
#[serde(default = "default_connectivity_url")]
|
||||||
|
pub connectivity_url: String,
|
||||||
|
#[serde(default = "default_ping_host")]
|
||||||
|
pub ping_host: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Settings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Settings {
|
||||||
|
dns: default_dns(),
|
||||||
|
nmcli_wait: default_nmcli_wait(),
|
||||||
|
exit_node: default_exit_node(),
|
||||||
|
default_profile: default_profile_name(),
|
||||||
|
watch_interval: default_watch_interval(),
|
||||||
|
connectivity_url: default_connectivity_url(),
|
||||||
|
ping_host: default_ping_host(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct NetworkDef {
|
||||||
|
pub ssid: String,
|
||||||
|
pub password: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub hidden: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Profile {
|
||||||
|
/// Optional SSID connected first to bootstrap connectivity (e.g. for Tailscale).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub bootstrap: Option<String>,
|
||||||
|
/// Ordered priority list of SSIDs this profile should end up connected to.
|
||||||
|
#[serde(default)]
|
||||||
|
pub networks: Vec<String>,
|
||||||
|
/// Require a healthy Tailscale + exit node before moving off the bootstrap.
|
||||||
|
#[serde(default)]
|
||||||
|
pub tailscale: bool,
|
||||||
|
/// Per-profile exit node override (falls back to settings.exit_node).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub exit_node: Option<String>,
|
||||||
|
/// After the explicit list, also try every other known network.
|
||||||
|
#[serde(default)]
|
||||||
|
pub include_all_known: bool,
|
||||||
|
/// SSIDs whose presence in a scan indicates this location.
|
||||||
|
/// Used by `breadcrumbs detect` to guess the active profile.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub detect_ssids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
#[serde(default)]
|
||||||
|
pub settings: Settings,
|
||||||
|
#[serde(default, rename = "networks")]
|
||||||
|
pub networks: Vec<NetworkDef>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub profiles: BTreeMap<String, Profile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn config_dir() -> PathBuf {
|
||||||
|
std::env::var_os("XDG_CONFIG_HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| home_dir().join(".config"))
|
||||||
|
.join("breadcrumbs")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn config_path() -> PathBuf {
|
||||||
|
config_dir().join("breadcrumbs.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn state_dir() -> PathBuf {
|
||||||
|
std::env::var_os("XDG_STATE_HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| home_dir().join(".local").join("state"))
|
||||||
|
.join("breadcrumbs")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn state_path() -> PathBuf {
|
||||||
|
state_dir().join("state.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn log_path() -> PathBuf {
|
||||||
|
state_dir().join("breadcrumbs.log")
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn profile<'a>(&'a self, name: &str) -> Option<&'a Profile> {
|
||||||
|
self.profiles.get(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn network<'a>(&'a self, ssid: &str) -> Option<&'a NetworkDef> {
|
||||||
|
self.networks.iter().find(|n| n.ssid == ssid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load config, creating a skeleton one on first run.
|
||||||
|
pub fn load() -> Result<Config, String> {
|
||||||
|
let path = config_path();
|
||||||
|
if !path.exists() {
|
||||||
|
let cfg = build_initial_config();
|
||||||
|
cfg.save()?;
|
||||||
|
return Ok(cfg);
|
||||||
|
}
|
||||||
|
let text =
|
||||||
|
fs::read_to_string(&path).map_err(|e| format!("reading {}: {e}", path.display()))?;
|
||||||
|
let mut cfg: Config =
|
||||||
|
toml::from_str(&text).map_err(|e| format!("parsing {}: {e}", path.display()))?;
|
||||||
|
// Self-heal: guarantee the three core profiles always exist.
|
||||||
|
ensure_core_profiles(&mut cfg);
|
||||||
|
Ok(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self) -> Result<(), String> {
|
||||||
|
let dir = config_dir();
|
||||||
|
fs::create_dir_all(&dir).map_err(|e| format!("creating {}: {e}", dir.display()))?;
|
||||||
|
let text = toml::to_string_pretty(self).map_err(|e| format!("serializing config: {e}"))?;
|
||||||
|
let path = config_path();
|
||||||
|
fs::write(&path, text).map_err(|e| format!("writing {}: {e}", path.display()))?;
|
||||||
|
// Plaintext Wi-Fi passwords live here — keep it owner-only.
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initial skeleton networks generated for a brand-new installation.
|
||||||
|
/// Passwords are intentionally blank — secrets never live in source.
|
||||||
|
/// Users fill them via `breadcrumbs add`, `breadcrumbs scan`, or
|
||||||
|
/// `breadcrumbs edit`, or by copying `breadcrumbs.example.toml`.
|
||||||
|
fn canonical_networks() -> Vec<NetworkDef> {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starter profiles generated for a brand-new installation.
|
||||||
|
/// These give users working examples of the three common location patterns:
|
||||||
|
/// a home profile, a profile requiring Tailscale (e.g. a workplace or school),
|
||||||
|
/// and an "away" catch-all. All network lists start empty; users populate them
|
||||||
|
/// via `breadcrumbs add --to <profile>` or `breadcrumbs edit`.
|
||||||
|
fn core_profiles() -> BTreeMap<String, Profile> {
|
||||||
|
let mut p = BTreeMap::new();
|
||||||
|
p.insert(
|
||||||
|
"home".to_string(),
|
||||||
|
Profile {
|
||||||
|
bootstrap: None,
|
||||||
|
networks: vec![],
|
||||||
|
tailscale: false,
|
||||||
|
exit_node: None,
|
||||||
|
include_all_known: false,
|
||||||
|
detect_ssids: vec![],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
p.insert(
|
||||||
|
"work".to_string(),
|
||||||
|
Profile {
|
||||||
|
bootstrap: None,
|
||||||
|
networks: vec![],
|
||||||
|
tailscale: false,
|
||||||
|
exit_node: None,
|
||||||
|
include_all_known: false,
|
||||||
|
detect_ssids: vec![],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
p.insert(
|
||||||
|
"away".to_string(),
|
||||||
|
Profile {
|
||||||
|
bootstrap: None,
|
||||||
|
networks: vec![],
|
||||||
|
tailscale: false,
|
||||||
|
exit_node: None,
|
||||||
|
include_all_known: true,
|
||||||
|
detect_ssids: vec![],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_core_profiles(cfg: &mut Config) {
|
||||||
|
for (name, prof) in core_profiles() {
|
||||||
|
cfg.profiles.entry(name).or_insert(prof);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The config generated on first run: no networks, the three core profiles.
|
||||||
|
/// Users populate it via `breadcrumbs add` / `edit` / `scan`.
|
||||||
|
fn build_initial_config() -> Config {
|
||||||
|
Config {
|
||||||
|
settings: Settings::default(),
|
||||||
|
networks: canonical_networks(),
|
||||||
|
profiles: core_profiles(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn initial_config_is_empty_with_core_profiles() {
|
||||||
|
let cfg = build_initial_config();
|
||||||
|
assert!(cfg.networks.is_empty());
|
||||||
|
assert_eq!(cfg.profiles.len(), 3);
|
||||||
|
assert!(cfg.profile("home").is_some());
|
||||||
|
assert!(cfg.profile("work").is_some());
|
||||||
|
assert!(cfg.profile("away").is_some());
|
||||||
|
assert!(cfg.profile("away").unwrap().include_all_known);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_toml_roundtrip() {
|
||||||
|
let cfg = build_initial_config();
|
||||||
|
let text = toml::to_string_pretty(&cfg).unwrap();
|
||||||
|
let back: Config = toml::from_str(&text).unwrap();
|
||||||
|
assert_eq!(back.networks.len(), cfg.networks.len());
|
||||||
|
assert_eq!(back.profiles.len(), 3);
|
||||||
|
assert!(back.profile("home").is_some());
|
||||||
|
assert!(back.profile("away").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ensure_core_profiles_backfills_missing() {
|
||||||
|
let mut cfg = Config {
|
||||||
|
settings: Settings::default(),
|
||||||
|
networks: vec![],
|
||||||
|
profiles: BTreeMap::new(),
|
||||||
|
};
|
||||||
|
ensure_core_profiles(&mut cfg);
|
||||||
|
assert!(cfg.profile("home").is_some());
|
||||||
|
assert!(cfg.profile("work").is_some());
|
||||||
|
assert!(cfg.profile("away").is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
322
src/flow.rs
Normal file
322
src/flow.rs
Normal file
|
|
@ -0,0 +1,322 @@
|
||||||
|
use crate::config::{Config, NetworkDef};
|
||||||
|
use crate::nm;
|
||||||
|
use crate::notify::{log, notify, Urgency};
|
||||||
|
use crate::status::internet_ok;
|
||||||
|
use crate::tailscale::{self, TsHealth};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum Outcome {
|
||||||
|
/// Connected to `ssid`; `note` carries any caveat (e.g. on bootstrap only).
|
||||||
|
Connected {
|
||||||
|
ssid: String,
|
||||||
|
note: Option<String>,
|
||||||
|
},
|
||||||
|
/// Tailscale required but unhealthy; left on `ssid` (bootstrap if available).
|
||||||
|
TailscaleError {
|
||||||
|
ssid: Option<String>,
|
||||||
|
health: TsHealth,
|
||||||
|
},
|
||||||
|
NoInterface,
|
||||||
|
NoNetworks,
|
||||||
|
UnknownProfile(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Outcome {
|
||||||
|
pub fn ok(&self) -> bool {
|
||||||
|
matches!(self, Outcome::Connected { .. })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_candidates<'a>(cfg: &'a Config, p: &crate::config::Profile) -> Vec<&'a NetworkDef> {
|
||||||
|
let mut out: Vec<&NetworkDef> = Vec::new();
|
||||||
|
for ssid in &p.networks {
|
||||||
|
if let Some(def) = cfg.network(ssid) {
|
||||||
|
if !out.iter().any(|d| d.ssid == def.ssid) {
|
||||||
|
out.push(def);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.include_all_known {
|
||||||
|
for def in &cfg.networks {
|
||||||
|
if !out.iter().any(|d| d.ssid == def.ssid) {
|
||||||
|
out.push(def);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to connect + confirm it actually carries traffic.
|
||||||
|
fn connect_and_verify(iface: &str, def: &NetworkDef, cfg: &Config) -> bool {
|
||||||
|
if !nm::connect(iface, def, cfg.settings.nmcli_wait, &cfg.settings.dns) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Associated. Confirm DHCP/route by checking the device is connected.
|
||||||
|
if !nm::device_connected(iface) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the connection state machine for `profile_name`.
|
||||||
|
pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
|
||||||
|
let profile = match cfg.profile(profile_name) {
|
||||||
|
Some(p) => p.clone(),
|
||||||
|
None => {
|
||||||
|
notify(
|
||||||
|
"breadcrumbs: unknown profile",
|
||||||
|
&format!("'{profile_name}' is not defined in breadcrumbs.toml"),
|
||||||
|
Urgency::Critical,
|
||||||
|
);
|
||||||
|
return Outcome::UnknownProfile(profile_name.to_string());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let iface = match nm::wifi_interface() {
|
||||||
|
Some(i) => i,
|
||||||
|
None => {
|
||||||
|
notify(
|
||||||
|
"breadcrumbs: no Wi-Fi adapter",
|
||||||
|
"Hardware issue — Wi-Fi device not found. Manual check needed.",
|
||||||
|
Urgency::Critical,
|
||||||
|
);
|
||||||
|
return Outcome::NoInterface;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
nm::radio_on();
|
||||||
|
|
||||||
|
let exit_node = profile
|
||||||
|
.exit_node
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| cfg.settings.exit_node.clone());
|
||||||
|
let candidates = resolve_candidates(cfg, &profile);
|
||||||
|
|
||||||
|
log(&format!(
|
||||||
|
"flow start: profile={profile_name} iface={iface} tailscale={} candidates=[{}]",
|
||||||
|
profile.tailscale,
|
||||||
|
candidates
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.ssid.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
));
|
||||||
|
|
||||||
|
// Pre-scan for everything we might want, including the bootstrap SSID.
|
||||||
|
let mut scan_targets: Vec<String> = candidates.iter().map(|c| c.ssid.clone()).collect();
|
||||||
|
if let Some(bs) = &profile.bootstrap {
|
||||||
|
scan_targets.push(bs.clone());
|
||||||
|
}
|
||||||
|
nm::rescan(&iface, &scan_targets);
|
||||||
|
let visible = nm::visible_ssids(&iface);
|
||||||
|
|
||||||
|
// ---- Tailscale-gated profiles (e.g. school) -------------------------
|
||||||
|
let mut on_bootstrap = false;
|
||||||
|
if profile.tailscale {
|
||||||
|
if let Some(bs_ssid) = profile.bootstrap.clone() {
|
||||||
|
match cfg.network(&bs_ssid) {
|
||||||
|
Some(bdef) => {
|
||||||
|
if visible.contains(&bdef.ssid) || bdef.hidden {
|
||||||
|
if connect_and_verify(&iface, bdef, cfg) {
|
||||||
|
on_bootstrap = true;
|
||||||
|
log(&format!("bootstrap connected: {}", bdef.ssid));
|
||||||
|
} else {
|
||||||
|
log(&format!("bootstrap connect failed: {}", bdef.ssid));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log(&format!("bootstrap not in range: {}", bdef.ssid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => log(&format!(
|
||||||
|
"bootstrap SSID '{bs_ssid}' has no credentials in config"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let ts = tailscale::ensure_exit_node(&exit_node);
|
||||||
|
if !ts.is_ok() {
|
||||||
|
let ssid = nm::active_ssid(&iface).or_else(|| profile.bootstrap.clone());
|
||||||
|
notify(
|
||||||
|
"Tailscale Error",
|
||||||
|
&format!(
|
||||||
|
"{} — staying on {}",
|
||||||
|
ts.describe(),
|
||||||
|
ssid.clone().unwrap_or_else(|| "Wi-Fi".into())
|
||||||
|
),
|
||||||
|
Urgency::Critical,
|
||||||
|
);
|
||||||
|
return Outcome::TailscaleError { ssid, health: ts };
|
||||||
|
}
|
||||||
|
log(&format!("tailscale healthy via exit node {exit_node}"));
|
||||||
|
// Refresh visibility before moving to the target network.
|
||||||
|
nm::rescan(&iface, &scan_targets);
|
||||||
|
}
|
||||||
|
|
||||||
|
let visible = nm::visible_ssids(&iface);
|
||||||
|
|
||||||
|
// ---- Connect to the priority list ----------------------------------
|
||||||
|
// Pass 1: visible networks in priority order.
|
||||||
|
for def in &candidates {
|
||||||
|
if visible.contains(&def.ssid) {
|
||||||
|
if connect_and_verify(&iface, def, cfg) {
|
||||||
|
let note = if internet_ok(cfg) {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some("associated but no internet yet".to_string())
|
||||||
|
};
|
||||||
|
finish_connected(&def.ssid, profile_name, ¬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::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
notify(
|
||||||
|
"breadcrumbs: no known networks",
|
||||||
|
&format!("profile '{profile_name}': none of [{names}] are in range"),
|
||||||
|
Urgency::Critical,
|
||||||
|
);
|
||||||
|
log(&format!(
|
||||||
|
"flow end: no networks connected (profile={profile_name})"
|
||||||
|
));
|
||||||
|
Outcome::NoNetworks
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_connected(ssid: &str, profile: &str, note: &Option<String>) {
|
||||||
|
match note {
|
||||||
|
None => {
|
||||||
|
notify(
|
||||||
|
"breadcrumbs: connected",
|
||||||
|
&format!("{ssid} ({profile})"),
|
||||||
|
Urgency::Low,
|
||||||
|
);
|
||||||
|
log(&format!("flow end: connected {ssid} (profile={profile})"));
|
||||||
|
}
|
||||||
|
Some(n) => {
|
||||||
|
notify(
|
||||||
|
"breadcrumbs: connected (degraded)",
|
||||||
|
&format!("{ssid} ({profile}) — {n}"),
|
||||||
|
Urgency::Normal,
|
||||||
|
);
|
||||||
|
log(&format!(
|
||||||
|
"flow end: connected {ssid} (profile={profile}) note={n}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::config::{Profile, Settings};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
fn net(ssid: &str) -> NetworkDef {
|
||||||
|
NetworkDef {
|
||||||
|
ssid: ssid.into(),
|
||||||
|
password: "x".into(),
|
||||||
|
hidden: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cfg() -> Config {
|
||||||
|
Config {
|
||||||
|
settings: Settings::default(),
|
||||||
|
networks: vec![
|
||||||
|
net("HomeWifi"),
|
||||||
|
net("WorkNet"),
|
||||||
|
net("CafeWifi"),
|
||||||
|
net("FallbackNet"),
|
||||||
|
],
|
||||||
|
profiles: BTreeMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn candidates_follow_priority_order() {
|
||||||
|
let c = cfg();
|
||||||
|
let p = Profile {
|
||||||
|
networks: vec!["FallbackNet".into(), "HomeWifi".into()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let got: Vec<&str> = resolve_candidates(&c, &p)
|
||||||
|
.iter()
|
||||||
|
.map(|n| n.ssid.as_str())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(got, vec!["FallbackNet", "HomeWifi"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn include_all_known_appends_remaining_without_dupes() {
|
||||||
|
let c = cfg();
|
||||||
|
let p = Profile {
|
||||||
|
networks: vec!["HomeWifi".into()],
|
||||||
|
include_all_known: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let got: Vec<&str> = resolve_candidates(&c, &p)
|
||||||
|
.iter()
|
||||||
|
.map(|n| n.ssid.as_str())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(got[0], "HomeWifi");
|
||||||
|
assert_eq!(got.len(), 4);
|
||||||
|
assert!(got.contains(&"WorkNet"));
|
||||||
|
// No duplicate of the explicitly-listed network.
|
||||||
|
assert_eq!(got.iter().filter(|s| **s == "HomeWifi").count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_ssids_in_profile_are_skipped() {
|
||||||
|
let c = cfg();
|
||||||
|
let p = Profile {
|
||||||
|
networks: vec!["Ghost".into(), "WorkNet".into()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let got: Vec<&str> = resolve_candidates(&c, &p)
|
||||||
|
.iter()
|
||||||
|
.map(|n| n.ssid.as_str())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(got, vec!["WorkNet"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
721
src/main.rs
Normal file
721
src/main.rs
Normal file
|
|
@ -0,0 +1,721 @@
|
||||||
|
mod config;
|
||||||
|
mod flow;
|
||||||
|
mod nm;
|
||||||
|
mod notify;
|
||||||
|
mod state;
|
||||||
|
mod status;
|
||||||
|
mod tailscale;
|
||||||
|
mod util;
|
||||||
|
mod watch;
|
||||||
|
|
||||||
|
use std::io::{BufRead, Write};
|
||||||
|
use std::process::Command;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
|
use config::{Config, NetworkDef};
|
||||||
|
use state::State;
|
||||||
|
use util::{command_exists, home_dir, run};
|
||||||
|
|
||||||
|
const C_RESET: &str = "\x1b[0m";
|
||||||
|
const C_BOLD: &str = "\x1b[1m";
|
||||||
|
const C_GREEN: &str = "\x1b[32m";
|
||||||
|
const C_RED: &str = "\x1b[31m";
|
||||||
|
const C_YELLOW: &str = "\x1b[33m";
|
||||||
|
const C_DIM: &str = "\x1b[2m";
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(
|
||||||
|
name = "breadcrumbs",
|
||||||
|
version,
|
||||||
|
about = "Profile-aware Wi-Fi state machine with Tailscale handling",
|
||||||
|
disable_help_subcommand = true
|
||||||
|
)]
|
||||||
|
struct Cli {
|
||||||
|
/// Override the active profile for this run only (does not persist)
|
||||||
|
#[arg(long, short, global = true)]
|
||||||
|
profile: Option<String>,
|
||||||
|
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: Option<Cmd>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Cmd {
|
||||||
|
/// Show current Wi-Fi / profile / Tailscale status (default)
|
||||||
|
Status,
|
||||||
|
/// Run the full connect sequence for the active profile
|
||||||
|
#[command(visible_aliases = ["up", "connect", "i"])]
|
||||||
|
Init,
|
||||||
|
/// Run as a daemon: watch for drops and auto-recover
|
||||||
|
Watch {
|
||||||
|
/// Skip the connect attempt on startup
|
||||||
|
#[arg(long)]
|
||||||
|
no_initial: bool,
|
||||||
|
},
|
||||||
|
/// Get / set / list location profiles (the state machine)
|
||||||
|
Profile {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: Option<ProfileCmd>,
|
||||||
|
},
|
||||||
|
/// Guess the profile from visible networks
|
||||||
|
Detect {
|
||||||
|
/// Set + apply the detected profile
|
||||||
|
#[arg(long)]
|
||||||
|
apply: bool,
|
||||||
|
},
|
||||||
|
/// Add or update a saved network
|
||||||
|
Add {
|
||||||
|
ssid: String,
|
||||||
|
/// Password (prompted if omitted)
|
||||||
|
password: Option<String>,
|
||||||
|
/// Network is hidden (does not broadcast its SSID)
|
||||||
|
#[arg(long)]
|
||||||
|
hidden: bool,
|
||||||
|
/// Attach this SSID to a profile's priority list
|
||||||
|
#[arg(long)]
|
||||||
|
to: Option<String>,
|
||||||
|
/// Position in the profile list (0 = highest priority)
|
||||||
|
#[arg(long)]
|
||||||
|
at: Option<usize>,
|
||||||
|
},
|
||||||
|
/// Remove a saved network (config + NetworkManager)
|
||||||
|
Forget { ssid: String },
|
||||||
|
/// Scan, pick, connect and save a network interactively
|
||||||
|
Scan {
|
||||||
|
/// Attach the saved network to this profile
|
||||||
|
#[arg(long)]
|
||||||
|
to: Option<String>,
|
||||||
|
},
|
||||||
|
/// List configured networks and profiles
|
||||||
|
List {
|
||||||
|
#[arg(long)]
|
||||||
|
show_passwords: bool,
|
||||||
|
},
|
||||||
|
/// Open the config file in $EDITOR
|
||||||
|
Edit,
|
||||||
|
/// Quick connectivity / Tailscale diagnostics
|
||||||
|
Doctor {
|
||||||
|
/// Run the full diag.sh report from the config directory
|
||||||
|
#[arg(long)]
|
||||||
|
full: bool,
|
||||||
|
},
|
||||||
|
/// Print the breadcrumbs config directory
|
||||||
|
Cd {
|
||||||
|
#[arg(long)]
|
||||||
|
shell: bool,
|
||||||
|
},
|
||||||
|
/// Install + enable the systemd user watcher service
|
||||||
|
InstallService {
|
||||||
|
/// Install the unit but do not enable/start it
|
||||||
|
#[arg(long)]
|
||||||
|
no_enable: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum ProfileCmd {
|
||||||
|
/// Print the active profile
|
||||||
|
Get,
|
||||||
|
/// Set the active profile (and apply it unless --no-apply)
|
||||||
|
Set {
|
||||||
|
name: String,
|
||||||
|
#[arg(long)]
|
||||||
|
no_apply: bool,
|
||||||
|
},
|
||||||
|
/// List available profiles
|
||||||
|
List,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
let code = match real_main(cli) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("{C_RED}error:{C_RESET} {e}");
|
||||||
|
1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
std::process::exit(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_profile(cfg: &Config, override_p: &Option<String>) -> String {
|
||||||
|
if let Some(p) = override_p {
|
||||||
|
return p.clone();
|
||||||
|
}
|
||||||
|
State::load(&cfg.settings.default_profile).profile
|
||||||
|
}
|
||||||
|
|
||||||
|
fn real_main(cli: Cli) -> Result<i32, String> {
|
||||||
|
let cmd = cli.cmd.unwrap_or(Cmd::Status);
|
||||||
|
|
||||||
|
// `cd` and `install-service` don't need a parsed config first.
|
||||||
|
if let Cmd::Cd { shell } = &cmd {
|
||||||
|
return cmd_cd(*shell);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cfg = Config::load()?;
|
||||||
|
|
||||||
|
match cmd {
|
||||||
|
Cmd::Status => cmd_status(&cfg, &cli.profile),
|
||||||
|
Cmd::Init => {
|
||||||
|
let p = active_profile(&cfg, &cli.profile);
|
||||||
|
let outcome = flow::run(&cfg, &p);
|
||||||
|
print_outcome(&p, &outcome);
|
||||||
|
Ok(if outcome.ok() { 0 } else { 1 })
|
||||||
|
}
|
||||||
|
Cmd::Watch { no_initial } => Ok(watch::run(cfg, !no_initial)),
|
||||||
|
Cmd::Profile { action } => cmd_profile(&cfg, action),
|
||||||
|
Cmd::Detect { apply } => cmd_detect(&cfg, apply),
|
||||||
|
Cmd::Add {
|
||||||
|
ssid,
|
||||||
|
password,
|
||||||
|
hidden,
|
||||||
|
to,
|
||||||
|
at,
|
||||||
|
} => cmd_add(&mut cfg, ssid, password, hidden, to, at),
|
||||||
|
Cmd::Forget { ssid } => cmd_forget(&mut cfg, &ssid),
|
||||||
|
Cmd::Scan { to } => cmd_scan(&mut cfg, to),
|
||||||
|
Cmd::List { show_passwords } => cmd_list(&cfg, show_passwords),
|
||||||
|
Cmd::Edit => cmd_edit(),
|
||||||
|
Cmd::Doctor { full } => cmd_doctor(&cfg, &cli.profile, full),
|
||||||
|
Cmd::InstallService { no_enable } => cmd_install_service(!no_enable),
|
||||||
|
Cmd::Cd { .. } => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_outcome(profile: &str, o: &flow::Outcome) {
|
||||||
|
match o {
|
||||||
|
flow::Outcome::Connected { ssid, note } => {
|
||||||
|
print!("{C_GREEN}connected{C_RESET} {C_BOLD}{ssid}{C_RESET} ({profile})");
|
||||||
|
match note {
|
||||||
|
Some(n) => println!(" {C_YELLOW}— {n}{C_RESET}"),
|
||||||
|
None => println!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flow::Outcome::TailscaleError { ssid, health } => {
|
||||||
|
println!(
|
||||||
|
"{C_RED}tailscale error{C_RESET}: {} {C_DIM}(on {}){C_RESET}",
|
||||||
|
health.describe(),
|
||||||
|
ssid.clone().unwrap_or_else(|| "—".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
flow::Outcome::NoInterface => {
|
||||||
|
println!("{C_RED}no Wi-Fi adapter{C_RESET} — hardware issue")
|
||||||
|
}
|
||||||
|
flow::Outcome::NoNetworks => {
|
||||||
|
println!("{C_RED}no known networks in range{C_RESET} (profile {profile})")
|
||||||
|
}
|
||||||
|
flow::Outcome::UnknownProfile(p) => {
|
||||||
|
println!("{C_RED}unknown profile{C_RESET}: {p}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_status(cfg: &Config, override_p: &Option<String>) -> Result<i32, String> {
|
||||||
|
let p = active_profile(cfg, override_p);
|
||||||
|
let s = status::gather(cfg, &p);
|
||||||
|
|
||||||
|
let dot = |ok: bool| {
|
||||||
|
if ok {
|
||||||
|
format!("{C_GREEN}●{C_RESET}")
|
||||||
|
} else {
|
||||||
|
format!("{C_RED}●{C_RESET}")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("{C_BOLD}breadcrumbs{C_RESET}");
|
||||||
|
println!(" profile {C_BOLD}{p}{C_RESET}");
|
||||||
|
println!(
|
||||||
|
" adapter {}",
|
||||||
|
s.iface
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("{C_RED}none{C_RESET}"))
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" ssid {}",
|
||||||
|
s.ssid
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("{C_DIM}—{C_RESET}"))
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" ip {}",
|
||||||
|
s.ip.clone().unwrap_or_else(|| format!("{C_DIM}—{C_RESET}"))
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" internet {} {}",
|
||||||
|
dot(s.internet),
|
||||||
|
if s.internet { "ok" } else { "down" }
|
||||||
|
);
|
||||||
|
|
||||||
|
match (&s.tailscale, s.tailscale_required) {
|
||||||
|
(Some(h), req) => {
|
||||||
|
let ok = h.is_ok();
|
||||||
|
println!(
|
||||||
|
" tailscale {} {} {C_DIM}(exit: {}{}){C_RESET}",
|
||||||
|
dot(ok || !req),
|
||||||
|
h.describe(),
|
||||||
|
s.exit_node,
|
||||||
|
if req { "" } else { ", optional" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
(None, _) => println!(" tailscale {C_DIM}not installed{C_RESET}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let healthy = s.internet
|
||||||
|
&& s.iface.is_some()
|
||||||
|
&& (!s.tailscale_required || s.tailscale.as_ref().map(|h| h.is_ok()).unwrap_or(false));
|
||||||
|
println!(
|
||||||
|
" state {}",
|
||||||
|
if healthy {
|
||||||
|
format!("{C_GREEN}healthy{C_RESET}")
|
||||||
|
} else {
|
||||||
|
format!("{C_YELLOW}needs attention{C_RESET} — run `breadcrumbs init`")
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Ok(if healthy { 0 } else { 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_profile(cfg: &Config, action: Option<ProfileCmd>) -> Result<i32, String> {
|
||||||
|
match action.unwrap_or(ProfileCmd::Get) {
|
||||||
|
ProfileCmd::Get => {
|
||||||
|
println!("{}", State::load(&cfg.settings.default_profile).profile);
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
ProfileCmd::List => {
|
||||||
|
let cur = State::load(&cfg.settings.default_profile).profile;
|
||||||
|
for name in cfg.profiles.keys() {
|
||||||
|
let mark = if *name == cur { "*" } else { " " };
|
||||||
|
println!("{mark} {name}");
|
||||||
|
}
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
ProfileCmd::Set { name, no_apply } => {
|
||||||
|
if !cfg.profiles.contains_key(&name) {
|
||||||
|
let avail: Vec<&String> = cfg.profiles.keys().collect();
|
||||||
|
return Err(format!("unknown profile '{name}'. Available: {avail:?}"));
|
||||||
|
}
|
||||||
|
let st = State {
|
||||||
|
profile: name.clone(),
|
||||||
|
updated: util::timestamp(),
|
||||||
|
};
|
||||||
|
st.save()?;
|
||||||
|
notify::log(&format!("profile set -> {name}"));
|
||||||
|
println!("profile = {C_BOLD}{name}{C_RESET}");
|
||||||
|
if no_apply {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let outcome = flow::run(cfg, &name);
|
||||||
|
print_outcome(&name, &outcome);
|
||||||
|
Ok(if outcome.ok() { 0 } else { 1 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_profile(cfg: &Config) -> Option<String> {
|
||||||
|
let iface = nm::wifi_interface()?;
|
||||||
|
nm::radio_on();
|
||||||
|
nm::rescan(&iface, &[]);
|
||||||
|
let visible = nm::visible_ssids(&iface);
|
||||||
|
|
||||||
|
// Profiles are stored in a BTreeMap so iteration order is deterministic
|
||||||
|
// (alphabetical). The caller can rely on that for tie-breaking.
|
||||||
|
for (name, profile) in &cfg.profiles {
|
||||||
|
if profile.detect_ssids.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if profile
|
||||||
|
.detect_ssids
|
||||||
|
.iter()
|
||||||
|
.any(|s| visible.contains(s.as_str()))
|
||||||
|
{
|
||||||
|
return Some(name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to the default profile if no markers matched.
|
||||||
|
Some(cfg.settings.default_profile.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_detect(cfg: &Config, apply: bool) -> Result<i32, String> {
|
||||||
|
match detect_profile(cfg) {
|
||||||
|
Some(p) => {
|
||||||
|
println!("{p}");
|
||||||
|
if apply {
|
||||||
|
State {
|
||||||
|
profile: p.clone(),
|
||||||
|
updated: util::timestamp(),
|
||||||
|
}
|
||||||
|
.save()?;
|
||||||
|
let outcome = flow::run(cfg, &p);
|
||||||
|
print_outcome(&p, &outcome);
|
||||||
|
return Ok(if outcome.ok() { 0 } else { 1 });
|
||||||
|
}
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
None => Err("could not detect a profile (no Wi-Fi adapter?)".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_line(msg: &str) -> String {
|
||||||
|
print!("{msg}");
|
||||||
|
let _ = std::io::stdout().flush();
|
||||||
|
let mut s = String::new();
|
||||||
|
let _ = std::io::stdin().lock().read_line(&mut s);
|
||||||
|
s.trim_end_matches(['\n', '\r']).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_secret(msg: &str) -> String {
|
||||||
|
// `util::run` redirects child stdin to /dev/null, so plain `stty -echo`
|
||||||
|
// would target the wrong fd and silently leave echo ON (leaking the
|
||||||
|
// password to the screen). `-F /dev/tty` makes stty act on the controlling
|
||||||
|
// terminal directly. If there is no tty we fall back to visible input.
|
||||||
|
let had_tty = run("stty", &["-F", "/dev/tty", "-echo"], Duration::from_secs(2)).success;
|
||||||
|
let val = prompt_line(msg);
|
||||||
|
if had_tty {
|
||||||
|
let _ = run("stty", &["-F", "/dev/tty", "echo"], Duration::from_secs(2));
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
val
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_add(
|
||||||
|
cfg: &mut Config,
|
||||||
|
ssid: String,
|
||||||
|
password: Option<String>,
|
||||||
|
hidden: bool,
|
||||||
|
to: Option<String>,
|
||||||
|
at: Option<usize>,
|
||||||
|
) -> Result<i32, String> {
|
||||||
|
let password = match password {
|
||||||
|
Some(p) => p,
|
||||||
|
None => prompt_secret(&format!("Password for '{ssid}': ")),
|
||||||
|
};
|
||||||
|
match cfg.networks.iter_mut().find(|n| n.ssid == ssid) {
|
||||||
|
Some(n) => {
|
||||||
|
n.password = password;
|
||||||
|
n.hidden = hidden || n.hidden;
|
||||||
|
}
|
||||||
|
None => cfg.networks.push(NetworkDef {
|
||||||
|
ssid: ssid.clone(),
|
||||||
|
password,
|
||||||
|
hidden,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
if let Some(prof_name) = to {
|
||||||
|
let prof = cfg
|
||||||
|
.profiles
|
||||||
|
.get_mut(&prof_name)
|
||||||
|
.ok_or_else(|| format!("unknown profile '{prof_name}'"))?;
|
||||||
|
prof.networks.retain(|s| s != &ssid);
|
||||||
|
let idx = at.unwrap_or(prof.networks.len()).min(prof.networks.len());
|
||||||
|
prof.networks.insert(idx, ssid.clone());
|
||||||
|
}
|
||||||
|
cfg.save()?;
|
||||||
|
println!("{C_GREEN}saved{C_RESET} {ssid}");
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_forget(cfg: &mut Config, ssid: &str) -> Result<i32, String> {
|
||||||
|
let before = cfg.networks.len();
|
||||||
|
cfg.networks.retain(|n| n.ssid != ssid);
|
||||||
|
for p in cfg.profiles.values_mut() {
|
||||||
|
p.networks.retain(|s| s != ssid);
|
||||||
|
if p.bootstrap.as_deref() == Some(ssid) {
|
||||||
|
p.bootstrap = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cfg.save()?;
|
||||||
|
let removed = nm::delete_connections_for_ssid(ssid);
|
||||||
|
println!(
|
||||||
|
"{C_GREEN}forgot{C_RESET} {ssid} (config: {}, NetworkManager: {})",
|
||||||
|
if cfg.networks.len() < before {
|
||||||
|
"removed"
|
||||||
|
} else {
|
||||||
|
"not present"
|
||||||
|
},
|
||||||
|
if removed { "removed" } else { "not present" }
|
||||||
|
);
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_scan(cfg: &mut Config, to: Option<String>) -> Result<i32, String> {
|
||||||
|
let iface = nm::wifi_interface().ok_or("no Wi-Fi adapter")?;
|
||||||
|
nm::radio_on();
|
||||||
|
nm::rescan(&iface, &[]);
|
||||||
|
let entries = nm::scan_list(&iface);
|
||||||
|
if entries.is_empty() {
|
||||||
|
return Err("no networks found".into());
|
||||||
|
}
|
||||||
|
for (i, e) in entries.iter().enumerate() {
|
||||||
|
println!(
|
||||||
|
"{:>2}. {C_BOLD}{}{C_RESET} {C_DIM}sig {} {}{C_RESET}",
|
||||||
|
i + 1,
|
||||||
|
if e.ssid.is_empty() {
|
||||||
|
"<hidden>"
|
||||||
|
} else {
|
||||||
|
&e.ssid
|
||||||
|
},
|
||||||
|
e.signal,
|
||||||
|
e.security
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let sel = prompt_line("Select number: ");
|
||||||
|
let idx: usize = sel
|
||||||
|
.parse::<usize>()
|
||||||
|
.ok()
|
||||||
|
.filter(|n| *n >= 1 && *n <= entries.len())
|
||||||
|
.ok_or("invalid selection")?;
|
||||||
|
let ssid = entries[idx - 1].ssid.clone();
|
||||||
|
if ssid.is_empty() {
|
||||||
|
return Err("cannot select a hidden SSID here; use `breadcrumbs add`".into());
|
||||||
|
}
|
||||||
|
let password = prompt_secret(&format!("Password for '{ssid}': "));
|
||||||
|
let def = NetworkDef {
|
||||||
|
ssid: ssid.clone(),
|
||||||
|
password: password.clone(),
|
||||||
|
hidden: false,
|
||||||
|
};
|
||||||
|
if !nm::connect(&iface, &def, cfg.settings.nmcli_wait, &cfg.settings.dns) {
|
||||||
|
return Err(format!("failed to connect to {ssid}"));
|
||||||
|
}
|
||||||
|
match cfg.networks.iter_mut().find(|n| n.ssid == ssid) {
|
||||||
|
Some(n) => n.password = password,
|
||||||
|
None => cfg.networks.push(def),
|
||||||
|
}
|
||||||
|
if let Some(prof_name) = to {
|
||||||
|
if let Some(prof) = cfg.profiles.get_mut(&prof_name) {
|
||||||
|
if !prof.networks.contains(&ssid) {
|
||||||
|
prof.networks.push(ssid.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cfg.save()?;
|
||||||
|
println!("{C_GREEN}connected + saved{C_RESET} {ssid}");
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mask(p: &str) -> String {
|
||||||
|
if p.len() <= 2 {
|
||||||
|
"••".into()
|
||||||
|
} else {
|
||||||
|
format!("{}{}", &p[..1], "•".repeat(p.len().saturating_sub(1)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_list(cfg: &Config, show_pw: bool) -> Result<i32, String> {
|
||||||
|
println!("{C_BOLD}settings{C_RESET}");
|
||||||
|
println!(" dns {}", cfg.settings.dns);
|
||||||
|
println!(" exit_node {}", cfg.settings.exit_node);
|
||||||
|
println!(" default {}", cfg.settings.default_profile);
|
||||||
|
println!(" watch every {}s", cfg.settings.watch_interval);
|
||||||
|
|
||||||
|
println!("\n{C_BOLD}networks{C_RESET}");
|
||||||
|
for n in &cfg.networks {
|
||||||
|
println!(
|
||||||
|
" {C_BOLD}{}{C_RESET} {C_DIM}{}{}{C_RESET}",
|
||||||
|
n.ssid,
|
||||||
|
if show_pw {
|
||||||
|
n.password.clone()
|
||||||
|
} else {
|
||||||
|
mask(&n.password)
|
||||||
|
},
|
||||||
|
if n.hidden { " (hidden)" } else { "" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\n{C_BOLD}profiles{C_RESET}");
|
||||||
|
let cur = State::load(&cfg.settings.default_profile).profile;
|
||||||
|
for (name, p) in &cfg.profiles {
|
||||||
|
let mark = if *name == cur {
|
||||||
|
format!("{C_GREEN}*{C_RESET}")
|
||||||
|
} else {
|
||||||
|
" ".into()
|
||||||
|
};
|
||||||
|
println!("{mark} {C_BOLD}{name}{C_RESET}");
|
||||||
|
if let Some(b) = &p.bootstrap {
|
||||||
|
println!(" bootstrap {b}");
|
||||||
|
}
|
||||||
|
if p.tailscale {
|
||||||
|
println!(
|
||||||
|
" tailscale required (exit: {})",
|
||||||
|
p.exit_node
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| cfg.settings.exit_node.clone())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut order: Vec<String> = p.networks.clone();
|
||||||
|
if p.include_all_known {
|
||||||
|
order.push("…all other known networks".into());
|
||||||
|
}
|
||||||
|
println!(" priority {}", order.join(" > "));
|
||||||
|
}
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_edit() -> Result<i32, String> {
|
||||||
|
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".into());
|
||||||
|
let path = config::config_path();
|
||||||
|
let status = Command::new(&editor)
|
||||||
|
.arg(&path)
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("launching {editor}: {e}"))?;
|
||||||
|
if !status.success() {
|
||||||
|
return Err("editor exited with error".into());
|
||||||
|
}
|
||||||
|
match Config::load() {
|
||||||
|
Ok(_) => {
|
||||||
|
println!("{C_GREEN}config OK{C_RESET}");
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
Err(e) => Err(format!("config is now invalid: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_doctor(cfg: &Config, override_p: &Option<String>, full: bool) -> Result<i32, String> {
|
||||||
|
if full {
|
||||||
|
let script = config::config_dir().join("diag.sh");
|
||||||
|
if !script.exists() {
|
||||||
|
return Err(format!(
|
||||||
|
"diag.sh not found (expected at {})",
|
||||||
|
script.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let st = Command::new("bash")
|
||||||
|
.arg(&script)
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("running diag: {e}"))?;
|
||||||
|
return Ok(st.code().unwrap_or(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
let p = active_profile(cfg, override_p);
|
||||||
|
let s = status::gather(cfg, &p);
|
||||||
|
println!("{C_BOLD}breadcrumbs doctor{C_RESET} (profile {p})");
|
||||||
|
println!(
|
||||||
|
" nmcli {}",
|
||||||
|
if command_exists("nmcli") {
|
||||||
|
"present"
|
||||||
|
} else {
|
||||||
|
"MISSING"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" tailscale {}",
|
||||||
|
if command_exists("tailscale") {
|
||||||
|
"present"
|
||||||
|
} else {
|
||||||
|
"absent"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" adapter {}",
|
||||||
|
s.iface.clone().unwrap_or_else(|| "none".into())
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" ssid {}",
|
||||||
|
s.ssid.clone().unwrap_or_else(|| "—".into())
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" ip {}",
|
||||||
|
s.ip.clone().unwrap_or_else(|| "—".into())
|
||||||
|
);
|
||||||
|
println!(" internet {}", if s.internet { "ok" } else { "DOWN" });
|
||||||
|
if let Some(h) = &s.tailscale {
|
||||||
|
println!(" tailscale {} (exit {})", h.describe(), s.exit_node);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(iface) = &s.iface {
|
||||||
|
let visible = nm::visible_ssids(iface);
|
||||||
|
let known: Vec<&str> = cfg
|
||||||
|
.networks
|
||||||
|
.iter()
|
||||||
|
.filter(|n| visible.contains(&n.ssid))
|
||||||
|
.map(|n| n.ssid.as_str())
|
||||||
|
.collect();
|
||||||
|
println!(
|
||||||
|
" in range {}",
|
||||||
|
if known.is_empty() {
|
||||||
|
"none of your saved networks".into()
|
||||||
|
} else {
|
||||||
|
known.join(", ")
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!("\nFull report: {C_DIM}breadcrumbs doctor --full{C_RESET}");
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_cd(shell: bool) -> Result<i32, String> {
|
||||||
|
let dir = config::config_dir();
|
||||||
|
if shell {
|
||||||
|
let sh = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into());
|
||||||
|
let err = exec_replace(&sh, &["-lc", &format!("cd {:?} && exec {sh}", dir)]);
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
println!("{}", dir.display());
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exec_replace(prog: &str, args: &[&str]) -> String {
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
let e = Command::new(prog).args(args).exec();
|
||||||
|
format!("exec {prog} failed: {e}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_install_service(enable: bool) -> Result<i32, String> {
|
||||||
|
let unit_dir = home_dir().join(".config/systemd/user");
|
||||||
|
std::fs::create_dir_all(&unit_dir)
|
||||||
|
.map_err(|e| format!("creating {}: {e}", unit_dir.display()))?;
|
||||||
|
let bin = std::env::current_exe().map_err(|e| format!("resolving current executable: {e}"))?;
|
||||||
|
// Ordering against graphical-session.target lets the watcher inherit the
|
||||||
|
// session's DISPLAY/WAYLAND_DISPLAY/DBUS so notify-send and the Tailscale
|
||||||
|
// login browser-open actually work. PATH is pinned because systemd --user
|
||||||
|
// units do not get the login shell's PATH, and the watcher shells out to
|
||||||
|
// nmcli/tailscale/sudo/xdg-open by name.
|
||||||
|
let unit = format!(
|
||||||
|
"[Unit]\n\
|
||||||
|
Description=breadcrumbs Wi-Fi state machine watcher\n\
|
||||||
|
After=network.target NetworkManager.service graphical-session.target\n\
|
||||||
|
Wants=network.target graphical-session.target\n\n\
|
||||||
|
[Service]\n\
|
||||||
|
Type=simple\n\
|
||||||
|
Environment=PATH=/usr/local/bin:/usr/bin:/bin\n\
|
||||||
|
ExecStart={bin} watch\n\
|
||||||
|
Restart=always\n\
|
||||||
|
RestartSec=5\n\
|
||||||
|
Nice=5\n\n\
|
||||||
|
[Install]\n\
|
||||||
|
WantedBy=default.target\n",
|
||||||
|
bin = bin.display()
|
||||||
|
);
|
||||||
|
let unit_path = unit_dir.join("breadcrumbs.service");
|
||||||
|
std::fs::write(&unit_path, unit)
|
||||||
|
.map_err(|e| format!("writing {}: {e}", unit_path.display()))?;
|
||||||
|
println!("{C_GREEN}wrote{C_RESET} {}", unit_path.display());
|
||||||
|
|
||||||
|
let _ = run(
|
||||||
|
"systemctl",
|
||||||
|
&["--user", "daemon-reload"],
|
||||||
|
Duration::from_secs(10),
|
||||||
|
);
|
||||||
|
if enable {
|
||||||
|
let o = run(
|
||||||
|
"systemctl",
|
||||||
|
&["--user", "enable", "--now", "breadcrumbs.service"],
|
||||||
|
Duration::from_secs(15),
|
||||||
|
);
|
||||||
|
if o.success {
|
||||||
|
println!("{C_GREEN}enabled + started{C_RESET} breadcrumbs.service");
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
"{C_YELLOW}unit installed{C_RESET}; enable failed: {}",
|
||||||
|
o.stderr.trim()
|
||||||
|
);
|
||||||
|
return Ok(1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!("Run: systemctl --user enable --now breadcrumbs.service");
|
||||||
|
}
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
345
src/nm.rs
Normal file
345
src/nm.rs
Normal file
|
|
@ -0,0 +1,345 @@
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::config::NetworkDef;
|
||||||
|
use crate::util::{run, run_ok, run_with_stdin};
|
||||||
|
|
||||||
|
/// nmcli `-t` escapes `:` and `\` in field values; undo that.
|
||||||
|
fn unescape(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
let mut chars = s.chars().peekable();
|
||||||
|
while let Some(c) = chars.next() {
|
||||||
|
if c == '\\' {
|
||||||
|
if let Some(&n) = chars.peek() {
|
||||||
|
out.push(n);
|
||||||
|
chars.next();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split one nmcli `-t` line into fields. Fields are ':'-separated but values
|
||||||
|
/// escape ':' as '\:' and '\' as '\\'.
|
||||||
|
fn parse_scan_line(line: &str) -> Vec<String> {
|
||||||
|
let mut fields: Vec<String> = Vec::new();
|
||||||
|
let mut cur = String::new();
|
||||||
|
let mut chars = line.chars().peekable();
|
||||||
|
while let Some(c) = chars.next() {
|
||||||
|
if c == '\\' {
|
||||||
|
if let Some(&n) = chars.peek() {
|
||||||
|
cur.push(n);
|
||||||
|
chars.next();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c == ':' {
|
||||||
|
fields.push(std::mem::take(&mut cur));
|
||||||
|
} else {
|
||||||
|
cur.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fields.push(cur);
|
||||||
|
fields
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wifi_interface() -> Option<String> {
|
||||||
|
let o = run(
|
||||||
|
"nmcli",
|
||||||
|
&["-t", "-f", "DEVICE,TYPE", "device", "status"],
|
||||||
|
Duration::from_secs(8),
|
||||||
|
);
|
||||||
|
if !o.success {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
for line in o.stdout.lines() {
|
||||||
|
let parts: Vec<&str> = line.splitn(2, ':').collect();
|
||||||
|
if parts.len() == 2 && parts[1] == "wifi" {
|
||||||
|
return Some(unescape(parts[0]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn radio_on() {
|
||||||
|
let _ = run("nmcli", &["radio", "wifi", "on"], Duration::from_secs(6));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rescan(iface: &str, ssids: &[String]) {
|
||||||
|
let mut args: Vec<String> = vec![
|
||||||
|
"device".into(),
|
||||||
|
"wifi".into(),
|
||||||
|
"rescan".into(),
|
||||||
|
"ifname".into(),
|
||||||
|
iface.into(),
|
||||||
|
];
|
||||||
|
for s in ssids {
|
||||||
|
args.push("ssid".into());
|
||||||
|
args.push(s.clone());
|
||||||
|
}
|
||||||
|
let argv: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
|
||||||
|
let _ = run("nmcli", &argv, Duration::from_secs(20));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn visible_ssids(iface: &str) -> HashSet<String> {
|
||||||
|
let o = run(
|
||||||
|
"nmcli",
|
||||||
|
&[
|
||||||
|
"-t", "-f", "SSID", "device", "wifi", "list", "ifname", iface,
|
||||||
|
],
|
||||||
|
Duration::from_secs(12),
|
||||||
|
);
|
||||||
|
let mut set = HashSet::new();
|
||||||
|
if !o.success {
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
for line in o.stdout.lines() {
|
||||||
|
let ssid = unescape(line.trim());
|
||||||
|
if !ssid.is_empty() {
|
||||||
|
set.insert(ssid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ScanEntry {
|
||||||
|
pub ssid: String,
|
||||||
|
pub signal: String,
|
||||||
|
pub security: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scan_list(iface: &str) -> Vec<ScanEntry> {
|
||||||
|
let o = run(
|
||||||
|
"nmcli",
|
||||||
|
&[
|
||||||
|
"-t",
|
||||||
|
"-f",
|
||||||
|
"SSID,SIGNAL,SECURITY",
|
||||||
|
"device",
|
||||||
|
"wifi",
|
||||||
|
"list",
|
||||||
|
"ifname",
|
||||||
|
iface,
|
||||||
|
],
|
||||||
|
Duration::from_secs(12),
|
||||||
|
);
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
if !o.success {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
for line in o.stdout.lines() {
|
||||||
|
let fields = parse_scan_line(line);
|
||||||
|
if fields.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let ssid = fields[0].trim().to_string();
|
||||||
|
if ssid.is_empty() || !seen.insert(ssid.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(ScanEntry {
|
||||||
|
ssid,
|
||||||
|
signal: fields.get(1).cloned().unwrap_or_default(),
|
||||||
|
security: fields.get(2).cloned().unwrap_or_default(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn active_ssid(iface: &str) -> Option<String> {
|
||||||
|
let o = run(
|
||||||
|
"nmcli",
|
||||||
|
&[
|
||||||
|
"-t",
|
||||||
|
"-f",
|
||||||
|
"ACTIVE,SSID",
|
||||||
|
"device",
|
||||||
|
"wifi",
|
||||||
|
"list",
|
||||||
|
"ifname",
|
||||||
|
iface,
|
||||||
|
],
|
||||||
|
Duration::from_secs(8),
|
||||||
|
);
|
||||||
|
if !o.success {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
for line in o.stdout.lines() {
|
||||||
|
let parts: Vec<&str> = line.splitn(2, ':').collect();
|
||||||
|
if parts.len() == 2 && parts[0] == "yes" {
|
||||||
|
let s = unescape(parts[1].trim());
|
||||||
|
if !s.is_empty() {
|
||||||
|
return Some(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn device_connected(iface: &str) -> bool {
|
||||||
|
let o = run(
|
||||||
|
"nmcli",
|
||||||
|
&["-t", "-f", "DEVICE,STATE", "device", "status"],
|
||||||
|
Duration::from_secs(6),
|
||||||
|
);
|
||||||
|
if !o.success {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for line in o.stdout.lines() {
|
||||||
|
let parts: Vec<&str> = line.splitn(2, ':').collect();
|
||||||
|
if parts.len() == 2 && unescape(parts[0]) == iface {
|
||||||
|
return parts[1].starts_with("connected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_uuid(iface: &str) -> Option<String> {
|
||||||
|
let o = run(
|
||||||
|
"nmcli",
|
||||||
|
&["-g", "GENERAL.CON-UUID", "device", "show", iface],
|
||||||
|
Duration::from_secs(6),
|
||||||
|
);
|
||||||
|
if !o.success {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let u = o.stdout.trim().to_string();
|
||||||
|
if u.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enforce_dns(uuid: &str, iface: &str, dns: &str) {
|
||||||
|
if dns.trim().is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ok = run_ok(
|
||||||
|
"nmcli",
|
||||||
|
&[
|
||||||
|
"connection",
|
||||||
|
"modify",
|
||||||
|
uuid,
|
||||||
|
"ipv4.ignore-auto-dns",
|
||||||
|
"yes",
|
||||||
|
"ipv4.dns",
|
||||||
|
dns,
|
||||||
|
],
|
||||||
|
Duration::from_secs(8),
|
||||||
|
);
|
||||||
|
if ok {
|
||||||
|
let _ = run(
|
||||||
|
"nmcli",
|
||||||
|
&["device", "reapply", iface],
|
||||||
|
Duration::from_secs(8),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to a network and pin DNS. Returns true only if associated.
|
||||||
|
pub fn connect(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> bool {
|
||||||
|
let wait_s = wait.to_string();
|
||||||
|
let hidden = if net.hidden { "yes" } else { "no" };
|
||||||
|
// `--ask` makes nmcli read the PSK from stdin instead of taking it on the
|
||||||
|
// command line, so the password never appears in `ps`/`/proc`.
|
||||||
|
let args = [
|
||||||
|
"--wait",
|
||||||
|
&wait_s,
|
||||||
|
"--ask",
|
||||||
|
"device",
|
||||||
|
"wifi",
|
||||||
|
"connect",
|
||||||
|
net.ssid.as_str(),
|
||||||
|
"hidden",
|
||||||
|
hidden,
|
||||||
|
"ifname",
|
||||||
|
iface,
|
||||||
|
];
|
||||||
|
let secret = format!("{}\n", net.password);
|
||||||
|
let o = run_with_stdin(
|
||||||
|
"nmcli",
|
||||||
|
&args,
|
||||||
|
Some(&secret),
|
||||||
|
Duration::from_secs(wait as u64 + 15),
|
||||||
|
);
|
||||||
|
if !o.success {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if let Some(uuid) = active_uuid(iface) {
|
||||||
|
enforce_dns(&uuid, iface, dns);
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete every saved connection profile whose name or 802-11-wireless SSID
|
||||||
|
/// matches `ssid` (used by `breadcrumbs forget` to purge stale entries).
|
||||||
|
pub fn delete_connections_for_ssid(ssid: &str) -> bool {
|
||||||
|
let list = run(
|
||||||
|
"nmcli",
|
||||||
|
&["-t", "-f", "NAME,TYPE", "connection", "show"],
|
||||||
|
Duration::from_secs(8),
|
||||||
|
);
|
||||||
|
if !list.success {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let mut removed = false;
|
||||||
|
for line in list.stdout.lines() {
|
||||||
|
let parts: Vec<&str> = line.splitn(2, ':').collect();
|
||||||
|
if parts.len() < 2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name = unescape(parts[0]);
|
||||||
|
let typ = parts[1];
|
||||||
|
if !typ.contains("wireless") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let conn_ssid = run(
|
||||||
|
"nmcli",
|
||||||
|
&["-g", "802-11-wireless.ssid", "connection", "show", &name],
|
||||||
|
Duration::from_secs(6),
|
||||||
|
);
|
||||||
|
let conn_ssid = conn_ssid.stdout.trim();
|
||||||
|
if (name == ssid || conn_ssid == ssid)
|
||||||
|
&& run_ok(
|
||||||
|
"nmcli",
|
||||||
|
&["connection", "delete", "id", &name],
|
||||||
|
Duration::from_secs(8),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
removed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
removed
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unescape_handles_nmcli_escaping() {
|
||||||
|
assert_eq!(unescape("plain"), "plain");
|
||||||
|
assert_eq!(unescape(r"a\:b"), "a:b");
|
||||||
|
assert_eq!(unescape(r"back\\slash"), r"back\slash");
|
||||||
|
assert_eq!(unescape("trailing\\"), "trailing\\");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_scan_line_splits_and_unescapes() {
|
||||||
|
// SSID:SIGNAL:SECURITY with an escaped ':' inside the SSID.
|
||||||
|
let f = parse_scan_line(r"My\:Net:72:WPA2");
|
||||||
|
assert_eq!(f, vec!["My:Net", "72", "WPA2"]);
|
||||||
|
|
||||||
|
// SSID with a space (common in real network names)
|
||||||
|
let f = parse_scan_line("My Network:88:WPA2");
|
||||||
|
assert_eq!(f, vec!["My Network", "88", "WPA2"]);
|
||||||
|
|
||||||
|
// Empty SSID (hidden) keeps the empty leading field.
|
||||||
|
let f = parse_scan_line(":40:WPA3");
|
||||||
|
assert_eq!(f, vec!["", "40", "WPA3"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/notify.rs
Normal file
79
src/notify.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
use std::fs::{self, OpenOptions};
|
||||||
|
use std::io::Write;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::config::{log_path, state_dir};
|
||||||
|
use crate::util::{command_exists, run, timestamp};
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Urgency {
|
||||||
|
Low,
|
||||||
|
Normal,
|
||||||
|
Critical,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Urgency {
|
||||||
|
fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Urgency::Low => "low",
|
||||||
|
Urgency::Normal => "normal",
|
||||||
|
Urgency::Critical => "critical",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_LOG_BYTES: u64 = 512 * 1024;
|
||||||
|
|
||||||
|
pub fn log(line: &str) {
|
||||||
|
let _ = fs::create_dir_all(state_dir());
|
||||||
|
let path = log_path();
|
||||||
|
if let Ok(meta) = fs::metadata(&path) {
|
||||||
|
if meta.len() > MAX_LOG_BYTES {
|
||||||
|
if let Ok(text) = fs::read_to_string(&path) {
|
||||||
|
let tail: Vec<&str> = text.lines().rev().take(300).collect();
|
||||||
|
let kept: String = tail.into_iter().rev().collect::<Vec<_>>().join("\n");
|
||||||
|
let _ = fs::write(&path, kept + "\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(&path) {
|
||||||
|
let _ = writeln!(f, "{} {}", timestamp(), line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Desktop notification (best effort) + log entry.
|
||||||
|
pub fn notify(summary: &str, body: &str, urgency: Urgency) {
|
||||||
|
log(&format!(
|
||||||
|
"[notify/{}] {} {}",
|
||||||
|
urgency.as_str(),
|
||||||
|
summary,
|
||||||
|
if body.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("- {body}")
|
||||||
|
}
|
||||||
|
));
|
||||||
|
if !command_exists("notify-send") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let timeout_ms = match urgency {
|
||||||
|
Urgency::Critical => "0",
|
||||||
|
Urgency::Normal => "6000",
|
||||||
|
Urgency::Low => "3500",
|
||||||
|
};
|
||||||
|
let mut args = vec![
|
||||||
|
"-a",
|
||||||
|
"breadcrumbs",
|
||||||
|
"-u",
|
||||||
|
urgency.as_str(),
|
||||||
|
"-t",
|
||||||
|
timeout_ms,
|
||||||
|
"-h",
|
||||||
|
"string:x-canonical-private-synchronous:breadcrumbs",
|
||||||
|
summary,
|
||||||
|
];
|
||||||
|
if !body.is_empty() {
|
||||||
|
args.push(body);
|
||||||
|
}
|
||||||
|
let _ = run("notify-send", &args, Duration::from_secs(5));
|
||||||
|
}
|
||||||
34
src/state.rs
Normal file
34
src/state.rs
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::config::{state_dir, state_path};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct State {
|
||||||
|
pub profile: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl State {
|
||||||
|
pub fn load(default_profile: &str) -> State {
|
||||||
|
if let Ok(text) = fs::read_to_string(state_path()) {
|
||||||
|
if let Ok(s) = toml::from_str::<State>(&text) {
|
||||||
|
if !s.profile.is_empty() {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
State {
|
||||||
|
profile: default_profile.to_string(),
|
||||||
|
updated: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self) -> Result<(), String> {
|
||||||
|
fs::create_dir_all(state_dir()).map_err(|e| format!("creating state dir: {e}"))?;
|
||||||
|
let text = toml::to_string_pretty(self).map_err(|e| format!("serializing state: {e}"))?;
|
||||||
|
fs::write(state_path(), text).map_err(|e| format!("writing state: {e}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
92
src/status.rs
Normal file
92
src/status.rs
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::nm;
|
||||||
|
use crate::tailscale::{self, TsHealth};
|
||||||
|
use crate::util::{command_exists, run};
|
||||||
|
|
||||||
|
pub fn internet_ok(cfg: &Config) -> bool {
|
||||||
|
if command_exists("curl") {
|
||||||
|
let o = run(
|
||||||
|
"curl",
|
||||||
|
&[
|
||||||
|
"-s",
|
||||||
|
"-o",
|
||||||
|
"/dev/null",
|
||||||
|
"-w",
|
||||||
|
"%{http_code}",
|
||||||
|
"--max-time",
|
||||||
|
"4",
|
||||||
|
&cfg.settings.connectivity_url,
|
||||||
|
],
|
||||||
|
Duration::from_secs(6),
|
||||||
|
);
|
||||||
|
let code = o.stdout.trim();
|
||||||
|
if code == "204" || code == "200" || code == "301" || code == "302" {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: ICMP to the configured host.
|
||||||
|
run(
|
||||||
|
"ping",
|
||||||
|
&["-c", "1", "-W", "2", &cfg.settings.ping_host],
|
||||||
|
Duration::from_secs(4),
|
||||||
|
)
|
||||||
|
.success
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ipv4(iface: &str) -> Option<String> {
|
||||||
|
let o = run(
|
||||||
|
"nmcli",
|
||||||
|
&["-g", "IP4.ADDRESS", "device", "show", iface],
|
||||||
|
Duration::from_secs(6),
|
||||||
|
);
|
||||||
|
if !o.success {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let s = o.stdout.trim();
|
||||||
|
if s.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(s.lines().next().unwrap_or(s).trim().to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Status {
|
||||||
|
pub iface: Option<String>,
|
||||||
|
pub ssid: Option<String>,
|
||||||
|
pub ip: Option<String>,
|
||||||
|
pub internet: bool,
|
||||||
|
pub tailscale_required: bool,
|
||||||
|
pub tailscale: Option<TsHealth>,
|
||||||
|
pub exit_node: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn gather(cfg: &Config, profile_name: &str) -> Status {
|
||||||
|
let iface = nm::wifi_interface();
|
||||||
|
let ssid = iface.as_deref().and_then(nm::active_ssid);
|
||||||
|
let ip = iface.as_deref().and_then(ipv4);
|
||||||
|
let internet = internet_ok(cfg);
|
||||||
|
|
||||||
|
let prof = cfg.profile(profile_name);
|
||||||
|
let ts_required = prof.map(|p| p.tailscale).unwrap_or(false);
|
||||||
|
let exit_node = prof
|
||||||
|
.and_then(|p| p.exit_node.clone())
|
||||||
|
.unwrap_or_else(|| cfg.settings.exit_node.clone());
|
||||||
|
|
||||||
|
let tailscale = if tailscale::installed() {
|
||||||
|
Some(tailscale::check(&exit_node))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Status {
|
||||||
|
iface,
|
||||||
|
ssid,
|
||||||
|
ip,
|
||||||
|
internet,
|
||||||
|
tailscale_required: ts_required,
|
||||||
|
tailscale,
|
||||||
|
exit_node,
|
||||||
|
}
|
||||||
|
}
|
||||||
384
src/tailscale.rs
Normal file
384
src/tailscale.rs
Normal file
|
|
@ -0,0 +1,384 @@
|
||||||
|
use std::io::{BufRead, BufReader};
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::notify::{log, notify, Urgency};
|
||||||
|
use crate::util::{command_exists, run};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum TsHealth {
|
||||||
|
/// Backend Running and the requested exit node is selected + online.
|
||||||
|
Ok,
|
||||||
|
NotInstalled,
|
||||||
|
NeedsLogin,
|
||||||
|
Stopped,
|
||||||
|
/// The exit node host is not present / not advertising as an exit node.
|
||||||
|
ExitNodeMissing,
|
||||||
|
/// The exit node exists but is offline.
|
||||||
|
ExitNodeOffline,
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TsHealth {
|
||||||
|
pub fn is_ok(&self) -> bool {
|
||||||
|
matches!(self, TsHealth::Ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn describe(&self) -> String {
|
||||||
|
match self {
|
||||||
|
TsHealth::Ok => "ok".into(),
|
||||||
|
TsHealth::NotInstalled => "tailscale not installed".into(),
|
||||||
|
TsHealth::NeedsLogin => "not logged in (run: tailscale up)".into(),
|
||||||
|
TsHealth::Stopped => "backend stopped".into(),
|
||||||
|
TsHealth::ExitNodeMissing => "exit node not found in tailnet".into(),
|
||||||
|
TsHealth::ExitNodeOffline => "exit node is offline".into(),
|
||||||
|
TsHealth::Error(e) => format!("error: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn installed() -> bool {
|
||||||
|
command_exists("tailscale")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_json() -> Option<Value> {
|
||||||
|
let o = run("tailscale", &["status", "--json"], Duration::from_secs(8));
|
||||||
|
if o.stdout.trim().is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
serde_json::from_str(&o.stdout).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backend_state(v: &Value) -> String {
|
||||||
|
v.get("BackendState")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does any peer named `node` advertise as an exit node, and is it online /
|
||||||
|
/// currently selected?
|
||||||
|
fn exit_node_state(
|
||||||
|
v: &Value,
|
||||||
|
node: &str,
|
||||||
|
) -> (
|
||||||
|
bool, /*exists*/
|
||||||
|
bool, /*online*/
|
||||||
|
bool, /*selected*/
|
||||||
|
) {
|
||||||
|
// Strong signal: ExitNodeStatus is populated when an exit node is active.
|
||||||
|
let ens_online = v
|
||||||
|
.get("ExitNodeStatus")
|
||||||
|
.and_then(|e| e.get("Online"))
|
||||||
|
.and_then(Value::as_bool);
|
||||||
|
|
||||||
|
let want = node.trim().to_lowercase();
|
||||||
|
let mut exists = false;
|
||||||
|
let mut online = false;
|
||||||
|
let mut selected = false;
|
||||||
|
|
||||||
|
if let Some(peers) = v.get("Peer").and_then(Value::as_object) {
|
||||||
|
for (_k, p) in peers {
|
||||||
|
let host = p
|
||||||
|
.get("HostName")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_lowercase();
|
||||||
|
let dns = p
|
||||||
|
.get("DNSName")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_lowercase();
|
||||||
|
let matches = host == want || dns.split('.').next() == Some(want.as_str());
|
||||||
|
let advertises = p
|
||||||
|
.get("ExitNodeOption")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if matches && advertises {
|
||||||
|
exists = true;
|
||||||
|
online = p.get("Online").and_then(Value::as_bool).unwrap_or(false);
|
||||||
|
selected = p.get("ExitNode").and_then(Value::as_bool).unwrap_or(false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if selected {
|
||||||
|
if let Some(o) = ens_online {
|
||||||
|
online = o;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(exists, online, selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_url(line: &str) -> Option<String> {
|
||||||
|
line.split_whitespace()
|
||||||
|
.find(|s| s.starts_with("https://"))
|
||||||
|
.map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
static LOGIN_INFLIGHT: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// Kick off browser-based Tailscale login in the background and return
|
||||||
|
/// immediately. The watch loop must never block on interactive auth, so the
|
||||||
|
/// actual `sudo tailscale login` + browser flow runs on its own thread; the
|
||||||
|
/// caller just keeps reporting `NeedsLogin` (→ stay on the bootstrap network)
|
||||||
|
/// until a later poll observes the backend come up. The guard collapses
|
||||||
|
/// repeated triggers into a single in-flight attempt.
|
||||||
|
fn login_and_open() {
|
||||||
|
if LOGIN_INFLIGHT.swap(true, Ordering::SeqCst) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
thread::spawn(|| {
|
||||||
|
run_login();
|
||||||
|
LOGIN_INFLIGHT.store(false, Ordering::SeqCst);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `sudo tailscale login`, open the auth URL in the browser, and block
|
||||||
|
/// until login completes (up to 5 minutes). Always called on a worker thread.
|
||||||
|
fn run_login() {
|
||||||
|
let mut child = match Command::new("sudo")
|
||||||
|
.args(["tailscale", "login"])
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
log(&format!("tailscale login: spawn failed: {e}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let stdout = child.stdout.take();
|
||||||
|
let stderr = child.stderr.take();
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::channel::<String>();
|
||||||
|
let tx2 = tx.clone();
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
if let Some(r) = stdout {
|
||||||
|
for line in BufReader::new(r).lines().map_while(Result::ok) {
|
||||||
|
if let Some(url) = extract_url(&line) {
|
||||||
|
let _ = tx.send(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
thread::spawn(move || {
|
||||||
|
if let Some(r) = stderr {
|
||||||
|
for line in BufReader::new(r).lines().map_while(Result::ok) {
|
||||||
|
if let Some(url) = extract_url(&line) {
|
||||||
|
let _ = tx2.send(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Ok(url) = rx.recv_timeout(Duration::from_secs(30)) {
|
||||||
|
log(&format!("tailscale login: opening {url}"));
|
||||||
|
notify(
|
||||||
|
"Tailscale: login required",
|
||||||
|
"Opening browser to authenticate.",
|
||||||
|
Urgency::Normal,
|
||||||
|
);
|
||||||
|
let _ = Command::new("xdg-open")
|
||||||
|
.arg(&url)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn();
|
||||||
|
} else {
|
||||||
|
log("tailscale login: no URL received within 30s");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait up to 5 min for the user to complete browser auth.
|
||||||
|
let start = Instant::now();
|
||||||
|
let timeout = Duration::from_secs(300);
|
||||||
|
loop {
|
||||||
|
match child.try_wait() {
|
||||||
|
Ok(Some(_)) => break,
|
||||||
|
Ok(None) => {
|
||||||
|
if start.elapsed() >= timeout {
|
||||||
|
log("tailscale login: timed out waiting for auth");
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(500));
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bring Tailscale to a state where `node` is the active, online exit node.
|
||||||
|
/// Performs at most one bring-up/login and one `tailscale set` attempt.
|
||||||
|
pub fn ensure_exit_node(node: &str) -> TsHealth {
|
||||||
|
if !installed() {
|
||||||
|
return TsHealth::NotInstalled;
|
||||||
|
}
|
||||||
|
|
||||||
|
let v = match status_json() {
|
||||||
|
Some(v) => v,
|
||||||
|
None => return TsHealth::Error("could not read tailscale status".into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
match backend_state(&v).as_str() {
|
||||||
|
"NeedsLogin" | "NoState" => {
|
||||||
|
login_and_open();
|
||||||
|
}
|
||||||
|
"Stopped" => {
|
||||||
|
// Daemon not running — bring it up, then check if it needs auth.
|
||||||
|
let _ = run("tailscale", &["up"], Duration::from_secs(20));
|
||||||
|
if let Some(v2) = status_json() {
|
||||||
|
if matches!(backend_state(&v2).as_str(), "NeedsLogin" | "NoState") {
|
||||||
|
login_and_open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"Running" => {}
|
||||||
|
"" => return TsHealth::Error("empty backend state".into()),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select the exit node (idempotent).
|
||||||
|
let _ = run(
|
||||||
|
"tailscale",
|
||||||
|
&["set", &format!("--exit-node={node}")],
|
||||||
|
Duration::from_secs(10),
|
||||||
|
);
|
||||||
|
|
||||||
|
let v = match status_json() {
|
||||||
|
Some(v) => v,
|
||||||
|
None => return TsHealth::Error("could not re-read tailscale status".into()),
|
||||||
|
};
|
||||||
|
|
||||||
|
match backend_state(&v).as_str() {
|
||||||
|
"Running" => {}
|
||||||
|
"NeedsLogin" | "NoState" => return TsHealth::NeedsLogin,
|
||||||
|
"Stopped" => return TsHealth::Stopped,
|
||||||
|
other => return TsHealth::Error(format!("backend state: {other}")),
|
||||||
|
}
|
||||||
|
|
||||||
|
let (exists, online, selected) = exit_node_state(&v, node);
|
||||||
|
if !exists {
|
||||||
|
TsHealth::ExitNodeMissing
|
||||||
|
} else if !online {
|
||||||
|
TsHealth::ExitNodeOffline
|
||||||
|
} else if !selected {
|
||||||
|
// Online and present but our set didn't take — treat as missing/selectable error.
|
||||||
|
TsHealth::Error("exit node not selected".into())
|
||||||
|
} else {
|
||||||
|
TsHealth::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lightweight health check without trying to (re)configure anything.
|
||||||
|
pub fn check(node: &str) -> TsHealth {
|
||||||
|
if !installed() {
|
||||||
|
return TsHealth::NotInstalled;
|
||||||
|
}
|
||||||
|
let v = match status_json() {
|
||||||
|
Some(v) => v,
|
||||||
|
None => return TsHealth::Error("status unavailable".into()),
|
||||||
|
};
|
||||||
|
match backend_state(&v).as_str() {
|
||||||
|
"Running" => {}
|
||||||
|
"NeedsLogin" | "NoState" => return TsHealth::NeedsLogin,
|
||||||
|
"Stopped" => return TsHealth::Stopped,
|
||||||
|
other => return TsHealth::Error(format!("backend state: {other}")),
|
||||||
|
}
|
||||||
|
let (exists, online, selected) = exit_node_state(&v, node);
|
||||||
|
if !exists {
|
||||||
|
TsHealth::ExitNodeMissing
|
||||||
|
} else if !online {
|
||||||
|
TsHealth::ExitNodeOffline
|
||||||
|
} else if !selected {
|
||||||
|
TsHealth::Error("exit node not selected".into())
|
||||||
|
} else {
|
||||||
|
TsHealth::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backend_state_extraction() {
|
||||||
|
assert_eq!(
|
||||||
|
backend_state(&json!({"BackendState": "Running"})),
|
||||||
|
"Running"
|
||||||
|
);
|
||||||
|
assert_eq!(backend_state(&json!({})), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exit_node_healthy_and_selected() {
|
||||||
|
let v = json!({
|
||||||
|
"BackendState": "Running",
|
||||||
|
"ExitNodeStatus": { "Online": true },
|
||||||
|
"Peer": {
|
||||||
|
"k1": { "HostName": "exitnode", "DNSName": "exitnode.ts.net.",
|
||||||
|
"Online": true, "ExitNode": true, "ExitNodeOption": true }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert_eq!(exit_node_state(&v, "exitnode"), (true, true, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exit_node_present_but_offline() {
|
||||||
|
let v = json!({
|
||||||
|
"BackendState": "Running",
|
||||||
|
"Peer": {
|
||||||
|
"k1": { "HostName": "exitnode", "DNSName": "exitnode.ts.net.",
|
||||||
|
"Online": false, "ExitNode": false, "ExitNodeOption": true }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert_eq!(exit_node_state(&v, "exitnode"), (true, false, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exit_node_missing_when_not_advertised() {
|
||||||
|
let v = json!({
|
||||||
|
"BackendState": "Running",
|
||||||
|
"Peer": {
|
||||||
|
"k1": { "HostName": "exitnode", "DNSName": "exitnode.ts.net.",
|
||||||
|
"Online": true, "ExitNode": false, "ExitNodeOption": false }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert_eq!(exit_node_state(&v, "exitnode"), (false, false, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exit_node_matches_via_dns_first_label() {
|
||||||
|
let v = json!({
|
||||||
|
"Peer": {
|
||||||
|
"k1": { "HostName": "box-1", "DNSName": "exitnode.example.ts.net.",
|
||||||
|
"Online": true, "ExitNode": true, "ExitNodeOption": true }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let (exists, online, selected) = exit_node_state(&v, "exitnode");
|
||||||
|
assert!(exists && online && selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exit_node_present_online_but_not_selected() {
|
||||||
|
let v = json!({
|
||||||
|
"Peer": {
|
||||||
|
"k1": { "HostName": "exitnode", "DNSName": "exitnode.ts.net.",
|
||||||
|
"Online": true, "ExitNode": false, "ExitNodeOption": true }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert_eq!(exit_node_state(&v, "exitnode"), (true, true, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
179
src/util.rs
Normal file
179
src/util.rs
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
pub fn home_dir() -> PathBuf {
|
||||||
|
std::env::var_os("HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| PathBuf::from("/root"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn command_exists(name: &str) -> bool {
|
||||||
|
if let Some(paths) = std::env::var_os("PATH") {
|
||||||
|
for dir in std::env::split_paths(&paths) {
|
||||||
|
if dir.join(name).is_file() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Output {
|
||||||
|
pub success: bool,
|
||||||
|
pub stdout: String,
|
||||||
|
pub stderr: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Output {
|
||||||
|
pub fn failed() -> Output {
|
||||||
|
Output {
|
||||||
|
success: false,
|
||||||
|
stdout: String::new(),
|
||||||
|
stderr: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a command with a hard timeout. The child is killed if it overruns so a
|
||||||
|
/// hung nmcli/tailscale can never wedge the daemon.
|
||||||
|
pub fn run(prog: &str, args: &[&str], timeout: Duration) -> Output {
|
||||||
|
run_with_stdin(prog, args, None, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`run`], but feeds `stdin` to the child's standard input. Used to hand
|
||||||
|
/// secrets (e.g. Wi-Fi PSKs) to `nmcli --ask` without exposing them in argv,
|
||||||
|
/// where any local user could read them via `ps`.
|
||||||
|
pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration) -> Output {
|
||||||
|
let stdin_cfg = if stdin.is_some() {
|
||||||
|
Stdio::piped()
|
||||||
|
} else {
|
||||||
|
Stdio::null()
|
||||||
|
};
|
||||||
|
let mut child = match Command::new(prog)
|
||||||
|
.args(args)
|
||||||
|
.stdin(stdin_cfg)
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => return Output::failed(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(data) = stdin {
|
||||||
|
if let Some(mut sink) = child.stdin.take() {
|
||||||
|
let _ = sink.write_all(data.as_bytes());
|
||||||
|
// Drop closes the pipe so the child's read sees EOF.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stdout_pipe = child.stdout.take();
|
||||||
|
let mut stderr_pipe = child.stderr.take();
|
||||||
|
|
||||||
|
let out_handle = thread::spawn(move || {
|
||||||
|
let mut buf = String::new();
|
||||||
|
if let Some(ref mut p) = stdout_pipe {
|
||||||
|
let _ = p.read_to_string(&mut buf);
|
||||||
|
}
|
||||||
|
buf
|
||||||
|
});
|
||||||
|
let err_handle = thread::spawn(move || {
|
||||||
|
let mut buf = String::new();
|
||||||
|
if let Some(ref mut p) = stderr_pipe {
|
||||||
|
let _ = p.read_to_string(&mut buf);
|
||||||
|
}
|
||||||
|
buf
|
||||||
|
});
|
||||||
|
|
||||||
|
let start = Instant::now();
|
||||||
|
let status = loop {
|
||||||
|
match child.try_wait() {
|
||||||
|
Ok(Some(s)) => break Some(s),
|
||||||
|
Ok(None) => {
|
||||||
|
if start.elapsed() >= timeout {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
break None;
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
Err(_) => break None,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let stdout = out_handle.join().unwrap_or_default();
|
||||||
|
let stderr = err_handle.join().unwrap_or_default();
|
||||||
|
|
||||||
|
Output {
|
||||||
|
success: status.map(|s| s.success()).unwrap_or(false),
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_ok(prog: &str, args: &[&str], timeout: Duration) -> bool {
|
||||||
|
run(prog, args, timeout).success
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local "YYYY-MM-DD HH:MM:SS". Uses `date` for correct local time, falling
|
||||||
|
/// back to a dependency-free UTC computation if it is unavailable.
|
||||||
|
pub fn timestamp() -> String {
|
||||||
|
let o = run("date", &["+%Y-%m-%d %H:%M:%S"], Duration::from_secs(2));
|
||||||
|
if o.success {
|
||||||
|
let t = o.stdout.trim();
|
||||||
|
if !t.is_empty() {
|
||||||
|
return t.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
timestamp_utc()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// epoch seconds -> "YYYY-MM-DD HH:MM:SS" (UTC), no external deps.
|
||||||
|
fn timestamp_utc() -> String {
|
||||||
|
let secs = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0) as i64;
|
||||||
|
fmt_epoch(secs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format UTC epoch seconds as "YYYY-MM-DD HH:MM:SS" (pure / testable).
|
||||||
|
fn fmt_epoch(secs: i64) -> String {
|
||||||
|
let days = secs.div_euclid(86_400);
|
||||||
|
let tod = secs.rem_euclid(86_400);
|
||||||
|
let (h, mi, s) = (tod / 3600, (tod % 3600) / 60, tod % 60);
|
||||||
|
|
||||||
|
// civil_from_days (Howard Hinnant's algorithm)
|
||||||
|
let z = days + 719_468;
|
||||||
|
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||||
|
let doe = z - era * 146_097;
|
||||||
|
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||||
|
let y = yoe + era * 400;
|
||||||
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||||
|
let mp = (5 * doy + 2) / 153;
|
||||||
|
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||||
|
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||||
|
let y = if m <= 2 { y + 1 } else { y };
|
||||||
|
|
||||||
|
format!("{y:04}-{m:02}-{d:02} {h:02}:{mi:02}:{s:02}")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fmt_epoch_known_values() {
|
||||||
|
assert_eq!(fmt_epoch(0), "1970-01-01 00:00:00");
|
||||||
|
// 2001-09-09 01:46:40 UTC
|
||||||
|
assert_eq!(fmt_epoch(1_000_000_000), "2001-09-09 01:46:40");
|
||||||
|
// 2021-01-01 00:00:00 UTC
|
||||||
|
assert_eq!(fmt_epoch(1_609_459_200), "2021-01-01 00:00:00");
|
||||||
|
// Leap day 2024-02-29 12:00:00 UTC
|
||||||
|
assert_eq!(fmt_epoch(1_709_208_000), "2024-02-29 12:00:00");
|
||||||
|
}
|
||||||
|
}
|
||||||
223
src/watch.rs
Normal file
223
src/watch.rs
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
use std::io::{BufRead, BufReader};
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use std::sync::mpsc::{self, Receiver};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::flow;
|
||||||
|
use crate::notify::{log, notify, Urgency};
|
||||||
|
use crate::state::State;
|
||||||
|
use crate::status::{self};
|
||||||
|
use crate::tailscale::TsHealth;
|
||||||
|
|
||||||
|
#[derive(PartialEq, Eq, Clone, Debug)]
|
||||||
|
enum Health {
|
||||||
|
Up,
|
||||||
|
DownNoNet,
|
||||||
|
DownTailscaleManual,
|
||||||
|
DownTailscaleOther,
|
||||||
|
NoAdapter,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify(cfg: &Config, profile: &str) -> (Health, Option<String>) {
|
||||||
|
let s = status::gather(cfg, profile);
|
||||||
|
if s.iface.is_none() {
|
||||||
|
return (Health::NoAdapter, None);
|
||||||
|
}
|
||||||
|
let ssid = s.ssid.clone();
|
||||||
|
if !s.internet {
|
||||||
|
return (Health::DownNoNet, ssid);
|
||||||
|
}
|
||||||
|
if s.tailscale_required {
|
||||||
|
match s.tailscale {
|
||||||
|
Some(TsHealth::Ok) => (Health::Up, ssid),
|
||||||
|
Some(TsHealth::NeedsLogin) | Some(TsHealth::NotInstalled) => {
|
||||||
|
(Health::DownTailscaleManual, ssid)
|
||||||
|
}
|
||||||
|
Some(_) => (Health::DownTailscaleOther, ssid),
|
||||||
|
None => (Health::DownTailscaleManual, ssid),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(Health::Up, ssid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tail `nmcli monitor` and ping the channel on link-state churn so we react
|
||||||
|
/// to drops within a second instead of waiting out the poll interval.
|
||||||
|
fn spawn_nm_monitor(tx: mpsc::Sender<()>) {
|
||||||
|
thread::spawn(move || loop {
|
||||||
|
let child = Command::new("nmcli")
|
||||||
|
.arg("monitor")
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn();
|
||||||
|
let mut child = match child {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => {
|
||||||
|
thread::sleep(Duration::from_secs(10));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(out) = child.stdout.take() {
|
||||||
|
let reader = BufReader::new(out);
|
||||||
|
let mut last = Instant::now() - Duration::from_secs(10);
|
||||||
|
for line in reader.lines().map_while(Result::ok) {
|
||||||
|
let l = line.to_lowercase();
|
||||||
|
let interesting = l.contains("disconnect")
|
||||||
|
|| l.contains("unavailable")
|
||||||
|
|| l.contains("connected")
|
||||||
|
|| l.contains("connection")
|
||||||
|
|| l.contains("now")
|
||||||
|
|| l.contains("state");
|
||||||
|
if interesting && last.elapsed() > Duration::from_millis(1500) {
|
||||||
|
last = Instant::now();
|
||||||
|
let _ = tx.send(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = child.wait();
|
||||||
|
// monitor died (NM restart?) — back off and respawn.
|
||||||
|
thread::sleep(Duration::from_secs(5));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sleep up to `dur`, but wake early if `nmcli monitor` signals link churn.
|
||||||
|
fn wait_for_tick(rx: &Receiver<()>, dur: Duration) {
|
||||||
|
match rx.recv_timeout(dur) {
|
||||||
|
Ok(()) => {
|
||||||
|
// Drain any burst of events so we don't re-fire immediately.
|
||||||
|
while rx.try_recv().is_ok() {}
|
||||||
|
}
|
||||||
|
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||||
|
// Monitor thread gone (shouldn't happen: we hold the sender) — fall
|
||||||
|
// back to a plain sleep so we don't busy-spin.
|
||||||
|
Err(mpsc::RecvTimeoutError::Disconnected) => thread::sleep(dur),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
||||||
|
let base = cfg.settings.watch_interval.max(4);
|
||||||
|
notify(
|
||||||
|
"breadcrumbs watcher started",
|
||||||
|
"Monitoring Wi-Fi; will auto-recover drops.",
|
||||||
|
Urgency::Low,
|
||||||
|
);
|
||||||
|
log("watch: started");
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::channel::<()>();
|
||||||
|
spawn_nm_monitor(tx);
|
||||||
|
|
||||||
|
let mut profile = State::load(&cfg.settings.default_profile).profile;
|
||||||
|
if run_initial {
|
||||||
|
// Don't churn an already-working connection on (re)start.
|
||||||
|
let (h, _) = classify(&cfg, &profile);
|
||||||
|
if h == Health::Up {
|
||||||
|
log(&format!(
|
||||||
|
"watch: already healthy on start (profile={profile}); skipping initial flow"
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
log(&format!("watch: initial flow for profile={profile}"));
|
||||||
|
let _ = flow::run(&cfg, &profile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut prev_health: Option<Health> = None;
|
||||||
|
let mut prev_profile = profile.clone();
|
||||||
|
let mut fail_streak: u32 = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Reload config + state so edits and `profile set` take effect live.
|
||||||
|
if let Ok(fresh) = Config::load() {
|
||||||
|
cfg = fresh;
|
||||||
|
}
|
||||||
|
profile = State::load(&cfg.settings.default_profile).profile;
|
||||||
|
|
||||||
|
let profile_changed = profile != prev_profile;
|
||||||
|
if profile_changed {
|
||||||
|
log(&format!(
|
||||||
|
"watch: profile changed {prev_profile} -> {profile}"
|
||||||
|
));
|
||||||
|
notify(
|
||||||
|
"breadcrumbs: profile changed",
|
||||||
|
&format!("{prev_profile} -> {profile}"),
|
||||||
|
Urgency::Low,
|
||||||
|
);
|
||||||
|
prev_profile = profile.clone();
|
||||||
|
prev_health = None; // force re-evaluation/recovery for new profile
|
||||||
|
}
|
||||||
|
|
||||||
|
let (health, ssid) = classify(&cfg, &profile);
|
||||||
|
let transition = prev_health.as_ref() != Some(&health);
|
||||||
|
|
||||||
|
match &health {
|
||||||
|
Health::Up => {
|
||||||
|
if transition && prev_health.is_some() {
|
||||||
|
notify(
|
||||||
|
"breadcrumbs: back online",
|
||||||
|
&format!(
|
||||||
|
"{} ({profile})",
|
||||||
|
ssid.clone().unwrap_or_else(|| "Wi-Fi".into())
|
||||||
|
),
|
||||||
|
Urgency::Low,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fail_streak = 0;
|
||||||
|
}
|
||||||
|
Health::NoAdapter => {
|
||||||
|
if transition {
|
||||||
|
notify(
|
||||||
|
"breadcrumbs: no Wi-Fi adapter",
|
||||||
|
"Hardware issue — manual check needed.",
|
||||||
|
Urgency::Critical,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fail_streak = fail_streak.saturating_add(1);
|
||||||
|
}
|
||||||
|
Health::DownTailscaleManual => {
|
||||||
|
// Can't be auto-fixed (login / not installed). Notify once.
|
||||||
|
if transition {
|
||||||
|
notify(
|
||||||
|
"Tailscale Error",
|
||||||
|
"Tailscale needs manual attention (login / install). \
|
||||||
|
Other Wi-Fi automation paused until resolved.",
|
||||||
|
Urgency::Critical,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Re-run flow only on transition so we land on the bootstrap net.
|
||||||
|
if transition || profile_changed {
|
||||||
|
let _ = flow::run(&cfg, &profile);
|
||||||
|
}
|
||||||
|
fail_streak = fail_streak.saturating_add(1);
|
||||||
|
}
|
||||||
|
Health::DownNoNet | Health::DownTailscaleOther => {
|
||||||
|
if transition {
|
||||||
|
notify(
|
||||||
|
"breadcrumbs: connection lost",
|
||||||
|
&format!("Recovering ({profile})…"),
|
||||||
|
Urgency::Normal,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
log(&format!(
|
||||||
|
"watch: down ({:?}) profile={profile} ssid={:?} — running flow",
|
||||||
|
health, ssid
|
||||||
|
));
|
||||||
|
let outcome = flow::run(&cfg, &profile);
|
||||||
|
log(&format!("watch: recovery outcome = {:?}", outcome));
|
||||||
|
fail_streak = if outcome.ok() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
fail_streak.saturating_add(1)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prev_health = Some(health);
|
||||||
|
|
||||||
|
// Adaptive backoff: healthy -> base; failing -> grow up to ~6x.
|
||||||
|
let mult = 1 + fail_streak.min(5);
|
||||||
|
let dur = Duration::from_secs(base * mult as u64);
|
||||||
|
wait_for_tick(&rx, dur);
|
||||||
|
}
|
||||||
|
}
|
||||||
144
tests/cli.rs
Normal file
144
tests/cli.rs
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
//! End-to-end CLI tests. Each run is fully isolated: HOME / XDG dirs point at a
|
||||||
|
//! throwaway tempdir and PATH is emptied so no real `nmcli`/`tailscale`/`date`
|
||||||
|
//! is ever invoked and the host system is never touched.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
const BIN: &str = env!("CARGO_BIN_EXE_breadcrumbs");
|
||||||
|
|
||||||
|
static COUNTER: AtomicU32 = AtomicU32::new(0);
|
||||||
|
|
||||||
|
struct Sandbox {
|
||||||
|
root: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sandbox {
|
||||||
|
fn new() -> Sandbox {
|
||||||
|
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"breadcrumbs-it-{}-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
n,
|
||||||
|
nanos
|
||||||
|
));
|
||||||
|
fs::create_dir_all(root.join("bin")).unwrap();
|
||||||
|
Sandbox { root }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Binary invocation with an isolated, side-effect-free environment.
|
||||||
|
fn cmd(&self, args: &[&str]) -> std::process::Output {
|
||||||
|
Command::new(BIN)
|
||||||
|
.args(args)
|
||||||
|
.env_clear()
|
||||||
|
.env("HOME", &self.root)
|
||||||
|
.env("XDG_CONFIG_HOME", self.root.join("config"))
|
||||||
|
.env("XDG_STATE_HOME", self.root.join("state"))
|
||||||
|
// Empty bin dir => no external commands resolve.
|
||||||
|
.env("PATH", self.root.join("bin"))
|
||||||
|
.output()
|
||||||
|
.expect("failed to spawn breadcrumbs")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_file(&self) -> PathBuf {
|
||||||
|
self.root.join("config/breadcrumbs/breadcrumbs.toml")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Sandbox {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = fs::remove_dir_all(&self.root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stdout(o: &std::process::Output) -> String {
|
||||||
|
String::from_utf8_lossy(&o.stdout).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_lists_all_commands() {
|
||||||
|
let sb = Sandbox::new();
|
||||||
|
let o = sb.cmd(&["--help"]);
|
||||||
|
assert!(o.status.success());
|
||||||
|
let out = stdout(&o);
|
||||||
|
assert!(out.contains("Profile-aware Wi-Fi state machine"));
|
||||||
|
for c in [
|
||||||
|
"status",
|
||||||
|
"init",
|
||||||
|
"watch",
|
||||||
|
"profile",
|
||||||
|
"doctor",
|
||||||
|
"install-service",
|
||||||
|
] {
|
||||||
|
assert!(out.contains(c), "help missing `{c}`");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn version_prints_crate_version() {
|
||||||
|
let sb = Sandbox::new();
|
||||||
|
let o = sb.cmd(&["--version"]);
|
||||||
|
assert!(o.status.success());
|
||||||
|
assert!(stdout(&o).contains("breadcrumbs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_bootstraps_config_with_core_profiles() {
|
||||||
|
let sb = Sandbox::new();
|
||||||
|
let o = sb.cmd(&["list"]);
|
||||||
|
assert!(
|
||||||
|
o.status.success(),
|
||||||
|
"stderr: {}",
|
||||||
|
String::from_utf8_lossy(&o.stderr)
|
||||||
|
);
|
||||||
|
let out = stdout(&o);
|
||||||
|
|
||||||
|
// All three core profiles must be present on a fresh install.
|
||||||
|
assert!(out.contains("home") && out.contains("work") && out.contains("away"));
|
||||||
|
|
||||||
|
// Config was materialised on disk and is valid TOML.
|
||||||
|
let cfg = sb.config_file();
|
||||||
|
assert!(cfg.exists(), "config not created at {}", cfg.display());
|
||||||
|
let text = fs::read_to_string(&cfg).unwrap();
|
||||||
|
assert!(text.contains("[profiles.home]"));
|
||||||
|
assert!(text.contains("[profiles.work]"));
|
||||||
|
assert!(text.contains("[profiles.away]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_defaults_to_away_then_persists_set() {
|
||||||
|
let sb = Sandbox::new();
|
||||||
|
|
||||||
|
let o = sb.cmd(&["profile", "get"]);
|
||||||
|
assert!(o.status.success());
|
||||||
|
assert_eq!(stdout(&o).trim(), "away");
|
||||||
|
|
||||||
|
// `set --no-apply` must not touch the network (no nmcli available anyway).
|
||||||
|
let o = sb.cmd(&["profile", "set", "home", "--no-apply"]);
|
||||||
|
assert!(
|
||||||
|
o.status.success(),
|
||||||
|
"stderr: {}",
|
||||||
|
String::from_utf8_lossy(&o.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
let o = sb.cmd(&["profile", "get"]);
|
||||||
|
assert_eq!(stdout(&o).trim(), "home");
|
||||||
|
|
||||||
|
// Unknown profile is rejected.
|
||||||
|
let o = sb.cmd(&["profile", "set", "bogus"]);
|
||||||
|
assert!(!o.status.success());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_profile_override_is_reported() {
|
||||||
|
let sb = Sandbox::new();
|
||||||
|
let o = sb.cmd(&["--profile", "nope", "init"]);
|
||||||
|
assert!(!o.status.success());
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue