Compare commits
20 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02e96126e0 | ||
|
|
92fb40d69b | ||
|
|
5094677c4e | ||
|
|
fe1198ed74 | ||
|
|
f999730a7d | ||
|
|
7a58d4acab | ||
|
|
3b2a6e827b | ||
|
|
9009404536 | ||
| 0f48b1499d | |||
|
|
fe9ddbda57 | ||
|
|
b3444337ab | ||
|
|
f9da2e4652 | ||
|
|
66892a49f9 | ||
|
|
2f0f3b9194 | ||
|
|
e63e66fe3f | ||
|
|
629bb6c945 | ||
|
|
d615bdce4b | ||
|
|
c4e1618f79 | ||
|
|
d2964ebcc2 | ||
|
|
037c6e54c9 |
37 changed files with 3760 additions and 2504 deletions
24
.forgejo/workflows/check.yml
Normal file
24
.forgejo/workflows/check.yml
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
name: check
|
||||||
|
|
||||||
|
# Fast-fail lint/test on short-lived work branches, before it ever reaches
|
||||||
|
# main and triggers a dev-track release build.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['feature/**', 'fix/**']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: [self-hosted, hestia]
|
||||||
|
steps:
|
||||||
|
- name: checkout
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
rm -rf src && mkdir src
|
||||||
|
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
|
||||||
|
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||||
|
|
||||||
|
- name: clippy
|
||||||
|
run: cd src && bash ci/build.sh cargo clippy --all-targets --locked -- -D warnings
|
||||||
|
|
||||||
|
- name: test
|
||||||
|
run: cd src && bash ci/build.sh cargo test --release --locked
|
||||||
77
.forgejo/workflows/dev-release.yml
Normal file
77
.forgejo/workflows/dev-release.yml
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
name: dev release
|
||||||
|
|
||||||
|
# Publishes a dev-track build on every push to `main` (the trunk
|
||||||
|
# branch — there is no separate `dev` branch). See bread-ecosystem's
|
||||||
|
# docs/release-channels.md for the release-track policy this is part of.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['main']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: [self-hosted, hestia]
|
||||||
|
steps:
|
||||||
|
- name: checkout
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
rm -rf src && mkdir src
|
||||||
|
git clone --branch main --depth 1 \
|
||||||
|
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||||
|
|
||||||
|
- name: build
|
||||||
|
run: cd src && bash ci/build.sh cargo build --release --locked
|
||||||
|
|
||||||
|
- name: compute dev version
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd src
|
||||||
|
# Base the dev version off the latest published stable tag,
|
||||||
|
# not Cargo.toml — Cargo.toml can go stale relative to the last
|
||||||
|
# real release (seen in practice: breadbox/breadpad/breadcrumbs/
|
||||||
|
# breadpaper), which would make a dev build sort as OLDER than
|
||||||
|
# what's already installed and bakery would correctly refuse it.
|
||||||
|
LATEST_TAG="$(git ls-remote --tags --refs \
|
||||||
|
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" 'v*' \
|
||||||
|
| awk -F/ '{print $NF}' | sed 's/^v//' | (grep -v -- '-' || true) | sort -V | tail -1)"
|
||||||
|
if [ -n "${LATEST_TAG}" ]; then
|
||||||
|
CUR="${LATEST_TAG}"
|
||||||
|
else
|
||||||
|
CUR="$(grep -m1 '^version' Cargo.toml | sed -E 's/.*"(.*)".*/\1/')"
|
||||||
|
fi
|
||||||
|
IFS='.' read -r MA MI PA <<< "${CUR}"
|
||||||
|
SHA="$(git rev-parse --short HEAD)"
|
||||||
|
TS="$(date -u +%Y%m%d%H%M%S)"
|
||||||
|
echo "VERSION=${MA}.${MI}.$((PA + 1))-dev.${TS}+${SHA}" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: prepare artifacts
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
PKG_DIR="/srv/breadway-dl/dev/breadcrumbs/${VERSION}"
|
||||||
|
mkdir -p "${PKG_DIR}"
|
||||||
|
cp "src/target/release/breadcrumbs" "${PKG_DIR}/breadcrumbs-x86_64"
|
||||||
|
strip "${PKG_DIR}/breadcrumbs-x86_64"
|
||||||
|
sha256sum "${PKG_DIR}/breadcrumbs-x86_64" | awk '{print $1}' \
|
||||||
|
> "${PKG_DIR}/breadcrumbs-x86_64.sha256"
|
||||||
|
cp src/breadcrumbs.example.toml "${PKG_DIR}/"
|
||||||
|
cp src/LICENSE "${PKG_DIR}/"
|
||||||
|
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
|
||||||
|
ln -sfn "${VERSION}" "/srv/breadway-dl/dev/breadcrumbs/latest"
|
||||||
|
|
||||||
|
# No GitHub Release upload — dev, like the other non-stable track,
|
||||||
|
# is only distributed via dl.breadway.dev/dev/.
|
||||||
|
- name: regenerate dev index.json
|
||||||
|
env:
|
||||||
|
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
|
||||||
|
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate dev index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the dev track)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true
|
||||||
|
# mktemp: a fixed clone path races when multiple repos' dev/beta
|
||||||
|
# workflows run close together on the same self-hosted runner.
|
||||||
|
ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)"
|
||||||
|
git clone --branch main https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}"
|
||||||
|
TRACK=dev bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh"
|
||||||
|
rm -rf "${ECOSYSTEM_CI_DIR}"
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
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
|
|
||||||
git push --prune \
|
|
||||||
"https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadcrumbs.git" \
|
|
||||||
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
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"
|
|
||||||
58
.forgejo/workflows/rc-release.yml
Normal file
58
.forgejo/workflows/rc-release.yml
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
name: beta (rc) release
|
||||||
|
|
||||||
|
# Publishes a beta-track build for any `vX.Y.Z-rc.N` prerelease tag
|
||||||
|
# pushed to `main` — there is no separate `beta` branch; "freezing" is
|
||||||
|
# just pausing pushes to main while an RC gets tested. See
|
||||||
|
# bread-ecosystem's docs/release-channels.md for the release-track policy.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v*']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
if: ${{ contains(github.ref_name, '-rc.') }}
|
||||||
|
runs-on: [self-hosted, hestia]
|
||||||
|
steps:
|
||||||
|
- name: checkout
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
rm -rf src && mkdir src
|
||||||
|
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
|
||||||
|
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||||
|
|
||||||
|
- name: build
|
||||||
|
run: cd src && bash ci/build.sh cargo build --release --locked
|
||||||
|
|
||||||
|
- name: prepare artifacts
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
PKG_DIR="/srv/breadway-dl/beta/breadcrumbs/${VERSION}"
|
||||||
|
mkdir -p "${PKG_DIR}"
|
||||||
|
cp "src/target/release/breadcrumbs" "${PKG_DIR}/breadcrumbs-x86_64"
|
||||||
|
strip "${PKG_DIR}/breadcrumbs-x86_64"
|
||||||
|
sha256sum "${PKG_DIR}/breadcrumbs-x86_64" | awk '{print $1}' \
|
||||||
|
> "${PKG_DIR}/breadcrumbs-x86_64.sha256"
|
||||||
|
cp src/breadcrumbs.example.toml "${PKG_DIR}/"
|
||||||
|
cp src/LICENSE "${PKG_DIR}/"
|
||||||
|
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
|
||||||
|
ln -sfn "${VERSION}" "/srv/breadway-dl/beta/breadcrumbs/latest"
|
||||||
|
|
||||||
|
# No GitHub Release upload — beta, like dev, is only distributed via
|
||||||
|
# dl.breadway.dev/beta/.
|
||||||
|
- name: regenerate beta index.json
|
||||||
|
env:
|
||||||
|
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
|
||||||
|
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate beta index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the beta track)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -rf /tmp/bread-ecosystem-ci-* 2>/dev/null || true
|
||||||
|
# mktemp: a fixed clone path races when multiple repos' dev/beta
|
||||||
|
# workflows run close together on the same self-hosted runner.
|
||||||
|
ECOSYSTEM_CI_DIR="$(mktemp -d /tmp/bread-ecosystem-ci-XXXXXX)"
|
||||||
|
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "${ECOSYSTEM_CI_DIR}"
|
||||||
|
TRACK=beta bash "${ECOSYSTEM_CI_DIR}/scripts/gen-index.sh"
|
||||||
|
rm -rf "${ECOSYSTEM_CI_DIR}"
|
||||||
|
|
@ -6,6 +6,7 @@ on:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
if: ${{ !contains(github.ref_name, '-rc.') }}
|
||||||
runs-on: [self-hosted, hestia]
|
runs-on: [self-hosted, hestia]
|
||||||
steps:
|
steps:
|
||||||
- name: checkout
|
- name: checkout
|
||||||
|
|
@ -16,10 +17,19 @@ jobs:
|
||||||
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||||
|
|
||||||
- name: build
|
- name: build
|
||||||
run: cd src && cargo build --release --locked
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ ! -f src/ci/build.sh ]; then
|
||||||
|
echo "::error::ci/build.sh is missing — bakery release builds must go through the shared CI wrapper"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cd src && bash ci/build.sh cargo build --release --locked || {
|
||||||
|
echo "::error::cargo build --release --locked failed. If Cargo.lock drifted, update and commit it; do not drop --locked."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
- name: test
|
- name: test
|
||||||
run: cd src && cargo test --release --locked
|
run: cd src && bash ci/build.sh cargo test --release --locked
|
||||||
|
|
||||||
- name: prepare artifacts
|
- name: prepare artifacts
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -32,13 +42,19 @@ jobs:
|
||||||
sha256sum "${PKG_DIR}/breadcrumbs-x86_64" | awk '{print $1}' \
|
sha256sum "${PKG_DIR}/breadcrumbs-x86_64" | awk '{print $1}' \
|
||||||
> "${PKG_DIR}/breadcrumbs-x86_64.sha256"
|
> "${PKG_DIR}/breadcrumbs-x86_64.sha256"
|
||||||
cp src/breadcrumbs.example.toml "${PKG_DIR}/"
|
cp src/breadcrumbs.example.toml "${PKG_DIR}/"
|
||||||
|
cp src/LICENSE "${PKG_DIR}/"
|
||||||
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
|
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
|
||||||
cp src/contrib/breadcrumbs.service "${PKG_DIR}/"
|
|
||||||
ln -sfn "${VERSION}" "/srv/breadway-dl/breadcrumbs/latest"
|
ln -sfn "${VERSION}" "/srv/breadway-dl/breadcrumbs/latest"
|
||||||
|
|
||||||
- name: regenerate index.json
|
- name: regenerate index.json
|
||||||
|
env:
|
||||||
|
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
|
||||||
|
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
rm -rf /tmp/bread-ecosystem-ci
|
rm -rf /tmp/bread-ecosystem-ci
|
||||||
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci
|
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci
|
||||||
bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh
|
bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh
|
||||||
|
|
|
||||||
65
.github/workflows/release.yml
vendored
Normal file
65
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
name: release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
DL_DIR: /srv/breadway-dl
|
||||||
|
ECOSYSTEM_DIR: /home/breadway/Projects/bread-ecosystem
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: [self-hosted, hestia]
|
||||||
|
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
|
||||||
|
|
||||||
|
- name: test
|
||||||
|
run: cargo test --release --locked
|
||||||
|
|
||||||
|
- name: prepare artifacts
|
||||||
|
run: |
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
PKG_DIR="${DL_DIR}/breadcrumbs/${VERSION}"
|
||||||
|
mkdir -p "${PKG_DIR}"
|
||||||
|
cp target/release/breadcrumbs "${PKG_DIR}/breadcrumbs-x86_64"
|
||||||
|
strip "${PKG_DIR}/breadcrumbs-x86_64"
|
||||||
|
sha256sum "${PKG_DIR}/breadcrumbs-x86_64" | awk '{print $1}' \
|
||||||
|
> "${PKG_DIR}/breadcrumbs-x86_64.sha256"
|
||||||
|
cp breadcrumbs.example.toml "${PKG_DIR}/"
|
||||||
|
cp bakery.toml "${PKG_DIR}/bakery.toml"
|
||||||
|
ln -sfn "${VERSION}" "${DL_DIR}/breadcrumbs/latest"
|
||||||
|
|
||||||
|
- name: ensure bread-ecosystem
|
||||||
|
run: |
|
||||||
|
if [[ -d "${ECOSYSTEM_DIR}/.git" ]]; then
|
||||||
|
git -C "${ECOSYSTEM_DIR}" pull --ff-only
|
||||||
|
else
|
||||||
|
mkdir -p "$(dirname "${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"
|
||||||
|
|
||||||
|
- name: upload to GitHub Release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
PKG_DIR="${DL_DIR}/breadcrumbs/${VERSION}"
|
||||||
|
gh release create "${GITHUB_REF_NAME}" \
|
||||||
|
--title "breadcrumbs v${VERSION}" --generate-notes 2>/dev/null || true
|
||||||
|
gh release upload "${GITHUB_REF_NAME}" \
|
||||||
|
"${PKG_DIR}/breadcrumbs-x86_64" \
|
||||||
|
"${PKG_DIR}/breadcrumbs-x86_64.sha256" \
|
||||||
|
--clobber
|
||||||
13
.gitignore
vendored
13
.gitignore
vendored
|
|
@ -1,9 +1,12 @@
|
||||||
# Build output
|
# Build output
|
||||||
/target/
|
/target/
|
||||||
|
|
||||||
# Secrets: live config holds plaintext Wi-Fi passwords.
|
# Secrets: live config holds plaintext Wi-Fi passwords (until NetworkManager
|
||||||
# Keep local only; see breadcrumbs.example.toml for the schema.
|
# takes over each one on first connect — see README's "Credential handling").
|
||||||
|
# Keep local only; see breadcrumbs.example.toml / networks.example.toml for
|
||||||
|
# the schemas.
|
||||||
/breadcrumbs.toml
|
/breadcrumbs.toml
|
||||||
|
/networks.toml
|
||||||
|
|
||||||
# Legacy plaintext credential store (migrated into breadcrumbs.toml on first run)
|
# Legacy plaintext credential store (migrated into breadcrumbs.toml on first run)
|
||||||
/Networks/
|
/Networks/
|
||||||
|
|
@ -34,3 +37,9 @@ desktop.ini
|
||||||
|
|
||||||
# Claude Code local state
|
# Claude Code local state
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
# Local hygiene notes (not for commit)
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
|
# graphify knowledge-graph output (local tool cache, not for commit)
|
||||||
|
graphify-out/
|
||||||
|
|
|
||||||
50
AGENTS.md
Normal file
50
AGENTS.md
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
# AGENTS.md — Repo hygiene
|
||||||
|
|
||||||
|
Scope: this file covers *repo hygiene* — branching, remotes, CI, cleanup. It is not project documentation.
|
||||||
|
|
||||||
|
This repo follows the branch/release workflow documented in `CONTRIBUTING.md`
|
||||||
|
— read and follow it for any git, branch, or release work here (the
|
||||||
|
single-trunk model, `feature/x`/`fix/x` branch naming, how RC tags work,
|
||||||
|
etc). Don't improvise a different workflow. The short version: there is one
|
||||||
|
long-lived branch, `main` — no `dev` or `beta` branch exists. `main`
|
||||||
|
auto-publishes a dev-track build on every push. "Beta" and "stable" are both
|
||||||
|
just tags, not branches: push a `vX.Y.Z-rc.N` tag to publish a beta-track
|
||||||
|
build, push a plain `vX.Y.Z` tag to cut the signed stable release.
|
||||||
|
"Freezing" for stabilization means pausing pushes to `main`, not moving a
|
||||||
|
branch. This replaced an earlier three-branch (`dev`/`beta`/`main`) model
|
||||||
|
after `main` was found to have silently rotted out of sync with `dev`/`beta`
|
||||||
|
across most repos in this ecosystem — a manual "merge beta into main
|
||||||
|
monthly" step nobody reliably did across a dozen-plus repos. Collapsing to
|
||||||
|
one branch removes the class of bug; there's nothing left that can fall out
|
||||||
|
of sync.
|
||||||
|
|
||||||
|
## Remotes
|
||||||
|
- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative.
|
||||||
|
- `github` — GitHub mirror. Push both when publishing. Agents push `origin` only; the GitHub remote auto-mirrors.
|
||||||
|
|
||||||
|
## Distribution
|
||||||
|
- Bakery-only. `bakery.toml` is the product manifest; there is no
|
||||||
|
`packaging/arch/PKGBUILD` in this repo.
|
||||||
|
- Tracks: `bakery track set {dev,beta,stable}` then `bakery update breadcrumbs`
|
||||||
|
(or `bakery update --all`). See CONTRIBUTING.md and bread-ecosystem's
|
||||||
|
`docs/release-channels.md`.
|
||||||
|
|
||||||
|
## Events
|
||||||
|
- Bread bus contract: `EVENTS.md`. App id is `crumbs`.
|
||||||
|
- Fail-silent: breadcrumbs behaves the same whether `breadd` is running or
|
||||||
|
not. Commands (`bread.command.crumbs.*`) are only received while
|
||||||
|
`breadcrumbs watch` / the user systemd unit is up.
|
||||||
|
|
||||||
|
## CI
|
||||||
|
- `check.yml` — clippy + `cargo test --release` on `feature/**` and `fix/**`.
|
||||||
|
- `dev-release.yml` — triggered on push to `main` (dev-track bakery publish).
|
||||||
|
- `rc-release.yml` — triggered on any `vX.Y.Z-rc.N` tag push (beta track).
|
||||||
|
- `release.yml` — triggered on any other `v*` tag push (signed stable).
|
||||||
|
|
||||||
|
All CI runs on a self-hosted runner. No build/lint/test CI runs on ordinary
|
||||||
|
commits or PRs to `main` beyond the dev-track workflow above.
|
||||||
|
|
||||||
|
## Don't
|
||||||
|
- Don't embed credentials in remote URLs — SSH or a credential helper only.
|
||||||
|
- Don't invent bread command verbs that have no real breadcrumbs feature
|
||||||
|
behind them. See EVENTS.md.
|
||||||
84
CONTRIBUTING.md
Normal file
84
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
# Contributing
|
||||||
|
|
||||||
|
`breadcrumbs` — Profile-aware Wi-Fi state machine with Tailscale integration.
|
||||||
|
|
||||||
|
Part of the bread ecosystem; this repo follows the same branch/release
|
||||||
|
workflow as every other ecosystem product.
|
||||||
|
|
||||||
|
## Branches
|
||||||
|
|
||||||
|
There is one long-lived branch: **`main`**. All day-to-day work lands here.
|
||||||
|
Every push to `main` automatically builds and publishes a **dev-track**
|
||||||
|
build (see Tracks below) — a real install you can test before cutting
|
||||||
|
anything more formal.
|
||||||
|
|
||||||
|
New work — features and bug fixes alike — goes on a short-lived branch:
|
||||||
|
|
||||||
|
```
|
||||||
|
feature/<short-name>
|
||||||
|
fix/<issue-number-or-short-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
Branch off `main`, open a PR/push back into `main` when ready. Short-lived
|
||||||
|
branches get deleted on merge — they never accumulate the kind of drift a
|
||||||
|
second long-lived branch does.
|
||||||
|
|
||||||
|
## The release cycle
|
||||||
|
|
||||||
|
There's no separate `beta` or release branch — "stable" and "beta" are both
|
||||||
|
just **tags** on `main`, not branches that need to be kept in sync:
|
||||||
|
|
||||||
|
1. Work accumulates on `main` via `feature/x` / `fix/x` branches. Each push
|
||||||
|
auto-publishes a dev build — install it with `bakery track set dev` and
|
||||||
|
`bakery update --all`, then fix anything broken with another push.
|
||||||
|
2. When you want to stabilize before a real release, tag a release
|
||||||
|
candidate: `git tag vX.Y.Z-rc.1 && git push origin vX.Y.Z-rc.1` (push to
|
||||||
|
both remotes). That tag alone triggers a beta-track build —
|
||||||
|
"freezing" is just pausing pushes to `main` while you test it, not a
|
||||||
|
branch operation. Cut `-rc.2`, `-rc.3`, etc. for further fixes.
|
||||||
|
3. Once an RC has gone without issues, tag the real release:
|
||||||
|
`git tag vX.Y.Z && git push origin vX.Y.Z` — that's what triggers the
|
||||||
|
signed stable release build.
|
||||||
|
|
||||||
|
## Tracks, from a user's perspective
|
||||||
|
|
||||||
|
```
|
||||||
|
bakery track show # what you're currently on (defaults to stable)
|
||||||
|
bakery track set dev # or beta, or stable
|
||||||
|
bakery update --all # pull the latest build on your current track
|
||||||
|
```
|
||||||
|
|
||||||
|
| Track | What it is | Published from |
|
||||||
|
|--------|-----------|-----------------|
|
||||||
|
| `stable` | The last tagged release | a `vX.Y.Z` tag |
|
||||||
|
| `beta` | Latest release candidate | a `vX.Y.Z-rc.N` tag |
|
||||||
|
| `dev` | Bleeding edge | `main`, on every push |
|
||||||
|
|
||||||
|
Dev versions are auto-computed (`X.Y.Z-dev.<timestamp>+<sha>`) from the
|
||||||
|
latest published stable tag, so they always sort as newer than what you
|
||||||
|
have installed — no manual version bumping needed. Beta versions are just
|
||||||
|
the RC tag itself (already valid semver, already sorts below the real
|
||||||
|
release it's a candidate for).
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo build --release
|
||||||
|
cargo test --release
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
- `dev-release.yml` — triggered on push to `main`.
|
||||||
|
- `rc-release.yml` — triggered on any `vX.Y.Z-rc.N` tag push.
|
||||||
|
- `release.yml` — triggered on any other `v*` tag push, cuts the actual
|
||||||
|
stable release.
|
||||||
|
|
||||||
|
All CI runs on a self-hosted runner; nothing runs automatically on plain
|
||||||
|
commits or PRs beyond the track builds above. See
|
||||||
|
[bread-ecosystem's docs/release-channels.md](https://git.breadway.dev/Breadway/bread-ecosystem/src/branch/main/docs/release-channels.md)
|
||||||
|
for the full policy, including how a new product gets wired onto these tracks.
|
||||||
|
|
||||||
|
## Questions
|
||||||
|
|
||||||
|
Open an issue on this repo's Forgejo tracker.
|
||||||
191
Cargo.lock
generated
191
Cargo.lock
generated
|
|
@ -38,7 +38,7 @@ version = "1.1.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -49,19 +49,48 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"once_cell_polyfill",
|
"once_cell_polyfill",
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bread-shared"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0#22e34e2cf2202305d7960759dfccb54dc79f948b"
|
||||||
|
dependencies = [
|
||||||
|
"dirs",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"toml",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bread-utils"
|
||||||
|
version = "0.7.2"
|
||||||
|
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.2#30517f161724132cdeb658c04cf5e490be07ee73"
|
||||||
|
dependencies = [
|
||||||
|
"bread-shared",
|
||||||
|
"dirs",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "breadcrumbs"
|
name = "breadcrumbs"
|
||||||
version = "2.1.3"
|
version = "2.1.7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"bread-utils",
|
||||||
"clap",
|
"clap",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"toml",
|
"toml",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.6.1"
|
version = "4.6.1"
|
||||||
|
|
@ -108,12 +137,44 @@ version = "1.0.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dirs"
|
||||||
|
version = "5.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
|
||||||
|
dependencies = [
|
||||||
|
"dirs-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dirs-sys"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"option-ext",
|
||||||
|
"redox_users",
|
||||||
|
"windows-sys 0.48.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "getrandom"
|
||||||
|
version = "0.2.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"wasi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.17.1"
|
version = "0.17.1"
|
||||||
|
|
@ -148,6 +209,21 @@ version = "1.0.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.189"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libredox"
|
||||||
|
version = "0.1.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.0"
|
version = "2.8.0"
|
||||||
|
|
@ -160,6 +236,12 @@ version = "1.70.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "option-ext"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.106"
|
version = "1.0.106"
|
||||||
|
|
@ -178,6 +260,17 @@ dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "redox_users"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom",
|
||||||
|
"libredox",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.228"
|
version = "1.0.228"
|
||||||
|
|
@ -247,6 +340,26 @@ dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "0.8.23"
|
version = "0.8.23"
|
||||||
|
|
@ -300,12 +413,27 @@ version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasi"
|
||||||
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.48.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
|
|
@ -315,6 +443,63 @@ dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||||
|
dependencies = [
|
||||||
|
"windows_aarch64_gnullvm",
|
||||||
|
"windows_aarch64_msvc",
|
||||||
|
"windows_i686_gnu",
|
||||||
|
"windows_i686_msvc",
|
||||||
|
"windows_x86_64_gnu",
|
||||||
|
"windows_x86_64_gnullvm",
|
||||||
|
"windows_x86_64_msvc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winnow"
|
name = "winnow"
|
||||||
version = "0.7.15"
|
version = "0.7.15"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "breadcrumbs"
|
name = "breadcrumbs"
|
||||||
version = "2.1.3"
|
version = "2.1.7"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Profile-aware Wi-Fi state machine with Tailscale handling and self-healing watch daemon"
|
description = "Profile-aware Wi-Fi state machine with Tailscale handling and self-healing watch daemon"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
@ -14,6 +14,7 @@ clap = { version = "4", features = ["derive"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = "s"
|
opt-level = "s"
|
||||||
|
|
|
||||||
78
EVENTS.md
Normal file
78
EVENTS.md
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
# breadcrumbs — bread event integration
|
||||||
|
|
||||||
|
breadcrumbs is a standalone Wi-Fi state machine: it works exactly the same
|
||||||
|
with or without `breadd` running. When breadd *is* present **and** the
|
||||||
|
`breadcrumbs watch` daemon (or the systemd user service it installs) is up,
|
||||||
|
breadcrumbs publishes events into the shared bread automation fabric and
|
||||||
|
listens for a small set of commands. See the parent `bread` repo's
|
||||||
|
`Documentation.md` — specifically its "Namespaces" and "Integrating a
|
||||||
|
bread\* app" sections — for the general convention this follows.
|
||||||
|
|
||||||
|
App id: **`crumbs`**. Transport: `bread-utils`'s `bread_client` module
|
||||||
|
(feature `bread-client`) — the watch process links it directly, since it's
|
||||||
|
the long-running piece that both emits on real transitions and holds the
|
||||||
|
command subscription open.
|
||||||
|
|
||||||
|
One-shot CLI invocations (`breadcrumbs status`, `profile set`, `init`, …)
|
||||||
|
do **not** emit or subscribe on their own. A `breadcrumbs profile set home`
|
||||||
|
while the watcher is running is picked up on the watcher's next tick
|
||||||
|
(state is re-read every loop) and *then* published as
|
||||||
|
`bread.crumbs.profile.changed`. If the watcher is not running, the CLI
|
||||||
|
still switches the profile on disk — there is just nobody listening for
|
||||||
|
`bread.command.crumbs.*`, and nobody emitting `bread.crumbs.*`.
|
||||||
|
|
||||||
|
## Events published (`bread.crumbs.*`)
|
||||||
|
|
||||||
|
| Event | Data | When |
|
||||||
|
|-------|------|------|
|
||||||
|
| `bread.crumbs.profile.changed` | `{ "from": "<name>", "to": "<name>" }` | The watch loop observes that the persisted active profile is no longer the one it last acted on (CLI `profile set`, `detect --apply`, or `bread.command.crumbs.set_profile`). Not emitted on watcher start just because a profile is already selected. |
|
||||||
|
| `bread.crumbs.health.changed` | `{ "profile": "<name>", "health": "<variant>", "ssid": <string or null> }` | The watch loop's health classification changes — including the first observation after start, and the forced re-evaluation after a profile change. **Not** emitted on every poll tick while the classification stays the same. |
|
||||||
|
| `bread.crumbs.set_profile.done` | `{ "profile": "<name>" }` | `bread.command.crumbs.set_profile` persisted the new profile. |
|
||||||
|
| `bread.crumbs.set_profile.failed` | `{ "error": "<message>" }` | `bread.command.crumbs.set_profile` was received but rejected (unknown profile, missing `profile` field, config unreadable). |
|
||||||
|
|
||||||
|
`health` is the Rust enum variant name, not a prettier label:
|
||||||
|
|
||||||
|
| Variant | Meaning |
|
||||||
|
|---------|---------|
|
||||||
|
| `Up` | Adapter present, internet reachable, Tailscale healthy if the profile requires it. |
|
||||||
|
| `DownNoNet` | No internet. |
|
||||||
|
| `DownTailscaleManual` | Tailscale required but needs login / isn't installed — cannot auto-fix. |
|
||||||
|
| `DownTailscaleOther` | Tailscale required and unhealthy for some other (usually auto-recoverable) reason. |
|
||||||
|
| `NoAdapter` | No Wi-Fi interface. |
|
||||||
|
| `UnknownProfile` | Persisted profile name is not in the config. |
|
||||||
|
|
||||||
|
`ssid` is the currently-associated SSID, or `null` when there isn't one
|
||||||
|
(no adapter, not associated, unknown profile).
|
||||||
|
|
||||||
|
## Commands honored (`bread.command.crumbs.*`)
|
||||||
|
|
||||||
|
These are only received while `breadcrumbs watch` / `breadcrumbs.service`
|
||||||
|
is running. Publishing a command with no subscriber is a silent no-op —
|
||||||
|
that is the documented bread convention, not a breadcrumbs bug.
|
||||||
|
|
||||||
|
| Verb | Data | Effect |
|
||||||
|
|------|------|--------|
|
||||||
|
| `set_profile` | `{ "profile": "<name>" }` | Persist `<name>` via the same `state::set_profile` path the CLI uses. Wakes the watch loop immediately so the new profile is classified (and recovered, if down) on the next tick rather than waiting out the current poll interval. Does **not** run `flow::run` on the command thread — that would race the watch loop. Emits `bread.crumbs.set_profile.done`/`.failed`. |
|
||||||
|
|
||||||
|
### Not implemented: extra verbs
|
||||||
|
|
||||||
|
There is no `pin`, `select`, `scan`, `init`, or other command verb. The
|
||||||
|
CLI already covers those as synchronous one-shots (`breadcrumbs init`,
|
||||||
|
`breadcrumbs scan`, …), and breadcrumbs has no "pinned network" concept
|
||||||
|
to hang a bus verb on. If/when that changes, the corresponding
|
||||||
|
`bread.command.crumbs.*` verb should be added at the same time, not
|
||||||
|
stubbed out ahead of it.
|
||||||
|
|
||||||
|
## Fail-safe behavior
|
||||||
|
|
||||||
|
- If breadd isn't installed or isn't running, `emit` is a silent no-op
|
||||||
|
(`BreadClient::emit` never blocks or errors the caller) and the
|
||||||
|
command subscription simply never receives anything — breadcrumbs'
|
||||||
|
actual Wi-Fi / Tailscale / watch functionality is entirely unaffected
|
||||||
|
either way.
|
||||||
|
- If breadd restarts, the command subscription reconnects automatically
|
||||||
|
(`BreadClient::subscribe`'s background thread has its own backoff loop);
|
||||||
|
no restart of the breadcrumbs watcher is needed.
|
||||||
|
- If the breadcrumbs watcher is not running, commands are a graceful
|
||||||
|
no-op at the bus (no subscriber) and no `bread.crumbs.*` events fire.
|
||||||
|
The CLI still works.
|
||||||
39
README.md
39
README.md
|
|
@ -9,10 +9,8 @@ breadcrumbs sits on top of NetworkManager (`nmcli`) and manages your Wi-Fi based
|
||||||
- **Profile-based connection management** — define ordered network priority lists per location
|
- **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
|
- **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`
|
- **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 (picks the profile with the most markers in range)
|
- **Auto-detection** — scans visible SSIDs and guesses your location from config-defined markers
|
||||||
- **Captive-portal detection** — distinguishes a real connection from a sign-in page and surfaces the portal URL instead of falsely reporting "online"
|
- **Credential handling** — a saved network's password is only needed the *first* time breadcrumbs connects to it. Once that connect succeeds, NetworkManager durably owns the credential (a new connection profile, or an updated PSK on an existing one), so breadcrumbs clears its own local copy and stops writing it to disk. Both config files are `0600` (owner-only); saved networks live in a separate `networks.toml` from settings/profiles (see [Configuration](#configuration)). On that first connect the PSK is fed to `nmcli --ask` on stdin, never as a command argument, so it does not appear in `/proc/<pid>/cmdline`.
|
||||||
- **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)
|
- **Desktop notifications** via `notify-send` (optional)
|
||||||
- **systemd user service** generation via `breadcrumbs install-service`
|
- **systemd user service** generation via `breadcrumbs install-service`
|
||||||
|
|
||||||
|
|
@ -27,7 +25,7 @@ breadcrumbs sits on top of NetworkManager (`nmcli`) and manages your Wi-Fi based
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/Breadway/breadcrumbs
|
git clone https://github.com/breadway/breadcrumbs
|
||||||
cd breadcrumbs
|
cd breadcrumbs
|
||||||
cargo build --release
|
cargo build --release
|
||||||
# Copy to somewhere on your PATH:
|
# Copy to somewhere on your PATH:
|
||||||
|
|
@ -36,17 +34,21 @@ cp target/release/breadcrumbs ~/.local/bin/
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
On first run, breadcrumbs creates `~/.config/breadcrumbs/breadcrumbs.toml` with default profiles. Copy `breadcrumbs.example.toml` as a starting point and fill in your real network credentials:
|
On first run, breadcrumbs creates `~/.config/breadcrumbs/breadcrumbs.toml` (settings + profiles) and `~/.config/breadcrumbs/networks.toml` (saved networks) with default profiles. Copy `breadcrumbs.example.toml` as a starting point for the former:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp breadcrumbs.example.toml ~/.config/breadcrumbs/breadcrumbs.toml
|
cp breadcrumbs.example.toml ~/.config/breadcrumbs/breadcrumbs.toml
|
||||||
breadcrumbs edit # opens in $EDITOR
|
breadcrumbs edit # opens breadcrumbs.toml in $EDITOR
|
||||||
```
|
```
|
||||||
|
|
||||||
|
...then add your real networks with `breadcrumbs add`/`scan` rather than hand-editing `networks.toml` (see `networks.example.toml` if you want to see its shape or write it by hand anyway).
|
||||||
|
|
||||||
Config paths respect `$XDG_CONFIG_HOME` and `$XDG_STATE_HOME`.
|
Config paths respect `$XDG_CONFIG_HOME` and `$XDG_STATE_HOME`.
|
||||||
|
|
||||||
### Config structure
|
### Config structure
|
||||||
|
|
||||||
|
Settings and location profiles live in `breadcrumbs.toml` — the file people actually hand-edit or dotfile:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[settings]
|
[settings]
|
||||||
dns = "1.1.1.1" # DNS server pinned on every connection
|
dns = "1.1.1.1" # DNS server pinned on every connection
|
||||||
|
|
@ -57,11 +59,6 @@ watch_interval = 12 # seconds between health checks (minimum 4)
|
||||||
connectivity_url = "http://connectivitycheck.gstatic.com/generate_204"
|
connectivity_url = "http://connectivitycheck.gstatic.com/generate_204"
|
||||||
ping_host = "1.1.1.1"
|
ping_host = "1.1.1.1"
|
||||||
|
|
||||||
[[networks]]
|
|
||||||
ssid = "MyHomeNetwork"
|
|
||||||
password = "hunter2"
|
|
||||||
hidden = false
|
|
||||||
|
|
||||||
[profiles.home]
|
[profiles.home]
|
||||||
networks = ["MyHomeNetwork"] # priority-ordered SSIDs
|
networks = ["MyHomeNetwork"] # priority-ordered SSIDs
|
||||||
tailscale = false
|
tailscale = false
|
||||||
|
|
@ -76,6 +73,17 @@ exit_node = "jump-host" # per-profile override
|
||||||
detect_ssids = ["CorpWifi", "Corp-5G"]
|
detect_ssids = ["CorpWifi", "Corp-5G"]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Saved networks (SSID + optional local password) live separately, in `networks.toml`, managed via `add`/`scan`/`forget`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[networks]]
|
||||||
|
ssid = "MyHomeNetwork"
|
||||||
|
password = "hunter2" # optional — see "Credential handling" below
|
||||||
|
hidden = false
|
||||||
|
```
|
||||||
|
|
||||||
|
`password` is only needed the first time breadcrumbs connects to a network. Once NetworkManager durably saves the credential, breadcrumbs clears its local copy and omits the key on the next save — an existing config with `password = "..."` still loads fine either way, no migration step needed. A config with `[[networks]]` still written inline in `breadcrumbs.toml` (from before this split) also still loads: it's read once, then migrated into `networks.toml` automatically on the next save.
|
||||||
|
|
||||||
### Profiles
|
### Profiles
|
||||||
|
|
||||||
Each profile defines:
|
Each profile defines:
|
||||||
|
|
@ -97,21 +105,16 @@ breadcrumbs [--profile <name>] <command>
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `status [--json]` | Show current Wi-Fi / Tailscale health (default); `--json` for scripts |
|
| `status` | Show current Wi-Fi / Tailscale health (default) |
|
||||||
| `init` | Run the full connect sequence for the active profile |
|
| `init` | Run the full connect sequence for the active profile |
|
||||||
| `watch [--no-initial]` | Self-healing daemon: monitors and auto-recovers drops |
|
| `watch [--no-initial]` | Self-healing daemon: monitors and auto-recovers drops |
|
||||||
| `profile get` | Print the active profile |
|
| `profile get` | Print the active profile |
|
||||||
| `profile set <name>` | Switch profile (and apply it, unless `--no-apply`) |
|
| `profile set <name>` | Switch profile (and apply it, unless `--no-apply`) |
|
||||||
| `profile list` | List all profiles |
|
| `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 |
|
| `detect [--apply]` | Guess profile from visible networks; optionally apply it |
|
||||||
| `add <ssid> [password]` | Add or update a saved network |
|
| `add <ssid> [password]` | Add or update a saved network |
|
||||||
| `forget <ssid>` | Remove a network from config and NetworkManager |
|
| `forget <ssid>` | Remove a network from config and NetworkManager |
|
||||||
| `join <ssid>` | Connect to a specific saved network by SSID, bypassing profile routing |
|
|
||||||
| `networks [--json]` | List saved network SSIDs |
|
|
||||||
| `scan [--to <profile>]` | Interactive scan, pick, connect and save |
|
| `scan [--to <profile>]` | Interactive scan, pick, connect and save |
|
||||||
| `scan-list [--json]` | Scan for visible networks and list them with signal strength |
|
|
||||||
| `list [--show-passwords]` | Show config: settings, networks, profiles |
|
| `list [--show-passwords]` | Show config: settings, networks, profiles |
|
||||||
| `edit` | Open config in `$EDITOR`, validate on exit |
|
| `edit` | Open config in `$EDITOR`, validate on exit |
|
||||||
| `doctor [--full]` | Quick connectivity and Tailscale diagnostics |
|
| `doctor [--full]` | Quick connectivity and Tailscale diagnostics |
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,11 @@ binaries = ["breadcrumbs"]
|
||||||
system_deps = ["networkmanager"]
|
system_deps = ["networkmanager"]
|
||||||
optional_system_deps = ["tailscale", "sudo", "xdg-utils"]
|
optional_system_deps = ["tailscale", "sudo", "xdg-utils"]
|
||||||
bread_deps = []
|
bread_deps = []
|
||||||
|
license_file = "LICENSE"
|
||||||
|
|
||||||
[config]
|
[config]
|
||||||
dir = "~/.config/breadcrumbs"
|
dir = "~/.config/breadcrumbs"
|
||||||
example = "breadcrumbs.example.toml"
|
example = "breadcrumbs.example.toml"
|
||||||
|
|
||||||
[[service]]
|
|
||||||
unit = "breadcrumbs.service"
|
|
||||||
enable = true
|
|
||||||
|
|
||||||
[install]
|
[install]
|
||||||
post_install = [
|
post_install = []
|
||||||
"systemctl --user is-active --quiet breadcrumbs || systemctl --user start breadcrumbs",
|
|
||||||
]
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,12 @@
|
||||||
# just run breadcrumbs once (it generates a skeleton) and then use
|
# just run breadcrumbs once (it generates a skeleton) and then use
|
||||||
# `breadcrumbs add` / `breadcrumbs edit` to fill in your networks.
|
# `breadcrumbs add` / `breadcrumbs edit` to fill in your networks.
|
||||||
# The real breadcrumbs.toml is gitignored and never committed.
|
# The real breadcrumbs.toml is gitignored and never committed.
|
||||||
|
#
|
||||||
|
# Saved networks (SSID + optional local password) live in a separate file,
|
||||||
|
# networks.toml, in the same directory — not here. See
|
||||||
|
# networks.example.toml for its format; in practice you never hand-edit it,
|
||||||
|
# `breadcrumbs add` / `scan` / `forget` manage it for you. This file is just
|
||||||
|
# settings + the location profiles built from those saved networks.
|
||||||
|
|
||||||
[settings]
|
[settings]
|
||||||
dns = "1.1.1.1"
|
dns = "1.1.1.1"
|
||||||
|
|
@ -11,26 +17,9 @@ nmcli_wait = 8
|
||||||
exit_node = "my-exit-node" # Tailscale hostname of your preferred exit node
|
exit_node = "my-exit-node" # Tailscale hostname of your preferred exit node
|
||||||
default_profile = "away"
|
default_profile = "away"
|
||||||
watch_interval = 12
|
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"
|
connectivity_url = "http://connectivitycheck.gstatic.com/generate_204"
|
||||||
ping_host = "1.1.1.1"
|
ping_host = "1.1.1.1"
|
||||||
|
|
||||||
[[networks]]
|
|
||||||
ssid = "HomeWifi"
|
|
||||||
password = "REPLACE_ME"
|
|
||||||
hidden = false
|
|
||||||
|
|
||||||
[[networks]]
|
|
||||||
ssid = "WorkGuest"
|
|
||||||
password = "REPLACE_ME"
|
|
||||||
hidden = false
|
|
||||||
|
|
||||||
[[networks]]
|
|
||||||
ssid = "CorpWifi"
|
|
||||||
password = "REPLACE_ME"
|
|
||||||
hidden = false
|
|
||||||
|
|
||||||
# Location state machine. Switch with: breadcrumbs profile set <name>
|
# Location state machine. Switch with: breadcrumbs profile set <name>
|
||||||
#
|
#
|
||||||
# detect_ssids: list any SSIDs that reliably indicate you are at this location.
|
# detect_ssids: list any SSIDs that reliably indicate you are at this location.
|
||||||
|
|
|
||||||
1
ci/bread-ecosystem.rev
Normal file
1
ci/bread-ecosystem.rev
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
147cfbbf96ae4b171027defa1130d2caddb934b1
|
||||||
21
ci/build.sh
Executable file
21
ci/build.sh
Executable file
|
|
@ -0,0 +1,21 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Delegates to bread-ecosystem's shared CI build image/script, pinned to
|
||||||
|
# the commit in ci/bread-ecosystem.rev — not `main`. bread-ecosystem's CI
|
||||||
|
# files now affect every product's release pipeline, so bumping the pin
|
||||||
|
# is a deliberate act instead of silent drift (see the bread-theme test
|
||||||
|
# that broke here for exactly that reason, before it was pinned by rev).
|
||||||
|
#
|
||||||
|
# Usage: ci/build.sh cargo build --release --locked
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
REV="$(cat "${ROOT}/ci/bread-ecosystem.rev")"
|
||||||
|
|
||||||
|
CACHE_DIR="/tmp/bread-ecosystem-ci-${REV}"
|
||||||
|
if [ ! -d "$CACHE_DIR" ]; then
|
||||||
|
rm -rf /tmp/bread-ecosystem-ci-*
|
||||||
|
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git "$CACHE_DIR"
|
||||||
|
git -C "$CACHE_DIR" checkout --quiet "$REV"
|
||||||
|
fi
|
||||||
|
|
||||||
|
bash "${CACHE_DIR}/ci/build.sh" breadcrumbs "$ROOT" "$@"
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
[Unit]
|
|
||||||
Description=breadcrumbs Wi-Fi state machine watcher
|
|
||||||
Documentation=https://git.breadway.dev/Breadway/breadcrumbs
|
|
||||||
After=network.target NetworkManager.service
|
|
||||||
Wants=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
|
||||||
# ExecStart is rewritten at install time by bakery to point at the real
|
|
||||||
# bin_dir (patch_exec_start in bakery's install.rs) — this path is only a
|
|
||||||
# placeholder for local testing.
|
|
||||||
ExecStart=%h/.local/bin/breadcrumbs watch
|
|
||||||
Restart=always
|
|
||||||
RestartSec=5
|
|
||||||
Nice=5
|
|
||||||
|
|
||||||
# Forward stdout/stderr to the journal so `journalctl --user -u breadcrumbs` works
|
|
||||||
StandardOutput=journal
|
|
||||||
StandardError=journal
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
# default.target, not graphical-session.target: breadcrumbs is a headless
|
|
||||||
# network daemon with no Wayland/GUI dependency, and graphical-session.target
|
|
||||||
# ships with RefuseManualStart=yes (only a session manager like uwsm can
|
|
||||||
# activate it — BOS doesn't use one, which is why breadclipd needs a manual
|
|
||||||
# `systemctl --user start` in hyprland.lua's exec-once instead of relying on
|
|
||||||
# WantedBy). default.target activates normally with the user session, no
|
|
||||||
# workaround needed.
|
|
||||||
WantedBy=default.target
|
|
||||||
29
networks.example.toml
Normal file
29
networks.example.toml
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
# breadcrumbs saved-networks file.
|
||||||
|
#
|
||||||
|
# Lives alongside breadcrumbs.toml at ~/.config/breadcrumbs/networks.toml,
|
||||||
|
# 0600 permissions. Shown here purely for reference — you normally never
|
||||||
|
# hand-edit this file; use `breadcrumbs add` / `scan` / `forget` instead.
|
||||||
|
#
|
||||||
|
# `password` is optional and only needed the first time breadcrumbs connects
|
||||||
|
# to a network. Once NetworkManager has durably saved the credential (either
|
||||||
|
# a brand-new connection profile, or an updated PSK on an existing one),
|
||||||
|
# breadcrumbs clears its own local copy and omits the `password` key
|
||||||
|
# entirely on the next save — both the plaintext-on-disk copy and the
|
||||||
|
# argv exposure on every subsequent connect go away for that network from
|
||||||
|
# then on. Omit `password` altogether for a genuinely open (no-security)
|
||||||
|
# network, or for one NetworkManager already knows about.
|
||||||
|
|
||||||
|
[[networks]]
|
||||||
|
ssid = "HomeWifi"
|
||||||
|
password = "REPLACE_ME"
|
||||||
|
hidden = false
|
||||||
|
|
||||||
|
[[networks]]
|
||||||
|
ssid = "WorkGuest"
|
||||||
|
password = "REPLACE_ME"
|
||||||
|
hidden = false
|
||||||
|
|
||||||
|
[[networks]]
|
||||||
|
ssid = "CorpWifi"
|
||||||
|
password = "REPLACE_ME"
|
||||||
|
hidden = false
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
# 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"
|
|
||||||
}
|
|
||||||
786
src/app.rs
Normal file
786
src/app.rs
Normal file
|
|
@ -0,0 +1,786 @@
|
||||||
|
//! CLI argument parsing and command handlers. This is the only module
|
||||||
|
//! `src/main.rs` calls into; everything else (the actual state machine,
|
||||||
|
//! nmcli/tailscale wrappers, config, …) is exercised directly by library
|
||||||
|
//! consumers (including the integration tests under `tests/`).
|
||||||
|
|
||||||
|
use std::io::{BufRead, Write};
|
||||||
|
use std::process::Command;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
|
use crate::config::{Config, NetworkDef};
|
||||||
|
use crate::state::{self, State};
|
||||||
|
use crate::util::{self, command_exists, home_dir};
|
||||||
|
use crate::{config, flow, nm, watch};
|
||||||
|
|
||||||
|
const C_RESET: &str = "\x1b[0m";
|
||||||
|
const C_BOLD: &str = "\x1b[1m";
|
||||||
|
const C_GREEN: &str = "\x1b[32m";
|
||||||
|
const C_RED: &str = "\x1b[31m";
|
||||||
|
const C_YELLOW: &str = "\x1b[33m";
|
||||||
|
const C_DIM: &str = "\x1b[2m";
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(
|
||||||
|
name = "breadcrumbs",
|
||||||
|
version,
|
||||||
|
about = "Profile-aware Wi-Fi state machine with Tailscale handling",
|
||||||
|
disable_help_subcommand = true
|
||||||
|
)]
|
||||||
|
struct Cli {
|
||||||
|
/// Override the active profile for this run only (does not persist)
|
||||||
|
#[arg(long, short, global = true)]
|
||||||
|
profile: Option<String>,
|
||||||
|
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: Option<Cmd>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Cmd {
|
||||||
|
/// Show current Wi-Fi / profile / Tailscale status (default)
|
||||||
|
Status,
|
||||||
|
/// Run the full connect sequence for the active profile
|
||||||
|
#[command(visible_aliases = ["up", "connect", "i"])]
|
||||||
|
Init,
|
||||||
|
/// Run as a daemon: watch for drops and auto-recover
|
||||||
|
Watch {
|
||||||
|
/// Skip the connect attempt on startup
|
||||||
|
#[arg(long)]
|
||||||
|
no_initial: bool,
|
||||||
|
},
|
||||||
|
/// Get / set / list location profiles (the state machine)
|
||||||
|
Profile {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: Option<ProfileCmd>,
|
||||||
|
},
|
||||||
|
/// Guess the profile from visible networks
|
||||||
|
Detect {
|
||||||
|
/// Set + apply the detected profile
|
||||||
|
#[arg(long)]
|
||||||
|
apply: bool,
|
||||||
|
},
|
||||||
|
/// Add or update a saved network
|
||||||
|
Add {
|
||||||
|
ssid: String,
|
||||||
|
/// Password (prompted if omitted)
|
||||||
|
password: Option<String>,
|
||||||
|
/// Network is hidden (does not broadcast its SSID)
|
||||||
|
#[arg(long)]
|
||||||
|
hidden: bool,
|
||||||
|
/// Attach this SSID to a profile's priority list
|
||||||
|
#[arg(long)]
|
||||||
|
to: Option<String>,
|
||||||
|
/// Position in the profile list (0 = highest priority)
|
||||||
|
#[arg(long)]
|
||||||
|
at: Option<usize>,
|
||||||
|
},
|
||||||
|
/// Remove a saved network (config + NetworkManager)
|
||||||
|
Forget { ssid: String },
|
||||||
|
/// Scan, pick, connect and save a network interactively
|
||||||
|
Scan {
|
||||||
|
/// Attach the saved network to this profile
|
||||||
|
#[arg(long)]
|
||||||
|
to: Option<String>,
|
||||||
|
},
|
||||||
|
/// List configured networks and profiles
|
||||||
|
List {
|
||||||
|
#[arg(long)]
|
||||||
|
show_passwords: bool,
|
||||||
|
},
|
||||||
|
/// Open the config file in $EDITOR
|
||||||
|
Edit,
|
||||||
|
/// Quick connectivity / Tailscale diagnostics
|
||||||
|
Doctor {
|
||||||
|
/// Run the full diag.sh report from the config directory
|
||||||
|
#[arg(long)]
|
||||||
|
full: bool,
|
||||||
|
},
|
||||||
|
/// Print the breadcrumbs config directory
|
||||||
|
Cd {
|
||||||
|
#[arg(long)]
|
||||||
|
shell: bool,
|
||||||
|
},
|
||||||
|
/// Install + enable the systemd user watcher service
|
||||||
|
InstallService {
|
||||||
|
/// Install the unit but do not enable/start it
|
||||||
|
#[arg(long)]
|
||||||
|
no_enable: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum ProfileCmd {
|
||||||
|
/// Print the active profile
|
||||||
|
Get,
|
||||||
|
/// Set the active profile (and apply it unless --no-apply)
|
||||||
|
Set {
|
||||||
|
name: String,
|
||||||
|
#[arg(long)]
|
||||||
|
no_apply: bool,
|
||||||
|
},
|
||||||
|
/// List available profiles
|
||||||
|
List,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `argv` and run the requested command. Returns the process exit code.
|
||||||
|
pub fn run() -> i32 {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
match real_main(cli) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("{C_RED}error:{C_RESET} {e}");
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_profile(cfg: &Config, override_p: &Option<String>) -> String {
|
||||||
|
if let Some(p) = override_p {
|
||||||
|
return p.clone();
|
||||||
|
}
|
||||||
|
State::load(&cfg.settings.default_profile).profile
|
||||||
|
}
|
||||||
|
|
||||||
|
fn real_main(cli: Cli) -> Result<i32, String> {
|
||||||
|
let cmd = cli.cmd.unwrap_or(Cmd::Status);
|
||||||
|
|
||||||
|
// `cd` and `install-service` don't need a parsed config first.
|
||||||
|
if let Cmd::Cd { shell } = &cmd {
|
||||||
|
return cmd_cd(*shell);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cfg = Config::load()?;
|
||||||
|
|
||||||
|
match cmd {
|
||||||
|
Cmd::Status => cmd_status(&cfg, &cli.profile),
|
||||||
|
Cmd::Init => {
|
||||||
|
let p = active_profile(&cfg, &cli.profile);
|
||||||
|
let outcome = flow::run(&mut 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(&mut cfg, action),
|
||||||
|
Cmd::Detect { apply } => cmd_detect(&mut cfg, apply),
|
||||||
|
Cmd::Add {
|
||||||
|
ssid,
|
||||||
|
password,
|
||||||
|
hidden,
|
||||||
|
to,
|
||||||
|
at,
|
||||||
|
} => cmd_add(&mut cfg, ssid, password, hidden, to, at),
|
||||||
|
Cmd::Forget { ssid } => cmd_forget(&mut cfg, &ssid),
|
||||||
|
Cmd::Scan { to } => cmd_scan(&mut cfg, to),
|
||||||
|
Cmd::List { show_passwords } => cmd_list(&cfg, show_passwords),
|
||||||
|
Cmd::Edit => cmd_edit(),
|
||||||
|
Cmd::Doctor { full } => cmd_doctor(&cfg, &cli.profile, full),
|
||||||
|
Cmd::InstallService { no_enable } => cmd_install_service(!no_enable),
|
||||||
|
Cmd::Cd { .. } => unreachable!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_outcome(profile: &str, o: &flow::Outcome) {
|
||||||
|
match o {
|
||||||
|
flow::Outcome::Connected { ssid, note } => {
|
||||||
|
print!("{C_GREEN}connected{C_RESET} {C_BOLD}{ssid}{C_RESET} ({profile})");
|
||||||
|
match note {
|
||||||
|
Some(n) => println!(" {C_YELLOW}— {n}{C_RESET}"),
|
||||||
|
None => println!(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flow::Outcome::TailscaleError { ssid, health } => {
|
||||||
|
println!(
|
||||||
|
"{C_RED}tailscale error{C_RESET}: {} {C_DIM}(on {}){C_RESET}",
|
||||||
|
health.describe(),
|
||||||
|
ssid.clone().unwrap_or_else(|| "—".into())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
flow::Outcome::NoInterface => {
|
||||||
|
println!("{C_RED}no Wi-Fi adapter{C_RESET} — hardware issue")
|
||||||
|
}
|
||||||
|
flow::Outcome::NoNetworks => {
|
||||||
|
println!("{C_RED}no known networks in range{C_RESET} (profile {profile})")
|
||||||
|
}
|
||||||
|
flow::Outcome::UnknownProfile(p) => {
|
||||||
|
println!("{C_RED}unknown profile{C_RESET}: {p}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_status(cfg: &Config, override_p: &Option<String>) -> Result<i32, String> {
|
||||||
|
let p = active_profile(cfg, override_p);
|
||||||
|
let s = crate::status::gather(cfg, &p);
|
||||||
|
|
||||||
|
let dot = |ok: bool| {
|
||||||
|
if ok {
|
||||||
|
format!("{C_GREEN}●{C_RESET}")
|
||||||
|
} else {
|
||||||
|
format!("{C_RED}●{C_RESET}")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
println!("{C_BOLD}breadcrumbs{C_RESET}");
|
||||||
|
println!(" profile {C_BOLD}{p}{C_RESET}");
|
||||||
|
println!(
|
||||||
|
" adapter {}",
|
||||||
|
s.iface
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("{C_RED}none{C_RESET}"))
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" ssid {}",
|
||||||
|
s.ssid
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("{C_DIM}—{C_RESET}"))
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" ip {}",
|
||||||
|
s.ip.clone().unwrap_or_else(|| format!("{C_DIM}—{C_RESET}"))
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" internet {} {}",
|
||||||
|
dot(s.internet),
|
||||||
|
if s.internet { "ok" } else { "down" }
|
||||||
|
);
|
||||||
|
|
||||||
|
match (&s.tailscale, s.tailscale_required) {
|
||||||
|
(Some(h), req) => {
|
||||||
|
let ok = h.is_ok();
|
||||||
|
println!(
|
||||||
|
" tailscale {} {} {C_DIM}(exit: {}{}){C_RESET}",
|
||||||
|
dot(ok || !req),
|
||||||
|
h.describe(),
|
||||||
|
s.exit_node,
|
||||||
|
if req { "" } else { ", optional" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
(None, _) => println!(" tailscale {C_DIM}not installed{C_RESET}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let healthy = s.internet
|
||||||
|
&& s.iface.is_some()
|
||||||
|
&& (!s.tailscale_required || s.tailscale.as_ref().map(|h| h.is_ok()).unwrap_or(false));
|
||||||
|
println!(
|
||||||
|
" state {}",
|
||||||
|
if healthy {
|
||||||
|
format!("{C_GREEN}healthy{C_RESET}")
|
||||||
|
} else {
|
||||||
|
format!("{C_YELLOW}needs attention{C_RESET} — run `breadcrumbs init`")
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Ok(if healthy { 0 } else { 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_profile(cfg: &mut Config, action: Option<ProfileCmd>) -> Result<i32, String> {
|
||||||
|
match action.unwrap_or(ProfileCmd::Get) {
|
||||||
|
ProfileCmd::Get => {
|
||||||
|
println!("{}", State::load(&cfg.settings.default_profile).profile);
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
ProfileCmd::List => {
|
||||||
|
let cur = State::load(&cfg.settings.default_profile).profile;
|
||||||
|
for name in cfg.profiles.keys() {
|
||||||
|
let mark = if *name == cur { "*" } else { " " };
|
||||||
|
println!("{mark} {name}");
|
||||||
|
}
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
ProfileCmd::Set { name, no_apply } => {
|
||||||
|
state::set_profile(cfg, &name)?;
|
||||||
|
println!("profile = {C_BOLD}{name}{C_RESET}");
|
||||||
|
if no_apply {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let outcome = flow::run(cfg, &name);
|
||||||
|
print_outcome(&name, &outcome);
|
||||||
|
Ok(if outcome.ok() { 0 } else { 1 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_profile(cfg: &Config) -> Option<String> {
|
||||||
|
let iface = nm::wifi_interface()?;
|
||||||
|
nm::radio_on();
|
||||||
|
nm::rescan(&iface, &[]);
|
||||||
|
let visible = nm::visible_ssids(&iface);
|
||||||
|
|
||||||
|
// Profiles are stored in a BTreeMap so iteration order is deterministic
|
||||||
|
// (alphabetical). The caller can rely on that for tie-breaking.
|
||||||
|
for (name, profile) in &cfg.profiles {
|
||||||
|
if profile.detect_ssids.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if profile
|
||||||
|
.detect_ssids
|
||||||
|
.iter()
|
||||||
|
.any(|s| visible.contains(s.as_str()))
|
||||||
|
{
|
||||||
|
return Some(name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to the default profile if no markers matched.
|
||||||
|
Some(cfg.settings.default_profile.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_detect(cfg: &mut Config, apply: bool) -> Result<i32, String> {
|
||||||
|
match detect_profile(cfg) {
|
||||||
|
Some(p) => {
|
||||||
|
println!("{p}");
|
||||||
|
if apply {
|
||||||
|
State {
|
||||||
|
profile: p.clone(),
|
||||||
|
updated: crate::util::timestamp(),
|
||||||
|
}
|
||||||
|
.save()?;
|
||||||
|
let outcome = flow::run(cfg, &p);
|
||||||
|
print_outcome(&p, &outcome);
|
||||||
|
return Ok(if outcome.ok() { 0 } else { 1 });
|
||||||
|
}
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
None => Err("could not detect a profile (no Wi-Fi adapter?)".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_line(msg: &str) -> String {
|
||||||
|
print!("{msg}");
|
||||||
|
let _ = std::io::stdout().flush();
|
||||||
|
let mut s = String::new();
|
||||||
|
let _ = std::io::stdin().lock().read_line(&mut s);
|
||||||
|
s.trim_end_matches(['\n', '\r']).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An empty string entered for a password (CLI arg or a blank prompt
|
||||||
|
/// response) means "this network has no password" (open Wi-Fi) — normalize
|
||||||
|
/// it to `None` right at the point of entry so it flows the same way a
|
||||||
|
/// genuinely absent/cleared password does. Without this, `Some("")` would
|
||||||
|
/// make `nm::connect_verbose` treat it as a (blank) secret rather than an
|
||||||
|
/// open network, and the connect fails against a real open SSID.
|
||||||
|
fn non_empty(s: String) -> Option<String> {
|
||||||
|
if s.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_secret(msg: &str) -> String {
|
||||||
|
// `util::run` redirects child stdin to /dev/null, so plain `stty -echo`
|
||||||
|
// would target the wrong fd and silently leave echo ON (leaking the
|
||||||
|
// password to the screen). `-F /dev/tty` makes stty act on the controlling
|
||||||
|
// terminal directly. If there is no tty we fall back to visible input.
|
||||||
|
let had_tty = util::run("stty", &["-F", "/dev/tty", "-echo"], Duration::from_secs(2)).success;
|
||||||
|
let val = prompt_line(msg);
|
||||||
|
if had_tty {
|
||||||
|
let _ = util::run("stty", &["-F", "/dev/tty", "echo"], Duration::from_secs(2));
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
val
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_add(
|
||||||
|
cfg: &mut Config,
|
||||||
|
ssid: String,
|
||||||
|
password: Option<String>,
|
||||||
|
hidden: bool,
|
||||||
|
to: Option<String>,
|
||||||
|
at: Option<usize>,
|
||||||
|
) -> Result<i32, String> {
|
||||||
|
let password = match password {
|
||||||
|
Some(p) => p,
|
||||||
|
None => prompt_secret(&format!("Password for '{ssid}': ")),
|
||||||
|
};
|
||||||
|
let password = non_empty(password);
|
||||||
|
match cfg.networks.iter_mut().find(|n| n.ssid == ssid) {
|
||||||
|
Some(n) => {
|
||||||
|
n.password = password;
|
||||||
|
n.hidden = hidden || n.hidden;
|
||||||
|
}
|
||||||
|
None => cfg.networks.push(NetworkDef {
|
||||||
|
ssid: ssid.clone(),
|
||||||
|
password,
|
||||||
|
hidden,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
if let Some(prof_name) = to {
|
||||||
|
let prof = cfg
|
||||||
|
.profiles
|
||||||
|
.get_mut(&prof_name)
|
||||||
|
.ok_or_else(|| format!("unknown profile '{prof_name}'"))?;
|
||||||
|
prof.networks.retain(|s| s != &ssid);
|
||||||
|
let idx = at.unwrap_or(prof.networks.len()).min(prof.networks.len());
|
||||||
|
prof.networks.insert(idx, ssid.clone());
|
||||||
|
}
|
||||||
|
cfg.save()?;
|
||||||
|
println!("{C_GREEN}saved{C_RESET} {ssid}");
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_forget(cfg: &mut Config, ssid: &str) -> Result<i32, String> {
|
||||||
|
let before = cfg.networks.len();
|
||||||
|
cfg.networks.retain(|n| n.ssid != ssid);
|
||||||
|
for p in cfg.profiles.values_mut() {
|
||||||
|
p.networks.retain(|s| s != ssid);
|
||||||
|
if p.bootstrap.as_deref() == Some(ssid) {
|
||||||
|
p.bootstrap = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cfg.save()?;
|
||||||
|
let removed = nm::delete_connections_for_ssid(ssid);
|
||||||
|
println!(
|
||||||
|
"{C_GREEN}forgot{C_RESET} {ssid} (config: {}, NetworkManager: {})",
|
||||||
|
if cfg.networks.len() < before {
|
||||||
|
"removed"
|
||||||
|
} else {
|
||||||
|
"not present"
|
||||||
|
},
|
||||||
|
if removed { "removed" } else { "not present" }
|
||||||
|
);
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_scan(cfg: &mut Config, to: Option<String>) -> Result<i32, String> {
|
||||||
|
let iface = nm::wifi_interface().ok_or("no Wi-Fi adapter")?;
|
||||||
|
nm::radio_on();
|
||||||
|
nm::rescan(&iface, &[]);
|
||||||
|
let entries = nm::scan_list(&iface);
|
||||||
|
if entries.is_empty() {
|
||||||
|
return Err("no networks found".into());
|
||||||
|
}
|
||||||
|
for (i, e) in entries.iter().enumerate() {
|
||||||
|
println!(
|
||||||
|
"{:>2}. {C_BOLD}{}{C_RESET} {C_DIM}sig {} {}{C_RESET}",
|
||||||
|
i + 1,
|
||||||
|
if e.ssid.is_empty() {
|
||||||
|
"<hidden>"
|
||||||
|
} else {
|
||||||
|
&e.ssid
|
||||||
|
},
|
||||||
|
e.signal,
|
||||||
|
e.security
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let sel = prompt_line("Select number: ");
|
||||||
|
let idx: usize = sel
|
||||||
|
.parse::<usize>()
|
||||||
|
.ok()
|
||||||
|
.filter(|n| *n >= 1 && *n <= entries.len())
|
||||||
|
.ok_or("invalid selection")?;
|
||||||
|
let ssid = entries[idx - 1].ssid.clone();
|
||||||
|
if ssid.is_empty() {
|
||||||
|
return Err("cannot select a hidden SSID here; use `breadcrumbs add`".into());
|
||||||
|
}
|
||||||
|
let password = non_empty(prompt_secret(&format!("Password for '{ssid}': ")));
|
||||||
|
let mut def = NetworkDef {
|
||||||
|
ssid: ssid.clone(),
|
||||||
|
password,
|
||||||
|
hidden: false,
|
||||||
|
};
|
||||||
|
if !nm::connect(&iface, &def, cfg.settings.nmcli_wait, &cfg.settings.dns) {
|
||||||
|
return Err(format!("failed to connect to {ssid}"));
|
||||||
|
}
|
||||||
|
// A successful connect means NetworkManager now durably holds the PSK
|
||||||
|
// (either in a freshly created profile, or one whose PSK we just set) —
|
||||||
|
// breadcrumbs no longer needs to keep its own plaintext copy.
|
||||||
|
def.password = None;
|
||||||
|
match cfg.networks.iter_mut().find(|n| n.ssid == ssid) {
|
||||||
|
Some(n) => n.password = None,
|
||||||
|
None => cfg.networks.push(def),
|
||||||
|
}
|
||||||
|
if let Some(prof_name) = to {
|
||||||
|
if let Some(prof) = cfg.profiles.get_mut(&prof_name) {
|
||||||
|
if !prof.networks.contains(&ssid) {
|
||||||
|
prof.networks.push(ssid.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cfg.save()?;
|
||||||
|
println!("{C_GREEN}connected + saved{C_RESET} {ssid}");
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mask a secret for display. Operates on chars (not bytes) so multi-byte
|
||||||
|
/// UTF-8 passwords don't panic on a mid-character byte slice, and never
|
||||||
|
/// echoes back any real character of the secret (previously the first byte
|
||||||
|
/// was shown unmasked).
|
||||||
|
fn mask(p: &str) -> String {
|
||||||
|
"•".repeat(p.chars().count().max(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_list(cfg: &Config, show_pw: bool) -> Result<i32, String> {
|
||||||
|
println!("{C_BOLD}settings{C_RESET}");
|
||||||
|
println!(" dns {}", cfg.settings.dns);
|
||||||
|
println!(" exit_node {}", cfg.settings.exit_node);
|
||||||
|
println!(" default {}", cfg.settings.default_profile);
|
||||||
|
println!(" watch every {}s", cfg.settings.watch_interval);
|
||||||
|
|
||||||
|
println!("\n{C_BOLD}networks{C_RESET}");
|
||||||
|
for n in &cfg.networks {
|
||||||
|
let pw_display = match &n.password {
|
||||||
|
Some(p) if show_pw => p.clone(),
|
||||||
|
Some(p) => mask(p),
|
||||||
|
// No local secret: NetworkManager already owns the credential for
|
||||||
|
// this SSID, so there is nothing to mask — showing dots here
|
||||||
|
// would falsely imply breadcrumbs is still hiding a password.
|
||||||
|
None => format!("{C_DIM}managed by NetworkManager{C_RESET}"),
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
" {C_BOLD}{}{C_RESET} {C_DIM}{}{}{C_RESET}",
|
||||||
|
n.ssid,
|
||||||
|
pw_display,
|
||||||
|
if n.hidden { " (hidden)" } else { "" }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("\n{C_BOLD}profiles{C_RESET}");
|
||||||
|
let cur = State::load(&cfg.settings.default_profile).profile;
|
||||||
|
for (name, p) in &cfg.profiles {
|
||||||
|
let mark = if *name == cur {
|
||||||
|
format!("{C_GREEN}*{C_RESET}")
|
||||||
|
} else {
|
||||||
|
" ".into()
|
||||||
|
};
|
||||||
|
println!("{mark} {C_BOLD}{name}{C_RESET}");
|
||||||
|
if let Some(b) = &p.bootstrap {
|
||||||
|
println!(" bootstrap {b}");
|
||||||
|
}
|
||||||
|
if p.tailscale {
|
||||||
|
println!(
|
||||||
|
" tailscale required (exit: {})",
|
||||||
|
p.exit_node
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| cfg.settings.exit_node.clone())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut order: Vec<String> = p.networks.clone();
|
||||||
|
if p.include_all_known {
|
||||||
|
order.push("…all other known networks".into());
|
||||||
|
}
|
||||||
|
println!(" priority {}", order.join(" > "));
|
||||||
|
}
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_edit() -> Result<i32, String> {
|
||||||
|
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".into());
|
||||||
|
let path = config::config_path();
|
||||||
|
let status = Command::new(&editor)
|
||||||
|
.arg(&path)
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("launching {editor}: {e}"))?;
|
||||||
|
if !status.success() {
|
||||||
|
return Err("editor exited with error".into());
|
||||||
|
}
|
||||||
|
match Config::load() {
|
||||||
|
Ok(_) => {
|
||||||
|
println!("{C_GREEN}config OK{C_RESET}");
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
Err(e) => Err(format!("config is now invalid: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_doctor(cfg: &Config, override_p: &Option<String>, full: bool) -> Result<i32, String> {
|
||||||
|
if full {
|
||||||
|
let script = config::config_dir().join("diag.sh");
|
||||||
|
if !script.exists() {
|
||||||
|
return Err(format!(
|
||||||
|
"diag.sh not found (expected at {})",
|
||||||
|
script.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let st = Command::new("bash")
|
||||||
|
.arg(&script)
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("running diag: {e}"))?;
|
||||||
|
return Ok(st.code().unwrap_or(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
let p = active_profile(cfg, override_p);
|
||||||
|
let s = crate::status::gather(cfg, &p);
|
||||||
|
println!("{C_BOLD}breadcrumbs doctor{C_RESET} (profile {p})");
|
||||||
|
println!(
|
||||||
|
" nmcli {}",
|
||||||
|
if command_exists("nmcli") {
|
||||||
|
"present"
|
||||||
|
} else {
|
||||||
|
"MISSING"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" tailscale {}",
|
||||||
|
if command_exists("tailscale") {
|
||||||
|
"present"
|
||||||
|
} else {
|
||||||
|
"absent"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" adapter {}",
|
||||||
|
s.iface.clone().unwrap_or_else(|| "none".into())
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" ssid {}",
|
||||||
|
s.ssid.clone().unwrap_or_else(|| "—".into())
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" ip {}",
|
||||||
|
s.ip.clone().unwrap_or_else(|| "—".into())
|
||||||
|
);
|
||||||
|
println!(" internet {}", if s.internet { "ok" } else { "DOWN" });
|
||||||
|
if let Some(h) = &s.tailscale {
|
||||||
|
println!(" tailscale {} (exit {})", h.describe(), s.exit_node);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(iface) = &s.iface {
|
||||||
|
let visible = nm::visible_ssids(iface);
|
||||||
|
let known: Vec<&str> = cfg
|
||||||
|
.networks
|
||||||
|
.iter()
|
||||||
|
.filter(|n| visible.contains(&n.ssid))
|
||||||
|
.map(|n| n.ssid.as_str())
|
||||||
|
.collect();
|
||||||
|
println!(
|
||||||
|
" in range {}",
|
||||||
|
if known.is_empty() {
|
||||||
|
"none of your saved networks".into()
|
||||||
|
} else {
|
||||||
|
known.join(", ")
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!("\nFull report: {C_DIM}breadcrumbs doctor --full{C_RESET}");
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_cd(shell: bool) -> Result<i32, String> {
|
||||||
|
let dir = config::config_dir();
|
||||||
|
if shell {
|
||||||
|
let sh = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into());
|
||||||
|
let err = exec_replace(&sh, &dir);
|
||||||
|
return Err(err);
|
||||||
|
}
|
||||||
|
println!("{}", dir.display());
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-exec into an interactive login shell inside `dir`, replacing the
|
||||||
|
/// current process. `dir` is passed as `$1` to the shell script rather than
|
||||||
|
/// interpolated into the script text — the config dir can come from
|
||||||
|
/// `$XDG_CONFIG_HOME`/`$HOME`, and string-formatting an arbitrary path
|
||||||
|
/// straight into a `sh -c` command would let shell metacharacters (`$(...)`,
|
||||||
|
/// backticks, etc.) in that path execute as commands.
|
||||||
|
fn exec_replace(prog: &str, dir: &std::path::Path) -> String {
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
let e = Command::new(prog)
|
||||||
|
.arg("-lc")
|
||||||
|
.arg("cd \"$1\" && exec \"$0\"")
|
||||||
|
.arg(prog)
|
||||||
|
.arg(dir)
|
||||||
|
.exec();
|
||||||
|
format!("exec {prog} failed: {e}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_install_service(enable: bool) -> Result<i32, String> {
|
||||||
|
let unit_dir = home_dir().join(".config/systemd/user");
|
||||||
|
std::fs::create_dir_all(&unit_dir)
|
||||||
|
.map_err(|e| format!("creating {}: {e}", unit_dir.display()))?;
|
||||||
|
let bin = std::env::current_exe().map_err(|e| format!("resolving current executable: {e}"))?;
|
||||||
|
// Ordering against graphical-session.target lets the watcher inherit the
|
||||||
|
// session's DISPLAY/WAYLAND_DISPLAY/DBUS so notify-send and the Tailscale
|
||||||
|
// login browser-open actually work. PATH is pinned because systemd --user
|
||||||
|
// units do not get the login shell's PATH, and the watcher shells out to
|
||||||
|
// nmcli/tailscale/sudo/xdg-open by name.
|
||||||
|
let unit = format!(
|
||||||
|
"[Unit]\n\
|
||||||
|
Description=breadcrumbs Wi-Fi state machine watcher\n\
|
||||||
|
After=network.target NetworkManager.service graphical-session.target\n\
|
||||||
|
Wants=network.target graphical-session.target\n\n\
|
||||||
|
[Service]\n\
|
||||||
|
Type=simple\n\
|
||||||
|
Environment=PATH=/usr/local/bin:/usr/bin:/bin\n\
|
||||||
|
ExecStart={bin} watch\n\
|
||||||
|
Restart=always\n\
|
||||||
|
RestartSec=5\n\
|
||||||
|
Nice=5\n\n\
|
||||||
|
[Install]\n\
|
||||||
|
WantedBy=default.target\n",
|
||||||
|
bin = bin.display()
|
||||||
|
);
|
||||||
|
let unit_path = unit_dir.join("breadcrumbs.service");
|
||||||
|
std::fs::write(&unit_path, unit)
|
||||||
|
.map_err(|e| format!("writing {}: {e}", unit_path.display()))?;
|
||||||
|
println!("{C_GREEN}wrote{C_RESET} {}", unit_path.display());
|
||||||
|
|
||||||
|
let _ = util::run(
|
||||||
|
"systemctl",
|
||||||
|
&["--user", "daemon-reload"],
|
||||||
|
Duration::from_secs(10),
|
||||||
|
);
|
||||||
|
if enable {
|
||||||
|
let o = util::run(
|
||||||
|
"systemctl",
|
||||||
|
&["--user", "enable", "--now", "breadcrumbs.service"],
|
||||||
|
Duration::from_secs(15),
|
||||||
|
);
|
||||||
|
if o.success {
|
||||||
|
println!("{C_GREEN}enabled + started{C_RESET} breadcrumbs.service");
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
"{C_YELLOW}unit installed{C_RESET}; enable failed: {}",
|
||||||
|
o.stderr.trim()
|
||||||
|
);
|
||||||
|
return Ok(1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!("Run: systemctl --user enable --now breadcrumbs.service");
|
||||||
|
}
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mask_empty_password() {
|
||||||
|
// len() == 0 <= 2 branch: still at least 2 dots so an empty saved
|
||||||
|
// password doesn't visually collapse to nothing in `list`.
|
||||||
|
assert_eq!(mask(""), "••");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mask_short_passwords_reveal_nothing() {
|
||||||
|
assert_eq!(mask("a"), "••");
|
||||||
|
assert_eq!(mask("ab"), "••");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mask_never_echoes_a_real_character() {
|
||||||
|
let pw = "hunter2";
|
||||||
|
let masked = mask(pw);
|
||||||
|
assert_eq!(masked, "•".repeat(pw.len()));
|
||||||
|
assert!(!masked.contains('h'), "masked output leaked the first char");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mask_multibyte_password_does_not_panic() {
|
||||||
|
// Regression test: the old byte-slicing `&p[..1]` panicked whenever
|
||||||
|
// the first character of the password was multi-byte UTF-8 (e.g. an
|
||||||
|
// emoji or accented character), since byte index 1 can land mid-char.
|
||||||
|
let pw = "日本語パスワード";
|
||||||
|
let masked = mask(pw);
|
||||||
|
assert_eq!(masked.chars().count(), pw.chars().count());
|
||||||
|
assert!(masked.chars().all(|c| c == '•'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mask_emoji_first_character_does_not_panic() {
|
||||||
|
let pw = "🔒password123";
|
||||||
|
let masked = mask(pw);
|
||||||
|
assert_eq!(masked.chars().count(), pw.chars().count());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
//! 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
97
src/bread_events.rs
Normal file
97
src/bread_events.rs
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
//! `bread.crumbs.*` event integration — optional, non-blocking. See
|
||||||
|
//! `EVENTS.md` at the repo root for the full contract. breadcrumbs works
|
||||||
|
//! identically with or without breadd running; every call here is
|
||||||
|
//! fire-and-forget (`BreadClient::emit` never blocks or errors this
|
||||||
|
//! process) so a missing or restarting breadd never affects Wi-Fi
|
||||||
|
//! automation itself.
|
||||||
|
|
||||||
|
use bread_utils::bread_client::{BreadClient, BreadEvent};
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::state;
|
||||||
|
|
||||||
|
/// This app's id in bread's sibling-app namespace registry
|
||||||
|
/// (`bread_shared::apps::KNOWN_APPS`) — events publish as `bread.crumbs.*`,
|
||||||
|
/// commands arrive on `bread.command.crumbs.*`.
|
||||||
|
pub const APP_ID: &str = "crumbs";
|
||||||
|
|
||||||
|
pub fn client() -> BreadClient {
|
||||||
|
BreadClient::connect(APP_ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn emit_profile_changed(client: &BreadClient, from: &str, to: &str) {
|
||||||
|
client.emit(
|
||||||
|
"bread.crumbs.profile.changed",
|
||||||
|
serde_json::json!({ "from": from, "to": to }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn emit_health_changed(client: &BreadClient, profile: &str, health: &str, ssid: Option<&str>) {
|
||||||
|
client.emit(
|
||||||
|
"bread.crumbs.health.changed",
|
||||||
|
serde_json::json!({
|
||||||
|
"profile": profile,
|
||||||
|
"health": health,
|
||||||
|
"ssid": ssid,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reacts to `bread.command.crumbs.*` verbs. Only `set_profile` maps to
|
||||||
|
/// real, existing breadcrumbs functionality today — there is no pin/select
|
||||||
|
/// (or other) verb because breadcrumbs has no such concept. Unrecognized
|
||||||
|
/// verbs are ignored, not stubbed as no-ops that pretend to succeed.
|
||||||
|
///
|
||||||
|
/// Returns `true` when a profile was actually persisted, so the watch loop
|
||||||
|
/// can wake immediately and re-evaluate instead of waiting out the current
|
||||||
|
/// poll interval.
|
||||||
|
///
|
||||||
|
/// Emits `bread.crumbs.set_profile.done`/`.failed` per the confirmation
|
||||||
|
/// convention in bread's Documentation.md.
|
||||||
|
pub fn handle_command(event: &BreadEvent) -> bool {
|
||||||
|
let Some(verb) = event.event.strip_prefix("bread.command.crumbs.") else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
match verb {
|
||||||
|
"set_profile" => handle_set_profile(event),
|
||||||
|
other => {
|
||||||
|
crate::notify::log(&format!(
|
||||||
|
"watch: ignoring unrecognized bread.command.crumbs.{other}"
|
||||||
|
));
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_set_profile(event: &BreadEvent) -> bool {
|
||||||
|
let Some(name) = event.data.get("profile").and_then(|v| v.as_str()) else {
|
||||||
|
emit_set_profile_failed("missing string \"profile\" in command data");
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
match Config::load().and_then(|cfg| state::set_profile(&cfg, name)) {
|
||||||
|
Ok(()) => {
|
||||||
|
crate::notify::log(&format!(
|
||||||
|
"watch: profile set via bread.command.crumbs.set_profile -> {name}"
|
||||||
|
));
|
||||||
|
client().emit(
|
||||||
|
"bread.crumbs.set_profile.done",
|
||||||
|
serde_json::json!({ "profile": name }),
|
||||||
|
);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
emit_set_profile_failed(&e);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_set_profile_failed(error: &str) {
|
||||||
|
crate::notify::log(&format!(
|
||||||
|
"watch: bread.command.crumbs.set_profile failed: {error}"
|
||||||
|
));
|
||||||
|
client().emit(
|
||||||
|
"bread.crumbs.set_profile.failed",
|
||||||
|
serde_json::json!({ "error": error }),
|
||||||
|
);
|
||||||
|
}
|
||||||
271
src/config.rs
271
src/config.rs
|
|
@ -63,7 +63,17 @@ impl Default for Settings {
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct NetworkDef {
|
pub struct NetworkDef {
|
||||||
pub ssid: String,
|
pub ssid: String,
|
||||||
pub password: String,
|
/// A password is only needed once. On first successful connect,
|
||||||
|
/// NetworkManager durably saves the credential (a new connection
|
||||||
|
/// profile, or an updated PSK on an existing one); breadcrumbs then
|
||||||
|
/// clears this field and, on the next save, omits the key entirely
|
||||||
|
/// rather than writing a plaintext copy that's no longer needed. `None`
|
||||||
|
/// means either "NetworkManager already owns this secret" or "this is
|
||||||
|
/// an open (unsecured) network" — both cases behave the same way on
|
||||||
|
/// connect: no PSK is sent to nmcli at all. When `Some`, the PSK is
|
||||||
|
/// fed to `nmcli --ask` on stdin, never as an argv element.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub password: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub hidden: bool,
|
pub hidden: bool,
|
||||||
}
|
}
|
||||||
|
|
@ -95,12 +105,30 @@ pub struct Profile {
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub settings: Settings,
|
pub settings: Settings,
|
||||||
#[serde(default, rename = "networks")]
|
/// Saved networks (SSID + optional local password). Persisted to the
|
||||||
|
/// separate `networks.toml` file (see [`networks_path`]), not to
|
||||||
|
/// `breadcrumbs.toml` — kept here, and still deserialized from a
|
||||||
|
/// `[[networks]]` block if one is present in `breadcrumbs.toml`, purely
|
||||||
|
/// for backward compatibility with configs written before the secrets
|
||||||
|
/// split: an old-format file's inline networks load in on first read and
|
||||||
|
/// migrate to `networks.toml` automatically on the next `save()`, no
|
||||||
|
/// explicit migration step required.
|
||||||
|
#[serde(default, rename = "networks", skip_serializing)]
|
||||||
pub networks: Vec<NetworkDef>,
|
pub networks: Vec<NetworkDef>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub profiles: BTreeMap<String, Profile>,
|
pub profiles: BTreeMap<String, Profile>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The on-disk shape of `networks.toml`: just the `[[networks]]` array,
|
||||||
|
/// split out of the main config so a file that's mostly just settings and
|
||||||
|
/// profiles (the parts people actually hand-edit or dotfile) doesn't also
|
||||||
|
/// carry whatever plaintext Wi-Fi credentials breadcrumbs still holds.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
struct NetworksFile {
|
||||||
|
#[serde(default, rename = "networks")]
|
||||||
|
networks: Vec<NetworkDef>,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn config_dir() -> PathBuf {
|
pub fn config_dir() -> PathBuf {
|
||||||
std::env::var_os("XDG_CONFIG_HOME")
|
std::env::var_os("XDG_CONFIG_HOME")
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
|
|
@ -112,6 +140,13 @@ pub fn config_path() -> PathBuf {
|
||||||
config_dir().join("breadcrumbs.toml")
|
config_dir().join("breadcrumbs.toml")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where saved networks (SSID + optional local password) live, split out of
|
||||||
|
/// `breadcrumbs.toml`. Not meant to be hand-edited — managed via
|
||||||
|
/// `breadcrumbs add` / `scan` / `forget`.
|
||||||
|
pub fn networks_path() -> PathBuf {
|
||||||
|
config_dir().join("networks.toml")
|
||||||
|
}
|
||||||
|
|
||||||
pub fn state_dir() -> PathBuf {
|
pub fn state_dir() -> PathBuf {
|
||||||
std::env::var_os("XDG_STATE_HOME")
|
std::env::var_os("XDG_STATE_HOME")
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
|
|
@ -146,26 +181,69 @@ impl Config {
|
||||||
}
|
}
|
||||||
let text =
|
let text =
|
||||||
fs::read_to_string(&path).map_err(|e| format!("reading {}: {e}", path.display()))?;
|
fs::read_to_string(&path).map_err(|e| format!("reading {}: {e}", path.display()))?;
|
||||||
|
// May carry a legacy inline `[[networks]]` block (pre-split
|
||||||
|
// configs) — that's fine, see the field doc on `Config::networks`.
|
||||||
let mut cfg: Config =
|
let mut cfg: Config =
|
||||||
toml::from_str(&text).map_err(|e| format!("parsing {}: {e}", path.display()))?;
|
toml::from_str(&text).map_err(|e| format!("parsing {}: {e}", path.display()))?;
|
||||||
|
|
||||||
|
let net_path = networks_path();
|
||||||
|
if net_path.exists() {
|
||||||
|
let net_text = fs::read_to_string(&net_path)
|
||||||
|
.map_err(|e| format!("reading {}: {e}", net_path.display()))?;
|
||||||
|
let nf: NetworksFile = toml::from_str(&net_text)
|
||||||
|
.map_err(|e| format!("parsing {}: {e}", net_path.display()))?;
|
||||||
|
cfg.networks = nf.networks;
|
||||||
|
}
|
||||||
|
// else: no networks.toml yet — keep whatever legacy inline networks
|
||||||
|
// were read from breadcrumbs.toml above (or none, on a genuinely
|
||||||
|
// fresh config). The next `save()` writes them to networks.toml and
|
||||||
|
// stops writing them into breadcrumbs.toml, completing the migration.
|
||||||
|
|
||||||
// Self-heal: guarantee the three core profiles always exist.
|
// Self-heal: guarantee the three core profiles always exist.
|
||||||
ensure_core_profiles(&mut cfg);
|
ensure_core_profiles(&mut cfg);
|
||||||
Ok(cfg)
|
Ok(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persist settings + profiles to `breadcrumbs.toml` and networks to the
|
||||||
|
/// separate `networks.toml`, both `0600`. Every mutating command (`add`,
|
||||||
|
/// `forget`, `scan`, `profile set`, and `flow::run`'s own credential
|
||||||
|
/// clearing) goes through this single method so the two files never
|
||||||
|
/// drift out of sync with each other.
|
||||||
pub fn save(&self) -> Result<(), String> {
|
pub fn save(&self) -> Result<(), String> {
|
||||||
let dir = config_dir();
|
let dir = config_dir();
|
||||||
fs::create_dir_all(&dir).map_err(|e| format!("creating {}: {e}", dir.display()))?;
|
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 text = toml::to_string_pretty(self).map_err(|e| format!("serializing config: {e}"))?;
|
||||||
let path = config_path();
|
let path = config_path();
|
||||||
// Plaintext Wi-Fi passwords live here: write atomically and owner-only,
|
fs::write(&path, text).map_err(|e| format!("writing {}: {e}", path.display()))?;
|
||||||
// so there's no torn read and no world-readable window.
|
secure_permissions(&path);
|
||||||
crate::util::write_atomic(&path, &text, 0o600)
|
|
||||||
.map_err(|e| format!("writing {}: {e}", path.display()))?;
|
let nf = NetworksFile {
|
||||||
|
networks: self.networks.clone(),
|
||||||
|
};
|
||||||
|
let net_text =
|
||||||
|
toml::to_string_pretty(&nf).map_err(|e| format!("serializing networks: {e}"))?;
|
||||||
|
let net_path = networks_path();
|
||||||
|
fs::write(&net_path, net_text)
|
||||||
|
.map_err(|e| format!("writing {}: {e}", net_path.display()))?;
|
||||||
|
secure_permissions(&net_path);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Any local Wi-Fi passwords still held (pre-first-connect, or a network
|
||||||
|
/// breadcrumbs doesn't yet know NetworkManager owns) live in plaintext on
|
||||||
|
/// disk — keep both config files owner-only. Best-effort: a failure here
|
||||||
|
/// isn't fatal to saving the config itself.
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn secure_permissions(path: &std::path::Path) {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn secure_permissions(_path: &std::path::Path) {}
|
||||||
|
|
||||||
/// Initial skeleton networks generated for a brand-new installation.
|
/// Initial skeleton networks generated for a brand-new installation.
|
||||||
/// Passwords are intentionally blank — secrets never live in source.
|
/// Passwords are intentionally blank — secrets never live in source.
|
||||||
/// Users fill them via `breadcrumbs add`, `breadcrumbs scan`, or
|
/// Users fill them via `breadcrumbs add`, `breadcrumbs scan`, or
|
||||||
|
|
@ -273,122 +351,123 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ensure_core_profiles_does_not_overwrite_existing() {
|
fn ensure_core_profiles_preserves_user_customized_core_profile() {
|
||||||
let mut cfg = Config {
|
// A user-edited "home" (custom SSIDs) must not be clobbered by the
|
||||||
settings: Settings::default(),
|
// self-heal backfill — only genuinely *missing* core profiles should
|
||||||
networks: vec![],
|
// be inserted.
|
||||||
profiles: BTreeMap::new(),
|
let mut profiles = BTreeMap::new();
|
||||||
};
|
profiles.insert(
|
||||||
cfg.profiles.insert(
|
|
||||||
"home".to_string(),
|
"home".to_string(),
|
||||||
Profile {
|
Profile {
|
||||||
tailscale: true,
|
networks: vec!["CustomSSID".into()],
|
||||||
exit_node: Some("mynode".into()),
|
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
let mut cfg = Config {
|
||||||
|
settings: Settings::default(),
|
||||||
|
networks: vec![],
|
||||||
|
profiles,
|
||||||
|
};
|
||||||
ensure_core_profiles(&mut cfg);
|
ensure_core_profiles(&mut cfg);
|
||||||
let home = cfg.profile("home").unwrap();
|
assert_eq!(
|
||||||
assert!(home.tailscale, "existing field should be preserved");
|
cfg.profile("home").unwrap().networks,
|
||||||
assert_eq!(home.exit_node.as_deref(), Some("mynode"));
|
vec!["CustomSSID".to_string()]
|
||||||
|
);
|
||||||
|
// Still backfills the ones that were actually missing.
|
||||||
|
assert!(cfg.profile("work").is_some());
|
||||||
|
assert!(cfg.profile("away").is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn network_lookup_found_and_not_found() {
|
fn settings_default_matches_documented_defaults() {
|
||||||
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();
|
let s = Settings::default();
|
||||||
assert_eq!(s.dns, "1.1.1.1");
|
assert_eq!(s.dns, "1.1.1.1");
|
||||||
assert_eq!(s.nmcli_wait, 8);
|
assert_eq!(s.nmcli_wait, 8);
|
||||||
assert!(s.exit_node.is_empty());
|
|
||||||
assert_eq!(s.default_profile, "away");
|
assert_eq!(s.default_profile, "away");
|
||||||
assert_eq!(s.watch_interval, 12);
|
assert_eq!(s.watch_interval, 12);
|
||||||
assert!(!s.connectivity_url.is_empty());
|
assert_eq!(s.ping_host, "1.1.1.1");
|
||||||
assert!(!s.ping_host.is_empty());
|
assert!(s.exit_node.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn config_toml_roundtrip_with_hidden_network() {
|
fn network_def_hidden_defaults_false_when_omitted() {
|
||||||
let mut cfg = build_initial_config();
|
let text = r#"ssid = "Cafe"
|
||||||
cfg.networks.push(NetworkDef {
|
password = "pw""#;
|
||||||
ssid: "HiddenNet".into(),
|
let n: NetworkDef = toml::from_str(text).unwrap();
|
||||||
password: "pw".into(),
|
assert!(!n.hidden);
|
||||||
hidden: true,
|
}
|
||||||
});
|
|
||||||
cfg.networks.push(NetworkDef {
|
#[test]
|
||||||
ssid: "VisibleNet".into(),
|
fn network_def_password_defaults_to_none_when_key_absent() {
|
||||||
password: "pw2".into(),
|
// No `password` key at all — e.g. a network whose secret breadcrumbs
|
||||||
|
// already cleared after NetworkManager took it over.
|
||||||
|
let text = r#"ssid = "Cafe"
|
||||||
|
hidden = false"#;
|
||||||
|
let n: NetworkDef = toml::from_str(text).unwrap();
|
||||||
|
assert_eq!(n.password, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn network_def_omits_password_key_entirely_when_none() {
|
||||||
|
// Round-tripping a cleared password must not write `password = ""`
|
||||||
|
// (which would read back as "has an empty secret") or any other
|
||||||
|
// stand-in — the key should be gone, full stop.
|
||||||
|
let n = NetworkDef {
|
||||||
|
ssid: "Cafe".into(),
|
||||||
|
password: None,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
});
|
};
|
||||||
let text = toml::to_string_pretty(&cfg).unwrap();
|
let text = toml::to_string_pretty(&n).unwrap();
|
||||||
let back: Config = toml::from_str(&text).unwrap();
|
assert!(!text.contains("password"), "text: {text}");
|
||||||
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]
|
#[test]
|
||||||
fn config_toml_roundtrip_with_full_profile_fields() {
|
fn network_def_password_round_trips_through_toml() {
|
||||||
let mut cfg = build_initial_config();
|
let n = NetworkDef {
|
||||||
let work = cfg.profiles.get_mut("work").unwrap();
|
ssid: "Cafe".into(),
|
||||||
work.tailscale = true;
|
password: Some("hunter2".into()),
|
||||||
work.exit_node = Some("myexit".into());
|
hidden: false,
|
||||||
work.bootstrap = Some("BootstrapSSID".into());
|
};
|
||||||
work.detect_ssids = vec!["WorkWifi".into(), "CorpGuest".into()];
|
let text = toml::to_string_pretty(&n).unwrap();
|
||||||
work.networks = vec!["WorkWifi".into()];
|
assert!(text.contains("hunter2"));
|
||||||
let text = toml::to_string_pretty(&cfg).unwrap();
|
let back: NetworkDef = toml::from_str(&text).unwrap();
|
||||||
let back: Config = toml::from_str(&text).unwrap();
|
assert_eq!(back.password, Some("hunter2".to_string()));
|
||||||
let w = back.profile("work").unwrap();
|
}
|
||||||
assert!(w.tailscale);
|
|
||||||
assert_eq!(w.exit_node.as_deref(), Some("myexit"));
|
// Note: `Config::save`/`Config::load`'s real filesystem behavior (the
|
||||||
assert_eq!(w.bootstrap.as_deref(), Some("BootstrapSSID"));
|
// networks.toml split, and a cleared password actually landing on disk)
|
||||||
assert_eq!(w.detect_ssids, vec!["WorkWifi", "CorpGuest"]);
|
// is covered by `tests/cli.rs`'s Sandbox-isolated integration tests
|
||||||
assert_eq!(w.networks, vec!["WorkWifi"]);
|
// (`networks_are_stored_separately_from_settings_and_profiles`,
|
||||||
|
// `password_is_cleared_after_first_connect_and_never_sent_again`) rather
|
||||||
|
// than here — this module's tests stay pure per the project's test
|
||||||
|
// discipline (no real fs/env/subprocess access from a `#[cfg(test)]`
|
||||||
|
// unit test).
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_default_has_no_bootstrap_or_tailscale() {
|
||||||
|
let p = Profile::default();
|
||||||
|
assert!(p.bootstrap.is_none());
|
||||||
|
assert!(!p.tailscale);
|
||||||
|
assert!(!p.include_all_known);
|
||||||
|
assert!(p.networks.is_empty());
|
||||||
|
assert!(p.detect_ssids.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn config_deserialization_applies_settings_defaults_for_missing_fields() {
|
fn malformed_toml_fails_to_parse() {
|
||||||
let toml_str = r#"
|
let bad = "this is not [ valid toml";
|
||||||
[settings]
|
assert!(toml::from_str::<Config>(bad).is_err());
|
||||||
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]
|
#[test]
|
||||||
fn network_def_hidden_defaults_to_false() {
|
fn config_with_only_settings_defaults_networks_and_profiles() {
|
||||||
let toml_str = r#"
|
// A hand-written config that only sets `[settings]` shouldn't require
|
||||||
[[networks]]
|
// `networks`/`profiles` sections — both must fall back to `#[serde(default)]`.
|
||||||
ssid = "MyNet"
|
let text = r#"[settings]
|
||||||
password = "pass"
|
dns = "9.9.9.9""#;
|
||||||
"#;
|
let cfg: Config = toml::from_str(text).unwrap();
|
||||||
let cfg: Config = toml::from_str(toml_str).unwrap();
|
assert_eq!(cfg.settings.dns, "9.9.9.9");
|
||||||
assert!(!cfg.networks[0].hidden);
|
assert!(cfg.networks.is_empty());
|
||||||
|
assert!(cfg.profiles.is_empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
598
src/flow.rs
598
src/flow.rs
|
|
@ -1,8 +1,8 @@
|
||||||
use crate::backend::Backend;
|
|
||||||
use crate::config::{Config, NetworkDef};
|
use crate::config::{Config, NetworkDef};
|
||||||
use crate::notify::Urgency;
|
use crate::nm;
|
||||||
use crate::status::Connectivity;
|
use crate::notify::{log, notify, Urgency};
|
||||||
use crate::tailscale::TsHealth;
|
use crate::status::internet_ok;
|
||||||
|
use crate::tailscale::{self, TsHealth};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum Outcome {
|
pub enum Outcome {
|
||||||
|
|
@ -27,56 +27,75 @@ impl Outcome {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn resolve_candidates<'a>(cfg: &'a Config, p: &crate::config::Profile) -> Vec<&'a NetworkDef> {
|
/// Resolve a profile's priority list to concrete network definitions.
|
||||||
let mut out: Vec<&NetworkDef> = Vec::new();
|
///
|
||||||
|
/// Returns owned clones rather than borrows of `cfg` — small structs, and it
|
||||||
|
/// decouples the result's lifetime from `cfg` so callers (namely [`run`]) can
|
||||||
|
/// still mutate `cfg` (to clear a used password and persist it) while a
|
||||||
|
/// candidate from this list is being acted on.
|
||||||
|
fn resolve_candidates(cfg: &Config, p: &crate::config::Profile) -> Vec<NetworkDef> {
|
||||||
|
let mut out: Vec<NetworkDef> = Vec::new();
|
||||||
for ssid in &p.networks {
|
for ssid in &p.networks {
|
||||||
if let Some(def) = cfg.network(ssid) {
|
if let Some(def) = cfg.network(ssid) {
|
||||||
if !out.iter().any(|d| d.ssid == def.ssid) {
|
if !out.iter().any(|d| d.ssid == def.ssid) {
|
||||||
out.push(def);
|
out.push(def.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if p.include_all_known {
|
if p.include_all_known {
|
||||||
for def in &cfg.networks {
|
for def in &cfg.networks {
|
||||||
if !out.iter().any(|d| d.ssid == def.ssid) {
|
if !out.iter().any(|d| d.ssid == def.ssid) {
|
||||||
out.push(def);
|
out.push(def.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A network was just connected to using a local password, and NetworkManager
|
||||||
|
/// now durably holds that secret — either in a freshly created connection
|
||||||
|
/// profile (`device wifi connect --ask`) or an existing one whose PSK we just
|
||||||
|
/// supplied via `--ask connection up`. Either way breadcrumbs no longer needs
|
||||||
|
/// its own plaintext copy: clear it and persist immediately so it doesn't sit
|
||||||
|
/// on disk any longer than necessary. A no-op (no save) if the network has no
|
||||||
|
/// local password to begin with.
|
||||||
|
fn clear_password_if_used(cfg: &mut Config, ssid: &str) {
|
||||||
|
let Some(def) = cfg.networks.iter_mut().find(|n| n.ssid == ssid) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if def.password.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
def.password = None;
|
||||||
|
if let Err(e) = cfg.save() {
|
||||||
|
log(&format!(
|
||||||
|
"failed to persist cleared password for {ssid}: {e}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Try to connect + confirm it actually carries traffic.
|
/// Try to connect + confirm it actually carries traffic.
|
||||||
/// Returns Ok(()) on success, Err(reason) on failure.
|
/// Returns Ok(()) on success, Err(reason) on failure.
|
||||||
fn connect_and_verify(
|
fn connect_and_verify(iface: &str, def: &NetworkDef, cfg: &Config) -> Result<(), String> {
|
||||||
be: &dyn Backend,
|
nm::connect_verbose(iface, def, cfg.settings.nmcli_wait, &cfg.settings.dns)?;
|
||||||
iface: &str,
|
if !nm::device_connected(iface) {
|
||||||
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());
|
return Err("device not connected after nmcli success".into());
|
||||||
}
|
}
|
||||||
Ok(())
|
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`.
|
/// Run the connection state machine for `profile_name`.
|
||||||
pub fn run(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Outcome {
|
///
|
||||||
|
/// Takes `cfg` mutably: a successful connect that used a local password
|
||||||
|
/// clears that network's `password` field and persists the config
|
||||||
|
/// immediately (see [`clear_password_if_used`]) — this is the only way that
|
||||||
|
/// clearing happens for the `init` / `profile set --apply` / `detect --apply`
|
||||||
|
/// commands and the watch loop, all of which route through here.
|
||||||
|
pub fn run(cfg: &mut Config, profile_name: &str) -> Outcome {
|
||||||
let profile = match cfg.profile(profile_name) {
|
let profile = match cfg.profile(profile_name) {
|
||||||
Some(p) => p.clone(),
|
Some(p) => p.clone(),
|
||||||
None => {
|
None => {
|
||||||
be.notify(
|
notify(
|
||||||
"breadcrumbs: unknown profile",
|
"breadcrumbs: unknown profile",
|
||||||
&format!("'{profile_name}' is not defined in breadcrumbs.toml"),
|
&format!("'{profile_name}' is not defined in breadcrumbs.toml"),
|
||||||
Urgency::Critical,
|
Urgency::Critical,
|
||||||
|
|
@ -85,10 +104,10 @@ pub fn run(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Outcome {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let iface = match be.wifi_interface() {
|
let iface = match nm::wifi_interface() {
|
||||||
Some(i) => i,
|
Some(i) => i,
|
||||||
None => {
|
None => {
|
||||||
be.notify(
|
notify(
|
||||||
"breadcrumbs: no Wi-Fi adapter",
|
"breadcrumbs: no Wi-Fi adapter",
|
||||||
"Hardware issue — Wi-Fi device not found. Manual check needed.",
|
"Hardware issue — Wi-Fi device not found. Manual check needed.",
|
||||||
Urgency::Critical,
|
Urgency::Critical,
|
||||||
|
|
@ -96,7 +115,7 @@ pub fn run(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Outcome {
|
||||||
return Outcome::NoInterface;
|
return Outcome::NoInterface;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
be.radio_on();
|
nm::radio_on();
|
||||||
|
|
||||||
let exit_node = profile
|
let exit_node = profile
|
||||||
.exit_node
|
.exit_node
|
||||||
|
|
@ -104,7 +123,7 @@ pub fn run(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Outcome {
|
||||||
.unwrap_or_else(|| cfg.settings.exit_node.clone());
|
.unwrap_or_else(|| cfg.settings.exit_node.clone());
|
||||||
let candidates = resolve_candidates(cfg, &profile);
|
let candidates = resolve_candidates(cfg, &profile);
|
||||||
|
|
||||||
be.log(&format!(
|
log(&format!(
|
||||||
"flow start: profile={profile_name} iface={iface} tailscale={} candidates=[{}]",
|
"flow start: profile={profile_name} iface={iface} tailscale={} candidates=[{}]",
|
||||||
profile.tailscale,
|
profile.tailscale,
|
||||||
candidates
|
candidates
|
||||||
|
|
@ -119,39 +138,42 @@ pub fn run(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Outcome {
|
||||||
if let Some(bs) = &profile.bootstrap {
|
if let Some(bs) = &profile.bootstrap {
|
||||||
scan_targets.push(bs.clone());
|
scan_targets.push(bs.clone());
|
||||||
}
|
}
|
||||||
be.rescan(&iface, &scan_targets);
|
nm::rescan(&iface, &scan_targets);
|
||||||
let visible = be.visible_ssids(&iface);
|
let visible = nm::visible_ssids(&iface);
|
||||||
|
|
||||||
// ---- Tailscale-gated profiles (e.g. school) -------------------------
|
// ---- Tailscale-gated profiles (e.g. school) -------------------------
|
||||||
let mut on_bootstrap = false;
|
let mut on_bootstrap = false;
|
||||||
if profile.tailscale {
|
if profile.tailscale {
|
||||||
if let Some(bs_ssid) = profile.bootstrap.clone() {
|
if let Some(bs_ssid) = profile.bootstrap.clone() {
|
||||||
match cfg.network(&bs_ssid) {
|
// Owned clone (not a borrow of `cfg`) so we're free to mutate
|
||||||
|
// `cfg` below on a successful connect.
|
||||||
|
match cfg.network(&bs_ssid).cloned() {
|
||||||
Some(bdef) => {
|
Some(bdef) => {
|
||||||
if visible.contains(&bdef.ssid) || bdef.hidden {
|
if visible.contains(&bdef.ssid) || bdef.hidden {
|
||||||
match connect_and_verify(be, &iface, bdef, cfg) {
|
match connect_and_verify(&iface, &bdef, cfg) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
on_bootstrap = true;
|
on_bootstrap = true;
|
||||||
be.log(&format!("bootstrap connected: {}", bdef.ssid));
|
log(&format!("bootstrap connected: {}", bdef.ssid));
|
||||||
|
clear_password_if_used(cfg, &bdef.ssid);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
be.log(&format!("bootstrap connect failed: {} — {e}", bdef.ssid))
|
log(&format!("bootstrap connect failed: {} — {e}", bdef.ssid))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
be.log(&format!("bootstrap not in range: {}", bdef.ssid));
|
log(&format!("bootstrap not in range: {}", bdef.ssid));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => be.log(&format!(
|
None => log(&format!(
|
||||||
"bootstrap SSID '{bs_ssid}' has no credentials in config"
|
"bootstrap SSID '{bs_ssid}' has no credentials in config"
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let ts = be.ensure_exit_node(&exit_node);
|
let ts = tailscale::ensure_exit_node(&exit_node);
|
||||||
if !ts.is_ok() {
|
if !ts.is_ok() {
|
||||||
let ssid = be.active_ssid(&iface).or_else(|| profile.bootstrap.clone());
|
let ssid = nm::active_ssid(&iface).or_else(|| profile.bootstrap.clone());
|
||||||
be.notify(
|
notify(
|
||||||
"Tailscale Error",
|
"Tailscale Error",
|
||||||
&format!(
|
&format!(
|
||||||
"{} — staying on {}",
|
"{} — staying on {}",
|
||||||
|
|
@ -162,12 +184,12 @@ pub fn run(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Outcome {
|
||||||
);
|
);
|
||||||
return Outcome::TailscaleError { ssid, health: ts };
|
return Outcome::TailscaleError { ssid, health: ts };
|
||||||
}
|
}
|
||||||
be.log(&format!("tailscale healthy via exit node {exit_node}"));
|
log(&format!("tailscale healthy via exit node {exit_node}"));
|
||||||
// Refresh visibility before moving to the target network.
|
// Refresh visibility before moving to the target network.
|
||||||
be.rescan(&iface, &scan_targets);
|
nm::rescan(&iface, &scan_targets);
|
||||||
}
|
}
|
||||||
|
|
||||||
let visible = be.visible_ssids(&iface);
|
let visible = nm::visible_ssids(&iface);
|
||||||
|
|
||||||
// ---- Connect to the priority list ----------------------------------
|
// ---- Connect to the priority list ----------------------------------
|
||||||
// Pass 1: visible networks in priority order.
|
// Pass 1: visible networks in priority order.
|
||||||
|
|
@ -175,16 +197,21 @@ pub fn run(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Outcome {
|
||||||
for def in &candidates {
|
for def in &candidates {
|
||||||
if visible.contains(&def.ssid) {
|
if visible.contains(&def.ssid) {
|
||||||
any_attempted = true;
|
any_attempted = true;
|
||||||
match connect_and_verify(be, &iface, def, cfg) {
|
match connect_and_verify(&iface, def, cfg) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let note = connectivity_note(be, cfg);
|
clear_password_if_used(cfg, &def.ssid);
|
||||||
finish_connected(be, &def.ssid, profile_name, ¬e);
|
let note = if internet_ok(cfg) {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some("associated but no internet yet".to_string())
|
||||||
|
};
|
||||||
|
finish_connected(&def.ssid, profile_name, ¬e);
|
||||||
return Outcome::Connected {
|
return Outcome::Connected {
|
||||||
ssid: def.ssid.clone(),
|
ssid: def.ssid.clone(),
|
||||||
note,
|
note,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Err(e) => be.log(&format!("connect failed (visible): {} — {e}", def.ssid)),
|
Err(e) => log(&format!("connect failed (visible): {} — {e}", def.ssid)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -192,16 +219,21 @@ pub fn run(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Outcome {
|
||||||
for def in &candidates {
|
for def in &candidates {
|
||||||
if def.hidden && !visible.contains(&def.ssid) {
|
if def.hidden && !visible.contains(&def.ssid) {
|
||||||
any_attempted = true;
|
any_attempted = true;
|
||||||
match connect_and_verify(be, &iface, def, cfg) {
|
match connect_and_verify(&iface, def, cfg) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let note = connectivity_note(be, cfg);
|
clear_password_if_used(cfg, &def.ssid);
|
||||||
finish_connected(be, &def.ssid, profile_name, ¬e);
|
let note = if internet_ok(cfg) {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some("associated but no internet yet".to_string())
|
||||||
|
};
|
||||||
|
finish_connected(&def.ssid, profile_name, ¬e);
|
||||||
return Outcome::Connected {
|
return Outcome::Connected {
|
||||||
ssid: def.ssid.clone(),
|
ssid: def.ssid.clone(),
|
||||||
note,
|
note,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
Err(e) => be.log(&format!("connect failed (hidden): {} — {e}", def.ssid)),
|
Err(e) => log(&format!("connect failed (hidden): {} — {e}", def.ssid)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -214,12 +246,21 @@ pub fn run(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Outcome {
|
||||||
.bootstrap
|
.bootstrap
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| "bootstrap".into());
|
.unwrap_or_else(|| "bootstrap".into());
|
||||||
if !be.device_connected(&iface) {
|
if !nm::device_connected(&iface) {
|
||||||
if let Some(bdef) = profile.bootstrap.as_deref().and_then(|s| cfg.network(s)) {
|
// Owned clone (not a borrow of `cfg`) so a successful reconnect
|
||||||
match connect_and_verify(be, &iface, bdef, cfg) {
|
// is free to mutate `cfg` to clear the used password.
|
||||||
Ok(()) => be.log(&format!("bootstrap reconnected: {}", bdef.ssid)),
|
if let Some(bdef) = profile
|
||||||
|
.bootstrap
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|s| cfg.network(s).cloned())
|
||||||
|
{
|
||||||
|
match connect_and_verify(&iface, &bdef, cfg) {
|
||||||
|
Ok(()) => {
|
||||||
|
log(&format!("bootstrap reconnected: {}", bdef.ssid));
|
||||||
|
clear_password_if_used(cfg, &bdef.ssid);
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
be.log(&format!("bootstrap reconnect failed: {} — {e}", bdef.ssid));
|
log(&format!("bootstrap reconnect failed: {} — {e}", bdef.ssid));
|
||||||
on_bootstrap = false;
|
on_bootstrap = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -231,8 +272,8 @@ pub fn run(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Outcome {
|
||||||
} else {
|
} else {
|
||||||
format!("target network not in range — staying on {bs_ssid} (Tailscale OK)")
|
format!("target network not in range — staying on {bs_ssid} (Tailscale OK)")
|
||||||
};
|
};
|
||||||
be.notify("breadcrumbs: using bootstrap", &reason, Urgency::Normal);
|
notify("breadcrumbs: using bootstrap", &reason, Urgency::Normal);
|
||||||
be.log(&format!("flow end: on bootstrap {bs_ssid}; {reason}"));
|
log(&format!("flow end: on bootstrap {bs_ssid}; {reason}"));
|
||||||
return Outcome::Connected {
|
return Outcome::Connected {
|
||||||
ssid: bs_ssid,
|
ssid: bs_ssid,
|
||||||
note: Some(reason),
|
note: Some(reason),
|
||||||
|
|
@ -245,34 +286,34 @@ pub fn run(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Outcome {
|
||||||
.map(|c| c.ssid.as_str())
|
.map(|c| c.ssid.as_str())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ");
|
.join(", ");
|
||||||
be.notify(
|
notify(
|
||||||
"breadcrumbs: no known networks",
|
"breadcrumbs: no known networks",
|
||||||
&format!("profile '{profile_name}': none of [{names}] are in range"),
|
&format!("profile '{profile_name}': none of [{names}] are in range"),
|
||||||
Urgency::Critical,
|
Urgency::Critical,
|
||||||
);
|
);
|
||||||
be.log(&format!(
|
log(&format!(
|
||||||
"flow end: no networks connected (profile={profile_name})"
|
"flow end: no networks connected (profile={profile_name})"
|
||||||
));
|
));
|
||||||
Outcome::NoNetworks
|
Outcome::NoNetworks
|
||||||
}
|
}
|
||||||
|
|
||||||
fn finish_connected(be: &dyn Backend, ssid: &str, profile: &str, note: &Option<String>) {
|
fn finish_connected(ssid: &str, profile: &str, note: &Option<String>) {
|
||||||
match note {
|
match note {
|
||||||
None => {
|
None => {
|
||||||
be.notify(
|
notify(
|
||||||
"breadcrumbs: connected",
|
"breadcrumbs: connected",
|
||||||
&format!("{ssid} ({profile})"),
|
&format!("{ssid} ({profile})"),
|
||||||
Urgency::Low,
|
Urgency::Low,
|
||||||
);
|
);
|
||||||
be.log(&format!("flow end: connected {ssid} (profile={profile})"));
|
log(&format!("flow end: connected {ssid} (profile={profile})"));
|
||||||
}
|
}
|
||||||
Some(n) => {
|
Some(n) => {
|
||||||
be.notify(
|
notify(
|
||||||
"breadcrumbs: connected (degraded)",
|
"breadcrumbs: connected (degraded)",
|
||||||
&format!("{ssid} ({profile}) — {n}"),
|
&format!("{ssid} ({profile}) — {n}"),
|
||||||
Urgency::Normal,
|
Urgency::Normal,
|
||||||
);
|
);
|
||||||
be.log(&format!(
|
log(&format!(
|
||||||
"flow end: connected {ssid} (profile={profile}) note={n}"
|
"flow end: connected {ssid} (profile={profile}) note={n}"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
@ -283,13 +324,12 @@ fn finish_connected(be: &dyn Backend, ssid: &str, profile: &str, note: &Option<S
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::{Profile, Settings};
|
use crate::config::{Profile, Settings};
|
||||||
use crate::tailscale::TsHealth;
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
fn net(ssid: &str) -> NetworkDef {
|
fn net(ssid: &str) -> NetworkDef {
|
||||||
NetworkDef {
|
NetworkDef {
|
||||||
ssid: ssid.into(),
|
ssid: ssid.into(),
|
||||||
password: "x".into(),
|
password: Some("x".into()),
|
||||||
hidden: false,
|
hidden: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -307,341 +347,6 @@ 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]
|
#[test]
|
||||||
fn candidates_follow_priority_order() {
|
fn candidates_follow_priority_order() {
|
||||||
let c = cfg();
|
let c = cfg();
|
||||||
|
|
@ -649,10 +354,8 @@ mod tests {
|
||||||
networks: vec!["FallbackNet".into(), "HomeWifi".into()],
|
networks: vec!["FallbackNet".into(), "HomeWifi".into()],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let got: Vec<&str> = resolve_candidates(&c, &p)
|
let candidates = resolve_candidates(&c, &p);
|
||||||
.iter()
|
let got: Vec<&str> = candidates.iter().map(|n| n.ssid.as_str()).collect();
|
||||||
.map(|n| n.ssid.as_str())
|
|
||||||
.collect();
|
|
||||||
assert_eq!(got, vec!["FallbackNet", "HomeWifi"]);
|
assert_eq!(got, vec!["FallbackNet", "HomeWifi"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -664,10 +367,8 @@ mod tests {
|
||||||
include_all_known: true,
|
include_all_known: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let got: Vec<&str> = resolve_candidates(&c, &p)
|
let candidates = resolve_candidates(&c, &p);
|
||||||
.iter()
|
let got: Vec<&str> = candidates.iter().map(|n| n.ssid.as_str()).collect();
|
||||||
.map(|n| n.ssid.as_str())
|
|
||||||
.collect();
|
|
||||||
assert_eq!(got[0], "HomeWifi");
|
assert_eq!(got[0], "HomeWifi");
|
||||||
assert_eq!(got.len(), 4);
|
assert_eq!(got.len(), 4);
|
||||||
assert!(got.contains(&"WorkNet"));
|
assert!(got.contains(&"WorkNet"));
|
||||||
|
|
@ -682,56 +383,71 @@ mod tests {
|
||||||
networks: vec!["Ghost".into(), "WorkNet".into()],
|
networks: vec!["Ghost".into(), "WorkNet".into()],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let got: Vec<&str> = resolve_candidates(&c, &p)
|
let candidates = resolve_candidates(&c, &p);
|
||||||
.iter()
|
let got: Vec<&str> = candidates.iter().map(|n| n.ssid.as_str()).collect();
|
||||||
.map(|n| n.ssid.as_str())
|
|
||||||
.collect();
|
|
||||||
assert_eq!(got, vec!["WorkNet"]);
|
assert_eq!(got, vec!["WorkNet"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn candidates_empty_when_profile_has_no_networks() {
|
fn empty_profile_network_list_yields_no_candidates() {
|
||||||
let c = cfg();
|
let c = cfg();
|
||||||
let p = Profile::default();
|
let p = Profile::default();
|
||||||
assert!(resolve_candidates(&c, &p).is_empty());
|
assert!(resolve_candidates(&c, &p).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn candidates_include_all_known_only_no_explicit_networks() {
|
fn duplicate_ssids_within_profile_list_are_deduped() {
|
||||||
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 c = cfg();
|
||||||
let p = Profile {
|
let p = Profile {
|
||||||
networks: vec!["HomeWifi".into(), "HomeWifi".into(), "WorkNet".into()],
|
networks: vec!["HomeWifi".into(), "HomeWifi".into(), "WorkNet".into()],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let got: Vec<&str> = resolve_candidates(&c, &p)
|
let candidates = resolve_candidates(&c, &p);
|
||||||
.iter()
|
let got: Vec<&str> = candidates.iter().map(|n| n.ssid.as_str()).collect();
|
||||||
.map(|n| n.ssid.as_str())
|
|
||||||
.collect();
|
|
||||||
assert_eq!(got, vec!["HomeWifi", "WorkNet"]);
|
assert_eq!(got, vec!["HomeWifi", "WorkNet"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn candidates_all_unknown_ssids_returns_empty() {
|
fn include_all_known_with_full_priority_list_appends_nothing_new() {
|
||||||
let c = cfg();
|
let c = cfg();
|
||||||
let p = Profile {
|
let p = Profile {
|
||||||
networks: vec!["Ghost1".into(), "Ghost2".into()],
|
networks: vec![
|
||||||
|
"HomeWifi".into(),
|
||||||
|
"WorkNet".into(),
|
||||||
|
"CafeWifi".into(),
|
||||||
|
"FallbackNet".into(),
|
||||||
|
],
|
||||||
|
include_all_known: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
assert!(resolve_candidates(&c, &p).is_empty());
|
let got = resolve_candidates(&c, &p);
|
||||||
|
assert_eq!(got.len(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn include_all_known_on_empty_priority_list_returns_all_networks() {
|
||||||
|
let c = cfg();
|
||||||
|
let p = Profile {
|
||||||
|
include_all_known: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(resolve_candidates(&c, &p).len(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn outcome_ok_is_true_only_for_connected() {
|
||||||
|
assert!(Outcome::Connected {
|
||||||
|
ssid: "x".into(),
|
||||||
|
note: None
|
||||||
|
}
|
||||||
|
.ok());
|
||||||
|
assert!(!Outcome::NoInterface.ok());
|
||||||
|
assert!(!Outcome::NoNetworks.ok());
|
||||||
|
assert!(!Outcome::UnknownProfile("ghost".into()).ok());
|
||||||
|
assert!(!Outcome::TailscaleError {
|
||||||
|
ssid: None,
|
||||||
|
health: crate::tailscale::TsHealth::NotInstalled
|
||||||
|
}
|
||||||
|
.ok());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
20
src/lib.rs
Normal file
20
src/lib.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
//! breadcrumbs library crate.
|
||||||
|
//!
|
||||||
|
//! All actual logic lives here; `src/main.rs` is a thin binary shim that
|
||||||
|
//! parses no arguments itself — it just calls [`app::run`]. Splitting things
|
||||||
|
//! this way means integration tests can link against `breadcrumbs` as an
|
||||||
|
//! ordinary library crate and drive the real state machine (`flow::run`,
|
||||||
|
//! `watch::classify`, …) in-process, instead of only being able to spawn the
|
||||||
|
//! compiled binary.
|
||||||
|
|
||||||
|
pub mod app;
|
||||||
|
pub mod bread_events;
|
||||||
|
pub mod config;
|
||||||
|
pub mod flow;
|
||||||
|
pub mod nm;
|
||||||
|
pub mod notify;
|
||||||
|
pub mod state;
|
||||||
|
pub mod status;
|
||||||
|
pub mod tailscale;
|
||||||
|
pub mod util;
|
||||||
|
pub mod watch;
|
||||||
904
src/main.rs
904
src/main.rs
|
|
@ -1,903 +1,7 @@
|
||||||
mod backend;
|
//! Thin binary entry point. All argument parsing and command logic lives in
|
||||||
mod config;
|
//! the library crate (`breadcrumbs::app`) so it can also be exercised
|
||||||
mod flow;
|
//! in-process by the integration tests under `tests/`.
|
||||||
mod nm;
|
|
||||||
mod notify;
|
|
||||||
mod state;
|
|
||||||
mod status;
|
|
||||||
mod tailscale;
|
|
||||||
mod util;
|
|
||||||
mod watch;
|
|
||||||
|
|
||||||
use std::io::{BufRead, Write};
|
|
||||||
use std::process::Command;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use clap::{Parser, Subcommand};
|
|
||||||
|
|
||||||
use backend::Backend;
|
|
||||||
use config::{Config, NetworkDef};
|
|
||||||
use state::State;
|
|
||||||
use util::{command_exists, home_dir, run};
|
|
||||||
|
|
||||||
const C_RESET: &str = "\x1b[0m";
|
|
||||||
const C_BOLD: &str = "\x1b[1m";
|
|
||||||
const C_GREEN: &str = "\x1b[32m";
|
|
||||||
const C_RED: &str = "\x1b[31m";
|
|
||||||
const C_YELLOW: &str = "\x1b[33m";
|
|
||||||
const C_DIM: &str = "\x1b[2m";
|
|
||||||
|
|
||||||
#[derive(Parser)]
|
|
||||||
#[command(
|
|
||||||
name = "breadcrumbs",
|
|
||||||
version,
|
|
||||||
about = "Profile-aware Wi-Fi state machine with Tailscale handling",
|
|
||||||
disable_help_subcommand = true
|
|
||||||
)]
|
|
||||||
struct Cli {
|
|
||||||
/// Override the active profile for this run only (does not persist)
|
|
||||||
#[arg(long, short, global = true)]
|
|
||||||
profile: Option<String>,
|
|
||||||
|
|
||||||
#[command(subcommand)]
|
|
||||||
cmd: Option<Cmd>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
|
||||||
enum Cmd {
|
|
||||||
/// Show current Wi-Fi / profile / Tailscale status (default)
|
|
||||||
Status {
|
|
||||||
/// 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,
|
|
||||||
/// Run as a daemon: watch for drops and auto-recover
|
|
||||||
Watch {
|
|
||||||
/// Skip the connect attempt on startup
|
|
||||||
#[arg(long)]
|
|
||||||
no_initial: bool,
|
|
||||||
},
|
|
||||||
/// Get / set / list location profiles (the state machine)
|
|
||||||
Profile {
|
|
||||||
#[command(subcommand)]
|
|
||||||
action: Option<ProfileCmd>,
|
|
||||||
},
|
|
||||||
/// Guess the profile from visible networks
|
|
||||||
Detect {
|
|
||||||
/// Set + apply the detected profile
|
|
||||||
#[arg(long)]
|
|
||||||
apply: bool,
|
|
||||||
},
|
|
||||||
/// Add or update a saved network
|
|
||||||
Add {
|
|
||||||
ssid: String,
|
|
||||||
/// Password (prompted if omitted)
|
|
||||||
password: Option<String>,
|
|
||||||
/// Network is hidden (does not broadcast its SSID)
|
|
||||||
#[arg(long)]
|
|
||||||
hidden: bool,
|
|
||||||
/// Attach this SSID to a profile's priority list
|
|
||||||
#[arg(long)]
|
|
||||||
to: Option<String>,
|
|
||||||
/// Position in the profile list (0 = highest priority)
|
|
||||||
#[arg(long)]
|
|
||||||
at: Option<usize>,
|
|
||||||
},
|
|
||||||
/// Remove a saved network (config + NetworkManager)
|
|
||||||
Forget { ssid: String },
|
|
||||||
/// Scan, pick, connect and save a network interactively
|
|
||||||
Scan {
|
|
||||||
/// Attach the saved network to this profile
|
|
||||||
#[arg(long)]
|
|
||||||
to: Option<String>,
|
|
||||||
},
|
|
||||||
/// List configured networks and profiles
|
|
||||||
List {
|
|
||||||
#[arg(long)]
|
|
||||||
show_passwords: bool,
|
|
||||||
},
|
|
||||||
/// 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
|
|
||||||
Doctor {
|
|
||||||
/// Run the full diag.sh report from the config directory
|
|
||||||
#[arg(long)]
|
|
||||||
full: bool,
|
|
||||||
},
|
|
||||||
/// Print the breadcrumbs config directory
|
|
||||||
Cd {
|
|
||||||
#[arg(long)]
|
|
||||||
shell: bool,
|
|
||||||
},
|
|
||||||
/// Install + enable the systemd user watcher service
|
|
||||||
InstallService {
|
|
||||||
/// Install the unit but do not enable/start it
|
|
||||||
#[arg(long)]
|
|
||||||
no_enable: bool,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
|
||||||
enum ProfileCmd {
|
|
||||||
/// Print the active profile
|
|
||||||
Get,
|
|
||||||
/// Set the active profile (and apply it unless --no-apply)
|
|
||||||
Set {
|
|
||||||
name: String,
|
|
||||||
#[arg(long)]
|
|
||||||
no_apply: bool,
|
|
||||||
},
|
|
||||||
/// List available profiles
|
|
||||||
List,
|
|
||||||
/// 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() {
|
fn main() {
|
||||||
let cli = Cli::parse();
|
std::process::exit(breadcrumbs::app::run());
|
||||||
let code = match real_main(cli) {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("{C_RED}error:{C_RESET} {e}");
|
|
||||||
1
|
|
||||||
}
|
|
||||||
};
|
|
||||||
std::process::exit(code);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn active_profile(cfg: &Config, override_p: &Option<String>) -> String {
|
|
||||||
if let Some(p) = override_p {
|
|
||||||
return p.clone();
|
|
||||||
}
|
|
||||||
State::load(&cfg.settings.default_profile).profile
|
|
||||||
}
|
|
||||||
|
|
||||||
fn real_main(cli: Cli) -> Result<i32, String> {
|
|
||||||
let cmd = cli.cmd.unwrap_or(Cmd::Status { json: false });
|
|
||||||
|
|
||||||
// `cd` and `install-service` don't need a parsed config first.
|
|
||||||
if let Cmd::Cd { shell } = &cmd {
|
|
||||||
return cmd_cd(*shell);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut cfg = Config::load()?;
|
|
||||||
let be = backend::System;
|
|
||||||
|
|
||||||
match cmd {
|
|
||||||
Cmd::Status { json } => cmd_status(&be, &cfg, &cli.profile, json),
|
|
||||||
Cmd::Init => {
|
|
||||||
let p = active_profile(&cfg, &cli.profile);
|
|
||||||
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(&be, &mut cfg, action),
|
|
||||||
Cmd::Detect { apply } => cmd_detect(&be, &cfg, apply),
|
|
||||||
Cmd::Add {
|
|
||||||
ssid,
|
|
||||||
password,
|
|
||||||
hidden,
|
|
||||||
to,
|
|
||||||
at,
|
|
||||||
} => cmd_add(&mut cfg, ssid, password, hidden, to, at),
|
|
||||||
Cmd::Forget { ssid } => cmd_forget(&mut cfg, &ssid),
|
|
||||||
Cmd::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(&be, &cfg, &cli.profile, full),
|
|
||||||
Cmd::InstallService { no_enable } => cmd_install_service(!no_enable),
|
|
||||||
Cmd::Cd { .. } => unreachable!(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn print_outcome(profile: &str, o: &flow::Outcome) {
|
|
||||||
match o {
|
|
||||||
flow::Outcome::Connected { ssid, note } => {
|
|
||||||
print!("{C_GREEN}connected{C_RESET} {C_BOLD}{ssid}{C_RESET} ({profile})");
|
|
||||||
match note {
|
|
||||||
Some(n) => println!(" {C_YELLOW}— {n}{C_RESET}"),
|
|
||||||
None => println!(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
flow::Outcome::TailscaleError { ssid, health } => {
|
|
||||||
println!(
|
|
||||||
"{C_RED}tailscale error{C_RESET}: {} {C_DIM}(on {}){C_RESET}",
|
|
||||||
health.describe(),
|
|
||||||
ssid.clone().unwrap_or_else(|| "—".into())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
flow::Outcome::NoInterface => {
|
|
||||||
println!("{C_RED}no Wi-Fi adapter{C_RESET} — hardware issue")
|
|
||||||
}
|
|
||||||
flow::Outcome::NoNetworks => {
|
|
||||||
println!("{C_RED}no known networks in range{C_RESET} (profile {profile})")
|
|
||||||
}
|
|
||||||
flow::Outcome::UnknownProfile(p) => {
|
|
||||||
println!("{C_RED}unknown profile{C_RESET}: {p}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn 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(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 {
|
|
||||||
format!("{C_GREEN}●{C_RESET}")
|
|
||||||
} else {
|
|
||||||
format!("{C_RED}●{C_RESET}")
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
println!("{C_BOLD}breadcrumbs{C_RESET}");
|
|
||||||
println!(" profile {C_BOLD}{p}{C_RESET}");
|
|
||||||
println!(
|
|
||||||
" adapter {}",
|
|
||||||
s.iface
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| format!("{C_RED}none{C_RESET}"))
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
" ssid {}",
|
|
||||||
s.ssid
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| format!("{C_DIM}—{C_RESET}"))
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
" ip {}",
|
|
||||||
s.ip.clone().unwrap_or_else(|| format!("{C_DIM}—{C_RESET}"))
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
" internet {} {}",
|
|
||||||
dot(s.internet),
|
|
||||||
if s.internet { "ok" } else { "down" }
|
|
||||||
);
|
|
||||||
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) => {
|
|
||||||
let ok = h.is_ok();
|
|
||||||
println!(
|
|
||||||
" tailscale {} {} {C_DIM}(exit: {}{}){C_RESET}",
|
|
||||||
dot(ok || !req),
|
|
||||||
h.describe(),
|
|
||||||
s.exit_node,
|
|
||||||
if req { "" } else { ", optional" }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
(None, _) => println!(" tailscale {C_DIM}not installed{C_RESET}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
println!(
|
|
||||||
" state {}",
|
|
||||||
if healthy {
|
|
||||||
format!("{C_GREEN}healthy{C_RESET}")
|
|
||||||
} else {
|
|
||||||
format!("{C_YELLOW}needs attention{C_RESET} — run `breadcrumbs init`")
|
|
||||||
}
|
|
||||||
);
|
|
||||||
Ok(if healthy { 0 } else { 1 })
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
Ok(0)
|
|
||||||
}
|
|
||||||
ProfileCmd::List => {
|
|
||||||
let cur = State::load(&cfg.settings.default_profile).profile;
|
|
||||||
for name in cfg.profiles.keys() {
|
|
||||||
let mark = if *name == cur { "*" } else { " " };
|
|
||||||
println!("{mark} {name}");
|
|
||||||
}
|
|
||||||
Ok(0)
|
|
||||||
}
|
|
||||||
ProfileCmd::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();
|
|
||||||
return Err(format!("unknown profile '{name}'. Available: {avail:?}"));
|
|
||||||
}
|
|
||||||
let st = State {
|
|
||||||
profile: name.clone(),
|
|
||||||
updated: util::timestamp(),
|
|
||||||
};
|
|
||||||
st.save()?;
|
|
||||||
notify::log(&format!("profile set -> {name}"));
|
|
||||||
println!("profile = {C_BOLD}{name}{C_RESET}");
|
|
||||||
if no_apply {
|
|
||||||
return Ok(0);
|
|
||||||
}
|
|
||||||
let outcome = flow::run(be, cfg, &name);
|
|
||||||
print_outcome(&name, &outcome);
|
|
||||||
Ok(if outcome.ok() { 0 } else { 1 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
// 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 {
|
|
||||||
let score = profile
|
|
||||||
.detect_ssids
|
|
||||||
.iter()
|
|
||||||
.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(
|
|
||||||
best.map(|(_, name)| name)
|
|
||||||
.unwrap_or_else(|| cfg.settings.default_profile.clone()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cmd_detect(be: &dyn Backend, cfg: &Config, apply: bool) -> Result<i32, String> {
|
|
||||||
match detect_profile(be, cfg) {
|
|
||||||
Some(p) => {
|
|
||||||
println!("{p}");
|
|
||||||
if apply {
|
|
||||||
State {
|
|
||||||
profile: p.clone(),
|
|
||||||
updated: util::timestamp(),
|
|
||||||
}
|
|
||||||
.save()?;
|
|
||||||
let outcome = flow::run(be, cfg, &p);
|
|
||||||
print_outcome(&p, &outcome);
|
|
||||||
return Ok(if outcome.ok() { 0 } else { 1 });
|
|
||||||
}
|
|
||||||
Ok(0)
|
|
||||||
}
|
|
||||||
None => Err("could not detect a profile (no Wi-Fi adapter?)".into()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prompt_line(msg: &str) -> String {
|
|
||||||
print!("{msg}");
|
|
||||||
let _ = std::io::stdout().flush();
|
|
||||||
let mut s = String::new();
|
|
||||||
let _ = std::io::stdin().lock().read_line(&mut s);
|
|
||||||
s.trim_end_matches(['\n', '\r']).to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prompt_secret(msg: &str) -> String {
|
|
||||||
// `util::run` redirects child stdin to /dev/null, so plain `stty -echo`
|
|
||||||
// would target the wrong fd and silently leave echo ON (leaking the
|
|
||||||
// password to the screen). `-F /dev/tty` makes stty act on the controlling
|
|
||||||
// terminal directly. If there is no tty we fall back to visible input.
|
|
||||||
let had_tty = run("stty", &["-F", "/dev/tty", "-echo"], Duration::from_secs(2)).success;
|
|
||||||
let val = prompt_line(msg);
|
|
||||||
if had_tty {
|
|
||||||
let _ = run("stty", &["-F", "/dev/tty", "echo"], Duration::from_secs(2));
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
val
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cmd_add(
|
|
||||||
cfg: &mut Config,
|
|
||||||
ssid: String,
|
|
||||||
password: Option<String>,
|
|
||||||
hidden: bool,
|
|
||||||
to: Option<String>,
|
|
||||||
at: Option<usize>,
|
|
||||||
) -> Result<i32, String> {
|
|
||||||
let password = match password {
|
|
||||||
Some(p) => p,
|
|
||||||
None => prompt_secret(&format!("Password for '{ssid}': ")),
|
|
||||||
};
|
|
||||||
match cfg.networks.iter_mut().find(|n| n.ssid == ssid) {
|
|
||||||
Some(n) => {
|
|
||||||
n.password = password;
|
|
||||||
n.hidden = hidden || n.hidden;
|
|
||||||
}
|
|
||||||
None => cfg.networks.push(NetworkDef {
|
|
||||||
ssid: ssid.clone(),
|
|
||||||
password,
|
|
||||||
hidden,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
if let Some(prof_name) = to {
|
|
||||||
let prof = cfg
|
|
||||||
.profiles
|
|
||||||
.get_mut(&prof_name)
|
|
||||||
.ok_or_else(|| format!("unknown profile '{prof_name}'"))?;
|
|
||||||
prof.networks.retain(|s| s != &ssid);
|
|
||||||
let idx = at.unwrap_or(prof.networks.len()).min(prof.networks.len());
|
|
||||||
prof.networks.insert(idx, ssid.clone());
|
|
||||||
}
|
|
||||||
cfg.save()?;
|
|
||||||
println!("{C_GREEN}saved{C_RESET} {ssid}");
|
|
||||||
Ok(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cmd_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);
|
|
||||||
for p in cfg.profiles.values_mut() {
|
|
||||||
p.networks.retain(|s| s != ssid);
|
|
||||||
if p.bootstrap.as_deref() == Some(ssid) {
|
|
||||||
p.bootstrap = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cfg.save()?;
|
|
||||||
let removed = nm::delete_connections_for_ssid(ssid);
|
|
||||||
println!(
|
|
||||||
"{C_GREEN}forgot{C_RESET} {ssid} (config: {}, NetworkManager: {})",
|
|
||||||
if cfg.networks.len() < before {
|
|
||||||
"removed"
|
|
||||||
} else {
|
|
||||||
"not present"
|
|
||||||
},
|
|
||||||
if removed { "removed" } else { "not present" }
|
|
||||||
);
|
|
||||||
Ok(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cmd_scan(cfg: &mut Config, to: Option<String>) -> Result<i32, String> {
|
|
||||||
let iface = nm::wifi_interface().ok_or("no Wi-Fi adapter")?;
|
|
||||||
nm::radio_on();
|
|
||||||
nm::rescan(&iface, &[]);
|
|
||||||
let entries = nm::scan_list(&iface);
|
|
||||||
if entries.is_empty() {
|
|
||||||
return Err("no networks found".into());
|
|
||||||
}
|
|
||||||
for (i, e) in entries.iter().enumerate() {
|
|
||||||
println!(
|
|
||||||
"{:>2}. {C_BOLD}{}{C_RESET} {C_DIM}sig {} {}{C_RESET}",
|
|
||||||
i + 1,
|
|
||||||
if e.ssid.is_empty() {
|
|
||||||
"<hidden>"
|
|
||||||
} else {
|
|
||||||
&e.ssid
|
|
||||||
},
|
|
||||||
e.signal,
|
|
||||||
e.security
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let sel = prompt_line("Select number: ");
|
|
||||||
let idx: usize = sel
|
|
||||||
.parse::<usize>()
|
|
||||||
.ok()
|
|
||||||
.filter(|n| *n >= 1 && *n <= entries.len())
|
|
||||||
.ok_or("invalid selection")?;
|
|
||||||
let ssid = entries[idx - 1].ssid.clone();
|
|
||||||
if ssid.is_empty() {
|
|
||||||
return Err("cannot select a hidden SSID here; use `breadcrumbs add`".into());
|
|
||||||
}
|
|
||||||
let password = prompt_secret(&format!("Password for '{ssid}': "));
|
|
||||||
let def = NetworkDef {
|
|
||||||
ssid: ssid.clone(),
|
|
||||||
password: password.clone(),
|
|
||||||
hidden: false,
|
|
||||||
};
|
|
||||||
if !nm::connect(&iface, &def, cfg.settings.nmcli_wait, &cfg.settings.dns) {
|
|
||||||
return Err(format!("failed to connect to {ssid}"));
|
|
||||||
}
|
|
||||||
match cfg.networks.iter_mut().find(|n| n.ssid == ssid) {
|
|
||||||
Some(n) => n.password = password,
|
|
||||||
None => cfg.networks.push(def),
|
|
||||||
}
|
|
||||||
if let Some(prof_name) = to {
|
|
||||||
if let Some(prof) = cfg.profiles.get_mut(&prof_name) {
|
|
||||||
if !prof.networks.contains(&ssid) {
|
|
||||||
prof.networks.push(ssid.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cfg.save()?;
|
|
||||||
println!("{C_GREEN}connected + saved{C_RESET} {ssid}");
|
|
||||||
Ok(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mask(p: &str) -> String {
|
|
||||||
// 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 {
|
|
||||||
let first: String = p.chars().take(1).collect();
|
|
||||||
format!("{}{}", first, "•".repeat(count - 1))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cmd_list(cfg: &Config, show_pw: bool) -> Result<i32, String> {
|
|
||||||
println!("{C_BOLD}settings{C_RESET}");
|
|
||||||
println!(" dns {}", cfg.settings.dns);
|
|
||||||
println!(" exit_node {}", cfg.settings.exit_node);
|
|
||||||
println!(" default {}", cfg.settings.default_profile);
|
|
||||||
println!(" watch every {}s", cfg.settings.watch_interval);
|
|
||||||
|
|
||||||
println!("\n{C_BOLD}networks{C_RESET}");
|
|
||||||
for n in &cfg.networks {
|
|
||||||
println!(
|
|
||||||
" {C_BOLD}{}{C_RESET} {C_DIM}{}{}{C_RESET}",
|
|
||||||
n.ssid,
|
|
||||||
if show_pw {
|
|
||||||
n.password.clone()
|
|
||||||
} else {
|
|
||||||
mask(&n.password)
|
|
||||||
},
|
|
||||||
if n.hidden { " (hidden)" } else { "" }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("\n{C_BOLD}profiles{C_RESET}");
|
|
||||||
let cur = State::load(&cfg.settings.default_profile).profile;
|
|
||||||
for (name, p) in &cfg.profiles {
|
|
||||||
let mark = if *name == cur {
|
|
||||||
format!("{C_GREEN}*{C_RESET}")
|
|
||||||
} else {
|
|
||||||
" ".into()
|
|
||||||
};
|
|
||||||
println!("{mark} {C_BOLD}{name}{C_RESET}");
|
|
||||||
if let Some(b) = &p.bootstrap {
|
|
||||||
println!(" bootstrap {b}");
|
|
||||||
}
|
|
||||||
if p.tailscale {
|
|
||||||
println!(
|
|
||||||
" tailscale required (exit: {})",
|
|
||||||
p.exit_node
|
|
||||||
.clone()
|
|
||||||
.unwrap_or_else(|| cfg.settings.exit_node.clone())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let mut order: Vec<String> = p.networks.clone();
|
|
||||||
if p.include_all_known {
|
|
||||||
order.push("…all other known networks".into());
|
|
||||||
}
|
|
||||||
println!(" priority {}", order.join(" > "));
|
|
||||||
}
|
|
||||||
Ok(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cmd_edit() -> Result<i32, String> {
|
|
||||||
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".into());
|
|
||||||
let path = config::config_path();
|
|
||||||
let status = Command::new(&editor)
|
|
||||||
.arg(&path)
|
|
||||||
.status()
|
|
||||||
.map_err(|e| format!("launching {editor}: {e}"))?;
|
|
||||||
if !status.success() {
|
|
||||||
return Err("editor exited with error".into());
|
|
||||||
}
|
|
||||||
match Config::load() {
|
|
||||||
Ok(_) => {
|
|
||||||
println!("{C_GREEN}config OK{C_RESET}");
|
|
||||||
Ok(0)
|
|
||||||
}
|
|
||||||
Err(e) => Err(format!("config is now invalid: {e}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cmd_doctor(
|
|
||||||
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() {
|
|
||||||
return Err(format!(
|
|
||||||
"diag.sh not found (expected at {})",
|
|
||||||
script.display()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let st = Command::new("bash")
|
|
||||||
.arg(&script)
|
|
||||||
.status()
|
|
||||||
.map_err(|e| format!("running diag: {e}"))?;
|
|
||||||
return Ok(st.code().unwrap_or(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
let p = active_profile(cfg, override_p);
|
|
||||||
let s = status::gather(be, cfg, &p);
|
|
||||||
println!("{C_BOLD}breadcrumbs doctor{C_RESET} (profile {p})");
|
|
||||||
println!(
|
|
||||||
" nmcli {}",
|
|
||||||
if command_exists("nmcli") {
|
|
||||||
"present"
|
|
||||||
} else {
|
|
||||||
"MISSING"
|
|
||||||
}
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
" tailscale {}",
|
|
||||||
if command_exists("tailscale") {
|
|
||||||
"present"
|
|
||||||
} else {
|
|
||||||
"absent"
|
|
||||||
}
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
" adapter {}",
|
|
||||||
s.iface.clone().unwrap_or_else(|| "none".into())
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
" ssid {}",
|
|
||||||
s.ssid.clone().unwrap_or_else(|| "—".into())
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
" ip {}",
|
|
||||||
s.ip.clone().unwrap_or_else(|| "—".into())
|
|
||||||
);
|
|
||||||
println!(" internet {}", if s.internet { "ok" } else { "DOWN" });
|
|
||||||
if let Some(h) = &s.tailscale {
|
|
||||||
println!(" tailscale {} (exit {})", h.describe(), s.exit_node);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(iface) = &s.iface {
|
|
||||||
let visible = nm::visible_ssids(iface);
|
|
||||||
let known: Vec<&str> = cfg
|
|
||||||
.networks
|
|
||||||
.iter()
|
|
||||||
.filter(|n| visible.contains(&n.ssid))
|
|
||||||
.map(|n| n.ssid.as_str())
|
|
||||||
.collect();
|
|
||||||
println!(
|
|
||||||
" in range {}",
|
|
||||||
if known.is_empty() {
|
|
||||||
"none of your saved networks".into()
|
|
||||||
} else {
|
|
||||||
known.join(", ")
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
println!("\nFull report: {C_DIM}breadcrumbs doctor --full{C_RESET}");
|
|
||||||
Ok(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cmd_cd(shell: bool) -> Result<i32, String> {
|
|
||||||
let dir = config::config_dir();
|
|
||||||
if shell {
|
|
||||||
let sh = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into());
|
|
||||||
let err = exec_replace(&sh, &["-lc", &format!("cd {:?} && exec {sh}", dir)]);
|
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
println!("{}", dir.display());
|
|
||||||
Ok(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn exec_replace(prog: &str, args: &[&str]) -> String {
|
|
||||||
use std::os::unix::process::CommandExt;
|
|
||||||
let e = Command::new(prog).args(args).exec();
|
|
||||||
format!("exec {prog} failed: {e}")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cmd_install_service(enable: bool) -> Result<i32, String> {
|
|
||||||
let unit_dir = home_dir().join(".config/systemd/user");
|
|
||||||
std::fs::create_dir_all(&unit_dir)
|
|
||||||
.map_err(|e| format!("creating {}: {e}", unit_dir.display()))?;
|
|
||||||
let bin = std::env::current_exe().map_err(|e| format!("resolving current executable: {e}"))?;
|
|
||||||
// Ordering against graphical-session.target lets the watcher inherit the
|
|
||||||
// session's DISPLAY/WAYLAND_DISPLAY/DBUS so notify-send and the Tailscale
|
|
||||||
// login browser-open actually work. PATH is pinned because systemd --user
|
|
||||||
// units do not get the login shell's PATH, and the watcher shells out to
|
|
||||||
// nmcli/tailscale/sudo/xdg-open by name.
|
|
||||||
let unit = format!(
|
|
||||||
"[Unit]\n\
|
|
||||||
Description=breadcrumbs Wi-Fi state machine watcher\n\
|
|
||||||
After=network.target NetworkManager.service graphical-session.target\n\
|
|
||||||
Wants=network.target graphical-session.target\n\n\
|
|
||||||
[Service]\n\
|
|
||||||
Type=simple\n\
|
|
||||||
Environment=PATH=/usr/local/bin:/usr/bin:/bin\n\
|
|
||||||
ExecStart={bin} watch\n\
|
|
||||||
Restart=always\n\
|
|
||||||
RestartSec=5\n\
|
|
||||||
Nice=5\n\n\
|
|
||||||
[Install]\n\
|
|
||||||
WantedBy=default.target\n",
|
|
||||||
bin = bin.display()
|
|
||||||
);
|
|
||||||
let unit_path = unit_dir.join("breadcrumbs.service");
|
|
||||||
std::fs::write(&unit_path, unit)
|
|
||||||
.map_err(|e| format!("writing {}: {e}", unit_path.display()))?;
|
|
||||||
println!("{C_GREEN}wrote{C_RESET} {}", unit_path.display());
|
|
||||||
|
|
||||||
let _ = run(
|
|
||||||
"systemctl",
|
|
||||||
&["--user", "daemon-reload"],
|
|
||||||
Duration::from_secs(10),
|
|
||||||
);
|
|
||||||
if enable {
|
|
||||||
let o = run(
|
|
||||||
"systemctl",
|
|
||||||
&["--user", "enable", "--now", "breadcrumbs.service"],
|
|
||||||
Duration::from_secs(15),
|
|
||||||
);
|
|
||||||
if o.success {
|
|
||||||
println!("{C_GREEN}enabled + started{C_RESET} breadcrumbs.service");
|
|
||||||
} else {
|
|
||||||
println!(
|
|
||||||
"{C_YELLOW}unit installed{C_RESET}; enable failed: {}",
|
|
||||||
o.stderr.trim()
|
|
||||||
);
|
|
||||||
return Ok(1);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
println!("Run: systemctl --user enable --now breadcrumbs.service");
|
|
||||||
}
|
|
||||||
Ok(0)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
361
src/nm.rs
361
src/nm.rs
|
|
@ -1,8 +1,7 @@
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::config::{state_dir, NetworkDef};
|
use crate::config::NetworkDef;
|
||||||
use crate::util::{run, run_ok, run_with_stdin};
|
use crate::util::{run, run_ok, run_with_stdin};
|
||||||
|
|
||||||
/// nmcli `-t` escapes `:` and `\` in field values; undo that.
|
/// nmcli `-t` escapes `:` and `\` in field values; undo that.
|
||||||
|
|
@ -23,8 +22,11 @@ fn unescape(s: &str) -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Split one nmcli `-t` line into fields. Fields are ':'-separated but values
|
/// Split one nmcli `-t` line into fields. Fields are ':'-separated but values
|
||||||
/// escape ':' as '\:' and '\' as '\\'.
|
/// escape ':' as '\:' and '\' as '\\' — a plain `splitn(2, ':')` mis-splits
|
||||||
fn parse_scan_line(line: &str) -> Vec<String> {
|
/// any field (device name, connection name, SSID, …) that legitimately
|
||||||
|
/// contains a colon, so every terse-output parse in this module goes through
|
||||||
|
/// here rather than splitting on raw bytes. Fields are returned unescaped.
|
||||||
|
fn split_fields(line: &str) -> Vec<String> {
|
||||||
let mut fields: Vec<String> = Vec::new();
|
let mut fields: Vec<String> = Vec::new();
|
||||||
let mut cur = String::new();
|
let mut cur = String::new();
|
||||||
let mut chars = line.chars().peekable();
|
let mut chars = line.chars().peekable();
|
||||||
|
|
@ -56,9 +58,9 @@ pub fn wifi_interface() -> Option<String> {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
for line in o.stdout.lines() {
|
for line in o.stdout.lines() {
|
||||||
let parts: Vec<&str> = line.splitn(2, ':').collect();
|
let fields = split_fields(line);
|
||||||
if parts.len() == 2 && parts[1] == "wifi" {
|
if fields.len() >= 2 && fields[1] == "wifi" {
|
||||||
return Some(unescape(parts[0]));
|
return Some(fields[0].clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
|
|
@ -133,7 +135,7 @@ pub fn scan_list(iface: &str) -> Vec<ScanEntry> {
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
for line in o.stdout.lines() {
|
for line in o.stdout.lines() {
|
||||||
let fields = parse_scan_line(line);
|
let fields = split_fields(line);
|
||||||
if fields.is_empty() {
|
if fields.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -169,9 +171,9 @@ pub fn active_ssid(iface: &str) -> Option<String> {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
for line in o.stdout.lines() {
|
for line in o.stdout.lines() {
|
||||||
let parts: Vec<&str> = line.splitn(2, ':').collect();
|
let fields = split_fields(line);
|
||||||
if parts.len() == 2 && parts[0] == "yes" {
|
if fields.len() >= 2 && fields[0] == "yes" {
|
||||||
let s = unescape(parts[1].trim());
|
let s = fields[1].trim().to_string();
|
||||||
if !s.is_empty() {
|
if !s.is_empty() {
|
||||||
return Some(s);
|
return Some(s);
|
||||||
}
|
}
|
||||||
|
|
@ -190,9 +192,9 @@ pub fn device_connected(iface: &str) -> bool {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
for line in o.stdout.lines() {
|
for line in o.stdout.lines() {
|
||||||
let parts: Vec<&str> = line.splitn(2, ':').collect();
|
let fields = split_fields(line);
|
||||||
if parts.len() == 2 && unescape(parts[0]) == iface {
|
if fields.len() >= 2 && fields[0] == iface {
|
||||||
return parts[1].starts_with("connected");
|
return fields[1].starts_with("connected");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
false
|
false
|
||||||
|
|
@ -241,19 +243,6 @@ 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
|
/// Return the name of the first saved NM connection profile whose name is
|
||||||
/// either exactly `ssid` or `ssid N` (NM's numbered-duplicate convention).
|
/// either exactly `ssid` or `ssid N` (NM's numbered-duplicate convention).
|
||||||
/// Returns `None` if no such profile exists.
|
/// Returns `None` if no such profile exists.
|
||||||
|
|
@ -268,9 +257,7 @@ fn first_profile_for_ssid(ssid: &str) -> Option<String> {
|
||||||
}
|
}
|
||||||
let mut fallback: Option<String> = None;
|
let mut fallback: Option<String> = None;
|
||||||
for line in o.stdout.lines() {
|
for line in o.stdout.lines() {
|
||||||
// NAME may itself contain an (escaped) ':', so a plain splitn would
|
let fields = split_fields(line);
|
||||||
// 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") {
|
if fields.len() < 2 || !fields[1].contains("wireless") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -278,48 +265,18 @@ fn first_profile_for_ssid(ssid: &str) -> Option<String> {
|
||||||
if name == ssid {
|
if name == ssid {
|
||||||
return Some(name);
|
return Some(name);
|
||||||
}
|
}
|
||||||
if fallback.is_none() && is_numbered_nm_duplicate(&name, ssid) {
|
if fallback.is_none() {
|
||||||
fallback = Some(name);
|
if let Some(suffix) = name.strip_prefix(ssid) {
|
||||||
|
let s = suffix.trim();
|
||||||
|
if !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()) {
|
||||||
|
fallback = Some(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fallback
|
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.
|
/// Connect to a network and pin DNS. Returns true only if associated.
|
||||||
pub fn connect(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> bool {
|
pub fn connect(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> bool {
|
||||||
connect_verbose(iface, net, wait, dns).is_ok()
|
connect_verbose(iface, net, wait, dns).is_ok()
|
||||||
|
|
@ -327,17 +284,27 @@ pub fn connect(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> bool {
|
||||||
|
|
||||||
/// Connect to a network and pin DNS. Returns the nmcli error on failure.
|
/// Connect to a network and pin DNS. Returns the nmcli error on failure.
|
||||||
///
|
///
|
||||||
/// Reuses an existing saved profile for the SSID when one exists (updating its
|
/// Reuses an existing saved profile for the SSID when one exists so that
|
||||||
/// PSK) so that repeated connections do not accumulate numbered duplicates in
|
/// repeated connections do not accumulate numbered duplicates in
|
||||||
/// NetworkManager ("NCC", "NCC 1", "NCC 2", …). Falls back to
|
/// NetworkManager ("NCC", "NCC 1", "NCC 2", …). Falls back to
|
||||||
/// `nmcli device wifi connect` — which creates a new profile — only when no
|
/// `nmcli device wifi connect` — which creates a new profile — only when no
|
||||||
/// saved profile is found.
|
/// saved profile is found.
|
||||||
|
///
|
||||||
|
/// `net.password` is only sent when `Some`: on the reuse path, `None` means
|
||||||
|
/// "leave the saved PSK alone" (either NetworkManager already durably owns
|
||||||
|
/// it, or the network is open); on the create path it means "no password
|
||||||
|
/// at all", which is also how a genuinely open (no-security) SSID is
|
||||||
|
/// connected. See the field doc on [`NetworkDef::password`] for how a local
|
||||||
|
/// secret transitions to `None` after its first successful use.
|
||||||
|
///
|
||||||
|
/// When a password *is* sent it goes to `nmcli --ask` on stdin, never on
|
||||||
|
/// argv — `/proc/<pid>/cmdline` is world-readable. After the first success
|
||||||
|
/// breadcrumbs clears its local copy, so subsequent connects pass nothing.
|
||||||
pub fn connect_verbose(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> Result<(), String> {
|
pub fn connect_verbose(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> Result<(), String> {
|
||||||
let wait_s = wait.to_string();
|
let wait_s = wait.to_string();
|
||||||
|
let timeout = Duration::from_secs(wait as u64 + 15);
|
||||||
|
|
||||||
if let Some(profile) = first_profile_for_ssid(&net.ssid) {
|
if let Some(profile) = first_profile_for_ssid(&net.ssid) {
|
||||||
// Ensure the hidden flag is set — this carries no secret, so passing it
|
|
||||||
// in argv is safe.
|
|
||||||
if net.hidden {
|
if net.hidden {
|
||||||
let _ = run(
|
let _ = run(
|
||||||
"nmcli",
|
"nmcli",
|
||||||
|
|
@ -351,64 +318,113 @@ pub fn connect_verbose(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> R
|
||||||
Duration::from_secs(6),
|
Duration::from_secs(6),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Supply the PSK to the activation via a 0600 passwd-file rather than
|
let o = if let Some(pw) = &net.password {
|
||||||
// argv. Harmless if NM already has a matching secret stored.
|
// WHY: a stored PSK makes NM skip the secret agent, so --ask
|
||||||
let psk_file = if net.password.is_empty() {
|
// would never read stdin. Resetting the property (empty value,
|
||||||
None
|
// not a secret) forces a request; the new PSK arrives on stdin.
|
||||||
|
let _ = run(
|
||||||
|
"nmcli",
|
||||||
|
&[
|
||||||
|
"connection",
|
||||||
|
"modify",
|
||||||
|
&profile,
|
||||||
|
"802-11-wireless-security.psk",
|
||||||
|
"",
|
||||||
|
],
|
||||||
|
Duration::from_secs(6),
|
||||||
|
);
|
||||||
|
let stdin = format!("{pw}\n");
|
||||||
|
run_with_stdin(
|
||||||
|
"nmcli",
|
||||||
|
&[
|
||||||
|
"--ask",
|
||||||
|
"--wait",
|
||||||
|
&wait_s,
|
||||||
|
"connection",
|
||||||
|
"up",
|
||||||
|
&profile,
|
||||||
|
"ifname",
|
||||||
|
iface,
|
||||||
|
],
|
||||||
|
Some(&stdin),
|
||||||
|
timeout,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
write_psk_file(&net.password)
|
run(
|
||||||
|
"nmcli",
|
||||||
|
&[
|
||||||
|
"--wait",
|
||||||
|
&wait_s,
|
||||||
|
"connection",
|
||||||
|
"up",
|
||||||
|
&profile,
|
||||||
|
"ifname",
|
||||||
|
iface,
|
||||||
|
],
|
||||||
|
timeout,
|
||||||
|
)
|
||||||
};
|
};
|
||||||
let psk_path = psk_file.as_ref().map(|p| p.display().to_string());
|
if !o.success {
|
||||||
let mut args: Vec<&str> = vec![
|
let detail = o.stderr.trim().to_string();
|
||||||
"--wait",
|
return Err(if detail.is_empty() {
|
||||||
&wait_s,
|
o.stdout.trim().to_string()
|
||||||
"connection",
|
} else {
|
||||||
"up",
|
detail
|
||||||
&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(uuid) = active_uuid(iface) {
|
||||||
if let Some(p) = psk_file {
|
enforce_dns(&uuid, iface, dns);
|
||||||
let _ = std::fs::remove_file(p);
|
|
||||||
}
|
}
|
||||||
if o.success {
|
return Ok(());
|
||||||
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 (or reuse failed) — create/refresh via device wifi
|
// No saved profile — create one via device wifi connect.
|
||||||
// connect. `--ask` makes nmcli read the PSK from stdin instead of argv.
|
|
||||||
let hidden = if net.hidden { "yes" } else { "no" };
|
let hidden = if net.hidden { "yes" } else { "no" };
|
||||||
let args = [
|
let o = if let Some(pw) = &net.password {
|
||||||
"--ask",
|
// WHY: never put the PSK on argv — /proc/<pid>/cmdline is
|
||||||
"--wait",
|
// world-readable. `nmcli --ask` registers as a secret agent and
|
||||||
&wait_s,
|
// nmc_readline reads the PSK from stdin (one line). Open networks
|
||||||
"device",
|
// stay on the no-ask path so we don't hang on a prompt.
|
||||||
"wifi",
|
let stdin = format!("{pw}\n");
|
||||||
"connect",
|
run_with_stdin(
|
||||||
net.ssid.as_str(),
|
"nmcli",
|
||||||
"hidden",
|
&[
|
||||||
hidden,
|
"--ask",
|
||||||
"ifname",
|
"--wait",
|
||||||
iface,
|
&wait_s,
|
||||||
];
|
"device",
|
||||||
let stdin = format!("{}\n", net.password);
|
"wifi",
|
||||||
let o = run_with_stdin(
|
"connect",
|
||||||
"nmcli",
|
net.ssid.as_str(),
|
||||||
&args,
|
"hidden",
|
||||||
Some(&stdin),
|
hidden,
|
||||||
Duration::from_secs(wait as u64 + 15),
|
"ifname",
|
||||||
);
|
iface,
|
||||||
|
],
|
||||||
|
Some(&stdin),
|
||||||
|
timeout,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// No local PSK: either the SSID is open, or NM should already hold
|
||||||
|
// the secret — the latter only succeeds if a saved profile exists,
|
||||||
|
// which is why we only reach this branch when that assumption held.
|
||||||
|
run(
|
||||||
|
"nmcli",
|
||||||
|
&[
|
||||||
|
"--wait",
|
||||||
|
&wait_s,
|
||||||
|
"device",
|
||||||
|
"wifi",
|
||||||
|
"connect",
|
||||||
|
net.ssid.as_str(),
|
||||||
|
"hidden",
|
||||||
|
hidden,
|
||||||
|
"ifname",
|
||||||
|
iface,
|
||||||
|
],
|
||||||
|
timeout,
|
||||||
|
)
|
||||||
|
};
|
||||||
if !o.success {
|
if !o.success {
|
||||||
let detail = o.stderr.trim().to_string();
|
let detail = o.stderr.trim().to_string();
|
||||||
return Err(if detail.is_empty() {
|
return Err(if detail.is_empty() {
|
||||||
|
|
@ -436,12 +452,15 @@ pub fn delete_connections_for_ssid(ssid: &str) -> bool {
|
||||||
}
|
}
|
||||||
let mut removed = false;
|
let mut removed = false;
|
||||||
for line in list.stdout.lines() {
|
for line in list.stdout.lines() {
|
||||||
// NAME may contain an escaped ':' — parse rather than naive-split.
|
let fields = split_fields(line);
|
||||||
let fields = parse_scan_line(line);
|
if fields.len() < 2 {
|
||||||
if fields.len() < 2 || !fields[1].contains("wireless") {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let name = fields[0].clone();
|
let name = fields[0].clone();
|
||||||
|
let typ = &fields[1];
|
||||||
|
if !typ.contains("wireless") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let conn_ssid = run(
|
let conn_ssid = run(
|
||||||
"nmcli",
|
"nmcli",
|
||||||
&["-g", "802-11-wireless.ssid", "connection", "show", &name],
|
&["-g", "802-11-wireless.ssid", "connection", "show", &name],
|
||||||
|
|
@ -474,99 +493,37 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unescape_empty_string() {
|
fn split_fields_splits_and_unescapes() {
|
||||||
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.
|
// SSID:SIGNAL:SECURITY with an escaped ':' inside the SSID.
|
||||||
let f = parse_scan_line(r"My\:Net:72:WPA2");
|
let f = split_fields(r"My\:Net:72:WPA2");
|
||||||
assert_eq!(f, vec!["My:Net", "72", "WPA2"]);
|
assert_eq!(f, vec!["My:Net", "72", "WPA2"]);
|
||||||
|
|
||||||
// SSID with a space (common in real network names)
|
// SSID with a space (common in real network names)
|
||||||
let f = parse_scan_line("My Network:88:WPA2");
|
let f = split_fields("My Network:88:WPA2");
|
||||||
assert_eq!(f, vec!["My Network", "88", "WPA2"]);
|
assert_eq!(f, vec!["My Network", "88", "WPA2"]);
|
||||||
|
|
||||||
// Empty SSID (hidden) keeps the empty leading field.
|
// Empty SSID (hidden) keeps the empty leading field.
|
||||||
let f = parse_scan_line(":40:WPA3");
|
let f = split_fields(":40:WPA3");
|
||||||
assert_eq!(f, vec!["", "40", "WPA3"]);
|
assert_eq!(f, vec!["", "40", "WPA3"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_scan_line_single_field_no_separators() {
|
fn split_fields_two_column_with_colon_in_first_field() {
|
||||||
let f = parse_scan_line("OnlySSID");
|
// A connection NAME or SSID containing a literal ':' must not be
|
||||||
assert_eq!(f, vec!["OnlySSID"]);
|
// mis-split into TYPE — this is what a plain `splitn(2, ':')` gets
|
||||||
|
// wrong (e.g. `wifi_interface`/`first_profile_for_ssid` parsing).
|
||||||
|
let f = split_fields(r"Office\:5G:802-11-wireless");
|
||||||
|
assert_eq!(f, vec!["Office:5G", "802-11-wireless"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_scan_line_empty_input_yields_one_empty_field() {
|
fn split_fields_empty_line() {
|
||||||
let f = parse_scan_line("");
|
assert_eq!(split_fields(""), vec![""]);
|
||||||
assert_eq!(f, vec![""]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_scan_line_all_empty_fields() {
|
fn split_fields_trailing_backslash_in_field() {
|
||||||
// Three colons → four empty fields.
|
let f = split_fields(r"trail\\:wifi");
|
||||||
let f = parse_scan_line(":::");
|
assert_eq!(f, vec![r"trail\", "wifi"]);
|
||||||
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"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
24
src/state.rs
24
src/state.rs
|
|
@ -2,7 +2,7 @@ use std::fs;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::config::{state_dir, state_path};
|
use crate::config::{state_dir, state_path, Config};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct State {
|
pub struct State {
|
||||||
|
|
@ -29,7 +29,25 @@ impl State {
|
||||||
pub fn save(&self) -> Result<(), String> {
|
pub fn save(&self) -> Result<(), String> {
|
||||||
fs::create_dir_all(state_dir()).map_err(|e| format!("creating state dir: {e}"))?;
|
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}"))?;
|
let text = toml::to_string_pretty(self).map_err(|e| format!("serializing state: {e}"))?;
|
||||||
crate::util::write_atomic(&state_path(), &text, 0o644)
|
fs::write(state_path(), text).map_err(|e| format!("writing state: {e}"))
|
||||||
.map_err(|e| format!("writing state: {e}"))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persist `name` as the active profile if it exists in `cfg`. Shared by the
|
||||||
|
/// CLI `profile set` path and `bread.command.crumbs.set_profile` so they
|
||||||
|
/// cannot drift. Does not run [`crate::flow::run`] — the CLI applies
|
||||||
|
/// afterwards unless `--no-apply`, and the watch daemon picks the new
|
||||||
|
/// profile up on its next tick.
|
||||||
|
pub fn set_profile(cfg: &Config, name: &str) -> Result<(), String> {
|
||||||
|
if !cfg.profiles.contains_key(name) {
|
||||||
|
let avail: Vec<&String> = cfg.profiles.keys().collect();
|
||||||
|
return Err(format!("unknown profile '{name}'. Available: {avail:?}"));
|
||||||
|
}
|
||||||
|
State {
|
||||||
|
profile: name.to_string(),
|
||||||
|
updated: crate::util::timestamp(),
|
||||||
|
}
|
||||||
|
.save()?;
|
||||||
|
crate::notify::log(&format!("profile set -> {name}"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,11 @@
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::backend::Backend;
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::tailscale::TsHealth;
|
use crate::nm;
|
||||||
|
use crate::tailscale::{self, TsHealth};
|
||||||
use crate::util::{command_exists, run};
|
use crate::util::{command_exists, run};
|
||||||
|
|
||||||
/// Result of a connectivity probe. `Portal` distinguishes a captive portal
|
pub fn internet_ok(cfg: &Config) -> bool {
|
||||||
/// (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") {
|
if command_exists("curl") {
|
||||||
let o = run(
|
let o = run(
|
||||||
"curl",
|
"curl",
|
||||||
|
|
@ -34,45 +14,28 @@ pub fn connectivity(cfg: &Config) -> Connectivity {
|
||||||
"-o",
|
"-o",
|
||||||
"/dev/null",
|
"/dev/null",
|
||||||
"-w",
|
"-w",
|
||||||
"%{http_code} %{redirect_url}",
|
"%{http_code}",
|
||||||
"--max-time",
|
"--max-time",
|
||||||
"4",
|
"4",
|
||||||
&cfg.settings.connectivity_url,
|
&cfg.settings.connectivity_url,
|
||||||
],
|
],
|
||||||
Duration::from_secs(6),
|
Duration::from_secs(6),
|
||||||
);
|
);
|
||||||
let mut parts = o.stdout.split_whitespace();
|
let code = o.stdout.trim();
|
||||||
let code = parts.next().unwrap_or("");
|
if code == "204" || code == "200" || code == "301" || code == "302" {
|
||||||
let redirect = parts.next().unwrap_or("").trim();
|
return true;
|
||||||
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.
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let ping = run(
|
// Fallback: ICMP to the configured host.
|
||||||
|
run(
|
||||||
"ping",
|
"ping",
|
||||||
&["-c", "1", "-W", "2", &cfg.settings.ping_host],
|
&["-c", "1", "-W", "2", &cfg.settings.ping_host],
|
||||||
Duration::from_secs(4),
|
Duration::from_secs(4),
|
||||||
)
|
)
|
||||||
.success;
|
.success
|
||||||
if ping {
|
|
||||||
Connectivity::Online
|
|
||||||
} else {
|
|
||||||
Connectivity::Offline
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Best-effort IPv4 address of `iface` via nmcli, with the CIDR prefix stripped.
|
fn ipv4(iface: &str) -> Option<String> {
|
||||||
pub fn ipv4(iface: &str) -> Option<String> {
|
|
||||||
let o = run(
|
let o = run(
|
||||||
"nmcli",
|
"nmcli",
|
||||||
&["-g", "IP4.ADDRESS", "device", "show", iface],
|
&["-g", "IP4.ADDRESS", "device", "show", iface],
|
||||||
|
|
@ -85,9 +48,7 @@ pub fn ipv4(iface: &str) -> Option<String> {
|
||||||
if s.is_empty() {
|
if s.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
// nmcli reports "192.168.1.5/24"; drop the prefix length for display.
|
Some(s.lines().next().unwrap_or(s).trim().to_string())
|
||||||
let first = s.lines().next().unwrap_or(s).trim();
|
|
||||||
Some(first.split('/').next().unwrap_or(first).to_string())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,24 +57,16 @@ pub struct Status {
|
||||||
pub ssid: Option<String>,
|
pub ssid: Option<String>,
|
||||||
pub ip: Option<String>,
|
pub ip: Option<String>,
|
||||||
pub internet: bool,
|
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_required: bool,
|
||||||
pub tailscale: Option<TsHealth>,
|
pub tailscale: Option<TsHealth>,
|
||||||
pub exit_node: String,
|
pub exit_node: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn gather(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Status {
|
pub fn gather(cfg: &Config, profile_name: &str) -> Status {
|
||||||
let iface = be.wifi_interface();
|
let iface = nm::wifi_interface();
|
||||||
let ssid = iface.as_deref().and_then(|i| be.active_ssid(i));
|
let ssid = iface.as_deref().and_then(nm::active_ssid);
|
||||||
let ip = iface.as_deref().and_then(|i| be.ipv4(i));
|
let ip = iface.as_deref().and_then(ipv4);
|
||||||
|
let internet = internet_ok(cfg);
|
||||||
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 prof = cfg.profile(profile_name);
|
||||||
let ts_required = prof.map(|p| p.tailscale).unwrap_or(false);
|
let ts_required = prof.map(|p| p.tailscale).unwrap_or(false);
|
||||||
|
|
@ -121,8 +74,8 @@ pub fn gather(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Status {
|
||||||
.and_then(|p| p.exit_node.clone())
|
.and_then(|p| p.exit_node.clone())
|
||||||
.unwrap_or_else(|| cfg.settings.exit_node.clone());
|
.unwrap_or_else(|| cfg.settings.exit_node.clone());
|
||||||
|
|
||||||
let tailscale = if be.tailscale_installed() {
|
let tailscale = if tailscale::installed() {
|
||||||
Some(be.tailscale_check(&exit_node))
|
Some(tailscale::check(&exit_node))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
@ -132,7 +85,6 @@ pub fn gather(be: &dyn Backend, cfg: &Config, profile_name: &str) -> Status {
|
||||||
ssid,
|
ssid,
|
||||||
ip,
|
ip,
|
||||||
internet,
|
internet,
|
||||||
portal,
|
|
||||||
tailscale_required: ts_required,
|
tailscale_required: ts_required,
|
||||||
tailscale,
|
tailscale,
|
||||||
exit_node,
|
exit_node,
|
||||||
|
|
|
||||||
135
src/tailscale.rs
135
src/tailscale.rs
|
|
@ -313,54 +313,6 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use serde_json::json;
|
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]
|
#[test]
|
||||||
fn backend_state_extraction() {
|
fn backend_state_extraction() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -370,13 +322,6 @@ mod tests {
|
||||||
assert_eq!(backend_state(&json!({})), "");
|
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]
|
#[test]
|
||||||
fn exit_node_healthy_and_selected() {
|
fn exit_node_healthy_and_selected() {
|
||||||
let v = json!({
|
let v = json!({
|
||||||
|
|
@ -438,57 +383,75 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn exit_node_state_empty_peer_map() {
|
fn exit_node_lookup_is_case_insensitive() {
|
||||||
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!({
|
let v = json!({
|
||||||
"Peer": {
|
"Peer": {
|
||||||
"k1": { "HostName": "MYNODE", "DNSName": "mynode.ts.net.",
|
"k1": { "HostName": "ExitNode", "DNSName": "ExitNode.ts.net.",
|
||||||
"Online": true, "ExitNode": true, "ExitNodeOption": true }
|
"Online": true, "ExitNode": true, "ExitNodeOption": true }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let (exists, online, selected) = exit_node_state(&v, "mynode");
|
assert_eq!(exit_node_state(&v, "EXITNODE"), (true, true, true));
|
||||||
assert!(exists && online && selected);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn exit_node_status_overrides_peer_online_when_selected() {
|
fn exit_node_state_with_no_peers_is_all_false() {
|
||||||
// ExitNodeStatus.Online=false should override the peer's Online=true
|
let v = json!({ "BackendState": "Running" });
|
||||||
// when the peer is the currently-selected exit node.
|
assert_eq!(exit_node_state(&v, "exitnode"), (false, false, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exit_node_state_empty_node_name_matches_nothing() {
|
||||||
let v = json!({
|
let v = json!({
|
||||||
"ExitNodeStatus": { "Online": false },
|
|
||||||
"Peer": {
|
"Peer": {
|
||||||
"k1": { "HostName": "exitnode", "DNSName": "exitnode.ts.net.",
|
"k1": { "HostName": "exitnode", "DNSName": "exitnode.ts.net.",
|
||||||
"Online": true, "ExitNode": true, "ExitNodeOption": true }
|
"Online": true, "ExitNode": true, "ExitNodeOption": true }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let (exists, online, selected) = exit_node_state(&v, "exitnode");
|
assert_eq!(exit_node_state(&v, ""), (false, false, false));
|
||||||
assert!(exists);
|
|
||||||
assert!(
|
|
||||||
!online,
|
|
||||||
"ExitNodeStatus.Online=false should override peer Online"
|
|
||||||
);
|
|
||||||
assert!(selected);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn exit_node_state_wrong_node_name_not_matched() {
|
fn exit_node_status_online_only_applies_when_selected() {
|
||||||
|
// ExitNodeStatus.Online reflects the currently *active* exit node —
|
||||||
|
// it must not leak into the reported state of a peer that merely
|
||||||
|
// matches by name but isn't the one actually selected.
|
||||||
let v = json!({
|
let v = json!({
|
||||||
|
"ExitNodeStatus": { "Online": true },
|
||||||
"Peer": {
|
"Peer": {
|
||||||
"k1": { "HostName": "othernode", "DNSName": "othernode.ts.net.",
|
"k1": { "HostName": "other", "DNSName": "other.ts.net.",
|
||||||
"Online": true, "ExitNode": true, "ExitNodeOption": true }
|
"Online": false, "ExitNode": false, "ExitNodeOption": true }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
assert_eq!(exit_node_state(&v, "exitnode"), (false, false, false));
|
assert_eq!(exit_node_state(&v, "other"), (true, false, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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_is_human_readable() {
|
||||||
|
assert_eq!(TsHealth::Ok.describe(), "ok");
|
||||||
|
assert_eq!(
|
||||||
|
TsHealth::NeedsLogin.describe(),
|
||||||
|
"not logged in (run: tailscale up)"
|
||||||
|
);
|
||||||
|
assert_eq!(TsHealth::Error("boom".into()).describe(), "error: boom");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_url_finds_https_token_among_others() {
|
||||||
|
assert_eq!(
|
||||||
|
extract_url("To authenticate, visit: https://login.tailscale.com/abc"),
|
||||||
|
Some("https://login.tailscale.com/abc".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(extract_url("no url on this line"), None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
221
src/util.rs
221
src/util.rs
|
|
@ -1,6 +1,6 @@
|
||||||
use std::fs;
|
use std::cell::RefCell;
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::PathBuf;
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
@ -11,53 +11,6 @@ pub fn home_dir() -> PathBuf {
|
||||||
.unwrap_or_else(|| PathBuf::from("/root"))
|
.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) {
|
|
||||||
if dir.join(name).is_file() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Output {
|
pub struct Output {
|
||||||
pub success: bool,
|
pub success: bool,
|
||||||
|
|
@ -75,6 +28,65 @@ impl Output {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Everything breadcrumbs does to touch the outside world *other than* its
|
||||||
|
/// own file I/O and env var reads: spawning an external program, and
|
||||||
|
/// checking whether one is available at all. Every call site in this crate
|
||||||
|
/// (`nm.rs`, `tailscale.rs`, `status.rs`, `notify.rs`, `app.rs`) goes through
|
||||||
|
/// the free functions below (`run`/`run_with_stdin`/`run_ok`/
|
||||||
|
/// `command_exists`), which are thin wrappers dispatching to whatever
|
||||||
|
/// `Runner` is currently installed in the thread-local slot — `RealRunner` by
|
||||||
|
/// default.
|
||||||
|
///
|
||||||
|
/// Tests swap in a fake implementation via [`with_runner`] so real call
|
||||||
|
/// chains (`flow::run`, `watch::classify`, …) can be driven in-process
|
||||||
|
/// against canned output, with no subprocess ever spawned and full
|
||||||
|
/// visibility into exactly what *would* have been executed — the natural
|
||||||
|
/// mechanism for asserting things like "no password ever reaches nmcli's
|
||||||
|
/// argv on a repeat connect" (see the credential tests under `tests/`).
|
||||||
|
///
|
||||||
|
/// A thread-local (rather than an explicit parameter threaded through every
|
||||||
|
/// function) was chosen so the large existing call surface in `nm.rs` et al.
|
||||||
|
/// didn't need every signature rewritten to carry a `&dyn Runner` — call
|
||||||
|
/// sites are unchanged, only `util`'s internals dispatch differently. It's
|
||||||
|
/// safe across `cargo test`'s parallel test threads because each thread gets
|
||||||
|
/// its own independent slot, defaulting to `RealRunner`, so tests that don't
|
||||||
|
/// install a fake are unaffected by ones that do.
|
||||||
|
pub trait Runner {
|
||||||
|
fn run(&self, prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration) -> Output;
|
||||||
|
fn command_exists(&self, name: &str) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RealRunner;
|
||||||
|
|
||||||
|
impl Runner for RealRunner {
|
||||||
|
fn run(&self, prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration) -> Output {
|
||||||
|
spawn_run(prog, args, stdin, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_exists(&self, name: &str) -> bool {
|
||||||
|
path_lookup_exists(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_lookup_exists(name: &str) -> bool {
|
||||||
|
if let Some(paths) = std::env::var_os("PATH") {
|
||||||
|
for dir in std::env::split_paths(&paths) {
|
||||||
|
if dir.join(name).is_file() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
static RUNNER: RefCell<Box<dyn Runner>> = RefCell::new(Box::new(RealRunner));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn command_exists(name: &str) -> bool {
|
||||||
|
RUNNER.with(|r| r.borrow().command_exists(name))
|
||||||
|
}
|
||||||
|
|
||||||
/// Run a command with a hard timeout. The child is killed if it overruns so a
|
/// Run a command with a hard timeout. The child is killed if it overruns so a
|
||||||
/// hung nmcli/tailscale can never wedge the daemon.
|
/// hung nmcli/tailscale can never wedge the daemon.
|
||||||
pub fn run(prog: &str, args: &[&str], timeout: Duration) -> Output {
|
pub fn run(prog: &str, args: &[&str], timeout: Duration) -> Output {
|
||||||
|
|
@ -85,6 +97,32 @@ pub fn run(prog: &str, args: &[&str], timeout: Duration) -> Output {
|
||||||
/// secrets (e.g. Wi-Fi PSKs) to `nmcli --ask` without exposing them in argv,
|
/// secrets (e.g. Wi-Fi PSKs) to `nmcli --ask` without exposing them in argv,
|
||||||
/// where any local user could read them via `ps`.
|
/// where any local user could read them via `ps`.
|
||||||
pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration) -> Output {
|
pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration) -> Output {
|
||||||
|
RUNNER.with(|r| r.borrow().run(prog, args, stdin, timeout))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_ok(prog: &str, args: &[&str], timeout: Duration) -> bool {
|
||||||
|
run(prog, args, timeout).success
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Swap the thread-local [`Runner`] for `runner` for the duration of `f`,
|
||||||
|
/// restoring whatever was previously installed afterward — even if `f`
|
||||||
|
/// panics, so a failing assertion inside a test can't leak a fake runner
|
||||||
|
/// into whatever test happens to run next on this thread. This is the seam
|
||||||
|
/// integration tests use to drive real logic without spawning subprocesses.
|
||||||
|
pub fn with_runner<R, T>(runner: R, f: impl FnOnce() -> T) -> T
|
||||||
|
where
|
||||||
|
R: Runner + 'static,
|
||||||
|
{
|
||||||
|
let prev = RUNNER.with(|r| std::mem::replace(&mut *r.borrow_mut(), Box::new(runner)));
|
||||||
|
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
|
||||||
|
RUNNER.with(|r| *r.borrow_mut() = prev);
|
||||||
|
match result {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(payload) => std::panic::resume_unwind(payload),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_run(prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration) -> Output {
|
||||||
let stdin_cfg = if stdin.is_some() {
|
let stdin_cfg = if stdin.is_some() {
|
||||||
Stdio::piped()
|
Stdio::piped()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -92,11 +130,6 @@ pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: D
|
||||||
};
|
};
|
||||||
let mut child = match Command::new(prog)
|
let mut child = match Command::new(prog)
|
||||||
.args(args)
|
.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)
|
.stdin(stdin_cfg)
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped())
|
.stderr(Stdio::piped())
|
||||||
|
|
@ -106,6 +139,13 @@ pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: D
|
||||||
Err(_) => return Output::failed(),
|
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 stdout_pipe = child.stdout.take();
|
||||||
let mut stderr_pipe = child.stderr.take();
|
let mut stderr_pipe = child.stderr.take();
|
||||||
|
|
||||||
|
|
@ -124,16 +164,6 @@ pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: D
|
||||||
buf
|
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 start = Instant::now();
|
||||||
let status = loop {
|
let status = loop {
|
||||||
match child.try_wait() {
|
match child.try_wait() {
|
||||||
|
|
@ -160,10 +190,6 @@ pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: D
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run_ok(prog: &str, args: &[&str], timeout: Duration) -> bool {
|
|
||||||
run(prog, args, timeout).success
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Local "YYYY-MM-DD HH:MM:SS". Uses `date` for correct local time, falling
|
/// Local "YYYY-MM-DD HH:MM:SS". Uses `date` for correct local time, falling
|
||||||
/// back to a dependency-free UTC computation if it is unavailable.
|
/// back to a dependency-free UTC computation if it is unavailable.
|
||||||
pub fn timestamp() -> String {
|
pub fn timestamp() -> String {
|
||||||
|
|
@ -223,56 +249,39 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fmt_epoch_year_2000_century_divisible_by_400_leap() {
|
fn fmt_epoch_pre_1970_is_handled() {
|
||||||
// 2000-01-01 00:00:00 UTC — divisible by 400, so it IS a leap year.
|
// The div_euclid/rem_euclid split must stay correct for negative
|
||||||
assert_eq!(fmt_epoch(946_684_800), "2000-01-01 00:00:00");
|
// epoch seconds (dates before 1970), not just the common positive case.
|
||||||
}
|
|
||||||
|
|
||||||
#[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");
|
assert_eq!(fmt_epoch(-86_400), "1969-12-31 00:00:00");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fmt_epoch_february_non_leap_year_boundary() {
|
fn fmt_epoch_year_and_month_boundaries() {
|
||||||
// 2023-02-28 00:00:00 UTC (2023 is not a leap year)
|
assert_eq!(fmt_epoch(1_704_067_199), "2023-12-31 23:59:59");
|
||||||
assert_eq!(fmt_epoch(1_677_542_400), "2023-02-28 00:00:00");
|
assert_eq!(fmt_epoch(1_735_689_600), "2025-01-01 00:00:00");
|
||||||
// 2023-03-01 00:00:00 UTC — next day after Feb 28 in non-leap year
|
// Last second of October (non-leap-day month boundary).
|
||||||
assert_eq!(fmt_epoch(1_677_628_800), "2023-03-01 00:00:00");
|
assert_eq!(fmt_epoch(1_730_419_199), "2024-10-31 23:59:59");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fmt_epoch_century_non_leap_year_1900_equivalent() {
|
fn command_exists_false_for_bogus_binary() {
|
||||||
// 1900 is NOT a leap year (div by 100 but not 400).
|
assert!(!command_exists("definitely-not-a-real-binary-xyz123"));
|
||||||
// 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]
|
#[test]
|
||||||
fn fmt_epoch_midnight_vs_end_of_day() {
|
fn command_exists_true_for_a_real_binary() {
|
||||||
// 2022-06-15 00:00:00 UTC
|
// `sh` is guaranteed present on any POSIX system this runs on.
|
||||||
assert_eq!(fmt_epoch(1_655_251_200), "2022-06-15 00:00:00");
|
assert!(command_exists("sh"));
|
||||||
// 2022-06-15 23:59:59 UTC
|
|
||||||
assert_eq!(fmt_epoch(1_655_337_599), "2022-06-15 23:59:59");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fmt_epoch_time_of_day_components() {
|
fn run_on_missing_binary_fails_cleanly_instead_of_panicking() {
|
||||||
// 1970-01-01 01:02:03 UTC
|
let o = run(
|
||||||
assert_eq!(fmt_epoch(3723), "1970-01-01 01:02:03");
|
"definitely-not-a-real-binary-xyz123",
|
||||||
// 1970-01-01 23:59:59 UTC
|
&[],
|
||||||
assert_eq!(fmt_epoch(86_399), "1970-01-01 23:59:59");
|
Duration::from_secs(1),
|
||||||
|
);
|
||||||
|
assert!(!o.success);
|
||||||
|
assert_eq!(o.stdout, "");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
188
src/watch.rs
188
src/watch.rs
|
|
@ -4,7 +4,9 @@ use std::sync::mpsc::{self, Receiver};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use crate::backend::{Backend, System};
|
use bread_utils::bread_client::BreadClient;
|
||||||
|
|
||||||
|
use crate::bread_events;
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::flow;
|
use crate::flow;
|
||||||
use crate::notify::{log, notify, Urgency};
|
use crate::notify::{log, notify, Urgency};
|
||||||
|
|
@ -12,38 +14,74 @@ use crate::state::State;
|
||||||
use crate::status::{self};
|
use crate::status::{self};
|
||||||
use crate::tailscale::TsHealth;
|
use crate::tailscale::TsHealth;
|
||||||
|
|
||||||
|
/// Coarse health classification the watch loop reacts to each tick. `pub`
|
||||||
|
/// (and so is [`classify`]) purely so integration tests can drive the real
|
||||||
|
/// classification logic in-process against a faked [`crate::util::Runner`],
|
||||||
|
/// instead of only being able to observe it indirectly through the watch
|
||||||
|
/// loop's side effects.
|
||||||
#[derive(PartialEq, Eq, Clone, Debug)]
|
#[derive(PartialEq, Eq, Clone, Debug)]
|
||||||
enum Health {
|
pub enum Health {
|
||||||
Up,
|
Up,
|
||||||
DownNoNet,
|
DownNoNet,
|
||||||
/// Associated, but a captive portal is intercepting traffic (manual login).
|
|
||||||
CaptivePortal,
|
|
||||||
DownTailscaleManual,
|
DownTailscaleManual,
|
||||||
DownTailscaleOther,
|
DownTailscaleOther,
|
||||||
NoAdapter,
|
NoAdapter,
|
||||||
|
/// `profile` isn't defined in the config (e.g. state still points at a
|
||||||
|
/// custom profile the user deleted from breadcrumbs.toml).
|
||||||
|
UnknownProfile,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn classify(be: &dyn Backend, cfg: &Config, profile: &str) -> (Health, status::Status) {
|
impl Health {
|
||||||
let s = status::gather(be, cfg, profile);
|
/// Wire name used in `bread.crumbs.health.changed` — the Rust variant
|
||||||
let health = if s.iface.is_none() {
|
/// as a string, not a prettier label.
|
||||||
Health::NoAdapter
|
pub fn as_str(&self) -> &'static str {
|
||||||
} else if s.portal.is_some() {
|
match self {
|
||||||
Health::CaptivePortal
|
Health::Up => "Up",
|
||||||
} else if !s.internet {
|
Health::DownNoNet => "DownNoNet",
|
||||||
Health::DownNoNet
|
Health::DownTailscaleManual => "DownTailscaleManual",
|
||||||
} else if s.tailscale_required {
|
Health::DownTailscaleOther => "DownTailscaleOther",
|
||||||
|
Health::NoAdapter => "NoAdapter",
|
||||||
|
Health::UnknownProfile => "UnknownProfile",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn classify(cfg: &Config, profile: &str) -> (Health, Option<String>) {
|
||||||
|
// Checked before gather(): a profile missing from config would otherwise
|
||||||
|
// silently fall back to "tailscale not required" and read as healthy off
|
||||||
|
// of nothing but a bare internet check, never surfacing the misconfig.
|
||||||
|
if cfg.profile(profile).is_none() {
|
||||||
|
return (Health::UnknownProfile, None);
|
||||||
|
}
|
||||||
|
let s = status::gather(cfg, profile);
|
||||||
|
if s.iface.is_none() {
|
||||||
|
return (Health::NoAdapter, None);
|
||||||
|
}
|
||||||
|
let ssid = s.ssid.clone();
|
||||||
|
if !s.internet {
|
||||||
|
return (Health::DownNoNet, ssid);
|
||||||
|
}
|
||||||
|
if s.tailscale_required {
|
||||||
match s.tailscale {
|
match s.tailscale {
|
||||||
Some(TsHealth::Ok) => Health::Up,
|
Some(TsHealth::Ok) => (Health::Up, ssid),
|
||||||
Some(TsHealth::NeedsLogin) | Some(TsHealth::NotInstalled) => {
|
Some(TsHealth::NeedsLogin) | Some(TsHealth::NotInstalled) => {
|
||||||
Health::DownTailscaleManual
|
(Health::DownTailscaleManual, ssid)
|
||||||
}
|
}
|
||||||
Some(_) => Health::DownTailscaleOther,
|
Some(_) => (Health::DownTailscaleOther, ssid),
|
||||||
None => Health::DownTailscaleManual,
|
None => (Health::DownTailscaleManual, ssid),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Health::Up
|
(Health::Up, ssid)
|
||||||
};
|
}
|
||||||
(health, s)
|
}
|
||||||
|
|
||||||
|
/// Whether a debounced signal is allowed to fire. `None` (never fired) always
|
||||||
|
/// fires; otherwise it fires only once more than `gap` has elapsed since the
|
||||||
|
/// last fire. Pulled out as a pure helper so the debounce logic is testable and
|
||||||
|
/// so the "first event fires immediately" case is expressed without the
|
||||||
|
/// panic-prone `Instant::now() - gap` seed.
|
||||||
|
fn debounce_ready(last: Option<Instant>, gap: Duration) -> bool {
|
||||||
|
last.map(|t| t.elapsed() > gap).unwrap_or(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tail `nmcli monitor` and ping the channel on link-state churn so we react
|
/// Tail `nmcli monitor` and ping the channel on link-state churn so we react
|
||||||
|
|
@ -65,13 +103,20 @@ fn spawn_nm_monitor(tx: mpsc::Sender<()>) {
|
||||||
};
|
};
|
||||||
if let Some(out) = child.stdout.take() {
|
if let Some(out) = child.stdout.take() {
|
||||||
let reader = BufReader::new(out);
|
let reader = BufReader::new(out);
|
||||||
let mut last = Instant::now() - Duration::from_secs(10);
|
// `None` means "haven't fired yet, so fire on the first interesting
|
||||||
|
// line". Storing an `Option` instead of seeding with
|
||||||
|
// `Instant::now() - 10s` avoids a panic: `Instant - Duration`
|
||||||
|
// underflows (and panics) when the monotonic clock is younger than
|
||||||
|
// the offset, which happens if `watch` starts within ~10s of boot —
|
||||||
|
// exactly when the systemd unit (ordered after graphical-session)
|
||||||
|
// tends to launch.
|
||||||
|
let mut last: Option<Instant> = None;
|
||||||
for line in reader.lines().map_while(Result::ok) {
|
for line in reader.lines().map_while(Result::ok) {
|
||||||
let l = line.to_lowercase();
|
let l = line.to_lowercase();
|
||||||
let interesting =
|
let interesting =
|
||||||
l.contains("disconnect") || l.contains("unavailable") || l.contains("failed");
|
l.contains("disconnect") || l.contains("unavailable") || l.contains("failed");
|
||||||
if interesting && last.elapsed() > Duration::from_millis(1500) {
|
if interesting && debounce_ready(last, Duration::from_millis(1500)) {
|
||||||
last = Instant::now();
|
last = Some(Instant::now());
|
||||||
let _ = tx.send(());
|
let _ = tx.send(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -106,20 +151,34 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
||||||
log("watch: started");
|
log("watch: started");
|
||||||
|
|
||||||
let (tx, rx) = mpsc::channel::<()>();
|
let (tx, rx) = mpsc::channel::<()>();
|
||||||
spawn_nm_monitor(tx);
|
spawn_nm_monitor(tx.clone());
|
||||||
|
|
||||||
|
// Long-lived, so this uses BreadClient::subscribe (a persistent
|
||||||
|
// background thread with its own reconnect/backoff loop). breadd being
|
||||||
|
// absent or restarting is transparent: the subscription just quietly
|
||||||
|
// stops delivering commands until it reconnects. A successful
|
||||||
|
// `set_profile` wakes this loop the same way `nmcli monitor` does, so
|
||||||
|
// the new profile is applied on the next tick instead of waiting out
|
||||||
|
// the current poll interval.
|
||||||
|
let bread = BreadClient::connect(bread_events::APP_ID);
|
||||||
|
let wake = tx;
|
||||||
|
let _commands = bread.subscribe("bread.command.crumbs.**", move |event| {
|
||||||
|
if bread_events::handle_command(&event) {
|
||||||
|
let _ = wake.send(());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let be = System;
|
|
||||||
let mut profile = State::load(&cfg.settings.default_profile).profile;
|
let mut profile = State::load(&cfg.settings.default_profile).profile;
|
||||||
if run_initial {
|
if run_initial {
|
||||||
// Don't churn an already-working connection on (re)start.
|
// Don't churn an already-working connection on (re)start.
|
||||||
let (h, _) = classify(&be, &cfg, &profile);
|
let (h, _) = classify(&cfg, &profile);
|
||||||
if h == Health::Up {
|
if h == Health::Up {
|
||||||
log(&format!(
|
log(&format!(
|
||||||
"watch: already healthy on start (profile={profile}); skipping initial flow"
|
"watch: already healthy on start (profile={profile}); skipping initial flow"
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
log(&format!("watch: initial flow for profile={profile}"));
|
log(&format!("watch: initial flow for profile={profile}"));
|
||||||
let _ = flow::run(&be, &cfg, &profile);
|
let _ = flow::run(&mut cfg, &profile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,6 +190,12 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Reload config + state so edits and `profile set` take effect live.
|
// Reload config + state so edits and `profile set` take effect live.
|
||||||
|
// This always runs *before* `flow::run` below (never after, within
|
||||||
|
// the same tick), so a password `flow::run` clears-and-saves this
|
||||||
|
// iteration is durably on disk by the time the *next* iteration's
|
||||||
|
// reload runs — there's no window where a stale (still-has-password)
|
||||||
|
// reload could clobber the save, since the two never race on
|
||||||
|
// different threads: everything here is sequential on this loop.
|
||||||
if let Ok(fresh) = Config::load() {
|
if let Ok(fresh) = Config::load() {
|
||||||
cfg = fresh;
|
cfg = fresh;
|
||||||
}
|
}
|
||||||
|
|
@ -146,14 +211,17 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
||||||
&format!("{prev_profile} -> {profile}"),
|
&format!("{prev_profile} -> {profile}"),
|
||||||
Urgency::Low,
|
Urgency::Low,
|
||||||
);
|
);
|
||||||
|
bread_events::emit_profile_changed(&bread, &prev_profile, &profile);
|
||||||
prev_profile = profile.clone();
|
prev_profile = profile.clone();
|
||||||
prev_health = None; // force re-evaluation/recovery for new profile
|
prev_health = None; // force re-evaluation/recovery for new profile
|
||||||
last_flow_at = None; // allow immediate recovery on profile change
|
last_flow_at = None; // allow immediate recovery on profile change
|
||||||
}
|
}
|
||||||
|
|
||||||
let (health, s) = classify(&be, &cfg, &profile);
|
let (health, ssid) = classify(&cfg, &profile);
|
||||||
let ssid = s.ssid.clone();
|
|
||||||
let transition = prev_health.as_ref() != Some(&health);
|
let transition = prev_health.as_ref() != Some(&health);
|
||||||
|
if transition {
|
||||||
|
bread_events::emit_health_changed(&bread, &profile, health.as_str(), ssid.as_deref());
|
||||||
|
}
|
||||||
|
|
||||||
match &health {
|
match &health {
|
||||||
Health::Up => {
|
Health::Up => {
|
||||||
|
|
@ -169,18 +237,6 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
||||||
}
|
}
|
||||||
fail_streak = 0;
|
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 => {
|
Health::NoAdapter => {
|
||||||
if transition {
|
if transition {
|
||||||
notify(
|
notify(
|
||||||
|
|
@ -191,6 +247,15 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
||||||
}
|
}
|
||||||
fail_streak = fail_streak.saturating_add(1);
|
fail_streak = fail_streak.saturating_add(1);
|
||||||
}
|
}
|
||||||
|
Health::UnknownProfile => {
|
||||||
|
// flow::run() already notifies + logs the "unknown profile"
|
||||||
|
// critical error; re-running it here would just spam that on
|
||||||
|
// every tick, so only surface it once per transition.
|
||||||
|
if transition || profile_changed {
|
||||||
|
let _ = flow::run(&mut cfg, &profile);
|
||||||
|
}
|
||||||
|
fail_streak = fail_streak.saturating_add(1);
|
||||||
|
}
|
||||||
Health::DownTailscaleManual => {
|
Health::DownTailscaleManual => {
|
||||||
// Can't be auto-fixed (login / not installed). Notify once.
|
// Can't be auto-fixed (login / not installed). Notify once.
|
||||||
if transition {
|
if transition {
|
||||||
|
|
@ -203,8 +268,7 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
||||||
}
|
}
|
||||||
// Re-run flow only on transition so we land on the bootstrap net.
|
// Re-run flow only on transition so we land on the bootstrap net.
|
||||||
if transition || profile_changed {
|
if transition || profile_changed {
|
||||||
let _ = flow::run(&be, &cfg, &profile);
|
let _ = flow::run(&mut cfg, &profile);
|
||||||
last_flow_at = Some(Instant::now());
|
|
||||||
}
|
}
|
||||||
fail_streak = fail_streak.saturating_add(1);
|
fail_streak = fail_streak.saturating_add(1);
|
||||||
}
|
}
|
||||||
|
|
@ -224,7 +288,7 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
||||||
"watch: down ({:?}) profile={profile} ssid={:?} — running flow",
|
"watch: down ({:?}) profile={profile} ssid={:?} — running flow",
|
||||||
health, ssid
|
health, ssid
|
||||||
));
|
));
|
||||||
let outcome = flow::run(&be, &cfg, &profile);
|
let outcome = flow::run(&mut cfg, &profile);
|
||||||
log(&format!("watch: recovery outcome = {:?}", outcome));
|
log(&format!("watch: recovery outcome = {:?}", outcome));
|
||||||
last_flow_at = Some(Instant::now());
|
last_flow_at = Some(Instant::now());
|
||||||
fail_streak = if outcome.ok() {
|
fail_streak = if outcome.ok() {
|
||||||
|
|
@ -249,3 +313,39 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 {
|
||||||
wait_for_tick(&rx, dur);
|
wait_for_tick(&rx, dur);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debounce_fires_immediately_when_never_fired() {
|
||||||
|
// Regression guard for the old `Instant::now() - Duration::from_secs(10)`
|
||||||
|
// seed, which panicked near boot. `None` must fire without any
|
||||||
|
// subtraction on the clock.
|
||||||
|
assert!(debounce_ready(None, Duration::from_millis(1500)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debounce_suppresses_immediately_after_firing() {
|
||||||
|
let just_now = Instant::now();
|
||||||
|
assert!(!debounce_ready(Some(just_now), Duration::from_secs(3600)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debounce_fires_again_after_gap_elapses() {
|
||||||
|
// A zero gap is always already-elapsed, so a prior fire doesn't block.
|
||||||
|
let earlier = Instant::now();
|
||||||
|
assert!(debounce_ready(Some(earlier), Duration::from_millis(0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn health_as_str_is_the_variant_name() {
|
||||||
|
assert_eq!(Health::Up.as_str(), "Up");
|
||||||
|
assert_eq!(Health::DownNoNet.as_str(), "DownNoNet");
|
||||||
|
assert_eq!(Health::DownTailscaleManual.as_str(), "DownTailscaleManual");
|
||||||
|
assert_eq!(Health::DownTailscaleOther.as_str(), "DownTailscaleOther");
|
||||||
|
assert_eq!(Health::NoAdapter.as_str(), "NoAdapter");
|
||||||
|
assert_eq!(Health::UnknownProfile.as_str(), "UnknownProfile");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
702
tests/cli.rs
702
tests/cli.rs
|
|
@ -35,21 +35,48 @@ impl Sandbox {
|
||||||
|
|
||||||
/// Binary invocation with an isolated, side-effect-free environment.
|
/// Binary invocation with an isolated, side-effect-free environment.
|
||||||
fn cmd(&self, args: &[&str]) -> std::process::Output {
|
fn cmd(&self, args: &[&str]) -> std::process::Output {
|
||||||
Command::new(BIN)
|
self.cmd_env(args, &[])
|
||||||
.args(args)
|
}
|
||||||
|
|
||||||
|
/// Like [`cmd`], with extra environment variables layered on top of the
|
||||||
|
/// isolated base (e.g. `EDITOR` for the `edit` command).
|
||||||
|
fn cmd_env(&self, args: &[&str], extra: &[(&str, &str)]) -> std::process::Output {
|
||||||
|
let mut c = Command::new(BIN);
|
||||||
|
c.args(args)
|
||||||
.env_clear()
|
.env_clear()
|
||||||
.env("HOME", &self.root)
|
.env("HOME", &self.root)
|
||||||
.env("XDG_CONFIG_HOME", self.root.join("config"))
|
.env("XDG_CONFIG_HOME", self.root.join("config"))
|
||||||
.env("XDG_STATE_HOME", self.root.join("state"))
|
.env("XDG_STATE_HOME", self.root.join("state"))
|
||||||
// Empty bin dir => no external commands resolve.
|
// Empty bin dir => no external commands resolve.
|
||||||
.env("PATH", self.root.join("bin"))
|
.env("PATH", self.root.join("bin"));
|
||||||
.output()
|
for (k, v) in extra {
|
||||||
.expect("failed to spawn breadcrumbs")
|
c.env(k, v);
|
||||||
|
}
|
||||||
|
c.output().expect("failed to spawn breadcrumbs")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn config_file(&self) -> PathBuf {
|
fn config_file(&self) -> PathBuf {
|
||||||
self.root.join("config/breadcrumbs/breadcrumbs.toml")
|
self.root.join("config/breadcrumbs/breadcrumbs.toml")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Saved networks live in their own file, split out of `breadcrumbs.toml`
|
||||||
|
/// (see `config::networks_path`).
|
||||||
|
fn networks_file(&self) -> PathBuf {
|
||||||
|
self.root.join("config/breadcrumbs/networks.toml")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write an executable shell script into the sandbox's PATH dir so a
|
||||||
|
/// test can stand in for an external command (e.g. `$EDITOR`).
|
||||||
|
fn write_fake_bin(&self, name: &str, script: &str) -> PathBuf {
|
||||||
|
let path = self.root.join("bin").join(name);
|
||||||
|
fs::write(&path, script).unwrap();
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap();
|
||||||
|
}
|
||||||
|
path
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for Sandbox {
|
impl Drop for Sandbox {
|
||||||
|
|
@ -62,6 +89,10 @@ fn stdout(o: &std::process::Output) -> String {
|
||||||
String::from_utf8_lossy(&o.stdout).to_string()
|
String::from_utf8_lossy(&o.stdout).to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn stderr(o: &std::process::Output) -> String {
|
||||||
|
String::from_utf8_lossy(&o.stderr).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn help_lists_all_commands() {
|
fn help_lists_all_commands() {
|
||||||
let sb = Sandbox::new();
|
let sb = Sandbox::new();
|
||||||
|
|
@ -144,348 +175,413 @@ fn unknown_profile_override_is_reported() {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn profile_list_marks_active_profile() {
|
fn profile_set_unknown_reports_available_profiles() {
|
||||||
let sb = Sandbox::new();
|
let sb = Sandbox::new();
|
||||||
|
sb.cmd(&["list"]); // bootstrap the config
|
||||||
// Default profile is "away".
|
let o = sb.cmd(&["profile", "set", "bogus"]);
|
||||||
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());
|
assert!(!o.status.success());
|
||||||
|
let err = stderr(&o);
|
||||||
|
assert!(err.contains("unknown profile"), "stderr: {err}");
|
||||||
|
assert!(err.contains("home") && err.contains("work") && err.contains("away"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn add_updates_existing_network_password() {
|
fn profile_list_marks_exactly_the_current_profile() {
|
||||||
let sb = Sandbox::new();
|
let sb = Sandbox::new();
|
||||||
|
sb.cmd(&["profile", "set", "home", "--no-apply"]);
|
||||||
sb.cmd(&["add", "MyNet", "oldpass"]);
|
let o = sb.cmd(&["profile", "list"]);
|
||||||
let o = sb.cmd(&["add", "MyNet", "newpass"]);
|
|
||||||
assert!(o.status.success());
|
assert!(o.status.success());
|
||||||
|
let out = stdout(&o);
|
||||||
|
assert!(out.contains("* home"), "out: {out}");
|
||||||
|
assert_eq!(
|
||||||
|
out.lines()
|
||||||
|
.filter(|l| l.trim_start().starts_with('*'))
|
||||||
|
.count(),
|
||||||
|
1,
|
||||||
|
"expected exactly one marked profile, got: {out}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_with_multibyte_password_does_not_crash_list() {
|
||||||
|
// Regression test for a byte-slicing panic in the password-masking code:
|
||||||
|
// `list` used to index into the first *byte* of the password, which
|
||||||
|
// panicked whenever that byte fell mid-character in a multi-byte UTF-8
|
||||||
|
// password (e.g. non-Latin scripts or an emoji as the first character).
|
||||||
|
let sb = Sandbox::new();
|
||||||
|
let add = sb.cmd(&["add", "CafeWifi", "日本語パスワード🔒"]);
|
||||||
|
assert!(add.status.success(), "stderr: {}", stderr(&add));
|
||||||
|
|
||||||
|
let list = sb.cmd(&["list"]);
|
||||||
|
assert!(
|
||||||
|
list.status.success(),
|
||||||
|
"list crashed on multibyte password — stderr: {}",
|
||||||
|
stderr(&list)
|
||||||
|
);
|
||||||
|
assert!(!stdout(&list).contains("日本語パスワード🔒"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_hides_password_by_default_and_reveals_with_flag() {
|
||||||
|
let sb = Sandbox::new();
|
||||||
|
sb.cmd(&["add", "CafeWifi", "hunter2"]);
|
||||||
|
|
||||||
|
let hidden = sb.cmd(&["list"]);
|
||||||
|
assert!(!stdout(&hidden).contains("hunter2"));
|
||||||
|
|
||||||
|
let shown = sb.cmd(&["list", "--show-passwords"]);
|
||||||
|
assert!(stdout(&shown).contains("hunter2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_to_profile_persists_in_config_priority_list() {
|
||||||
|
let sb = Sandbox::new();
|
||||||
|
sb.cmd(&["list"]); // bootstrap
|
||||||
|
let o = sb.cmd(&["add", "CafeWifi", "pw", "--to", "home"]);
|
||||||
|
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||||
|
|
||||||
let text = fs::read_to_string(sb.config_file()).unwrap();
|
let text = fs::read_to_string(sb.config_file()).unwrap();
|
||||||
assert!(text.contains("newpass"), "updated password missing");
|
let home_section = text.split("[profiles.home]").nth(1).unwrap_or("");
|
||||||
// Old password must be gone.
|
assert!(
|
||||||
assert!(!text.contains("oldpass"), "old password still present");
|
home_section.contains("CafeWifi"),
|
||||||
// Only one entry for MyNet.
|
"CafeWifi not attached under [profiles.home]: {text}"
|
||||||
assert_eq!(
|
|
||||||
text.matches("MyNet").count(),
|
|
||||||
1,
|
|
||||||
"duplicate network entries"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn forget_removes_network_from_config() {
|
fn forget_removes_network_from_config() {
|
||||||
let sb = Sandbox::new();
|
let sb = Sandbox::new();
|
||||||
|
sb.cmd(&["add", "CafeWifi", "pw", "--to", "away"]);
|
||||||
|
let o = sb.cmd(&["forget", "CafeWifi"]);
|
||||||
|
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||||
|
|
||||||
sb.cmd(&["add", "ToDelete", "pw"]);
|
// Networks live in networks.toml (see the split-secrets test below) —
|
||||||
let text = fs::read_to_string(sb.config_file()).unwrap();
|
// that's the file that actually needs to lose the entry.
|
||||||
assert!(text.contains("ToDelete"));
|
let networks = fs::read_to_string(sb.networks_file()).unwrap();
|
||||||
|
assert!(
|
||||||
let o = sb.cmd(&["forget", "ToDelete"]);
|
!networks.contains("CafeWifi"),
|
||||||
assert!(o.status.success());
|
"network still in networks.toml: {networks}"
|
||||||
assert!(stdout(&o).contains("forgot"));
|
);
|
||||||
|
// ...and it should never have been in breadcrumbs.toml to begin with.
|
||||||
let text = fs::read_to_string(sb.config_file()).unwrap();
|
let text = fs::read_to_string(sb.config_file()).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
!text.contains("ToDelete"),
|
!text.contains("CafeWifi"),
|
||||||
"network still in config after forget"
|
"network leaked into config: {text}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn forget_nonexistent_network_is_graceful() {
|
fn detect_without_wifi_adapter_errors() {
|
||||||
let sb = Sandbox::new();
|
let sb = Sandbox::new();
|
||||||
// Should succeed (idempotent) even if the SSID was never saved.
|
let o = sb.cmd(&["detect"]);
|
||||||
let o = sb.cmd(&["forget", "NeverSaved"]);
|
assert!(!o.status.success());
|
||||||
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!(
|
assert!(
|
||||||
!text.contains("WorkNet"),
|
stderr(&o).contains("could not detect"),
|
||||||
"SSID still in profile after forget"
|
"stderr: {}",
|
||||||
|
stderr(&o)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn list_masks_passwords_by_default() {
|
fn doctor_reports_missing_nmcli_in_sandbox() {
|
||||||
let sb = Sandbox::new();
|
let sb = Sandbox::new();
|
||||||
sb.cmd(&["add", "SecretNet", "hunter2"]);
|
let o = sb.cmd(&["doctor"]);
|
||||||
|
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||||
|
assert!(stdout(&o).contains("MISSING"));
|
||||||
|
}
|
||||||
|
|
||||||
let o = sb.cmd(&["list"]);
|
#[test]
|
||||||
assert!(o.status.success());
|
fn status_runs_without_crashing_when_offline() {
|
||||||
|
let sb = Sandbox::new();
|
||||||
|
let o = sb.cmd(&["status"]);
|
||||||
|
// No adapter/internet in the sandbox, so this reports unhealthy (exit 1)
|
||||||
|
// rather than crashing.
|
||||||
|
assert_eq!(o.status.code(), Some(1));
|
||||||
let out = stdout(&o);
|
let out = stdout(&o);
|
||||||
assert!(
|
assert!(out.contains("breadcrumbs"));
|
||||||
!out.contains("hunter2"),
|
assert!(out.contains("needs attention"));
|
||||||
"plain-text password exposed in list"
|
|
||||||
);
|
|
||||||
// A masking bullet should appear.
|
|
||||||
assert!(out.contains('•'), "no masking character in list output");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn list_masks_multibyte_password_without_panicking() {
|
fn cd_prints_the_config_directory() {
|
||||||
let sb = Sandbox::new();
|
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"]);
|
let o = sb.cmd(&["cd"]);
|
||||||
assert!(o.status.success());
|
assert!(o.status.success());
|
||||||
let out = stdout(&o).trim().to_string();
|
let printed = PathBuf::from(stdout(&o).trim());
|
||||||
assert!(!out.is_empty(), "cd produced no output");
|
assert_eq!(printed, sb.root.join("config/breadcrumbs"));
|
||||||
// The printed path must end with "breadcrumbs" (the config subdirectory).
|
|
||||||
assert!(out.ends_with("breadcrumbs"), "unexpected config dir: {out}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn doctor_runs_without_crashing() {
|
fn install_service_no_enable_writes_valid_unit_file() {
|
||||||
let sb = Sandbox::new();
|
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"]);
|
let o = sb.cmd(&["install-service", "--no-enable"]);
|
||||||
|
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||||
|
|
||||||
|
let unit_path = sb.root.join(".config/systemd/user/breadcrumbs.service");
|
||||||
|
assert!(unit_path.exists());
|
||||||
|
let text = fs::read_to_string(unit_path).unwrap();
|
||||||
|
assert!(text.contains("ExecStart="));
|
||||||
|
assert!(text.contains("breadcrumbs watch"));
|
||||||
|
assert!(text.contains("[Install]"));
|
||||||
|
assert!(text.contains("WantedBy=default.target"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn edit_invokes_editor_then_validates_config() {
|
||||||
|
let sb = Sandbox::new();
|
||||||
|
sb.write_fake_bin("fake-editor", "#!/bin/sh\nexit 0\n");
|
||||||
|
|
||||||
|
let o = sb.cmd_env(&["edit"], &[("EDITOR", "fake-editor")]);
|
||||||
|
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||||
|
assert!(stdout(&o).contains("config OK"));
|
||||||
|
assert!(sb.config_file().exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn edit_reports_editor_failure() {
|
||||||
|
let sb = Sandbox::new();
|
||||||
|
sb.write_fake_bin("fake-editor", "#!/bin/sh\nexit 1\n");
|
||||||
|
|
||||||
|
let o = sb.cmd_env(&["edit"], &[("EDITOR", "fake-editor")]);
|
||||||
|
assert!(!o.status.success());
|
||||||
|
assert!(stderr(&o).contains("editor exited with error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Split secrets file (item 5)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn networks_are_stored_separately_from_settings_and_profiles() {
|
||||||
|
let sb = Sandbox::new();
|
||||||
|
let o = sb.cmd(&["add", "CafeWifi", "hunter2", "--to", "away"]);
|
||||||
|
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||||
|
|
||||||
|
let settings = fs::read_to_string(sb.config_file()).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
o.status.success(),
|
!settings.contains("hunter2") && !settings.contains("[[networks]]"),
|
||||||
"stderr: {}",
|
"breadcrumbs.toml should hold settings/profiles only, not network credentials: {settings}"
|
||||||
String::from_utf8_lossy(&o.stderr)
|
|
||||||
);
|
);
|
||||||
let unit = sb.root.join(".config/systemd/user/breadcrumbs.service");
|
// The profile's priority list (an SSID *reference*, not a credential)
|
||||||
assert!(unit.exists(), "unit file not written at {}", unit.display());
|
// does still live in breadcrumbs.toml — that's expected and fine.
|
||||||
let content = fs::read_to_string(&unit).unwrap();
|
assert!(settings.contains("[profiles.away]"));
|
||||||
assert!(content.contains("ExecStart="), "unit missing ExecStart");
|
assert!(settings.contains("CafeWifi"));
|
||||||
|
|
||||||
|
let networks = fs::read_to_string(sb.networks_file()).unwrap();
|
||||||
|
assert!(networks.contains("CafeWifi") && networks.contains("hunter2"));
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mode = fs::metadata(sb.networks_file())
|
||||||
|
.unwrap()
|
||||||
|
.permissions()
|
||||||
|
.mode()
|
||||||
|
& 0o777;
|
||||||
|
assert_eq!(mode, 0o600, "networks.toml should be owner-only");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_with_empty_password_is_stored_as_no_password() {
|
||||||
|
// An explicitly empty password (e.g. `add SSID ""`, or a blank response
|
||||||
|
// at the interactive prompt) means "this is an open network" — it must
|
||||||
|
// round-trip as an absent `password` key, the same as a cleared one,
|
||||||
|
// not as `password = ""` (which `nm::connect_verbose` would treat as a
|
||||||
|
// blank secret rather than an open network).
|
||||||
|
let sb = Sandbox::new();
|
||||||
|
let o = sb.cmd(&["add", "OpenCafe", ""]);
|
||||||
|
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||||
|
|
||||||
|
let networks = fs::read_to_string(sb.networks_file()).unwrap();
|
||||||
|
assert!(networks.contains("OpenCafe"));
|
||||||
|
assert!(!networks.contains("password"), "networks.toml: {networks}");
|
||||||
|
|
||||||
|
let list = sb.cmd(&["list"]);
|
||||||
|
assert!(stdout(&list).contains("managed by NetworkManager"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// NM-owned credentials (item 4): a password is only ever needed once.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A fake `nmcli` that behaves statefully enough to exercise the "first
|
||||||
|
/// connect creates a profile with a password, second connect reuses it
|
||||||
|
/// without one" path: it records every invocation's argv (one per line) to
|
||||||
|
/// `$HOME/.nmcli-calls`, and remembers — via a marker file, also under
|
||||||
|
/// `$HOME` — that "device wifi connect TestNet" has already run, so a
|
||||||
|
/// following `connection show` reports a saved profile exists.
|
||||||
|
const FAKE_NMCLI_STATEFUL: &str = r#"#!/bin/sh
|
||||||
|
record="$HOME/.nmcli-calls"
|
||||||
|
marker="$HOME/.nmcli-profile-created"
|
||||||
|
echo "$@" >> "$record"
|
||||||
|
args="$*"
|
||||||
|
case "$args" in
|
||||||
|
"-t -f DEVICE,TYPE device status")
|
||||||
|
echo "wlan0:wifi" ;;
|
||||||
|
"radio wifi on") ;;
|
||||||
|
"device wifi rescan"*) ;;
|
||||||
|
"-t -f SSID device wifi list ifname wlan0")
|
||||||
|
echo "TestNet" ;;
|
||||||
|
"-t -f NAME,TYPE connection show")
|
||||||
|
if [ -f "$marker" ]; then
|
||||||
|
echo "TestNet:802-11-wireless"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*"device wifi connect TestNet"*)
|
||||||
|
# `: > file` (truncate-or-create via a shell builtin + redirection) —
|
||||||
|
# not `touch`, which is an external binary and the sandbox's PATH
|
||||||
|
# deliberately contains nothing but this fake nmcli itself.
|
||||||
|
: > "$marker" ;;
|
||||||
|
*"connection up TestNet"*) ;;
|
||||||
|
*"802-11-wireless-security.psk"*) ;;
|
||||||
|
"-g GENERAL.CON-UUID device show wlan0")
|
||||||
|
echo "uuid-1" ;;
|
||||||
|
*"ipv4.ignore-auto-dns"*) ;;
|
||||||
|
"device reapply wlan0") ;;
|
||||||
|
"-t -f DEVICE,STATE device status")
|
||||||
|
echo "wlan0:connected" ;;
|
||||||
|
*) ;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
"#;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn password_is_cleared_after_first_connect_and_never_sent_again() {
|
||||||
|
let sb = Sandbox::new();
|
||||||
|
sb.write_fake_bin("nmcli", FAKE_NMCLI_STATEFUL);
|
||||||
|
|
||||||
|
let add = sb.cmd(&["add", "TestNet", "hunter2"]);
|
||||||
|
assert!(add.status.success(), "stderr: {}", stderr(&add));
|
||||||
|
// "away" is the default profile and defaults to include_all_known, so
|
||||||
|
// TestNet is already a connect candidate with no `--to` needed.
|
||||||
|
|
||||||
|
let record = sb.root.join(".nmcli-calls");
|
||||||
|
|
||||||
|
// First connect: no saved NM profile yet, so breadcrumbs creates one via
|
||||||
|
// `nmcli --ask device wifi connect ...` with the PSK on stdin, not argv.
|
||||||
|
let first = sb.cmd(&["init"]);
|
||||||
|
assert!(first.status.success(), "stderr: {}", stderr(&first));
|
||||||
|
let first_calls = fs::read_to_string(&record).unwrap_or_default();
|
||||||
assert!(
|
assert!(
|
||||||
content.contains("breadcrumbs watch"),
|
first_calls.contains("device wifi connect TestNet") && first_calls.contains("--ask"),
|
||||||
"unit missing watch subcommand"
|
"first connect should create a new NM profile via --ask: {first_calls}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!first_calls.contains("hunter2"),
|
||||||
|
"PSK must not appear on nmcli argv: {first_calls}"
|
||||||
|
);
|
||||||
|
// stdin payload is asserted in-process via FakeRunner (see flow_watch).
|
||||||
|
|
||||||
|
// The local copy is gone from disk immediately after.
|
||||||
|
let networks = fs::read_to_string(sb.networks_file()).unwrap();
|
||||||
|
assert!(
|
||||||
|
!networks.contains("hunter2"),
|
||||||
|
"password should have been cleared from networks.toml: {networks}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
networks.contains("TestNet"),
|
||||||
|
"network entry itself should remain"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset the recording so the second run's argv can be checked in isolation.
|
||||||
|
fs::write(&record, "").unwrap();
|
||||||
|
|
||||||
|
// Second connect: a saved profile now exists (per the fake nmcli's own
|
||||||
|
// bookkeeping) and breadcrumbs has no local password anymore, so it must
|
||||||
|
// reuse the profile via `connection up` and never send a PSK argument.
|
||||||
|
let second = sb.cmd(&["init"]);
|
||||||
|
assert!(second.status.success(), "stderr: {}", stderr(&second));
|
||||||
|
let second_calls = fs::read_to_string(&record).unwrap_or_default();
|
||||||
|
assert!(
|
||||||
|
second_calls.contains("connection up TestNet"),
|
||||||
|
"second connect should reuse the existing NM profile: {second_calls}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!second_calls.to_lowercase().contains("hunter2")
|
||||||
|
&& !second_calls.contains("psk")
|
||||||
|
&& !second_calls.contains("password"),
|
||||||
|
"second connect must never send a password argument: {second_calls}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// CLI-level coverage through fake nmcli/tailscale (item 3)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
const FAKE_NMCLI_HEALTHY: &str = r#"#!/bin/sh
|
||||||
|
args="$*"
|
||||||
|
case "$args" in
|
||||||
|
"-t -f DEVICE,TYPE device status")
|
||||||
|
echo "wlan0:wifi" ;;
|
||||||
|
"-t -f ACTIVE,SSID device wifi list ifname wlan0")
|
||||||
|
echo "yes:HomeWifi" ;;
|
||||||
|
"-g IP4.ADDRESS device show wlan0")
|
||||||
|
echo "192.168.1.50/24" ;;
|
||||||
|
*) ;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
"#;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_reports_healthy_through_fake_nmcli_and_curl() {
|
||||||
|
let sb = Sandbox::new();
|
||||||
|
sb.write_fake_bin("nmcli", FAKE_NMCLI_HEALTHY);
|
||||||
|
sb.write_fake_bin("curl", "#!/bin/sh\necho -n 204\nexit 0\n");
|
||||||
|
|
||||||
|
let o = sb.cmd(&["status"]);
|
||||||
|
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||||
|
let out = stdout(&o);
|
||||||
|
assert!(out.contains("HomeWifi"), "out: {out}");
|
||||||
|
assert!(out.contains("healthy"), "out: {out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn doctor_reports_present_when_nmcli_and_tailscale_are_on_path() {
|
||||||
|
let sb = Sandbox::new();
|
||||||
|
sb.write_fake_bin("nmcli", FAKE_NMCLI_HEALTHY);
|
||||||
|
sb.write_fake_bin("tailscale", "#!/bin/sh\nexit 0\n");
|
||||||
|
|
||||||
|
let o = sb.cmd(&["doctor"]);
|
||||||
|
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||||
|
let out = stdout(&o);
|
||||||
|
assert!(
|
||||||
|
out.contains("nmcli") && out.contains("present"),
|
||||||
|
"out: {out}"
|
||||||
|
);
|
||||||
|
assert!(!out.contains("MISSING"), "out: {out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
const FAKE_NMCLI_DETECT: &str = r#"#!/bin/sh
|
||||||
|
args="$*"
|
||||||
|
case "$args" in
|
||||||
|
"-t -f DEVICE,TYPE device status")
|
||||||
|
echo "wlan0:wifi" ;;
|
||||||
|
"radio wifi on") ;;
|
||||||
|
"device wifi rescan"*) ;;
|
||||||
|
"-t -f SSID device wifi list ifname wlan0")
|
||||||
|
echo "CorpWifi" ;;
|
||||||
|
*) ;;
|
||||||
|
esac
|
||||||
|
exit 0
|
||||||
|
"#;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_picks_profile_whose_detect_ssids_are_visible() {
|
||||||
|
let sb = Sandbox::new();
|
||||||
|
sb.write_fake_bin("nmcli", FAKE_NMCLI_DETECT);
|
||||||
|
sb.cmd(&["list"]); // bootstrap the default config (home/work/away)
|
||||||
|
|
||||||
|
// Attach a marker SSID to "work" so detection has something to match —
|
||||||
|
// the skeleton config ships with empty detect_ssids everywhere.
|
||||||
|
let text = fs::read_to_string(sb.config_file()).unwrap();
|
||||||
|
let patched = text.replace(
|
||||||
|
"[profiles.work]",
|
||||||
|
"[profiles.work]\ndetect_ssids = [\"CorpWifi\"]",
|
||||||
|
);
|
||||||
|
fs::write(sb.config_file(), patched).unwrap();
|
||||||
|
|
||||||
|
let o = sb.cmd(&["detect"]);
|
||||||
|
assert!(o.status.success(), "stderr: {}", stderr(&o));
|
||||||
|
assert_eq!(stdout(&o).trim(), "work");
|
||||||
|
}
|
||||||
|
|
|
||||||
224
tests/common/mod.rs
Normal file
224
tests/common/mod.rs
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
//! Shared test infrastructure for in-process integration tests (as opposed
|
||||||
|
//! to `tests/cli.rs`'s black-box `Sandbox`, which spawns the compiled
|
||||||
|
//! binary). This module is `mod`-included by each test file that needs it —
|
||||||
|
//! see `tests/flow_watch.rs`.
|
||||||
|
//!
|
||||||
|
//! Two pieces:
|
||||||
|
//!
|
||||||
|
//! - [`FakeRunner`]: a `breadcrumbs::util::Runner` implementation driven by
|
||||||
|
//! rules ("if the program+args match this predicate, return this canned
|
||||||
|
//! `Output`"), which also records every invocation so a test can assert
|
||||||
|
//! exactly what was — or, just as importantly, was *not* — passed (e.g.
|
||||||
|
//! that a password argument never reaches a fake `nmcli`).
|
||||||
|
//! - [`EnvSandbox`]: real logic (`flow::run`, `watch::classify`) still does
|
||||||
|
//! its own best-effort file logging via `notify::log`, which resolves a
|
||||||
|
//! path from `$HOME`/`$XDG_STATE_HOME`. `EnvSandbox` points those at a
|
||||||
|
//! throwaway tempdir for the duration of a test so nothing lands in the
|
||||||
|
//! developer's real `~/.local/state/breadcrumbs`. Mutating process env is
|
||||||
|
//! inherently cross-test-within-this-binary racy, so it's guarded by a
|
||||||
|
//! process-wide mutex — tests using it serialize against each other but
|
||||||
|
//! not against unrelated tests (each `tests/*.rs` file is its own binary).
|
||||||
|
|
||||||
|
#![allow(dead_code)] // not every test file uses every helper here
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
use std::sync::{Mutex, MutexGuard, OnceLock};
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use breadcrumbs::util::{Output, Runner};
|
||||||
|
|
||||||
|
/// One recorded call to the fake `Runner::run`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RecordedCall {
|
||||||
|
pub prog: String,
|
||||||
|
pub args: Vec<String>,
|
||||||
|
pub stdin: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecordedCall {
|
||||||
|
/// Convenience for glob-style assertions, e.g.
|
||||||
|
/// `call.argv().contains(&"connection")`.
|
||||||
|
pub fn argv(&self) -> Vec<&str> {
|
||||||
|
std::iter::once(self.prog.as_str())
|
||||||
|
.chain(self.args.iter().map(String::as_str))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Matcher = Box<dyn Fn(&str, &[&str]) -> bool>;
|
||||||
|
|
||||||
|
/// A canned, rule-based [`Runner`]. Rules are tried in registration order;
|
||||||
|
/// the first whose matcher returns `true` supplies the response. No rule
|
||||||
|
/// matching falls back to [`Output::failed`] — the same "closed" default the
|
||||||
|
/// old empty-`PATH` sandbox relied on, so an un-anticipated call fails loud
|
||||||
|
/// (a wrong exit code) rather than silently returning success.
|
||||||
|
pub struct FakeRunner {
|
||||||
|
rules: Vec<(Matcher, Output)>,
|
||||||
|
commands: HashSet<String>,
|
||||||
|
calls: Rc<RefCell<Vec<RecordedCall>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeRunner {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
FakeRunner {
|
||||||
|
rules: Vec::new(),
|
||||||
|
commands: HashSet::new(),
|
||||||
|
calls: Rc::new(RefCell::new(Vec::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle to inspect recorded calls after the runner has been consumed by
|
||||||
|
/// [`breadcrumbs::util::with_runner`] (which takes it by value).
|
||||||
|
pub fn calls_handle(&self) -> Rc<RefCell<Vec<RecordedCall>>> {
|
||||||
|
self.calls.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make `breadcrumbs::util::command_exists(name)` report present.
|
||||||
|
pub fn with_command(mut self, name: &str) -> Self {
|
||||||
|
self.commands.insert(name.to_string());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a canned response: the first registered matcher that returns
|
||||||
|
/// `true` for a given `(prog, args)` supplies the `Output`.
|
||||||
|
pub fn on(mut self, matcher: impl Fn(&str, &[&str]) -> bool + 'static, output: Output) -> Self {
|
||||||
|
self.rules.push((Box::new(matcher), output));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shorthand for matching on `prog` plus a whitespace-joined view of
|
||||||
|
/// `args` containing `substr` (handy for `nmcli`/`tailscale` calls, whose
|
||||||
|
/// interesting bit is usually a subcommand somewhere in the middle).
|
||||||
|
pub fn on_contains(self, prog: &'static str, substr: &'static str, output: Output) -> Self {
|
||||||
|
self.on(
|
||||||
|
move |p, args| p == prog && args.join(" ").contains(substr),
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FakeRunner {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Runner for FakeRunner {
|
||||||
|
fn run(&self, prog: &str, args: &[&str], stdin: Option<&str>, _timeout: Duration) -> Output {
|
||||||
|
self.calls.borrow_mut().push(RecordedCall {
|
||||||
|
prog: prog.to_string(),
|
||||||
|
args: args.iter().map(|s| s.to_string()).collect(),
|
||||||
|
stdin: stdin.map(|s| s.to_string()),
|
||||||
|
});
|
||||||
|
for (matcher, out) in &self.rules {
|
||||||
|
if matcher(prog, args) {
|
||||||
|
return out.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Output::failed()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_exists(&self, name: &str) -> bool {
|
||||||
|
self.commands.contains(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ok(stdout: &str) -> Output {
|
||||||
|
Output {
|
||||||
|
success: true,
|
||||||
|
stdout: stdout.to_string(),
|
||||||
|
stderr: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ok_empty() -> Output {
|
||||||
|
ok("")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fail(stderr: &str) -> Output {
|
||||||
|
Output {
|
||||||
|
success: false,
|
||||||
|
stdout: String::new(),
|
||||||
|
stderr: stderr.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env_lock() -> &'static Mutex<()> {
|
||||||
|
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||||
|
LOCK.get_or_init(|| Mutex::new(()))
|
||||||
|
}
|
||||||
|
|
||||||
|
static SANDBOX_COUNTER: AtomicU32 = AtomicU32::new(0);
|
||||||
|
|
||||||
|
/// Points `HOME` / `XDG_CONFIG_HOME` / `XDG_STATE_HOME` at a throwaway
|
||||||
|
/// tempdir for its lifetime, so any real filesystem side effect
|
||||||
|
/// (`notify::log`'s best-effort log file, `Config::save`, …) that in-process
|
||||||
|
/// logic performs during a test lands there instead of the developer's real
|
||||||
|
/// home directory. Holds a process-wide lock for its lifetime — construct
|
||||||
|
/// one per test, drop it (or let it go out of scope) before the test ends.
|
||||||
|
pub struct EnvSandbox {
|
||||||
|
_guard: MutexGuard<'static, ()>,
|
||||||
|
root: PathBuf,
|
||||||
|
prev: Vec<(&'static str, Option<String>)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENV_VARS: [&str; 3] = ["HOME", "XDG_CONFIG_HOME", "XDG_STATE_HOME"];
|
||||||
|
|
||||||
|
impl EnvSandbox {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let guard = env_lock().lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
|
||||||
|
let n = SANDBOX_COUNTER.fetch_add(1, Ordering::SeqCst);
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos();
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"breadcrumbs-inproc-{}-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
n,
|
||||||
|
nanos
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&root).expect("create EnvSandbox root");
|
||||||
|
|
||||||
|
let prev: Vec<(&'static str, Option<String>)> = ENV_VARS
|
||||||
|
.iter()
|
||||||
|
.map(|v| (*v, std::env::var(v).ok()))
|
||||||
|
.collect();
|
||||||
|
std::env::set_var("HOME", &root);
|
||||||
|
std::env::set_var("XDG_CONFIG_HOME", root.join("config"));
|
||||||
|
std::env::set_var("XDG_STATE_HOME", root.join("state"));
|
||||||
|
|
||||||
|
EnvSandbox {
|
||||||
|
_guard: guard,
|
||||||
|
root,
|
||||||
|
prev,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn root(&self) -> &Path {
|
||||||
|
&self.root
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for EnvSandbox {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for EnvSandbox {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
for (k, v) in &self.prev {
|
||||||
|
match v {
|
||||||
|
Some(val) => std::env::set_var(k, val),
|
||||||
|
None => std::env::remove_var(k),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_dir_all(&self.root);
|
||||||
|
}
|
||||||
|
}
|
||||||
655
tests/flow_watch.rs
Normal file
655
tests/flow_watch.rs
Normal file
|
|
@ -0,0 +1,655 @@
|
||||||
|
//! In-process tests for the actual state machine (`flow::run`) and the watch
|
||||||
|
//! loop's health classification (`watch::classify`), driven entirely through
|
||||||
|
//! a faked `breadcrumbs::util::Runner` (see `tests/common`) — no subprocess
|
||||||
|
//! is ever spawned. This complements `tests/cli.rs`'s black-box coverage
|
||||||
|
//! (which spawns the real binary against fake-bin shell scripts) with fast,
|
||||||
|
//! precise coverage of the logic itself: candidate priority order, the
|
||||||
|
//! bootstrap+Tailscale gate, and every `watch::Health` transition.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use bread_utils::bread_client::BreadEvent;
|
||||||
|
use breadcrumbs::bread_events;
|
||||||
|
use breadcrumbs::config::{Config, NetworkDef, Profile, Settings};
|
||||||
|
use breadcrumbs::flow;
|
||||||
|
use breadcrumbs::nm;
|
||||||
|
use breadcrumbs::state::{self, State};
|
||||||
|
use breadcrumbs::util::with_runner;
|
||||||
|
use breadcrumbs::watch::{classify, Health};
|
||||||
|
|
||||||
|
use common::{fail, ok, EnvSandbox, FakeRunner};
|
||||||
|
|
||||||
|
fn net(ssid: &str, password: Option<&str>) -> NetworkDef {
|
||||||
|
NetworkDef {
|
||||||
|
ssid: ssid.to_string(),
|
||||||
|
password: password.map(str::to_string),
|
||||||
|
hidden: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hidden_net(ssid: &str, password: Option<&str>) -> NetworkDef {
|
||||||
|
NetworkDef {
|
||||||
|
ssid: ssid.to_string(),
|
||||||
|
password: password.map(str::to_string),
|
||||||
|
hidden: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base_config() -> Config {
|
||||||
|
Config {
|
||||||
|
settings: Settings::default(),
|
||||||
|
networks: Vec::new(),
|
||||||
|
profiles: BTreeMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wires up the nmcli plumbing every `flow::run` call needs regardless of
|
||||||
|
/// scenario: a Wi-Fi interface exists, radio/rescan calls are no-ops, no
|
||||||
|
/// saved NM connection profiles exist yet (so every connect takes the
|
||||||
|
/// "create via `device wifi connect`" path), DNS enforcement succeeds, and
|
||||||
|
/// the device reports connected after any successful connect attempt.
|
||||||
|
fn base_nm(visible_ssids: &[&str]) -> FakeRunner {
|
||||||
|
let visible = visible_ssids.join("\n");
|
||||||
|
FakeRunner::new()
|
||||||
|
.on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi"))
|
||||||
|
.on_contains("nmcli", "radio wifi on", ok(""))
|
||||||
|
.on_contains("nmcli", "wifi rescan", ok(""))
|
||||||
|
.on_contains("nmcli", "-f SSID device wifi list", ok(&visible))
|
||||||
|
.on_contains("nmcli", "NAME,TYPE", ok("")) // no saved profiles
|
||||||
|
.on_contains("nmcli", "GENERAL.CON-UUID", ok("uuid-1"))
|
||||||
|
.on_contains("nmcli", "ipv4.ignore-auto-dns", ok(""))
|
||||||
|
.on_contains("nmcli", "device reapply", ok(""))
|
||||||
|
.on_contains("nmcli", "DEVICE,STATE", ok("wlan0:connected"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A successful `device wifi connect <ssid> ...` for every ssid in `ssids`.
|
||||||
|
fn allow_connects(runner: FakeRunner, ssids: &[&'static str]) -> FakeRunner {
|
||||||
|
ssids.iter().fold(runner, |r, ssid| {
|
||||||
|
let ssid: &'static str = ssid;
|
||||||
|
r.on(
|
||||||
|
move |prog, args| prog == "nmcli" && args.contains(&"connect") && args.contains(&ssid),
|
||||||
|
ok(""),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// flow::run — candidate priority (pass 1 / pass 2)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flow_run_connects_to_first_visible_candidate_in_priority_order() {
|
||||||
|
let _env = EnvSandbox::new();
|
||||||
|
|
||||||
|
let mut cfg = base_config();
|
||||||
|
cfg.networks = vec![net("First", Some("pw1")), net("Second", Some("pw2"))];
|
||||||
|
cfg.profiles.insert(
|
||||||
|
"home".into(),
|
||||||
|
Profile {
|
||||||
|
networks: vec!["First".into(), "Second".into()],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let runner = allow_connects(base_nm(&["First", "Second"]), &["First", "Second"])
|
||||||
|
.on(|prog, _| prog == "curl", ok("204"))
|
||||||
|
.with_command("curl");
|
||||||
|
let calls = runner.calls_handle();
|
||||||
|
|
||||||
|
let outcome = with_runner(runner, || flow::run(&mut cfg, "home"));
|
||||||
|
|
||||||
|
match outcome {
|
||||||
|
flow::Outcome::Connected { ssid, note } => {
|
||||||
|
assert_eq!(ssid, "First");
|
||||||
|
assert_eq!(note, None);
|
||||||
|
}
|
||||||
|
other => panic!("expected Connected, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority order actually mattered: "Second" was never dialed even
|
||||||
|
// though it was visible and would have succeeded too.
|
||||||
|
let dialed_second = calls.borrow().iter().any(|c| {
|
||||||
|
c.prog == "nmcli"
|
||||||
|
&& c.args.contains(&"connect".to_string())
|
||||||
|
&& c.args.iter().any(|a| a == "Second")
|
||||||
|
});
|
||||||
|
assert!(!dialed_second, "connected to Second when First should win");
|
||||||
|
|
||||||
|
// The password used for the winning connect is now NM's problem, not
|
||||||
|
// breadcrumbs' — cleared and (via clear_password_if_used) persisted.
|
||||||
|
assert_eq!(cfg.network("First").unwrap().password, None);
|
||||||
|
// Never touched, so its password is untouched too.
|
||||||
|
assert_eq!(
|
||||||
|
cfg.network("Second").unwrap().password,
|
||||||
|
Some("pw2".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flow_run_pass2_falls_back_to_hidden_candidate_not_in_scan() {
|
||||||
|
let _env = EnvSandbox::new();
|
||||||
|
|
||||||
|
let mut cfg = base_config();
|
||||||
|
// "Ghost" is neither visible nor hidden, so pass 1 *and* pass 2 both
|
||||||
|
// skip it outright — it should never be dialed.
|
||||||
|
cfg.networks = vec![
|
||||||
|
net("Ghost", Some("pw-ghost")),
|
||||||
|
hidden_net("Shadow", Some("pw-shadow")),
|
||||||
|
];
|
||||||
|
cfg.profiles.insert(
|
||||||
|
"away".into(),
|
||||||
|
Profile {
|
||||||
|
networks: vec!["Ghost".into(), "Shadow".into()],
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Neither SSID shows up in the scan — "Shadow" is only reachable via the
|
||||||
|
// pass-2 "hidden and unseen" path.
|
||||||
|
let runner = allow_connects(base_nm(&[]), &["Shadow"])
|
||||||
|
.on(|prog, _| prog == "curl", ok("204"))
|
||||||
|
.with_command("curl");
|
||||||
|
let calls = runner.calls_handle();
|
||||||
|
|
||||||
|
let outcome = with_runner(runner, || flow::run(&mut cfg, "away"));
|
||||||
|
|
||||||
|
match outcome {
|
||||||
|
flow::Outcome::Connected { ssid, .. } => assert_eq!(ssid, "Shadow"),
|
||||||
|
other => panic!("expected Connected to Shadow, got {other:?}"),
|
||||||
|
}
|
||||||
|
let dialed_ghost = calls
|
||||||
|
.borrow()
|
||||||
|
.iter()
|
||||||
|
.any(|c| c.args.iter().any(|a| a == "Ghost") && c.args.contains(&"connect".to_string()));
|
||||||
|
assert!(!dialed_ghost, "Ghost should never have been dialed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flow_run_unknown_profile_short_circuits_before_touching_nm() {
|
||||||
|
let _env = EnvSandbox::new();
|
||||||
|
let mut cfg = base_config();
|
||||||
|
|
||||||
|
let runner = FakeRunner::new(); // no rules at all
|
||||||
|
let calls = runner.calls_handle();
|
||||||
|
|
||||||
|
let outcome = with_runner(runner, || flow::run(&mut cfg, "does-not-exist"));
|
||||||
|
|
||||||
|
assert!(matches!(outcome, flow::Outcome::UnknownProfile(p) if p == "does-not-exist"));
|
||||||
|
// The only `Runner::run` call on this path is `notify`/`log`'s own
|
||||||
|
// `date` timestamp lookup — nmcli (or anything network-related) is
|
||||||
|
// never touched for a profile that doesn't exist.
|
||||||
|
assert!(
|
||||||
|
calls.borrow().iter().all(|c| c.prog != "nmcli"),
|
||||||
|
"unknown-profile path should never shell out to nmcli: {:?}",
|
||||||
|
calls.borrow()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// flow::run — bootstrap + Tailscale gating
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn tailscale_json_ok(exit_node: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"{{"BackendState":"Running","Peer":{{"k1":{{"HostName":"{exit_node}","DNSName":"{exit_node}.ts.net.","Online":true,"ExitNode":true,"ExitNodeOption":true}}}}}}"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tailscale_json_missing() -> &'static str {
|
||||||
|
r#"{"BackendState":"Running","Peer":{}}"#
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flow_run_moves_past_bootstrap_once_tailscale_is_healthy() {
|
||||||
|
let _env = EnvSandbox::new();
|
||||||
|
|
||||||
|
let mut cfg = base_config();
|
||||||
|
cfg.settings.exit_node = "exitnode".into();
|
||||||
|
cfg.networks = vec![net("Guest", Some("guest-pw")), net("Corp", Some("corp-pw"))];
|
||||||
|
cfg.profiles.insert(
|
||||||
|
"work".into(),
|
||||||
|
Profile {
|
||||||
|
bootstrap: Some("Guest".into()),
|
||||||
|
networks: vec!["Corp".into()],
|
||||||
|
tailscale: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let runner = allow_connects(base_nm(&["Guest", "Corp"]), &["Guest", "Corp"])
|
||||||
|
.with_command("curl")
|
||||||
|
.with_command("tailscale")
|
||||||
|
.on(|prog, _| prog == "curl", ok("204"))
|
||||||
|
.on_contains("tailscale", "status", ok(&tailscale_json_ok("exitnode")))
|
||||||
|
.on_contains("tailscale", "set", ok(""));
|
||||||
|
let calls = runner.calls_handle();
|
||||||
|
|
||||||
|
let outcome = with_runner(runner, || flow::run(&mut cfg, "work"));
|
||||||
|
|
||||||
|
match outcome {
|
||||||
|
flow::Outcome::Connected { ssid, .. } => assert_eq!(ssid, "Corp"),
|
||||||
|
other => panic!("expected Connected to Corp, got {other:?}"),
|
||||||
|
}
|
||||||
|
// Both the bootstrap and target connects used a local password, so both
|
||||||
|
// should have been cleared once NetworkManager took over.
|
||||||
|
assert_eq!(cfg.network("Guest").unwrap().password, None);
|
||||||
|
assert_eq!(cfg.network("Corp").unwrap().password, None);
|
||||||
|
|
||||||
|
let dialed_guest = calls
|
||||||
|
.borrow()
|
||||||
|
.iter()
|
||||||
|
.any(|c| c.args.iter().any(|a| a == "Guest") && c.args.contains(&"connect".to_string()));
|
||||||
|
assert!(dialed_guest, "bootstrap should have been dialed first");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flow_run_stays_on_bootstrap_and_never_dials_target_when_tailscale_unhealthy() {
|
||||||
|
let _env = EnvSandbox::new();
|
||||||
|
|
||||||
|
let mut cfg = base_config();
|
||||||
|
cfg.settings.exit_node = "exitnode".into();
|
||||||
|
cfg.networks = vec![net("Guest", Some("guest-pw")), net("Corp", Some("corp-pw"))];
|
||||||
|
cfg.profiles.insert(
|
||||||
|
"work".into(),
|
||||||
|
Profile {
|
||||||
|
bootstrap: Some("Guest".into()),
|
||||||
|
networks: vec!["Corp".into()],
|
||||||
|
tailscale: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let runner = allow_connects(base_nm(&["Guest", "Corp"]), &["Guest", "Corp"])
|
||||||
|
.with_command("curl")
|
||||||
|
.with_command("tailscale")
|
||||||
|
.on(|prog, _| prog == "curl", ok("204"))
|
||||||
|
.on(
|
||||||
|
|prog, args| prog == "tailscale" && args.contains(&"status"),
|
||||||
|
ok(tailscale_json_missing()),
|
||||||
|
)
|
||||||
|
.on(
|
||||||
|
|prog, args| prog == "tailscale" && args.contains(&"set"),
|
||||||
|
ok(""),
|
||||||
|
);
|
||||||
|
let calls = runner.calls_handle();
|
||||||
|
|
||||||
|
let outcome = with_runner(runner, || flow::run(&mut cfg, "work"));
|
||||||
|
|
||||||
|
match &outcome {
|
||||||
|
flow::Outcome::TailscaleError { ssid, health } => {
|
||||||
|
assert_eq!(ssid.as_deref(), Some("Guest"));
|
||||||
|
assert_eq!(*health, breadcrumbs::tailscale::TsHealth::ExitNodeMissing);
|
||||||
|
}
|
||||||
|
other => panic!("expected TailscaleError, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let dialed_corp = calls
|
||||||
|
.borrow()
|
||||||
|
.iter()
|
||||||
|
.any(|c| c.args.iter().any(|a| a == "Corp") && c.args.contains(&"connect".to_string()));
|
||||||
|
assert!(
|
||||||
|
!dialed_corp,
|
||||||
|
"target network must never be dialed while Tailscale is unhealthy"
|
||||||
|
);
|
||||||
|
// The bootstrap connect *did* use a password and succeeded, so it's
|
||||||
|
// cleared even though the overall flow ends in an error.
|
||||||
|
assert_eq!(cfg.network("Guest").unwrap().password, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// watch::classify — health-state transitions
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classify_reports_unknown_profile_without_touching_nm() {
|
||||||
|
let _env = EnvSandbox::new();
|
||||||
|
let cfg = base_config(); // no profiles at all
|
||||||
|
|
||||||
|
let runner = FakeRunner::new();
|
||||||
|
let calls = runner.calls_handle();
|
||||||
|
let (health, ssid) = with_runner(runner, || classify(&cfg, "ghost"));
|
||||||
|
|
||||||
|
assert_eq!(health, Health::UnknownProfile);
|
||||||
|
assert_eq!(ssid, None);
|
||||||
|
assert!(calls.borrow().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classify_reports_no_adapter_when_wifi_interface_absent() {
|
||||||
|
let _env = EnvSandbox::new();
|
||||||
|
let mut cfg = base_config();
|
||||||
|
cfg.profiles.insert("away".into(), Profile::default());
|
||||||
|
|
||||||
|
// `device status` succeeds but lists no wifi-type device.
|
||||||
|
let runner = FakeRunner::new().on_contains("nmcli", "DEVICE,TYPE", ok("eth0:ethernet"));
|
||||||
|
let (health, _) = with_runner(runner, || classify(&cfg, "away"));
|
||||||
|
|
||||||
|
assert_eq!(health, Health::NoAdapter);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classify_reports_down_no_net_when_internet_check_fails() {
|
||||||
|
let _env = EnvSandbox::new();
|
||||||
|
let mut cfg = base_config();
|
||||||
|
cfg.profiles.insert("away".into(), Profile::default());
|
||||||
|
|
||||||
|
let runner = FakeRunner::new()
|
||||||
|
.on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi"))
|
||||||
|
.on_contains("nmcli", "ACTIVE,SSID", ok("yes:HomeWifi"))
|
||||||
|
.on_contains("nmcli", "IP4.ADDRESS", ok("192.168.1.50/24"))
|
||||||
|
.on(|prog, _| prog == "curl" || prog == "ping", fail(""));
|
||||||
|
let (health, ssid) = with_runner(runner, || classify(&cfg, "away"));
|
||||||
|
|
||||||
|
assert_eq!(health, Health::DownNoNet);
|
||||||
|
assert_eq!(ssid, Some("HomeWifi".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classify_reports_up_when_healthy_and_tailscale_not_required() {
|
||||||
|
let _env = EnvSandbox::new();
|
||||||
|
let mut cfg = base_config();
|
||||||
|
cfg.profiles.insert("home".into(), Profile::default()); // tailscale: false
|
||||||
|
|
||||||
|
let runner = FakeRunner::new()
|
||||||
|
.on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi"))
|
||||||
|
.on_contains("nmcli", "ACTIVE,SSID", ok("yes:HomeWifi"))
|
||||||
|
.on_contains("nmcli", "IP4.ADDRESS", ok("192.168.1.50/24"))
|
||||||
|
.with_command("curl")
|
||||||
|
.on(|prog, _| prog == "curl", ok("204"));
|
||||||
|
let (health, ssid) = with_runner(runner, || classify(&cfg, "home"));
|
||||||
|
|
||||||
|
assert_eq!(health, Health::Up);
|
||||||
|
assert_eq!(ssid, Some("HomeWifi".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classify_reports_down_tailscale_manual_when_not_installed() {
|
||||||
|
let _env = EnvSandbox::new();
|
||||||
|
let mut cfg = base_config();
|
||||||
|
cfg.profiles.insert(
|
||||||
|
"work".into(),
|
||||||
|
Profile {
|
||||||
|
tailscale: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// No `with_command("tailscale")`, so `tailscale::installed()` is false —
|
||||||
|
// `status::gather` never even tries to run the `tailscale` binary.
|
||||||
|
let runner = FakeRunner::new()
|
||||||
|
.on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi"))
|
||||||
|
.on_contains("nmcli", "ACTIVE,SSID", ok("yes:CorpWifi"))
|
||||||
|
.on_contains("nmcli", "IP4.ADDRESS", ok("10.0.0.5/24"))
|
||||||
|
.with_command("curl")
|
||||||
|
.on(|prog, _| prog == "curl", ok("204"));
|
||||||
|
let (health, _) = with_runner(runner, || classify(&cfg, "work"));
|
||||||
|
|
||||||
|
assert_eq!(health, Health::DownTailscaleManual);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classify_reports_down_tailscale_manual_when_needs_login() {
|
||||||
|
let _env = EnvSandbox::new();
|
||||||
|
let mut cfg = base_config();
|
||||||
|
cfg.profiles.insert(
|
||||||
|
"work".into(),
|
||||||
|
Profile {
|
||||||
|
tailscale: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let runner = FakeRunner::new()
|
||||||
|
.on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi"))
|
||||||
|
.on_contains("nmcli", "ACTIVE,SSID", ok("yes:CorpWifi"))
|
||||||
|
.on_contains("nmcli", "IP4.ADDRESS", ok("10.0.0.5/24"))
|
||||||
|
.with_command("curl")
|
||||||
|
.with_command("tailscale")
|
||||||
|
.on(|prog, _| prog == "curl", ok("204"))
|
||||||
|
.on(
|
||||||
|
|prog, args| prog == "tailscale" && args.contains(&"status"),
|
||||||
|
ok(r#"{"BackendState":"NeedsLogin"}"#),
|
||||||
|
);
|
||||||
|
let (health, _) = with_runner(runner, || classify(&cfg, "work"));
|
||||||
|
|
||||||
|
assert_eq!(health, Health::DownTailscaleManual);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classify_reports_down_tailscale_other_when_exit_node_offline() {
|
||||||
|
let _env = EnvSandbox::new();
|
||||||
|
let mut cfg = base_config();
|
||||||
|
cfg.settings.exit_node = "exitnode".into();
|
||||||
|
cfg.profiles.insert(
|
||||||
|
"work".into(),
|
||||||
|
Profile {
|
||||||
|
tailscale: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let json = r#"{"BackendState":"Running","Peer":{"k1":{"HostName":"exitnode","Online":false,"ExitNode":false,"ExitNodeOption":true}}}"#;
|
||||||
|
let runner = FakeRunner::new()
|
||||||
|
.on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi"))
|
||||||
|
.on_contains("nmcli", "ACTIVE,SSID", ok("yes:CorpWifi"))
|
||||||
|
.on_contains("nmcli", "IP4.ADDRESS", ok("10.0.0.5/24"))
|
||||||
|
.with_command("curl")
|
||||||
|
.with_command("tailscale")
|
||||||
|
.on(|prog, _| prog == "curl", ok("204"))
|
||||||
|
.on(
|
||||||
|
|prog, args| prog == "tailscale" && args.contains(&"status"),
|
||||||
|
ok(json),
|
||||||
|
);
|
||||||
|
let (health, _) = with_runner(runner, || classify(&cfg, "work"));
|
||||||
|
|
||||||
|
assert_eq!(health, Health::DownTailscaleOther);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classify_reports_up_when_tailscale_healthy() {
|
||||||
|
let _env = EnvSandbox::new();
|
||||||
|
let mut cfg = base_config();
|
||||||
|
cfg.settings.exit_node = "exitnode".into();
|
||||||
|
cfg.profiles.insert(
|
||||||
|
"work".into(),
|
||||||
|
Profile {
|
||||||
|
tailscale: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let runner = FakeRunner::new()
|
||||||
|
.on_contains("nmcli", "DEVICE,TYPE", ok("wlan0:wifi"))
|
||||||
|
.on_contains("nmcli", "ACTIVE,SSID", ok("yes:CorpWifi"))
|
||||||
|
.on_contains("nmcli", "IP4.ADDRESS", ok("10.0.0.5/24"))
|
||||||
|
.with_command("curl")
|
||||||
|
.with_command("tailscale")
|
||||||
|
.on(|prog, _| prog == "curl", ok("204"))
|
||||||
|
.on_contains("tailscale", "status", ok(&tailscale_json_ok("exitnode")));
|
||||||
|
let (health, _) = with_runner(runner, || classify(&cfg, "work"));
|
||||||
|
|
||||||
|
assert_eq!(health, Health::Up);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// bread.command.crumbs.set_profile — persists via the same path as the
|
||||||
|
// CLI, and must not depend on breadd being reachable (`emit` is
|
||||||
|
// fire-and-forget).
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn command_event(event: &str, data: serde_json::Value) -> BreadEvent {
|
||||||
|
BreadEvent {
|
||||||
|
event: event.to_string(),
|
||||||
|
timestamp: 0,
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_profile_command_persists_even_with_no_daemon_reachable() {
|
||||||
|
let _env = EnvSandbox::new();
|
||||||
|
let cfg = Config::load().expect("fresh config");
|
||||||
|
state::set_profile(&cfg, "away").unwrap();
|
||||||
|
assert_eq!(State::load("away").profile, "away");
|
||||||
|
|
||||||
|
let acted = bread_events::handle_command(&command_event(
|
||||||
|
"bread.command.crumbs.set_profile",
|
||||||
|
serde_json::json!({ "profile": "home" }),
|
||||||
|
));
|
||||||
|
|
||||||
|
assert!(acted, "known profile must persist");
|
||||||
|
assert_eq!(State::load("away").profile, "home");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_profile_command_rejects_unknown_profile() {
|
||||||
|
let _env = EnvSandbox::new();
|
||||||
|
let cfg = Config::load().expect("fresh config");
|
||||||
|
state::set_profile(&cfg, "away").unwrap();
|
||||||
|
|
||||||
|
let acted = bread_events::handle_command(&command_event(
|
||||||
|
"bread.command.crumbs.set_profile",
|
||||||
|
serde_json::json!({ "profile": "bogus" }),
|
||||||
|
));
|
||||||
|
|
||||||
|
assert!(!acted);
|
||||||
|
assert_eq!(
|
||||||
|
State::load("away").profile,
|
||||||
|
"away",
|
||||||
|
"a rejected set_profile must not touch state"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_profile_command_rejects_missing_profile_field() {
|
||||||
|
let _env = EnvSandbox::new();
|
||||||
|
let cfg = Config::load().expect("fresh config");
|
||||||
|
state::set_profile(&cfg, "away").unwrap();
|
||||||
|
|
||||||
|
let acted = bread_events::handle_command(&command_event(
|
||||||
|
"bread.command.crumbs.set_profile",
|
||||||
|
serde_json::json!({}),
|
||||||
|
));
|
||||||
|
|
||||||
|
assert!(!acted);
|
||||||
|
assert_eq!(State::load("away").profile, "away");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handle_command_ignores_unrecognized_verb() {
|
||||||
|
let _env = EnvSandbox::new();
|
||||||
|
let cfg = Config::load().expect("fresh config");
|
||||||
|
state::set_profile(&cfg, "away").unwrap();
|
||||||
|
|
||||||
|
let acted = bread_events::handle_command(&command_event(
|
||||||
|
"bread.command.crumbs.pin",
|
||||||
|
serde_json::json!({}),
|
||||||
|
));
|
||||||
|
|
||||||
|
assert!(!acted);
|
||||||
|
assert_eq!(
|
||||||
|
State::load("away").profile,
|
||||||
|
"away",
|
||||||
|
"an unrecognized verb must not touch state"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handle_command_ignores_events_outside_its_own_command_namespace() {
|
||||||
|
let _env = EnvSandbox::new();
|
||||||
|
let cfg = Config::load().expect("fresh config");
|
||||||
|
state::set_profile(&cfg, "away").unwrap();
|
||||||
|
|
||||||
|
assert!(!bread_events::handle_command(&command_event(
|
||||||
|
"bread.command.clip.clear",
|
||||||
|
serde_json::json!({}),
|
||||||
|
)));
|
||||||
|
assert!(!bread_events::handle_command(&command_event(
|
||||||
|
"bread.crumbs.profile.changed",
|
||||||
|
serde_json::json!({ "from": "away", "to": "home" }),
|
||||||
|
)));
|
||||||
|
assert_eq!(State::load("away").profile, "away");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// PSK never on argv (first connect feeds nmcli --ask on stdin)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn assert_psk_not_on_argv(calls: &[common::RecordedCall], psk: &str) {
|
||||||
|
for c in calls {
|
||||||
|
if c.prog != "nmcli" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!c.args.iter().any(|a| a == psk),
|
||||||
|
"PSK leaked onto nmcli argv: {:?}",
|
||||||
|
c.args
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn connect_verbose_create_feeds_psk_on_stdin_never_argv() {
|
||||||
|
let runner = FakeRunner::new()
|
||||||
|
.on_contains("nmcli", "NAME,TYPE", ok(""))
|
||||||
|
.on_contains("nmcli", "connect", ok(""))
|
||||||
|
.on_contains("nmcli", "GENERAL.CON-UUID", ok("uuid-1"))
|
||||||
|
.on_contains("nmcli", "ipv4.ignore-auto-dns", ok(""))
|
||||||
|
.on_contains("nmcli", "device reapply", ok(""));
|
||||||
|
let calls = runner.calls_handle();
|
||||||
|
|
||||||
|
let net = net("Cafe", Some("super-secret-psk"));
|
||||||
|
let result = with_runner(runner, || nm::connect_verbose("wlan0", &net, 8, "1.1.1.1"));
|
||||||
|
assert!(result.is_ok(), "{result:?}");
|
||||||
|
|
||||||
|
let calls = calls.borrow();
|
||||||
|
assert_psk_not_on_argv(&calls, "super-secret-psk");
|
||||||
|
let connect = calls
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.prog == "nmcli" && c.args.iter().any(|a| a == "connect"))
|
||||||
|
.expect("expected device wifi connect");
|
||||||
|
assert!(
|
||||||
|
connect.args.iter().any(|a| a == "--ask"),
|
||||||
|
"create path must use --ask: {:?}",
|
||||||
|
connect.args
|
||||||
|
);
|
||||||
|
assert_eq!(connect.stdin.as_deref(), Some("super-secret-psk\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn connect_verbose_reuse_feeds_psk_on_stdin_never_argv() {
|
||||||
|
let runner = FakeRunner::new()
|
||||||
|
.on_contains("nmcli", "NAME,TYPE", ok("Cafe:802-11-wireless"))
|
||||||
|
.on_contains("nmcli", "connection modify", ok(""))
|
||||||
|
.on_contains("nmcli", "connection up", ok(""))
|
||||||
|
.on_contains("nmcli", "GENERAL.CON-UUID", ok("uuid-1"))
|
||||||
|
.on_contains("nmcli", "ipv4.ignore-auto-dns", ok(""))
|
||||||
|
.on_contains("nmcli", "device reapply", ok(""));
|
||||||
|
let calls = runner.calls_handle();
|
||||||
|
|
||||||
|
let net = net("Cafe", Some("super-secret-psk"));
|
||||||
|
let result = with_runner(runner, || nm::connect_verbose("wlan0", &net, 8, "1.1.1.1"));
|
||||||
|
assert!(result.is_ok(), "{result:?}");
|
||||||
|
|
||||||
|
let calls = calls.borrow();
|
||||||
|
assert_psk_not_on_argv(&calls, "super-secret-psk");
|
||||||
|
let up = calls
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.prog == "nmcli" && c.args.iter().any(|a| a == "up"))
|
||||||
|
.expect("expected connection up");
|
||||||
|
assert!(
|
||||||
|
up.args.iter().any(|a| a == "--ask"),
|
||||||
|
"reuse path must use --ask: {:?}",
|
||||||
|
up.args
|
||||||
|
);
|
||||||
|
assert_eq!(up.stdin.as_deref(), Some("super-secret-psk\n"));
|
||||||
|
// Clearing the stored PSK uses an empty argv value, never the secret.
|
||||||
|
let cleared = calls.iter().any(|c| {
|
||||||
|
c.prog == "nmcli"
|
||||||
|
&& c.args.iter().any(|a| a == "802-11-wireless-security.psk")
|
||||||
|
&& c.args.last().is_some_and(|a| a.is_empty())
|
||||||
|
});
|
||||||
|
assert!(cleared, "reuse+password should reset stored PSK: {calls:?}");
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue