Compare commits
10 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fdac8e07c | ||
|
|
d3c1e19ba3 | ||
|
|
8aceab7857 | ||
|
|
d177cc8d82 | ||
|
|
6ec21a84aa | ||
|
|
d8ddf311e5 | ||
|
|
e484fd014a | ||
|
|
be53cb6cd6 | ||
|
|
f5c47a877e | ||
|
|
076e175672 |
19 changed files with 1844 additions and 219 deletions
21
.forgejo/workflows/mirror.yml
Normal file
21
.forgejo/workflows/mirror.yml
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
name: Mirror to GitHub
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
tags: ['**']
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: [self-hosted, hestia]
|
||||
steps:
|
||||
- name: Mirror to GitHub
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git
|
||||
cd repo.git
|
||||
# Mirror only branches and tags (not refs/pull/*, which GitHub rejects);
|
||||
# --prune deletes GitHub refs that no longer exist on Forgejo.
|
||||
git push --prune \
|
||||
"https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadcrumbs.git" \
|
||||
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'
|
||||
40
.forgejo/workflows/package.yml
Normal file
40
.forgejo/workflows/package.yml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
name: Build and publish package
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
jobs:
|
||||
package:
|
||||
runs-on: [self-hosted, hestia]
|
||||
container:
|
||||
image: archlinux:latest
|
||||
steps:
|
||||
# Note: no actions/checkout — the archlinux image has no Node, which JS
|
||||
# actions require. Everything runs as shell steps and clones manually.
|
||||
- name: Build and publish
|
||||
env:
|
||||
PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
pacman -Syu --noconfirm base-devel git rust cargo networkmanager
|
||||
useradd -m builder
|
||||
git config --global --add safe.directory '*'
|
||||
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
|
||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src
|
||||
cd /home/builder/src
|
||||
git archive --format=tar.gz --prefix="breadcrumbs-${VERSION}/" HEAD \
|
||||
> packaging/arch/breadcrumbs-${VERSION}.tar.gz
|
||||
SHA=$(sha256sum packaging/arch/breadcrumbs-${VERSION}.tar.gz | awk '{print $1}')
|
||||
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD
|
||||
sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD
|
||||
chown -R builder:builder /home/builder/src
|
||||
# --nocheck: packaging builds the artifact; tests belong in a CI job.
|
||||
su builder -c "cd /home/builder/src/packaging/arch && makepkg -f --noconfirm --nocheck"
|
||||
PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1)
|
||||
curl -fsS -X PUT \
|
||||
-H "Authorization: token ${PUBLISH_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@${PKG}" \
|
||||
"https://git.breadway.dev/api/packages/Breadway/arch/os"
|
||||
11
.github/workflows/release.yml
vendored
11
.github/workflows/release.yml
vendored
|
|
@ -9,7 +9,7 @@ permissions:
|
|||
|
||||
env:
|
||||
DL_DIR: /srv/breadway-dl
|
||||
ECOSYSTEM_DIR: /home/breadway/Projects/bread-ecosystem
|
||||
ECOSYSTEM_DIR: /tmp/bread-ecosystem-ci
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
|
@ -17,9 +17,6 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: install build deps
|
||||
run: sudo apt-get install -y libnm-dev libdbus-1-dev pkg-config 2>/dev/null || true
|
||||
|
||||
- name: build
|
||||
run: cargo build --release --locked
|
||||
|
||||
|
|
@ -41,12 +38,8 @@ jobs:
|
|||
|
||||
- name: ensure bread-ecosystem
|
||||
run: |
|
||||
if [[ -d "${ECOSYSTEM_DIR}/.git" ]]; then
|
||||
git -C "${ECOSYSTEM_DIR}" pull --ff-only
|
||||
else
|
||||
mkdir -p "$(dirname "${ECOSYSTEM_DIR}")"
|
||||
rm -rf "${ECOSYSTEM_DIR}"
|
||||
git clone https://github.com/Breadway/bread-ecosystem.git "${ECOSYSTEM_DIR}"
|
||||
fi
|
||||
|
||||
- name: regenerate index.json
|
||||
run: bash "${ECOSYSTEM_DIR}/scripts/gen-index.sh"
|
||||
|
|
|
|||
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -54,7 +54,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "breadcrumbs"
|
||||
version = "2.0.1"
|
||||
version = "2.1.1"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"serde",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "breadcrumbs"
|
||||
version = "2.0.1"
|
||||
version = "2.1.1"
|
||||
edition = "2021"
|
||||
description = "Profile-aware Wi-Fi state machine with Tailscale handling and self-healing watch daemon"
|
||||
license = "MIT"
|
||||
|
|
|
|||
12
README.md
12
README.md
|
|
@ -9,8 +9,10 @@ breadcrumbs sits on top of NetworkManager (`nmcli`) and manages your Wi-Fi based
|
|||
- **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
|
||||
- **Auto-detection** — scans visible SSIDs and guesses your location from config-defined markers (picks the profile with the most markers in range)
|
||||
- **Captive-portal detection** — distinguishes a real connection from a sign-in page and surfaces the portal URL instead of falsely reporting "online"
|
||||
- **Secure credential handling** — passwords fed to `nmcli` out-of-band (via stdin with `--ask`, or a 0600 `passwd-file`), never in argv/`ps`; config stored at 0600
|
||||
- **Machine-readable status** — `breadcrumbs status --json` for bars/scripts
|
||||
- **Desktop notifications** via `notify-send` (optional)
|
||||
- **systemd user service** generation via `breadcrumbs install-service`
|
||||
|
||||
|
|
@ -25,7 +27,7 @@ breadcrumbs sits on top of NetworkManager (`nmcli`) and manages your Wi-Fi based
|
|||
## Installation
|
||||
|
||||
```bash
|
||||
git clone https://github.com/breadway/breadcrumbs
|
||||
git clone https://github.com/Breadway/breadcrumbs
|
||||
cd breadcrumbs
|
||||
cargo build --release
|
||||
# Copy to somewhere on your PATH:
|
||||
|
|
@ -95,12 +97,14 @@ breadcrumbs [--profile <name>] <command>
|
|||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `status` | Show current Wi-Fi / Tailscale health (default) |
|
||||
| `status [--json]` | Show current Wi-Fi / Tailscale health (default); `--json` for scripts |
|
||||
| `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 |
|
||||
| `profile add <name> [--detect <ssid>]…` | Create a new (empty) profile, optionally with detection markers |
|
||||
| `profile remove <name>` | Delete a profile (core `home`/`work`/`away` are protected) |
|
||||
| `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 |
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ nmcli_wait = 8
|
|||
exit_node = "my-exit-node" # Tailscale hostname of your preferred exit node
|
||||
default_profile = "away"
|
||||
watch_interval = 12
|
||||
# Must be a "generate_204"-style endpoint: only an empty HTTP 204 counts as
|
||||
# online, so a captive portal (200 login page / 30x redirect) is detected.
|
||||
connectivity_url = "http://connectivitycheck.gstatic.com/generate_204"
|
||||
ping_host = "1.1.1.1"
|
||||
|
||||
|
|
|
|||
36
packaging/arch/PKGBUILD
Normal file
36
packaging/arch/PKGBUILD
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Maintainer: Breadway <rileyhorsham@gmail.com>
|
||||
|
||||
pkgname=breadcrumbs
|
||||
pkgver=2.1.0
|
||||
pkgrel=1
|
||||
pkgdesc="Profile-aware Wi-Fi state machine with Tailscale integration"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/Breadway/breadcrumbs"
|
||||
license=('MIT')
|
||||
# Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's
|
||||
# default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read,
|
||||
# causing undefined-symbol errors. Disable LTO.
|
||||
options=(!lto !debug)
|
||||
depends=('networkmanager')
|
||||
optdepends=(
|
||||
'tailscale: Tailscale VPN profile integration'
|
||||
)
|
||||
makedepends=('rust' 'cargo')
|
||||
source=("${pkgname}-${pkgver}.tar.gz")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
build() {
|
||||
cd "${srcdir}/${pkgname}-${pkgver}"
|
||||
cargo build --release --locked
|
||||
}
|
||||
|
||||
check() {
|
||||
cd "${srcdir}/${pkgname}-${pkgver}"
|
||||
cargo test --release --locked
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "${srcdir}/${pkgname}-${pkgver}"
|
||||
install -Dm755 target/release/breadcrumbs "${pkgdir}/usr/bin/breadcrumbs"
|
||||
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
||||
}
|
||||
80
src/backend.rs
Normal file
80
src/backend.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
//! The seam between breadcrumbs' decision logic and the outside world.
|
||||
//!
|
||||
//! Every interaction with NetworkManager, Tailscale, connectivity probes,
|
||||
//! notifications and logging goes through the [`Backend`] trait. Production code
|
||||
//! uses [`System`], which delegates to the `nm`/`tailscale`/`status`/`notify`
|
||||
//! modules that shell out. Tests inject a fake so the connect state machine
|
||||
//! (`flow`) and watch classifier can be exercised without touching the host.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::config::{Config, NetworkDef};
|
||||
use crate::nm;
|
||||
use crate::notify::{self, Urgency};
|
||||
use crate::status::{self, Connectivity};
|
||||
use crate::tailscale::{self, TsHealth};
|
||||
|
||||
pub trait Backend {
|
||||
fn wifi_interface(&self) -> Option<String>;
|
||||
fn radio_on(&self);
|
||||
fn rescan(&self, iface: &str, ssids: &[String]);
|
||||
fn visible_ssids(&self, iface: &str) -> HashSet<String>;
|
||||
fn active_ssid(&self, iface: &str) -> Option<String>;
|
||||
fn ipv4(&self, iface: &str) -> Option<String>;
|
||||
fn device_connected(&self, iface: &str) -> bool;
|
||||
fn connect(&self, iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> Result<(), String>;
|
||||
fn tailscale_installed(&self) -> bool;
|
||||
fn ensure_exit_node(&self, node: &str) -> TsHealth;
|
||||
fn tailscale_check(&self, node: &str) -> TsHealth;
|
||||
fn connectivity(&self, cfg: &Config) -> Connectivity;
|
||||
fn notify(&self, summary: &str, body: &str, urgency: Urgency);
|
||||
fn log(&self, line: &str);
|
||||
}
|
||||
|
||||
/// The real backend: every method delegates to the system-facing modules.
|
||||
pub struct System;
|
||||
|
||||
impl Backend for System {
|
||||
fn wifi_interface(&self) -> Option<String> {
|
||||
nm::wifi_interface()
|
||||
}
|
||||
fn radio_on(&self) {
|
||||
nm::radio_on()
|
||||
}
|
||||
fn rescan(&self, iface: &str, ssids: &[String]) {
|
||||
nm::rescan(iface, ssids)
|
||||
}
|
||||
fn visible_ssids(&self, iface: &str) -> HashSet<String> {
|
||||
nm::visible_ssids(iface)
|
||||
}
|
||||
fn active_ssid(&self, iface: &str) -> Option<String> {
|
||||
nm::active_ssid(iface)
|
||||
}
|
||||
fn ipv4(&self, iface: &str) -> Option<String> {
|
||||
status::ipv4(iface)
|
||||
}
|
||||
fn device_connected(&self, iface: &str) -> bool {
|
||||
nm::device_connected(iface)
|
||||
}
|
||||
fn connect(&self, iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> Result<(), String> {
|
||||
nm::connect_verbose(iface, net, wait, dns)
|
||||
}
|
||||
fn tailscale_installed(&self) -> bool {
|
||||
tailscale::installed()
|
||||
}
|
||||
fn ensure_exit_node(&self, node: &str) -> TsHealth {
|
||||
tailscale::ensure_exit_node(node)
|
||||
}
|
||||
fn tailscale_check(&self, node: &str) -> TsHealth {
|
||||
tailscale::check(node)
|
||||
}
|
||||
fn connectivity(&self, cfg: &Config) -> Connectivity {
|
||||
status::connectivity(cfg)
|
||||
}
|
||||
fn notify(&self, summary: &str, body: &str, urgency: Urgency) {
|
||||
notify::notify(summary, body, urgency)
|
||||
}
|
||||
fn log(&self, line: &str) {
|
||||
notify::log(line)
|
||||
}
|
||||
}
|
||||
131
src/config.rs
131
src/config.rs
|
|
@ -158,13 +158,10 @@ impl Config {
|
|||
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));
|
||||
}
|
||||
// Plaintext Wi-Fi passwords live here: write atomically and owner-only,
|
||||
// so there's no torn read and no world-readable window.
|
||||
crate::util::write_atomic(&path, &text, 0o600)
|
||||
.map_err(|e| format!("writing {}: {e}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -274,4 +271,124 @@ mod tests {
|
|||
assert!(cfg.profile("work").is_some());
|
||||
assert!(cfg.profile("away").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_core_profiles_does_not_overwrite_existing() {
|
||||
let mut cfg = Config {
|
||||
settings: Settings::default(),
|
||||
networks: vec![],
|
||||
profiles: BTreeMap::new(),
|
||||
};
|
||||
cfg.profiles.insert(
|
||||
"home".to_string(),
|
||||
Profile {
|
||||
tailscale: true,
|
||||
exit_node: Some("mynode".into()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
ensure_core_profiles(&mut cfg);
|
||||
let home = cfg.profile("home").unwrap();
|
||||
assert!(home.tailscale, "existing field should be preserved");
|
||||
assert_eq!(home.exit_node.as_deref(), Some("mynode"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_lookup_found_and_not_found() {
|
||||
let mut cfg = build_initial_config();
|
||||
cfg.networks.push(NetworkDef {
|
||||
ssid: "TestNet".into(),
|
||||
password: "secret".into(),
|
||||
hidden: false,
|
||||
});
|
||||
let found = cfg.network("TestNet");
|
||||
assert!(found.is_some());
|
||||
assert_eq!(found.unwrap().password, "secret");
|
||||
assert!(cfg.network("NoSuchSSID").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_lookup_found_and_not_found() {
|
||||
let cfg = build_initial_config();
|
||||
assert!(cfg.profile("home").is_some());
|
||||
assert!(cfg.profile("nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_default_values() {
|
||||
let s = Settings::default();
|
||||
assert_eq!(s.dns, "1.1.1.1");
|
||||
assert_eq!(s.nmcli_wait, 8);
|
||||
assert!(s.exit_node.is_empty());
|
||||
assert_eq!(s.default_profile, "away");
|
||||
assert_eq!(s.watch_interval, 12);
|
||||
assert!(!s.connectivity_url.is_empty());
|
||||
assert!(!s.ping_host.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_toml_roundtrip_with_hidden_network() {
|
||||
let mut cfg = build_initial_config();
|
||||
cfg.networks.push(NetworkDef {
|
||||
ssid: "HiddenNet".into(),
|
||||
password: "pw".into(),
|
||||
hidden: true,
|
||||
});
|
||||
cfg.networks.push(NetworkDef {
|
||||
ssid: "VisibleNet".into(),
|
||||
password: "pw2".into(),
|
||||
hidden: false,
|
||||
});
|
||||
let text = toml::to_string_pretty(&cfg).unwrap();
|
||||
let back: Config = toml::from_str(&text).unwrap();
|
||||
assert_eq!(back.networks.len(), 2);
|
||||
let hidden = back.network("HiddenNet").unwrap();
|
||||
assert!(hidden.hidden);
|
||||
let visible = back.network("VisibleNet").unwrap();
|
||||
assert!(!visible.hidden);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_toml_roundtrip_with_full_profile_fields() {
|
||||
let mut cfg = build_initial_config();
|
||||
let work = cfg.profiles.get_mut("work").unwrap();
|
||||
work.tailscale = true;
|
||||
work.exit_node = Some("myexit".into());
|
||||
work.bootstrap = Some("BootstrapSSID".into());
|
||||
work.detect_ssids = vec!["WorkWifi".into(), "CorpGuest".into()];
|
||||
work.networks = vec!["WorkWifi".into()];
|
||||
let text = toml::to_string_pretty(&cfg).unwrap();
|
||||
let back: Config = toml::from_str(&text).unwrap();
|
||||
let w = back.profile("work").unwrap();
|
||||
assert!(w.tailscale);
|
||||
assert_eq!(w.exit_node.as_deref(), Some("myexit"));
|
||||
assert_eq!(w.bootstrap.as_deref(), Some("BootstrapSSID"));
|
||||
assert_eq!(w.detect_ssids, vec!["WorkWifi", "CorpGuest"]);
|
||||
assert_eq!(w.networks, vec!["WorkWifi"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_deserialization_applies_settings_defaults_for_missing_fields() {
|
||||
let toml_str = r#"
|
||||
[settings]
|
||||
dns = "8.8.8.8"
|
||||
"#;
|
||||
let cfg: Config = toml::from_str(toml_str).unwrap();
|
||||
assert_eq!(cfg.settings.dns, "8.8.8.8");
|
||||
// Fields not specified should get their defaults.
|
||||
assert_eq!(cfg.settings.nmcli_wait, 8);
|
||||
assert_eq!(cfg.settings.default_profile, "away");
|
||||
assert_eq!(cfg.settings.watch_interval, 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_def_hidden_defaults_to_false() {
|
||||
let toml_str = r#"
|
||||
[[networks]]
|
||||
ssid = "MyNet"
|
||||
password = "pass"
|
||||
"#;
|
||||
let cfg: Config = toml::from_str(toml_str).unwrap();
|
||||
assert!(!cfg.networks[0].hidden);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
501
src/flow.rs
501
src/flow.rs
|
|
@ -1,8 +1,8 @@
|
|||
use crate::backend::Backend;
|
||||
use crate::config::{Config, NetworkDef};
|
||||
use crate::nm;
|
||||
use crate::notify::{log, notify, Urgency};
|
||||
use crate::status::internet_ok;
|
||||
use crate::tailscale::{self, TsHealth};
|
||||
use crate::notify::Urgency;
|
||||
use crate::status::Connectivity;
|
||||
use crate::tailscale::TsHealth;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Outcome {
|
||||
|
|
@ -48,20 +48,35 @@ fn resolve_candidates<'a>(cfg: &'a Config, p: &crate::config::Profile) -> Vec<&'
|
|||
|
||||
/// Try to connect + confirm it actually carries traffic.
|
||||
/// Returns Ok(()) on success, Err(reason) on failure.
|
||||
fn connect_and_verify(iface: &str, def: &NetworkDef, cfg: &Config) -> Result<(), String> {
|
||||
nm::connect_verbose(iface, def, cfg.settings.nmcli_wait, &cfg.settings.dns)?;
|
||||
if !nm::device_connected(iface) {
|
||||
fn connect_and_verify(
|
||||
be: &dyn Backend,
|
||||
iface: &str,
|
||||
def: &NetworkDef,
|
||||
cfg: &Config,
|
||||
) -> Result<(), String> {
|
||||
be.connect(iface, def, cfg.settings.nmcli_wait, &cfg.settings.dns)?;
|
||||
if !be.device_connected(iface) {
|
||||
return Err("device not connected after nmcli success".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Describe post-association connectivity as an optional caveat note.
|
||||
fn connectivity_note(be: &dyn Backend, cfg: &Config) -> Option<String> {
|
||||
match be.connectivity(cfg) {
|
||||
Connectivity::Online => None,
|
||||
Connectivity::Portal(Some(url)) => Some(format!("captive portal — sign in at {url}")),
|
||||
Connectivity::Portal(None) => Some("captive portal — sign in required".into()),
|
||||
Connectivity::Offline => Some("associated but no internet yet".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the connection state machine for `profile_name`.
|
||||
pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
|
||||
pub fn run(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Outcome {
|
||||
let profile = match cfg.profile(profile_name) {
|
||||
Some(p) => p.clone(),
|
||||
None => {
|
||||
notify(
|
||||
be.notify(
|
||||
"breadcrumbs: unknown profile",
|
||||
&format!("'{profile_name}' is not defined in breadcrumbs.toml"),
|
||||
Urgency::Critical,
|
||||
|
|
@ -70,10 +85,10 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
|
|||
}
|
||||
};
|
||||
|
||||
let iface = match nm::wifi_interface() {
|
||||
let iface = match be.wifi_interface() {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
notify(
|
||||
be.notify(
|
||||
"breadcrumbs: no Wi-Fi adapter",
|
||||
"Hardware issue — Wi-Fi device not found. Manual check needed.",
|
||||
Urgency::Critical,
|
||||
|
|
@ -81,7 +96,7 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
|
|||
return Outcome::NoInterface;
|
||||
}
|
||||
};
|
||||
nm::radio_on();
|
||||
be.radio_on();
|
||||
|
||||
let exit_node = profile
|
||||
.exit_node
|
||||
|
|
@ -89,7 +104,7 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
|
|||
.unwrap_or_else(|| cfg.settings.exit_node.clone());
|
||||
let candidates = resolve_candidates(cfg, &profile);
|
||||
|
||||
log(&format!(
|
||||
be.log(&format!(
|
||||
"flow start: profile={profile_name} iface={iface} tailscale={} candidates=[{}]",
|
||||
profile.tailscale,
|
||||
candidates
|
||||
|
|
@ -104,8 +119,8 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
|
|||
if let Some(bs) = &profile.bootstrap {
|
||||
scan_targets.push(bs.clone());
|
||||
}
|
||||
nm::rescan(&iface, &scan_targets);
|
||||
let visible = nm::visible_ssids(&iface);
|
||||
be.rescan(&iface, &scan_targets);
|
||||
let visible = be.visible_ssids(&iface);
|
||||
|
||||
// ---- Tailscale-gated profiles (e.g. school) -------------------------
|
||||
let mut on_bootstrap = false;
|
||||
|
|
@ -114,27 +129,29 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
|
|||
match cfg.network(&bs_ssid) {
|
||||
Some(bdef) => {
|
||||
if visible.contains(&bdef.ssid) || bdef.hidden {
|
||||
match connect_and_verify(&iface, bdef, cfg) {
|
||||
match connect_and_verify(be, &iface, bdef, cfg) {
|
||||
Ok(()) => {
|
||||
on_bootstrap = true;
|
||||
log(&format!("bootstrap connected: {}", bdef.ssid));
|
||||
be.log(&format!("bootstrap connected: {}", bdef.ssid));
|
||||
}
|
||||
Err(e) => {
|
||||
be.log(&format!("bootstrap connect failed: {} — {e}", bdef.ssid))
|
||||
}
|
||||
Err(e) => log(&format!("bootstrap connect failed: {} — {e}", bdef.ssid)),
|
||||
}
|
||||
} else {
|
||||
log(&format!("bootstrap not in range: {}", bdef.ssid));
|
||||
be.log(&format!("bootstrap not in range: {}", bdef.ssid));
|
||||
}
|
||||
}
|
||||
None => log(&format!(
|
||||
None => be.log(&format!(
|
||||
"bootstrap SSID '{bs_ssid}' has no credentials in config"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
let ts = tailscale::ensure_exit_node(&exit_node);
|
||||
let ts = be.ensure_exit_node(&exit_node);
|
||||
if !ts.is_ok() {
|
||||
let ssid = nm::active_ssid(&iface).or_else(|| profile.bootstrap.clone());
|
||||
notify(
|
||||
let ssid = be.active_ssid(&iface).or_else(|| profile.bootstrap.clone());
|
||||
be.notify(
|
||||
"Tailscale Error",
|
||||
&format!(
|
||||
"{} — staying on {}",
|
||||
|
|
@ -145,12 +162,12 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
|
|||
);
|
||||
return Outcome::TailscaleError { ssid, health: ts };
|
||||
}
|
||||
log(&format!("tailscale healthy via exit node {exit_node}"));
|
||||
be.log(&format!("tailscale healthy via exit node {exit_node}"));
|
||||
// Refresh visibility before moving to the target network.
|
||||
nm::rescan(&iface, &scan_targets);
|
||||
be.rescan(&iface, &scan_targets);
|
||||
}
|
||||
|
||||
let visible = nm::visible_ssids(&iface);
|
||||
let visible = be.visible_ssids(&iface);
|
||||
|
||||
// ---- Connect to the priority list ----------------------------------
|
||||
// Pass 1: visible networks in priority order.
|
||||
|
|
@ -158,20 +175,16 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
|
|||
for def in &candidates {
|
||||
if visible.contains(&def.ssid) {
|
||||
any_attempted = true;
|
||||
match connect_and_verify(&iface, def, cfg) {
|
||||
match connect_and_verify(be, &iface, def, cfg) {
|
||||
Ok(()) => {
|
||||
let note = if internet_ok(cfg) {
|
||||
None
|
||||
} else {
|
||||
Some("associated but no internet yet".to_string())
|
||||
};
|
||||
finish_connected(&def.ssid, profile_name, ¬e);
|
||||
let note = connectivity_note(be, cfg);
|
||||
finish_connected(be, &def.ssid, profile_name, ¬e);
|
||||
return Outcome::Connected {
|
||||
ssid: def.ssid.clone(),
|
||||
note,
|
||||
};
|
||||
}
|
||||
Err(e) => log(&format!("connect failed (visible): {} — {e}", def.ssid)),
|
||||
Err(e) => be.log(&format!("connect failed (visible): {} — {e}", def.ssid)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -179,20 +192,16 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
|
|||
for def in &candidates {
|
||||
if def.hidden && !visible.contains(&def.ssid) {
|
||||
any_attempted = true;
|
||||
match connect_and_verify(&iface, def, cfg) {
|
||||
match connect_and_verify(be, &iface, def, cfg) {
|
||||
Ok(()) => {
|
||||
let note = if internet_ok(cfg) {
|
||||
None
|
||||
} else {
|
||||
Some("associated but no internet yet".to_string())
|
||||
};
|
||||
finish_connected(&def.ssid, profile_name, ¬e);
|
||||
let note = connectivity_note(be, cfg);
|
||||
finish_connected(be, &def.ssid, profile_name, ¬e);
|
||||
return Outcome::Connected {
|
||||
ssid: def.ssid.clone(),
|
||||
note,
|
||||
};
|
||||
}
|
||||
Err(e) => log(&format!("connect failed (hidden): {} — {e}", def.ssid)),
|
||||
Err(e) => be.log(&format!("connect failed (hidden): {} — {e}", def.ssid)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -205,12 +214,12 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
|
|||
.bootstrap
|
||||
.clone()
|
||||
.unwrap_or_else(|| "bootstrap".into());
|
||||
if !nm::device_connected(&iface) {
|
||||
if !be.device_connected(&iface) {
|
||||
if let Some(bdef) = profile.bootstrap.as_deref().and_then(|s| cfg.network(s)) {
|
||||
match connect_and_verify(&iface, bdef, cfg) {
|
||||
Ok(()) => log(&format!("bootstrap reconnected: {}", bdef.ssid)),
|
||||
match connect_and_verify(be, &iface, bdef, cfg) {
|
||||
Ok(()) => be.log(&format!("bootstrap reconnected: {}", bdef.ssid)),
|
||||
Err(e) => {
|
||||
log(&format!("bootstrap reconnect failed: {} — {e}", bdef.ssid));
|
||||
be.log(&format!("bootstrap reconnect failed: {} — {e}", bdef.ssid));
|
||||
on_bootstrap = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -222,8 +231,8 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
|
|||
} else {
|
||||
format!("target network not in range — staying on {bs_ssid} (Tailscale OK)")
|
||||
};
|
||||
notify("breadcrumbs: using bootstrap", &reason, Urgency::Normal);
|
||||
log(&format!("flow end: on bootstrap {bs_ssid}; {reason}"));
|
||||
be.notify("breadcrumbs: using bootstrap", &reason, Urgency::Normal);
|
||||
be.log(&format!("flow end: on bootstrap {bs_ssid}; {reason}"));
|
||||
return Outcome::Connected {
|
||||
ssid: bs_ssid,
|
||||
note: Some(reason),
|
||||
|
|
@ -236,34 +245,34 @@ pub fn run(cfg: &Config, profile_name: &str) -> Outcome {
|
|||
.map(|c| c.ssid.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
notify(
|
||||
be.notify(
|
||||
"breadcrumbs: no known networks",
|
||||
&format!("profile '{profile_name}': none of [{names}] are in range"),
|
||||
Urgency::Critical,
|
||||
);
|
||||
log(&format!(
|
||||
be.log(&format!(
|
||||
"flow end: no networks connected (profile={profile_name})"
|
||||
));
|
||||
Outcome::NoNetworks
|
||||
}
|
||||
|
||||
fn finish_connected(ssid: &str, profile: &str, note: &Option<String>) {
|
||||
fn finish_connected(be: &dyn Backend, ssid: &str, profile: &str, note: &Option<String>) {
|
||||
match note {
|
||||
None => {
|
||||
notify(
|
||||
be.notify(
|
||||
"breadcrumbs: connected",
|
||||
&format!("{ssid} ({profile})"),
|
||||
Urgency::Low,
|
||||
);
|
||||
log(&format!("flow end: connected {ssid} (profile={profile})"));
|
||||
be.log(&format!("flow end: connected {ssid} (profile={profile})"));
|
||||
}
|
||||
Some(n) => {
|
||||
notify(
|
||||
be.notify(
|
||||
"breadcrumbs: connected (degraded)",
|
||||
&format!("{ssid} ({profile}) — {n}"),
|
||||
Urgency::Normal,
|
||||
);
|
||||
log(&format!(
|
||||
be.log(&format!(
|
||||
"flow end: connected {ssid} (profile={profile}) note={n}"
|
||||
));
|
||||
}
|
||||
|
|
@ -274,6 +283,7 @@ fn finish_connected(ssid: &str, profile: &str, note: &Option<String>) {
|
|||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{Profile, Settings};
|
||||
use crate::tailscale::TsHealth;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn net(ssid: &str) -> NetworkDef {
|
||||
|
|
@ -297,6 +307,341 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
// --- a scriptable in-memory Backend for testing the state machine ---
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashSet;
|
||||
|
||||
struct Fake {
|
||||
iface: Option<String>,
|
||||
visible: HashSet<String>,
|
||||
connectable: HashSet<String>,
|
||||
connected: RefCell<Option<String>>,
|
||||
ts: TsHealth,
|
||||
conn: Connectivity,
|
||||
notes: RefCell<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Fake {
|
||||
fn new() -> Fake {
|
||||
Fake {
|
||||
iface: Some("wlan0".into()),
|
||||
visible: HashSet::new(),
|
||||
connectable: HashSet::new(),
|
||||
connected: RefCell::new(None),
|
||||
ts: TsHealth::Ok,
|
||||
conn: Connectivity::Online,
|
||||
notes: RefCell::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
fn set(ssids: &[&str]) -> HashSet<String> {
|
||||
ssids.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
fn visible(mut self, ssids: &[&str]) -> Self {
|
||||
self.visible = Fake::set(ssids);
|
||||
self
|
||||
}
|
||||
fn connectable(mut self, ssids: &[&str]) -> Self {
|
||||
self.connectable = Fake::set(ssids);
|
||||
self
|
||||
}
|
||||
fn ts(mut self, h: TsHealth) -> Self {
|
||||
self.ts = h;
|
||||
self
|
||||
}
|
||||
fn conn(mut self, c: Connectivity) -> Self {
|
||||
self.conn = c;
|
||||
self
|
||||
}
|
||||
fn no_iface(mut self) -> Self {
|
||||
self.iface = None;
|
||||
self
|
||||
}
|
||||
fn notified(&self, needle: &str) -> bool {
|
||||
self.notes.borrow().iter().any(|n| n.contains(needle))
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for Fake {
|
||||
fn wifi_interface(&self) -> Option<String> {
|
||||
self.iface.clone()
|
||||
}
|
||||
fn radio_on(&self) {}
|
||||
fn rescan(&self, _: &str, _: &[String]) {}
|
||||
fn visible_ssids(&self, _: &str) -> HashSet<String> {
|
||||
self.visible.clone()
|
||||
}
|
||||
fn active_ssid(&self, _: &str) -> Option<String> {
|
||||
self.connected.borrow().clone()
|
||||
}
|
||||
fn ipv4(&self, _: &str) -> Option<String> {
|
||||
self.connected.borrow().as_ref().map(|_| "10.0.0.2".into())
|
||||
}
|
||||
fn device_connected(&self, _: &str) -> bool {
|
||||
self.connected.borrow().is_some()
|
||||
}
|
||||
fn connect(&self, _: &str, net: &NetworkDef, _: u32, _: &str) -> Result<(), String> {
|
||||
if self.connectable.contains(&net.ssid) {
|
||||
*self.connected.borrow_mut() = Some(net.ssid.clone());
|
||||
Ok(())
|
||||
} else {
|
||||
// A failed association drops the current link, like nmcli.
|
||||
*self.connected.borrow_mut() = None;
|
||||
Err(format!("cannot connect to {}", net.ssid))
|
||||
}
|
||||
}
|
||||
fn tailscale_installed(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn ensure_exit_node(&self, _: &str) -> TsHealth {
|
||||
self.ts.clone()
|
||||
}
|
||||
fn tailscale_check(&self, _: &str) -> TsHealth {
|
||||
self.ts.clone()
|
||||
}
|
||||
fn connectivity(&self, _: &Config) -> Connectivity {
|
||||
self.conn.clone()
|
||||
}
|
||||
fn notify(&self, summary: &str, _: &str, _: Urgency) {
|
||||
self.notes.borrow_mut().push(summary.to_string());
|
||||
}
|
||||
fn log(&self, _: &str) {}
|
||||
}
|
||||
|
||||
fn with_profile(name: &str, p: Profile) -> Config {
|
||||
let mut c = cfg();
|
||||
c.profiles.insert(name.to_string(), p);
|
||||
c
|
||||
}
|
||||
|
||||
fn connected(o: &Outcome) -> (&str, &Option<String>) {
|
||||
match o {
|
||||
Outcome::Connected { ssid, note } => (ssid.as_str(), note),
|
||||
other => panic!("expected Connected, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// --- flow::run state machine ---
|
||||
|
||||
#[test]
|
||||
fn run_unknown_profile_returns_unknown_and_notifies() {
|
||||
let be = Fake::new();
|
||||
let o = run(&be, &cfg(), "ghost");
|
||||
assert!(matches!(o, Outcome::UnknownProfile(p) if p == "ghost"));
|
||||
assert!(be.notified("unknown profile"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_no_interface_returns_no_interface() {
|
||||
let be = Fake::new().no_iface();
|
||||
let c = with_profile(
|
||||
"home",
|
||||
Profile {
|
||||
networks: vec!["HomeWifi".into()],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(matches!(run(&be, &c, "home"), Outcome::NoInterface));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_connects_to_visible_priority_network() {
|
||||
let c = with_profile(
|
||||
"home",
|
||||
Profile {
|
||||
networks: vec!["HomeWifi".into()],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let be = Fake::new()
|
||||
.visible(&["HomeWifi", "CafeWifi"])
|
||||
.connectable(&["HomeWifi"]);
|
||||
let o = run(&be, &c, "home");
|
||||
let (ssid, note) = connected(&o);
|
||||
assert_eq!(ssid, "HomeWifi");
|
||||
assert!(note.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_follows_priority_order() {
|
||||
let c = with_profile(
|
||||
"home",
|
||||
Profile {
|
||||
networks: vec!["WorkNet".into(), "HomeWifi".into()],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let be = Fake::new()
|
||||
.visible(&["WorkNet", "HomeWifi"])
|
||||
.connectable(&["WorkNet", "HomeWifi"]);
|
||||
assert_eq!(connected(&run(&be, &c, "home")).0, "WorkNet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_skips_failing_network_and_tries_next() {
|
||||
let c = with_profile(
|
||||
"home",
|
||||
Profile {
|
||||
networks: vec!["WorkNet".into(), "HomeWifi".into()],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let be = Fake::new()
|
||||
.visible(&["WorkNet", "HomeWifi"])
|
||||
.connectable(&["HomeWifi"]); // WorkNet fails
|
||||
assert_eq!(connected(&run(&be, &c, "home")).0, "HomeWifi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_no_networks_in_range_returns_no_networks() {
|
||||
let c = with_profile(
|
||||
"home",
|
||||
Profile {
|
||||
networks: vec!["HomeWifi".into()],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let be = Fake::new().visible(&["SomeoneElse"]);
|
||||
assert!(matches!(run(&be, &c, "home"), Outcome::NoNetworks));
|
||||
assert!(be.notified("no known networks"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_captive_portal_surfaces_as_note() {
|
||||
let c = with_profile(
|
||||
"home",
|
||||
Profile {
|
||||
networks: vec!["HomeWifi".into()],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let be = Fake::new()
|
||||
.visible(&["HomeWifi"])
|
||||
.connectable(&["HomeWifi"])
|
||||
.conn(Connectivity::Portal(Some("http://login.test".into())));
|
||||
let o = run(&be, &c, "home");
|
||||
let (_, note) = connected(&o);
|
||||
assert!(note.as_ref().unwrap().contains("captive portal"));
|
||||
assert!(note.as_ref().unwrap().contains("http://login.test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_offline_after_associate_is_degraded_note() {
|
||||
let c = with_profile(
|
||||
"home",
|
||||
Profile {
|
||||
networks: vec!["HomeWifi".into()],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let be = Fake::new()
|
||||
.visible(&["HomeWifi"])
|
||||
.connectable(&["HomeWifi"])
|
||||
.conn(Connectivity::Offline);
|
||||
let o = run(&be, &c, "home");
|
||||
let (_, note) = connected(&o);
|
||||
assert!(note.as_ref().unwrap().contains("no internet"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_tailscale_gated_moves_to_target_when_healthy() {
|
||||
let c = with_profile(
|
||||
"work",
|
||||
Profile {
|
||||
bootstrap: Some("CafeWifi".into()),
|
||||
networks: vec!["WorkNet".into()],
|
||||
tailscale: true,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let be = Fake::new()
|
||||
.visible(&["CafeWifi", "WorkNet"])
|
||||
.connectable(&["CafeWifi", "WorkNet"])
|
||||
.ts(TsHealth::Ok);
|
||||
assert_eq!(connected(&run(&be, &c, "work")).0, "WorkNet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_tailscale_unhealthy_stays_on_bootstrap() {
|
||||
let c = with_profile(
|
||||
"work",
|
||||
Profile {
|
||||
bootstrap: Some("CafeWifi".into()),
|
||||
networks: vec!["WorkNet".into()],
|
||||
tailscale: true,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let be = Fake::new()
|
||||
.visible(&["CafeWifi", "WorkNet"])
|
||||
.connectable(&["CafeWifi", "WorkNet"])
|
||||
.ts(TsHealth::NeedsLogin);
|
||||
match run(&be, &c, "work") {
|
||||
Outcome::TailscaleError { ssid, health } => {
|
||||
assert_eq!(ssid.as_deref(), Some("CafeWifi"));
|
||||
assert_eq!(health, TsHealth::NeedsLogin);
|
||||
}
|
||||
other => panic!("expected TailscaleError, got {other:?}"),
|
||||
}
|
||||
assert!(be.notified("Tailscale"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_tailscale_ok_but_target_out_of_range_keeps_bootstrap() {
|
||||
let c = with_profile(
|
||||
"work",
|
||||
Profile {
|
||||
bootstrap: Some("CafeWifi".into()),
|
||||
networks: vec!["WorkNet".into()],
|
||||
tailscale: true,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let be = Fake::new()
|
||||
.visible(&["CafeWifi"]) // WorkNet not in range
|
||||
.connectable(&["CafeWifi"])
|
||||
.ts(TsHealth::Ok);
|
||||
let o = run(&be, &c, "work");
|
||||
let (ssid, note) = connected(&o);
|
||||
assert_eq!(ssid, "CafeWifi");
|
||||
assert!(note.as_ref().unwrap().contains("not in range"));
|
||||
}
|
||||
|
||||
// --- Outcome::ok ---
|
||||
|
||||
#[test]
|
||||
fn outcome_ok_true_for_connected_with_and_without_note() {
|
||||
assert!(Outcome::Connected {
|
||||
ssid: "x".into(),
|
||||
note: None
|
||||
}
|
||||
.ok());
|
||||
assert!(Outcome::Connected {
|
||||
ssid: "x".into(),
|
||||
note: Some("associated but no internet yet".into()),
|
||||
}
|
||||
.ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outcome_ok_false_for_all_error_variants() {
|
||||
assert!(!Outcome::NoInterface.ok());
|
||||
assert!(!Outcome::NoNetworks.ok());
|
||||
assert!(!Outcome::UnknownProfile("p".into()).ok());
|
||||
assert!(!Outcome::TailscaleError {
|
||||
ssid: None,
|
||||
health: TsHealth::NeedsLogin,
|
||||
}
|
||||
.ok());
|
||||
assert!(!Outcome::TailscaleError {
|
||||
ssid: Some("boot".into()),
|
||||
health: TsHealth::ExitNodeOffline,
|
||||
}
|
||||
.ok());
|
||||
}
|
||||
|
||||
// --- resolve_candidates ---
|
||||
|
||||
#[test]
|
||||
fn candidates_follow_priority_order() {
|
||||
let c = cfg();
|
||||
|
|
@ -343,4 +688,50 @@ mod tests {
|
|||
.collect();
|
||||
assert_eq!(got, vec!["WorkNet"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidates_empty_when_profile_has_no_networks() {
|
||||
let c = cfg();
|
||||
let p = Profile::default();
|
||||
assert!(resolve_candidates(&c, &p).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidates_include_all_known_only_no_explicit_networks() {
|
||||
let c = cfg();
|
||||
let p = Profile {
|
||||
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.len(), 4);
|
||||
assert_eq!(got[0], "HomeWifi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidates_deduplicates_repeated_ssid_in_explicit_list() {
|
||||
let c = cfg();
|
||||
let p = Profile {
|
||||
networks: vec!["HomeWifi".into(), "HomeWifi".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!["HomeWifi", "WorkNet"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidates_all_unknown_ssids_returns_empty() {
|
||||
let c = cfg();
|
||||
let p = Profile {
|
||||
networks: vec!["Ghost1".into(), "Ghost2".into()],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(resolve_candidates(&c, &p).is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
254
src/main.rs
254
src/main.rs
|
|
@ -1,3 +1,4 @@
|
|||
mod backend;
|
||||
mod config;
|
||||
mod flow;
|
||||
mod nm;
|
||||
|
|
@ -14,6 +15,7 @@ use std::time::Duration;
|
|||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
use backend::Backend;
|
||||
use config::{Config, NetworkDef};
|
||||
use state::State;
|
||||
use util::{command_exists, home_dir, run};
|
||||
|
|
@ -44,7 +46,11 @@ struct Cli {
|
|||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Show current Wi-Fi / profile / Tailscale status (default)
|
||||
Status,
|
||||
Status {
|
||||
/// Emit machine-readable JSON instead of the human summary
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Run the full connect sequence for the active profile
|
||||
#[command(visible_aliases = ["up", "connect", "i"])]
|
||||
Init,
|
||||
|
|
@ -93,6 +99,20 @@ enum Cmd {
|
|||
#[arg(long)]
|
||||
show_passwords: bool,
|
||||
},
|
||||
/// Connect to a specific saved network by SSID, bypassing profile routing
|
||||
Join { ssid: String },
|
||||
/// List saved network SSIDs
|
||||
Networks {
|
||||
/// Emit a JSON array instead of one-per-line
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Scan for visible networks and list them with signal strength
|
||||
ScanList {
|
||||
/// Emit JSON instead of human-readable output
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
/// Open the config file in $EDITOR
|
||||
Edit,
|
||||
/// Quick connectivity / Tailscale diagnostics
|
||||
|
|
@ -126,6 +146,15 @@ enum ProfileCmd {
|
|||
},
|
||||
/// List available profiles
|
||||
List,
|
||||
/// Create a new (empty) profile
|
||||
Add {
|
||||
name: String,
|
||||
/// SSID whose presence marks this location (repeatable, for `detect`)
|
||||
#[arg(long = "detect")]
|
||||
detect: Vec<String>,
|
||||
},
|
||||
/// Delete a profile (core profiles home/work/away cannot be removed)
|
||||
Remove { name: String },
|
||||
}
|
||||
|
||||
fn main() {
|
||||
|
|
@ -148,7 +177,7 @@ fn active_profile(cfg: &Config, override_p: &Option<String>) -> String {
|
|||
}
|
||||
|
||||
fn real_main(cli: Cli) -> Result<i32, String> {
|
||||
let cmd = cli.cmd.unwrap_or(Cmd::Status);
|
||||
let cmd = cli.cmd.unwrap_or(Cmd::Status { json: false });
|
||||
|
||||
// `cd` and `install-service` don't need a parsed config first.
|
||||
if let Cmd::Cd { shell } = &cmd {
|
||||
|
|
@ -156,18 +185,19 @@ fn real_main(cli: Cli) -> Result<i32, String> {
|
|||
}
|
||||
|
||||
let mut cfg = Config::load()?;
|
||||
let be = backend::System;
|
||||
|
||||
match cmd {
|
||||
Cmd::Status => cmd_status(&cfg, &cli.profile),
|
||||
Cmd::Status { json } => cmd_status(&be, &cfg, &cli.profile, json),
|
||||
Cmd::Init => {
|
||||
let p = active_profile(&cfg, &cli.profile);
|
||||
let outcome = flow::run(&cfg, &p);
|
||||
let outcome = flow::run(&be, &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::Profile { action } => cmd_profile(&be, &mut cfg, action),
|
||||
Cmd::Detect { apply } => cmd_detect(&be, &cfg, apply),
|
||||
Cmd::Add {
|
||||
ssid,
|
||||
password,
|
||||
|
|
@ -176,10 +206,13 @@ fn real_main(cli: Cli) -> Result<i32, String> {
|
|||
at,
|
||||
} => cmd_add(&mut cfg, ssid, password, hidden, to, at),
|
||||
Cmd::Forget { ssid } => cmd_forget(&mut cfg, &ssid),
|
||||
Cmd::Join { ssid } => cmd_join(&be, &cfg, &ssid),
|
||||
Cmd::Networks { json } => cmd_networks(&cfg, json),
|
||||
Cmd::ScanList { json } => cmd_scan_list(&cfg, json),
|
||||
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::Doctor { full } => cmd_doctor(&be, &cfg, &cli.profile, full),
|
||||
Cmd::InstallService { no_enable } => cmd_install_service(!no_enable),
|
||||
Cmd::Cd { .. } => unreachable!(),
|
||||
}
|
||||
|
|
@ -213,9 +246,45 @@ fn print_outcome(profile: &str, o: &flow::Outcome) {
|
|||
}
|
||||
}
|
||||
|
||||
fn cmd_status(cfg: &Config, override_p: &Option<String>) -> Result<i32, String> {
|
||||
fn status_healthy(s: &status::Status) -> bool {
|
||||
s.internet
|
||||
&& s.iface.is_some()
|
||||
&& (!s.tailscale_required || s.tailscale.as_ref().map(|h| h.is_ok()).unwrap_or(false))
|
||||
}
|
||||
|
||||
fn cmd_status(
|
||||
be: &dyn Backend,
|
||||
cfg: &Config,
|
||||
override_p: &Option<String>,
|
||||
json: bool,
|
||||
) -> Result<i32, String> {
|
||||
let p = active_profile(cfg, override_p);
|
||||
let s = status::gather(cfg, &p);
|
||||
let s = status::gather(be, cfg, &p);
|
||||
let healthy = status_healthy(&s);
|
||||
|
||||
if json {
|
||||
let v = serde_json::json!({
|
||||
"profile": p,
|
||||
"adapter": s.iface,
|
||||
"ssid": s.ssid,
|
||||
"ip": s.ip,
|
||||
"internet": s.internet,
|
||||
"captive_portal": s.portal,
|
||||
"tailscale": {
|
||||
"required": s.tailscale_required,
|
||||
"installed": s.tailscale.is_some(),
|
||||
"ok": s.tailscale.as_ref().map(|h| h.is_ok()),
|
||||
"health": s.tailscale.as_ref().map(|h| h.describe()),
|
||||
"exit_node": s.exit_node,
|
||||
},
|
||||
"healthy": healthy,
|
||||
});
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".into())
|
||||
);
|
||||
return Ok(if healthy { 0 } else { 1 });
|
||||
}
|
||||
|
||||
let dot = |ok: bool| {
|
||||
if ok {
|
||||
|
|
@ -248,6 +317,14 @@ fn cmd_status(cfg: &Config, override_p: &Option<String>) -> Result<i32, String>
|
|||
dot(s.internet),
|
||||
if s.internet { "ok" } else { "down" }
|
||||
);
|
||||
if let Some(portal) = &s.portal {
|
||||
let detail = if portal.is_empty() {
|
||||
"sign-in required".to_string()
|
||||
} else {
|
||||
portal.clone()
|
||||
};
|
||||
println!(" portal {C_YELLOW}captive portal{C_RESET} {C_DIM}{detail}{C_RESET}");
|
||||
}
|
||||
|
||||
match (&s.tailscale, s.tailscale_required) {
|
||||
(Some(h), req) => {
|
||||
|
|
@ -263,9 +340,6 @@ fn cmd_status(cfg: &Config, override_p: &Option<String>) -> Result<i32, String>
|
|||
(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 {
|
||||
|
|
@ -277,7 +351,13 @@ fn cmd_status(cfg: &Config, override_p: &Option<String>) -> Result<i32, String>
|
|||
Ok(if healthy { 0 } else { 1 })
|
||||
}
|
||||
|
||||
fn cmd_profile(cfg: &Config, action: Option<ProfileCmd>) -> Result<i32, String> {
|
||||
const CORE_PROFILES: [&str; 3] = ["home", "work", "away"];
|
||||
|
||||
fn cmd_profile(
|
||||
be: &dyn Backend,
|
||||
cfg: &mut Config,
|
||||
action: Option<ProfileCmd>,
|
||||
) -> Result<i32, String> {
|
||||
match action.unwrap_or(ProfileCmd::Get) {
|
||||
ProfileCmd::Get => {
|
||||
println!("{}", State::load(&cfg.settings.default_profile).profile);
|
||||
|
|
@ -291,6 +371,34 @@ fn cmd_profile(cfg: &Config, action: Option<ProfileCmd>) -> Result<i32, String>
|
|||
}
|
||||
Ok(0)
|
||||
}
|
||||
ProfileCmd::Add { name, detect } => {
|
||||
if cfg.profiles.contains_key(&name) {
|
||||
return Err(format!("profile '{name}' already exists"));
|
||||
}
|
||||
cfg.profiles.insert(
|
||||
name.clone(),
|
||||
config::Profile {
|
||||
detect_ssids: detect,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
cfg.save()?;
|
||||
println!("{C_GREEN}added{C_RESET} profile {name}");
|
||||
Ok(0)
|
||||
}
|
||||
ProfileCmd::Remove { name } => {
|
||||
if CORE_PROFILES.contains(&name.as_str()) {
|
||||
return Err(format!(
|
||||
"'{name}' is a core profile and is always recreated; clear its networks instead"
|
||||
));
|
||||
}
|
||||
if cfg.profiles.remove(&name).is_none() {
|
||||
return Err(format!("unknown profile '{name}'"));
|
||||
}
|
||||
cfg.save()?;
|
||||
println!("{C_GREEN}removed{C_RESET} profile {name}");
|
||||
Ok(0)
|
||||
}
|
||||
ProfileCmd::Set { name, no_apply } => {
|
||||
if !cfg.profiles.contains_key(&name) {
|
||||
let avail: Vec<&String> = cfg.profiles.keys().collect();
|
||||
|
|
@ -306,40 +414,46 @@ fn cmd_profile(cfg: &Config, action: Option<ProfileCmd>) -> Result<i32, String>
|
|||
if no_apply {
|
||||
return Ok(0);
|
||||
}
|
||||
let outcome = flow::run(cfg, &name);
|
||||
let outcome = flow::run(be, 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);
|
||||
fn detect_profile(be: &dyn Backend, cfg: &Config) -> Option<String> {
|
||||
let iface = be.wifi_interface()?;
|
||||
be.radio_on();
|
||||
be.rescan(&iface, &[]);
|
||||
let visible = be.visible_ssids(&iface);
|
||||
|
||||
// Profiles are stored in a BTreeMap so iteration order is deterministic
|
||||
// (alphabetical). The caller can rely on that for tie-breaking.
|
||||
// Pick the profile with the most marker SSIDs in range, so overlapping
|
||||
// locations disambiguate by strength of evidence. Profiles iterate in
|
||||
// BTreeMap (alphabetical) order, which deterministically breaks ties.
|
||||
let mut best: Option<(usize, String)> = None;
|
||||
for (name, profile) in &cfg.profiles {
|
||||
if profile.detect_ssids.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if profile
|
||||
let score = profile
|
||||
.detect_ssids
|
||||
.iter()
|
||||
.any(|s| visible.contains(s.as_str()))
|
||||
{
|
||||
return Some(name.clone());
|
||||
.filter(|s| visible.contains(s.as_str()))
|
||||
.count();
|
||||
if score == 0 {
|
||||
continue;
|
||||
}
|
||||
if best.as_ref().map(|(s, _)| score > *s).unwrap_or(true) {
|
||||
best = Some((score, name.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to the default profile if no markers matched.
|
||||
Some(cfg.settings.default_profile.clone())
|
||||
Some(
|
||||
best.map(|(_, name)| name)
|
||||
.unwrap_or_else(|| cfg.settings.default_profile.clone()),
|
||||
)
|
||||
}
|
||||
|
||||
fn cmd_detect(cfg: &Config, apply: bool) -> Result<i32, String> {
|
||||
match detect_profile(cfg) {
|
||||
fn cmd_detect(be: &dyn Backend, cfg: &Config, apply: bool) -> Result<i32, String> {
|
||||
match detect_profile(be, cfg) {
|
||||
Some(p) => {
|
||||
println!("{p}");
|
||||
if apply {
|
||||
|
|
@ -348,7 +462,7 @@ fn cmd_detect(cfg: &Config, apply: bool) -> Result<i32, String> {
|
|||
updated: util::timestamp(),
|
||||
}
|
||||
.save()?;
|
||||
let outcome = flow::run(cfg, &p);
|
||||
let outcome = flow::run(be, cfg, &p);
|
||||
print_outcome(&p, &outcome);
|
||||
return Ok(if outcome.ok() { 0 } else { 1 });
|
||||
}
|
||||
|
|
@ -417,6 +531,65 @@ fn cmd_add(
|
|||
Ok(0)
|
||||
}
|
||||
|
||||
fn cmd_join(be: &dyn Backend, cfg: &Config, ssid: &str) -> Result<i32, String> {
|
||||
let net = cfg
|
||||
.network(ssid)
|
||||
.ok_or_else(|| format!("no saved network '{ssid}' — add it first with `breadcrumbs add {ssid}`"))?;
|
||||
let iface = be
|
||||
.wifi_interface()
|
||||
.ok_or_else(|| "no Wi-Fi adapter found".to_string())?;
|
||||
be.radio_on();
|
||||
match nm::connect_verbose(&iface, net, cfg.settings.nmcli_wait, &cfg.settings.dns) {
|
||||
Ok(()) => {
|
||||
println!("{C_GREEN}connected{C_RESET} {C_BOLD}{ssid}{C_RESET}");
|
||||
Ok(0)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("{C_RED}connect failed{C_RESET}: {e}");
|
||||
Ok(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_scan_list(cfg: &Config, json: bool) -> Result<i32, String> {
|
||||
let iface = nm::wifi_interface().ok_or("no Wi-Fi adapter found")?;
|
||||
let entries = nm::scan_list(&iface);
|
||||
let saved: std::collections::HashSet<&str> =
|
||||
cfg.networks.iter().map(|n| n.ssid.as_str()).collect();
|
||||
if json {
|
||||
let v: Vec<serde_json::Value> = entries
|
||||
.iter()
|
||||
.map(|e| {
|
||||
serde_json::json!({
|
||||
"ssid": e.ssid,
|
||||
"signal": e.signal,
|
||||
"security": e.security,
|
||||
"saved": saved.contains(e.ssid.as_str()),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
println!("{}", serde_json::to_string(&v).unwrap_or_else(|_| "[]".into()));
|
||||
} else {
|
||||
for e in &entries {
|
||||
let mark = if saved.contains(e.ssid.as_str()) { "*" } else { " " };
|
||||
println!("{mark} {:>3}% {} {}", e.signal, e.ssid, e.security);
|
||||
}
|
||||
}
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn cmd_networks(cfg: &Config, json: bool) -> Result<i32, String> {
|
||||
let ssids: Vec<&str> = cfg.networks.iter().map(|n| n.ssid.as_str()).collect();
|
||||
if json {
|
||||
println!("{}", serde_json::to_string(&ssids).unwrap_or_else(|_| "[]".into()));
|
||||
} else {
|
||||
for ssid in &ssids {
|
||||
println!("{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);
|
||||
|
|
@ -497,10 +670,14 @@ fn cmd_scan(cfg: &mut Config, to: Option<String>) -> Result<i32, String> {
|
|||
}
|
||||
|
||||
fn mask(p: &str) -> String {
|
||||
if p.len() <= 2 {
|
||||
// Count by characters, not bytes: slicing &p[..1] would panic on a
|
||||
// multi-byte first character (valid in WPA passphrases).
|
||||
let count = p.chars().count();
|
||||
if count <= 2 {
|
||||
"••".into()
|
||||
} else {
|
||||
format!("{}{}", &p[..1], "•".repeat(p.len().saturating_sub(1)))
|
||||
let first: String = p.chars().take(1).collect();
|
||||
format!("{}{}", first, "•".repeat(count - 1))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -573,7 +750,12 @@ fn cmd_edit() -> Result<i32, String> {
|
|||
}
|
||||
}
|
||||
|
||||
fn cmd_doctor(cfg: &Config, override_p: &Option<String>, full: bool) -> Result<i32, String> {
|
||||
fn cmd_doctor(
|
||||
be: &dyn Backend,
|
||||
cfg: &Config,
|
||||
override_p: &Option<String>,
|
||||
full: bool,
|
||||
) -> Result<i32, String> {
|
||||
if full {
|
||||
let script = config::config_dir().join("diag.sh");
|
||||
if !script.exists() {
|
||||
|
|
@ -590,7 +772,7 @@ fn cmd_doctor(cfg: &Config, override_p: &Option<String>, full: bool) -> Result<i
|
|||
}
|
||||
|
||||
let p = active_profile(cfg, override_p);
|
||||
let s = status::gather(cfg, &p);
|
||||
let s = status::gather(be, cfg, &p);
|
||||
println!("{C_BOLD}breadcrumbs doctor{C_RESET} (profile {p})");
|
||||
println!(
|
||||
" nmcli {}",
|
||||
|
|
|
|||
229
src/nm.rs
229
src/nm.rs
|
|
@ -1,8 +1,9 @@
|
|||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::config::NetworkDef;
|
||||
use crate::util::{run, run_ok};
|
||||
use crate::config::{state_dir, NetworkDef};
|
||||
use crate::util::{run, run_ok, run_with_stdin};
|
||||
|
||||
/// nmcli `-t` escapes `:` and `\` in field values; undo that.
|
||||
fn unescape(s: &str) -> String {
|
||||
|
|
@ -240,6 +241,19 @@ fn enforce_dns(uuid: &str, iface: &str, dns: &str) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Return true if `name` is NetworkManager's numbered-duplicate convention for
|
||||
/// `ssid`: exactly `ssid` followed by a space and one or more decimal digits
|
||||
/// (e.g. "MyNet 1", "MyNet 2"). A name like "MyNet1" (no space) is a distinct
|
||||
/// SSID and must not match.
|
||||
fn is_numbered_nm_duplicate(name: &str, ssid: &str) -> bool {
|
||||
if let Some(suffix) = name.strip_prefix(ssid) {
|
||||
if let Some(digits) = suffix.strip_prefix(' ') {
|
||||
return !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit());
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Return the name of the first saved NM connection profile whose name is
|
||||
/// either exactly `ssid` or `ssid N` (NM's numbered-duplicate convention).
|
||||
/// Returns `None` if no such profile exists.
|
||||
|
|
@ -254,26 +268,58 @@ fn first_profile_for_ssid(ssid: &str) -> Option<String> {
|
|||
}
|
||||
let mut fallback: Option<String> = None;
|
||||
for line in o.stdout.lines() {
|
||||
let parts: Vec<&str> = line.splitn(2, ':').collect();
|
||||
if parts.len() < 2 || !parts[1].contains("wireless") {
|
||||
// NAME may itself contain an (escaped) ':', so a plain splitn would
|
||||
// mis-split it — parse the line the same way nmcli escapes it.
|
||||
let fields = parse_scan_line(line);
|
||||
if fields.len() < 2 || !fields[1].contains("wireless") {
|
||||
continue;
|
||||
}
|
||||
let name = unescape(parts[0]);
|
||||
let name = fields[0].clone();
|
||||
if name == ssid {
|
||||
return Some(name);
|
||||
}
|
||||
if fallback.is_none() {
|
||||
if let Some(suffix) = name.strip_prefix(ssid) {
|
||||
let s = suffix.trim();
|
||||
if !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()) {
|
||||
if fallback.is_none() && is_numbered_nm_duplicate(&name, ssid) {
|
||||
fallback = Some(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fallback
|
||||
}
|
||||
|
||||
/// Write the PSK to a 0600 file in the `setting.property:value` format nmcli's
|
||||
/// `passwd-file` expects, so the secret reaches NetworkManager without ever
|
||||
/// appearing in argv (where any local user could read it via `ps`). Returns the
|
||||
/// path; the caller is responsible for removing it.
|
||||
fn write_psk_file(password: &str) -> Option<PathBuf> {
|
||||
use std::io::Write;
|
||||
// Prefer $XDG_RUNTIME_DIR (per-user tmpfs, mode 0700, wiped on logout) for a
|
||||
// transient secret; fall back to the on-disk state dir only if it's unset.
|
||||
let dir = std::env::var_os("XDG_RUNTIME_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(state_dir);
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let path = dir.join(format!("breadcrumbs.psk.{}", std::process::id()));
|
||||
let mut f = {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(&path)
|
||||
.ok()?
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
std::fs::File::create(&path).ok()?
|
||||
}
|
||||
};
|
||||
f.write_all(format!("802-11-wireless-security.psk:{password}\n").as_bytes())
|
||||
.ok()?;
|
||||
Some(path)
|
||||
}
|
||||
|
||||
/// Connect to a network and pin DNS. Returns true only if associated.
|
||||
pub fn connect(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> bool {
|
||||
connect_verbose(iface, net, wait, dns).is_ok()
|
||||
|
|
@ -290,20 +336,8 @@ pub fn connect_verbose(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> R
|
|||
let wait_s = wait.to_string();
|
||||
|
||||
if let Some(profile) = first_profile_for_ssid(&net.ssid) {
|
||||
// Update the saved PSK and, for hidden networks, ensure the flag is set.
|
||||
if !net.password.is_empty() {
|
||||
let _ = run(
|
||||
"nmcli",
|
||||
&[
|
||||
"connection",
|
||||
"modify",
|
||||
&profile,
|
||||
"802-11-wireless-security.psk",
|
||||
net.password.as_str(),
|
||||
],
|
||||
Duration::from_secs(6),
|
||||
);
|
||||
}
|
||||
// Ensure the hidden flag is set — this carries no secret, so passing it
|
||||
// in argv is safe.
|
||||
if net.hidden {
|
||||
let _ = run(
|
||||
"nmcli",
|
||||
|
|
@ -317,42 +351,64 @@ pub fn connect_verbose(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> R
|
|||
Duration::from_secs(6),
|
||||
);
|
||||
}
|
||||
let o = run(
|
||||
"nmcli",
|
||||
&["--wait", &wait_s, "connection", "up", &profile, "ifname", iface],
|
||||
Duration::from_secs(wait as u64 + 15),
|
||||
);
|
||||
if !o.success {
|
||||
let detail = o.stderr.trim().to_string();
|
||||
return Err(if detail.is_empty() {
|
||||
o.stdout.trim().to_string()
|
||||
// Supply the PSK to the activation via a 0600 passwd-file rather than
|
||||
// argv. Harmless if NM already has a matching secret stored.
|
||||
let psk_file = if net.password.is_empty() {
|
||||
None
|
||||
} else {
|
||||
detail
|
||||
});
|
||||
write_psk_file(&net.password)
|
||||
};
|
||||
let psk_path = psk_file.as_ref().map(|p| p.display().to_string());
|
||||
let mut args: Vec<&str> = vec![
|
||||
"--wait",
|
||||
&wait_s,
|
||||
"connection",
|
||||
"up",
|
||||
&profile,
|
||||
"ifname",
|
||||
iface,
|
||||
];
|
||||
if let Some(ref p) = psk_path {
|
||||
args.push("passwd-file");
|
||||
args.push(p.as_str());
|
||||
}
|
||||
let o = run("nmcli", &args, Duration::from_secs(wait as u64 + 15));
|
||||
if let Some(p) = psk_file {
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
if o.success {
|
||||
if let Some(uuid) = active_uuid(iface) {
|
||||
enforce_dns(&uuid, iface, dns);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// Activation failed (e.g. the saved secret is stale). Fall through to a
|
||||
// fresh connect below, which re-supplies the PSK over stdin.
|
||||
}
|
||||
|
||||
// No saved profile — create one via device wifi connect.
|
||||
// No saved profile (or reuse failed) — create/refresh via device wifi
|
||||
// connect. `--ask` makes nmcli read the PSK from stdin instead of argv.
|
||||
let hidden = if net.hidden { "yes" } else { "no" };
|
||||
let args = [
|
||||
"--ask",
|
||||
"--wait",
|
||||
&wait_s,
|
||||
"device",
|
||||
"wifi",
|
||||
"connect",
|
||||
net.ssid.as_str(),
|
||||
"password",
|
||||
net.password.as_str(),
|
||||
"hidden",
|
||||
hidden,
|
||||
"ifname",
|
||||
iface,
|
||||
];
|
||||
let o = run("nmcli", &args, Duration::from_secs(wait as u64 + 15));
|
||||
let stdin = format!("{}\n", net.password);
|
||||
let o = run_with_stdin(
|
||||
"nmcli",
|
||||
&args,
|
||||
Some(&stdin),
|
||||
Duration::from_secs(wait as u64 + 15),
|
||||
);
|
||||
if !o.success {
|
||||
let detail = o.stderr.trim().to_string();
|
||||
return Err(if detail.is_empty() {
|
||||
|
|
@ -380,15 +436,12 @@ pub fn delete_connections_for_ssid(ssid: &str) -> bool {
|
|||
}
|
||||
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") {
|
||||
// NAME may contain an escaped ':' — parse rather than naive-split.
|
||||
let fields = parse_scan_line(line);
|
||||
if fields.len() < 2 || !fields[1].contains("wireless") {
|
||||
continue;
|
||||
}
|
||||
let name = fields[0].clone();
|
||||
let conn_ssid = run(
|
||||
"nmcli",
|
||||
&["-g", "802-11-wireless.ssid", "connection", "show", &name],
|
||||
|
|
@ -420,6 +473,21 @@ mod tests {
|
|||
assert_eq!(unescape("trailing\\"), "trailing\\");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unescape_empty_string() {
|
||||
assert_eq!(unescape(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unescape_multiple_consecutive_escapes() {
|
||||
assert_eq!(unescape(r"a\:b\:c"), "a:b:c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unescape_double_backslash_produces_single() {
|
||||
assert_eq!(unescape(r"a\\b"), r"a\b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_scan_line_splits_and_unescapes() {
|
||||
// SSID:SIGNAL:SECURITY with an escaped ':' inside the SSID.
|
||||
|
|
@ -434,4 +502,71 @@ mod tests {
|
|||
let f = parse_scan_line(":40:WPA3");
|
||||
assert_eq!(f, vec!["", "40", "WPA3"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_scan_line_single_field_no_separators() {
|
||||
let f = parse_scan_line("OnlySSID");
|
||||
assert_eq!(f, vec!["OnlySSID"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_scan_line_empty_input_yields_one_empty_field() {
|
||||
let f = parse_scan_line("");
|
||||
assert_eq!(f, vec![""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_scan_line_all_empty_fields() {
|
||||
// Three colons → four empty fields.
|
||||
let f = parse_scan_line(":::");
|
||||
assert_eq!(f, vec!["", "", "", ""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_scan_line_multiple_escaped_colons_in_ssid() {
|
||||
let f = parse_scan_line(r"a\:b\:c:80:WPA3");
|
||||
assert_eq!(f, vec!["a:b:c", "80", "WPA3"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_scan_line_backslash_escape_then_colon_separator() {
|
||||
// "abc\:60:WPA2" — \: is an escaped colon inside the SSID, not a separator.
|
||||
let f = parse_scan_line(r"abc\:60:WPA2");
|
||||
assert_eq!(f, vec!["abc:60", "WPA2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_numbered_nm_duplicate_exact_match_is_not_duplicate() {
|
||||
assert!(!is_numbered_nm_duplicate("Net", "Net"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_numbered_nm_duplicate_space_digits_matches() {
|
||||
assert!(is_numbered_nm_duplicate("Net 1", "Net"));
|
||||
assert!(is_numbered_nm_duplicate("My Network 12", "My Network"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_numbered_nm_duplicate_no_space_does_not_match() {
|
||||
// "Net1" is a distinct SSID, not a numbered duplicate of "Net".
|
||||
assert!(!is_numbered_nm_duplicate("Net1", "Net"));
|
||||
assert!(!is_numbered_nm_duplicate("HomeWifi2", "HomeWifi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_numbered_nm_duplicate_non_numeric_suffix_does_not_match() {
|
||||
assert!(!is_numbered_nm_duplicate("Net foo", "Net"));
|
||||
assert!(!is_numbered_nm_duplicate("Net 1x", "Net"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_numbered_nm_duplicate_empty_digits_does_not_match() {
|
||||
// "Net " (trailing space only, no digits) must not match.
|
||||
assert!(!is_numbered_nm_duplicate("Net ", "Net"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_numbered_nm_duplicate_unrelated_name_does_not_match() {
|
||||
assert!(!is_numbered_nm_duplicate("OtherNet 1", "Net"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ impl State {
|
|||
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}"))
|
||||
crate::util::write_atomic(&state_path(), &text, 0o644)
|
||||
.map_err(|e| format!("writing state: {e}"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,31 @@
|
|||
use std::time::Duration;
|
||||
|
||||
use crate::backend::Backend;
|
||||
use crate::config::Config;
|
||||
use crate::nm;
|
||||
use crate::tailscale::{self, TsHealth};
|
||||
use crate::tailscale::TsHealth;
|
||||
use crate::util::{command_exists, run};
|
||||
|
||||
pub fn internet_ok(cfg: &Config) -> bool {
|
||||
/// Result of a connectivity probe. `Portal` distinguishes a captive portal
|
||||
/// (associated, but traffic is being intercepted) from real internet or a hard
|
||||
/// outage — the optional string is the portal's sign-in URL when known.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Connectivity {
|
||||
Online,
|
||||
Portal(Option<String>),
|
||||
Offline,
|
||||
}
|
||||
|
||||
impl Connectivity {
|
||||
pub fn online(&self) -> bool {
|
||||
matches!(self, Connectivity::Online)
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe connectivity. Only an empty HTTP 204 from the generate_204-style
|
||||
/// endpoint counts as online; a 200/redirect means a captive portal is
|
||||
/// intercepting traffic. If the HTTP probe is inconclusive (timeout/5xx) we fall
|
||||
/// back to ICMP, which reaching the host treats as online.
|
||||
pub fn connectivity(cfg: &Config) -> Connectivity {
|
||||
if command_exists("curl") {
|
||||
let o = run(
|
||||
"curl",
|
||||
|
|
@ -14,28 +34,45 @@ pub fn internet_ok(cfg: &Config) -> bool {
|
|||
"-o",
|
||||
"/dev/null",
|
||||
"-w",
|
||||
"%{http_code}",
|
||||
"%{http_code} %{redirect_url}",
|
||||
"--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;
|
||||
let mut parts = o.stdout.split_whitespace();
|
||||
let code = parts.next().unwrap_or("");
|
||||
let redirect = parts.next().unwrap_or("").trim();
|
||||
match code {
|
||||
"204" => return Connectivity::Online,
|
||||
"200" | "301" | "302" | "303" | "307" | "308" => {
|
||||
let url = if redirect.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(redirect.to_string())
|
||||
};
|
||||
return Connectivity::Portal(url);
|
||||
}
|
||||
// 000/timeout/5xx → inconclusive, try ICMP below.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Fallback: ICMP to the configured host.
|
||||
run(
|
||||
let ping = run(
|
||||
"ping",
|
||||
&["-c", "1", "-W", "2", &cfg.settings.ping_host],
|
||||
Duration::from_secs(4),
|
||||
)
|
||||
.success
|
||||
.success;
|
||||
if ping {
|
||||
Connectivity::Online
|
||||
} else {
|
||||
Connectivity::Offline
|
||||
}
|
||||
}
|
||||
|
||||
fn ipv4(iface: &str) -> Option<String> {
|
||||
/// Best-effort IPv4 address of `iface` via nmcli, with the CIDR prefix stripped.
|
||||
pub fn ipv4(iface: &str) -> Option<String> {
|
||||
let o = run(
|
||||
"nmcli",
|
||||
&["-g", "IP4.ADDRESS", "device", "show", iface],
|
||||
|
|
@ -48,7 +85,9 @@ fn ipv4(iface: &str) -> Option<String> {
|
|||
if s.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s.lines().next().unwrap_or(s).trim().to_string())
|
||||
// nmcli reports "192.168.1.5/24"; drop the prefix length for display.
|
||||
let first = s.lines().next().unwrap_or(s).trim();
|
||||
Some(first.split('/').next().unwrap_or(first).to_string())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -57,16 +96,24 @@ pub struct Status {
|
|||
pub ssid: Option<String>,
|
||||
pub ip: Option<String>,
|
||||
pub internet: bool,
|
||||
/// Set when a captive portal was detected; inner string is its URL if known.
|
||||
pub portal: Option<String>,
|
||||
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);
|
||||
pub fn gather(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Status {
|
||||
let iface = be.wifi_interface();
|
||||
let ssid = iface.as_deref().and_then(|i| be.active_ssid(i));
|
||||
let ip = iface.as_deref().and_then(|i| be.ipv4(i));
|
||||
|
||||
let conn = be.connectivity(cfg);
|
||||
let internet = conn.online();
|
||||
let portal = match conn {
|
||||
Connectivity::Portal(url) => Some(url.unwrap_or_default()),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let prof = cfg.profile(profile_name);
|
||||
let ts_required = prof.map(|p| p.tailscale).unwrap_or(false);
|
||||
|
|
@ -74,8 +121,8 @@ pub fn gather(cfg: &Config, profile_name: &str) -> Status {
|
|||
.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))
|
||||
let tailscale = if be.tailscale_installed() {
|
||||
Some(be.tailscale_check(&exit_node))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
|
@ -85,6 +132,7 @@ pub fn gather(cfg: &Config, profile_name: &str) -> Status {
|
|||
ssid,
|
||||
ip,
|
||||
internet,
|
||||
portal,
|
||||
tailscale_required: ts_required,
|
||||
tailscale,
|
||||
exit_node,
|
||||
|
|
|
|||
110
src/tailscale.rs
110
src/tailscale.rs
|
|
@ -313,6 +313,54 @@ mod tests {
|
|||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn ts_health_is_ok_only_for_ok_variant() {
|
||||
assert!(TsHealth::Ok.is_ok());
|
||||
assert!(!TsHealth::NotInstalled.is_ok());
|
||||
assert!(!TsHealth::NeedsLogin.is_ok());
|
||||
assert!(!TsHealth::Stopped.is_ok());
|
||||
assert!(!TsHealth::ExitNodeMissing.is_ok());
|
||||
assert!(!TsHealth::ExitNodeOffline.is_ok());
|
||||
assert!(!TsHealth::Error("x".into()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ts_health_describe_covers_all_variants() {
|
||||
assert_eq!(TsHealth::Ok.describe(), "ok");
|
||||
assert!(TsHealth::NotInstalled.describe().contains("not installed"));
|
||||
assert!(TsHealth::NeedsLogin.describe().contains("not logged in"));
|
||||
assert!(TsHealth::Stopped.describe().contains("stopped"));
|
||||
assert!(TsHealth::ExitNodeMissing.describe().contains("not found"));
|
||||
assert!(TsHealth::ExitNodeOffline.describe().contains("offline"));
|
||||
let msg = TsHealth::Error("boom".into()).describe();
|
||||
assert!(msg.contains("boom"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_url_finds_https_url() {
|
||||
assert_eq!(
|
||||
extract_url("To authenticate, visit https://login.tailscale.com/a/xxx"),
|
||||
Some("https://login.tailscale.com/a/xxx".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_url_returns_none_when_no_url() {
|
||||
assert_eq!(extract_url("Waiting for login..."), None);
|
||||
assert_eq!(extract_url(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_url_picks_first_https_token() {
|
||||
let line = "Try https://first.example.com https://second.example.com";
|
||||
assert_eq!(extract_url(line), Some("https://first.example.com".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_url_does_not_match_plain_http() {
|
||||
assert_eq!(extract_url("see http://example.com for info"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_state_extraction() {
|
||||
assert_eq!(
|
||||
|
|
@ -322,6 +370,13 @@ mod tests {
|
|||
assert_eq!(backend_state(&json!({})), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_state_all_known_values() {
|
||||
for state in ["Running", "NeedsLogin", "NoState", "Stopped"] {
|
||||
assert_eq!(backend_state(&json!({"BackendState": state})), state);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_node_healthy_and_selected() {
|
||||
let v = json!({
|
||||
|
|
@ -381,4 +436,59 @@ mod tests {
|
|||
});
|
||||
assert_eq!(exit_node_state(&v, "exitnode"), (true, true, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_node_state_empty_peer_map() {
|
||||
let v = json!({ "BackendState": "Running", "Peer": {} });
|
||||
assert_eq!(exit_node_state(&v, "anynode"), (false, false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_node_state_no_peer_field() {
|
||||
let v = json!({ "BackendState": "Running" });
|
||||
assert_eq!(exit_node_state(&v, "anynode"), (false, false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_node_state_case_insensitive_hostname() {
|
||||
let v = json!({
|
||||
"Peer": {
|
||||
"k1": { "HostName": "MYNODE", "DNSName": "mynode.ts.net.",
|
||||
"Online": true, "ExitNode": true, "ExitNodeOption": true }
|
||||
}
|
||||
});
|
||||
let (exists, online, selected) = exit_node_state(&v, "mynode");
|
||||
assert!(exists && online && selected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_node_status_overrides_peer_online_when_selected() {
|
||||
// ExitNodeStatus.Online=false should override the peer's Online=true
|
||||
// when the peer is the currently-selected exit node.
|
||||
let v = json!({
|
||||
"ExitNodeStatus": { "Online": false },
|
||||
"Peer": {
|
||||
"k1": { "HostName": "exitnode", "DNSName": "exitnode.ts.net.",
|
||||
"Online": true, "ExitNode": true, "ExitNodeOption": true }
|
||||
}
|
||||
});
|
||||
let (exists, online, selected) = exit_node_state(&v, "exitnode");
|
||||
assert!(exists);
|
||||
assert!(
|
||||
!online,
|
||||
"ExitNodeStatus.Online=false should override peer Online"
|
||||
);
|
||||
assert!(selected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_node_state_wrong_node_name_not_matched() {
|
||||
let v = json!({
|
||||
"Peer": {
|
||||
"k1": { "HostName": "othernode", "DNSName": "othernode.ts.net.",
|
||||
"Online": true, "ExitNode": true, "ExitNodeOption": true }
|
||||
}
|
||||
});
|
||||
assert_eq!(exit_node_state(&v, "exitnode"), (false, false, false));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
115
src/util.rs
115
src/util.rs
|
|
@ -1,5 +1,6 @@
|
|||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
|
@ -10,6 +11,42 @@ pub fn home_dir() -> PathBuf {
|
|||
.unwrap_or_else(|| PathBuf::from("/root"))
|
||||
}
|
||||
|
||||
/// Atomically replace `path` with `contents`: write a sibling temp file (created
|
||||
/// with `mode` on unix) and `rename` it over the target. Avoids torn reads by a
|
||||
/// concurrent reader (the watch daemon reloads config every tick) and never
|
||||
/// leaves a half-written file behind on crash. Because the temp file is created
|
||||
/// with `mode` up front, secrets never exist world-readable even briefly.
|
||||
pub fn write_atomic(path: &Path, contents: &str, mode: u32) -> std::io::Result<()> {
|
||||
let dir = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
fs::create_dir_all(dir)?;
|
||||
let stem = path
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("breadcrumbs");
|
||||
let tmp = dir.join(format!(".{stem}.tmp.{}", std::process::id()));
|
||||
|
||||
let mut open = fs::OpenOptions::new();
|
||||
open.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
open.mode(mode);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = mode;
|
||||
|
||||
let res = (|| {
|
||||
let mut f = open.open(&tmp)?;
|
||||
f.write_all(contents.as_bytes())?;
|
||||
f.sync_all()?;
|
||||
fs::rename(&tmp, path)
|
||||
})();
|
||||
if res.is_err() {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
pub fn command_exists(name: &str) -> bool {
|
||||
if let Some(paths) = std::env::var_os("PATH") {
|
||||
for dir in std::env::split_paths(&paths) {
|
||||
|
|
@ -55,6 +92,11 @@ pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: D
|
|||
};
|
||||
let mut child = match Command::new(prog)
|
||||
.args(args)
|
||||
// Pin the C locale so message text we parse (nmcli states, monitor
|
||||
// lines) is stable English regardless of the user's LANG. SSID/value
|
||||
// bytes are unaffected.
|
||||
.env("LC_ALL", "C")
|
||||
.env("LANG", "C")
|
||||
.stdin(stdin_cfg)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
|
|
@ -64,13 +106,6 @@ pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: D
|
|||
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();
|
||||
|
||||
|
|
@ -89,6 +124,16 @@ pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: D
|
|||
buf
|
||||
});
|
||||
|
||||
// Feed stdin only after the reader threads are draining stdout/stderr, so a
|
||||
// child that writes more than a pipe buffer before consuming stdin can't
|
||||
// deadlock against our blocking write.
|
||||
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 start = Instant::now();
|
||||
let status = loop {
|
||||
match child.try_wait() {
|
||||
|
|
@ -176,4 +221,58 @@ mod tests {
|
|||
// Leap day 2024-02-29 12:00:00 UTC
|
||||
assert_eq!(fmt_epoch(1_709_208_000), "2024-02-29 12:00:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmt_epoch_year_2000_century_divisible_by_400_leap() {
|
||||
// 2000-01-01 00:00:00 UTC — divisible by 400, so it IS a leap year.
|
||||
assert_eq!(fmt_epoch(946_684_800), "2000-01-01 00:00:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmt_epoch_end_of_year_boundary() {
|
||||
// 2023-12-31 23:59:59 UTC
|
||||
assert_eq!(fmt_epoch(1_704_067_199), "2023-12-31 23:59:59");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmt_epoch_negative_before_unix_epoch() {
|
||||
// 1969-12-31 23:59:59 UTC
|
||||
assert_eq!(fmt_epoch(-1), "1969-12-31 23:59:59");
|
||||
// 1969-12-31 00:00:00 UTC
|
||||
assert_eq!(fmt_epoch(-86_400), "1969-12-31 00:00:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmt_epoch_february_non_leap_year_boundary() {
|
||||
// 2023-02-28 00:00:00 UTC (2023 is not a leap year)
|
||||
assert_eq!(fmt_epoch(1_677_542_400), "2023-02-28 00:00:00");
|
||||
// 2023-03-01 00:00:00 UTC — next day after Feb 28 in non-leap year
|
||||
assert_eq!(fmt_epoch(1_677_628_800), "2023-03-01 00:00:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmt_epoch_century_non_leap_year_1900_equivalent() {
|
||||
// 1900 is NOT a leap year (div by 100 but not 400).
|
||||
// 1900-03-01 00:00:00 UTC: days from epoch = (1900-1970)*365.25 ≈ use known anchor.
|
||||
// 2100-02-28 00:00:00 UTC = epoch 4107456000; next day is Mar 1 (not Feb 29).
|
||||
// We verify via the leap day boundary: 2100-02-28 + 86400 must be 2100-03-01.
|
||||
assert_eq!(fmt_epoch(4_107_456_000), "2100-02-28 00:00:00");
|
||||
assert_eq!(fmt_epoch(4_107_456_000 + 86_400), "2100-03-01 00:00:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmt_epoch_midnight_vs_end_of_day() {
|
||||
// 2022-06-15 00:00:00 UTC
|
||||
assert_eq!(fmt_epoch(1_655_251_200), "2022-06-15 00:00:00");
|
||||
// 2022-06-15 23:59:59 UTC
|
||||
assert_eq!(fmt_epoch(1_655_337_599), "2022-06-15 23:59:59");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmt_epoch_time_of_day_components() {
|
||||
// 1970-01-01 01:02:03 UTC
|
||||
assert_eq!(fmt_epoch(3723), "1970-01-01 01:02:03");
|
||||
// 1970-01-01 23:59:59 UTC
|
||||
assert_eq!(fmt_epoch(86_399), "1970-01-01 23:59:59");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
69
src/watch.rs
69
src/watch.rs
|
|
@ -4,6 +4,7 @@ use std::sync::mpsc::{self, Receiver};
|
|||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::backend::{Backend, System};
|
||||
use crate::config::Config;
|
||||
use crate::flow;
|
||||
use crate::notify::{log, notify, Urgency};
|
||||
|
|
@ -15,32 +16,34 @@ use crate::tailscale::TsHealth;
|
|||
enum Health {
|
||||
Up,
|
||||
DownNoNet,
|
||||
/// Associated, but a captive portal is intercepting traffic (manual login).
|
||||
CaptivePortal,
|
||||
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 {
|
||||
fn classify(be: &dyn Backend, cfg: &Config, profile: &str) -> (Health, status::Status) {
|
||||
let s = status::gather(be, cfg, profile);
|
||||
let health = if s.iface.is_none() {
|
||||
Health::NoAdapter
|
||||
} else if s.portal.is_some() {
|
||||
Health::CaptivePortal
|
||||
} else if !s.internet {
|
||||
Health::DownNoNet
|
||||
} else if s.tailscale_required {
|
||||
match s.tailscale {
|
||||
Some(TsHealth::Ok) => (Health::Up, ssid),
|
||||
Some(TsHealth::Ok) => Health::Up,
|
||||
Some(TsHealth::NeedsLogin) | Some(TsHealth::NotInstalled) => {
|
||||
(Health::DownTailscaleManual, ssid)
|
||||
Health::DownTailscaleManual
|
||||
}
|
||||
Some(_) => (Health::DownTailscaleOther, ssid),
|
||||
None => (Health::DownTailscaleManual, ssid),
|
||||
Some(_) => Health::DownTailscaleOther,
|
||||
None => Health::DownTailscaleManual,
|
||||
}
|
||||
} else {
|
||||
(Health::Up, ssid)
|
||||
}
|
||||
Health::Up
|
||||
};
|
||||
(health, s)
|
||||
}
|
||||
|
||||
/// Tail `nmcli monitor` and ping the channel on link-state churn so we react
|
||||
|
|
@ -65,9 +68,8 @@ fn spawn_nm_monitor(tx: mpsc::Sender<()>) {
|
|||
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("failed");
|
||||
let interesting =
|
||||
l.contains("disconnect") || l.contains("unavailable") || l.contains("failed");
|
||||
if interesting && last.elapsed() > Duration::from_millis(1500) {
|
||||
last = Instant::now();
|
||||
let _ = tx.send(());
|
||||
|
|
@ -106,17 +108,18 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
|||
let (tx, rx) = mpsc::channel::<()>();
|
||||
spawn_nm_monitor(tx);
|
||||
|
||||
let be = System;
|
||||
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);
|
||||
let (h, _) = classify(&be, &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 _ = flow::run(&be, &cfg, &profile);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -148,7 +151,8 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
|||
last_flow_at = None; // allow immediate recovery on profile change
|
||||
}
|
||||
|
||||
let (health, ssid) = classify(&cfg, &profile);
|
||||
let (health, s) = classify(&be, &cfg, &profile);
|
||||
let ssid = s.ssid.clone();
|
||||
let transition = prev_health.as_ref() != Some(&health);
|
||||
|
||||
match &health {
|
||||
|
|
@ -165,6 +169,18 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
|||
}
|
||||
fail_streak = 0;
|
||||
}
|
||||
Health::CaptivePortal => {
|
||||
// Associated but gated behind a sign-in page we can't automate;
|
||||
// notify once and don't hammer flow (reconnecting won't help).
|
||||
if transition {
|
||||
let body = match s.portal.as_deref().filter(|u| !u.is_empty()) {
|
||||
Some(url) => format!("Sign in to continue: {url}"),
|
||||
None => format!("Sign in to continue ({profile})."),
|
||||
};
|
||||
notify("breadcrumbs: captive portal", &body, Urgency::Normal);
|
||||
}
|
||||
fail_streak = 0;
|
||||
}
|
||||
Health::NoAdapter => {
|
||||
if transition {
|
||||
notify(
|
||||
|
|
@ -187,7 +203,8 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
|||
}
|
||||
// Re-run flow only on transition so we land on the bootstrap net.
|
||||
if transition || profile_changed {
|
||||
let _ = flow::run(&cfg, &profile);
|
||||
let _ = flow::run(&be, &cfg, &profile);
|
||||
last_flow_at = Some(Instant::now());
|
||||
}
|
||||
fail_streak = fail_streak.saturating_add(1);
|
||||
}
|
||||
|
|
@ -199,13 +216,15 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
|||
Urgency::Normal,
|
||||
);
|
||||
}
|
||||
let elapsed = last_flow_at.map(|t| t.elapsed().as_secs()).unwrap_or(u64::MAX);
|
||||
let elapsed = last_flow_at
|
||||
.map(|t| t.elapsed().as_secs())
|
||||
.unwrap_or(u64::MAX);
|
||||
if elapsed >= FLOW_COOLDOWN {
|
||||
log(&format!(
|
||||
"watch: down ({:?}) profile={profile} ssid={:?} — running flow",
|
||||
health, ssid
|
||||
));
|
||||
let outcome = flow::run(&cfg, &profile);
|
||||
let outcome = flow::run(&be, &cfg, &profile);
|
||||
log(&format!("watch: recovery outcome = {:?}", outcome));
|
||||
last_flow_at = Some(Instant::now());
|
||||
fail_streak = if outcome.ok() {
|
||||
|
|
|
|||
347
tests/cli.rs
347
tests/cli.rs
|
|
@ -142,3 +142,350 @@ fn unknown_profile_override_is_reported() {
|
|||
let o = sb.cmd(&["--profile", "nope", "init"]);
|
||||
assert!(!o.status.success());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_list_marks_active_profile() {
|
||||
let sb = Sandbox::new();
|
||||
|
||||
// Default profile is "away".
|
||||
let o = sb.cmd(&["profile", "list"]);
|
||||
assert!(o.status.success());
|
||||
let out = stdout(&o);
|
||||
// The active profile line starts with "* ".
|
||||
assert!(
|
||||
out.lines().any(|l| l.starts_with("* away")),
|
||||
"active profile marker missing: {out}"
|
||||
);
|
||||
// Inactive profiles start with " ".
|
||||
assert!(
|
||||
out.lines().any(|l| l.starts_with(" home")),
|
||||
"inactive profile format wrong: {out}"
|
||||
);
|
||||
|
||||
// After switching to "work" the marker should move.
|
||||
sb.cmd(&["profile", "set", "work", "--no-apply"]);
|
||||
let o = sb.cmd(&["profile", "list"]);
|
||||
let out = stdout(&o);
|
||||
assert!(out.lines().any(|l| l.starts_with("* work")));
|
||||
assert!(out.lines().any(|l| l.starts_with(" away")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_saves_network_to_config() {
|
||||
let sb = Sandbox::new();
|
||||
|
||||
let o = sb.cmd(&["add", "CafeWifi", "mypassword"]);
|
||||
assert!(
|
||||
o.status.success(),
|
||||
"stderr: {}",
|
||||
String::from_utf8_lossy(&o.stderr)
|
||||
);
|
||||
assert!(stdout(&o).contains("saved"));
|
||||
|
||||
// The network should now appear in the config file.
|
||||
let text = fs::read_to_string(sb.config_file()).unwrap();
|
||||
assert!(text.contains("CafeWifi"), "SSID missing from config");
|
||||
assert!(text.contains("mypassword"), "password missing from config");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_attaches_ssid_to_profile_at_position() {
|
||||
let sb = Sandbox::new();
|
||||
|
||||
// First add without attaching, then add again with --to.
|
||||
sb.cmd(&["add", "HomeWifi", "pw1"]);
|
||||
let o = sb.cmd(&["add", "HomeWifi", "pw1", "--to", "home"]);
|
||||
assert!(
|
||||
o.status.success(),
|
||||
"stderr: {}",
|
||||
String::from_utf8_lossy(&o.stderr)
|
||||
);
|
||||
|
||||
let text = fs::read_to_string(sb.config_file()).unwrap();
|
||||
// After attaching, the home profile's networks list should contain HomeWifi.
|
||||
assert!(
|
||||
text.contains("HomeWifi"),
|
||||
"SSID not found in config after --to: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_to_unknown_profile_fails() {
|
||||
let sb = Sandbox::new();
|
||||
let o = sb.cmd(&["add", "SomeNet", "pw", "--to", "nonexistent"]);
|
||||
assert!(!o.status.success());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_updates_existing_network_password() {
|
||||
let sb = Sandbox::new();
|
||||
|
||||
sb.cmd(&["add", "MyNet", "oldpass"]);
|
||||
let o = sb.cmd(&["add", "MyNet", "newpass"]);
|
||||
assert!(o.status.success());
|
||||
|
||||
let text = fs::read_to_string(sb.config_file()).unwrap();
|
||||
assert!(text.contains("newpass"), "updated password missing");
|
||||
// Old password must be gone.
|
||||
assert!(!text.contains("oldpass"), "old password still present");
|
||||
// Only one entry for MyNet.
|
||||
assert_eq!(
|
||||
text.matches("MyNet").count(),
|
||||
1,
|
||||
"duplicate network entries"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forget_removes_network_from_config() {
|
||||
let sb = Sandbox::new();
|
||||
|
||||
sb.cmd(&["add", "ToDelete", "pw"]);
|
||||
let text = fs::read_to_string(sb.config_file()).unwrap();
|
||||
assert!(text.contains("ToDelete"));
|
||||
|
||||
let o = sb.cmd(&["forget", "ToDelete"]);
|
||||
assert!(o.status.success());
|
||||
assert!(stdout(&o).contains("forgot"));
|
||||
|
||||
let text = fs::read_to_string(sb.config_file()).unwrap();
|
||||
assert!(
|
||||
!text.contains("ToDelete"),
|
||||
"network still in config after forget"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forget_nonexistent_network_is_graceful() {
|
||||
let sb = Sandbox::new();
|
||||
// Should succeed (idempotent) even if the SSID was never saved.
|
||||
let o = sb.cmd(&["forget", "NeverSaved"]);
|
||||
assert!(o.status.success());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forget_removes_ssid_from_profile_networks_list() {
|
||||
let sb = Sandbox::new();
|
||||
|
||||
// Add "WorkNet" and attach it to the "work" profile.
|
||||
sb.cmd(&["add", "WorkNet", "pw", "--to", "work"]);
|
||||
let text = fs::read_to_string(sb.config_file()).unwrap();
|
||||
assert!(text.contains("WorkNet"));
|
||||
|
||||
sb.cmd(&["forget", "WorkNet"]);
|
||||
|
||||
let text = fs::read_to_string(sb.config_file()).unwrap();
|
||||
assert!(
|
||||
!text.contains("WorkNet"),
|
||||
"SSID still in profile after forget"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_masks_passwords_by_default() {
|
||||
let sb = Sandbox::new();
|
||||
sb.cmd(&["add", "SecretNet", "hunter2"]);
|
||||
|
||||
let o = sb.cmd(&["list"]);
|
||||
assert!(o.status.success());
|
||||
let out = stdout(&o);
|
||||
assert!(
|
||||
!out.contains("hunter2"),
|
||||
"plain-text password exposed in list"
|
||||
);
|
||||
// A masking bullet should appear.
|
||||
assert!(out.contains('•'), "no masking character in list output");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_masks_multibyte_password_without_panicking() {
|
||||
let sb = Sandbox::new();
|
||||
// A password whose first character is multi-byte UTF-8 used to panic the
|
||||
// byte-slicing mask(); list must mask it cleanly instead.
|
||||
sb.cmd(&["add", "UnicodeNet", "ñoño-café-🔐"]);
|
||||
|
||||
let o = sb.cmd(&["list"]);
|
||||
assert!(
|
||||
o.status.success(),
|
||||
"list crashed on multibyte password: {}",
|
||||
String::from_utf8_lossy(&o.stderr)
|
||||
);
|
||||
let out = stdout(&o);
|
||||
assert!(
|
||||
!out.contains("ñoño-café-🔐"),
|
||||
"password leaked in masked list"
|
||||
);
|
||||
assert!(out.contains('•'), "no masking character in list output");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_show_passwords_reveals_password() {
|
||||
let sb = Sandbox::new();
|
||||
sb.cmd(&["add", "SecretNet", "hunter2"]);
|
||||
|
||||
let o = sb.cmd(&["list", "--show-passwords"]);
|
||||
assert!(o.status.success());
|
||||
assert!(
|
||||
stdout(&o).contains("hunter2"),
|
||||
"password not shown with --show-passwords"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cd_prints_config_directory() {
|
||||
let sb = Sandbox::new();
|
||||
// Trigger config creation first.
|
||||
sb.cmd(&["list"]);
|
||||
|
||||
let o = sb.cmd(&["cd"]);
|
||||
assert!(o.status.success());
|
||||
let out = stdout(&o).trim().to_string();
|
||||
assert!(!out.is_empty(), "cd produced no output");
|
||||
// The printed path must end with "breadcrumbs" (the config subdirectory).
|
||||
assert!(out.ends_with("breadcrumbs"), "unexpected config dir: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doctor_runs_without_crashing() {
|
||||
let sb = Sandbox::new();
|
||||
// With an empty PATH, nmcli/tailscale are absent. Doctor should report
|
||||
// "MISSING"/"absent" but still exit successfully (it's a diag tool).
|
||||
let o = sb.cmd(&["doctor"]);
|
||||
assert!(
|
||||
o.status.success(),
|
||||
"stderr: {}",
|
||||
String::from_utf8_lossy(&o.stderr)
|
||||
);
|
||||
let out = stdout(&o);
|
||||
assert!(out.contains("nmcli"), "doctor missing nmcli line");
|
||||
assert!(out.contains("tailscale"), "doctor missing tailscale line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_exits_nonzero_when_unhealthy() {
|
||||
let sb = Sandbox::new();
|
||||
// No nmcli/tailscale → internet check fails → unhealthy → exit code 1.
|
||||
let o = sb.cmd(&["status"]);
|
||||
assert!(
|
||||
!o.status.success(),
|
||||
"expected non-zero exit for unhealthy status"
|
||||
);
|
||||
let out = stdout(&o);
|
||||
assert!(
|
||||
out.contains("breadcrumbs"),
|
||||
"missing header in status output"
|
||||
);
|
||||
assert!(
|
||||
out.contains("profile"),
|
||||
"missing profile line in status output"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_override_flag_does_not_persist() {
|
||||
let sb = Sandbox::new();
|
||||
|
||||
// Set profile to "home" persistently.
|
||||
sb.cmd(&["profile", "set", "home", "--no-apply"]);
|
||||
|
||||
// Use --profile flag to override for a single run (status).
|
||||
let o = sb.cmd(&["--profile", "work", "status"]);
|
||||
// Status exits non-zero (no network), but it should show the overridden profile.
|
||||
let out = stdout(&o);
|
||||
assert!(out.contains("work"), "override profile not shown in status");
|
||||
|
||||
// The persistent profile must still be "home".
|
||||
let o = sb.cmd(&["profile", "get"]);
|
||||
assert_eq!(stdout(&o).trim(), "home");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_hidden_flag_is_persisted() {
|
||||
let sb = Sandbox::new();
|
||||
let o = sb.cmd(&["add", "HiddenNet", "pw", "--hidden"]);
|
||||
assert!(o.status.success());
|
||||
|
||||
let text = fs::read_to_string(sb.config_file()).unwrap();
|
||||
assert!(text.contains("hidden = true"), "hidden flag not persisted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_json_is_valid_and_machine_readable() {
|
||||
let sb = Sandbox::new();
|
||||
// No nmcli/tailscale → unhealthy, exit 1, but JSON must still be valid.
|
||||
let o = sb.cmd(&["status", "--json"]);
|
||||
assert!(
|
||||
!o.status.success(),
|
||||
"expected non-zero exit for unhealthy status"
|
||||
);
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(&stdout(&o)).expect("status --json did not emit valid JSON");
|
||||
assert_eq!(v["profile"], "away");
|
||||
assert_eq!(v["internet"], false);
|
||||
assert_eq!(v["healthy"], false);
|
||||
assert!(v["tailscale"].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_add_persists_detect_ssids_and_remove_deletes() {
|
||||
let sb = Sandbox::new();
|
||||
|
||||
let o = sb.cmd(&[
|
||||
"profile", "add", "lab", "--detect", "LabWifi", "--detect", "LabGuest",
|
||||
]);
|
||||
assert!(
|
||||
o.status.success(),
|
||||
"stderr: {}",
|
||||
String::from_utf8_lossy(&o.stderr)
|
||||
);
|
||||
|
||||
let text = fs::read_to_string(sb.config_file()).unwrap();
|
||||
assert!(
|
||||
text.contains("[profiles.lab]"),
|
||||
"profile not written: {text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("LabWifi") && text.contains("LabGuest"),
|
||||
"detect ssids missing"
|
||||
);
|
||||
|
||||
// Adding the same profile again is rejected.
|
||||
assert!(!sb.cmd(&["profile", "add", "lab"]).status.success());
|
||||
|
||||
// Remove it.
|
||||
let o = sb.cmd(&["profile", "remove", "lab"]);
|
||||
assert!(o.status.success());
|
||||
let text = fs::read_to_string(sb.config_file()).unwrap();
|
||||
assert!(
|
||||
!text.contains("[profiles.lab]"),
|
||||
"profile still present after remove"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_remove_core_is_rejected() {
|
||||
let sb = Sandbox::new();
|
||||
let o = sb.cmd(&["profile", "remove", "home"]);
|
||||
assert!(!o.status.success(), "removing a core profile should fail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_service_writes_unit_file() {
|
||||
let sb = Sandbox::new();
|
||||
// Install but don't enable (systemctl is absent in the empty PATH, but
|
||||
// writing the unit file should succeed regardless).
|
||||
let o = sb.cmd(&["install-service", "--no-enable"]);
|
||||
assert!(
|
||||
o.status.success(),
|
||||
"stderr: {}",
|
||||
String::from_utf8_lossy(&o.stderr)
|
||||
);
|
||||
let unit = sb.root.join(".config/systemd/user/breadcrumbs.service");
|
||||
assert!(unit.exists(), "unit file not written at {}", unit.display());
|
||||
let content = fs::read_to_string(&unit).unwrap();
|
||||
assert!(content.contains("ExecStart="), "unit missing ExecStart");
|
||||
assert!(
|
||||
content.contains("breadcrumbs watch"),
|
||||
"unit missing watch subcommand"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue