diff options
Diffstat (limited to 'scripts/testing')
| -rw-r--r-- | scripts/testing/net-scenarios/10-nm-masked.sh | 29 | ||||
| -rw-r--r-- | scripts/testing/net-scenarios/20-rival-manager.sh | 27 | ||||
| -rw-r--r-- | scripts/testing/net-scenarios/30-keyfile-perms.sh | 30 | ||||
| -rwxr-xr-x | scripts/testing/run-net-scenarios.sh | 115 |
4 files changed, 201 insertions, 0 deletions
diff --git a/scripts/testing/net-scenarios/10-nm-masked.sh b/scripts/testing/net-scenarios/10-nm-masked.sh new file mode 100644 index 0000000..d3b382b --- /dev/null +++ b/scripts/testing/net-scenarios/10-nm-masked.sh @@ -0,0 +1,29 @@ +# shellcheck shell=bash disable=SC2034 # sourced by run-net-scenarios.sh +# Scenario: a masked NetworkManager is unmasked and started. +# +# Masking NetworkManager makes the generic "isn't running" verdict useless — +# systemctl can't start a masked unit — so the doctor must name the mask and +# unmask first. Break by masking; the fix's chain should unmask then start. +SCENARIO_DESC="masked NetworkManager is unmasked and started" +SCENARIO_GROUP="control-plane" + +scenario_break() { + nexec "systemctl mask NetworkManager" +} + +# The verdict the read-only diagnose should produce before any fix runs. +scenario_diagnose_expect() { + ndoctor_json ".outcome" | grep -qx fixable \ + && ndoctor_json '.report.control_plane.nm_state' | grep -qx masked +} + +scenario_fix() { + nfix # net doctor --fix +} + +scenario_assert() { + # Unmasked (the state flip, testable anywhere) AND active (needs a real + # NetworkManager, so a container without netlink can only show the unmask). + ! nexec "systemctl is-enabled NetworkManager | grep -qx masked" \ + && nexec "systemctl is-active NetworkManager >/dev/null" +} diff --git a/scripts/testing/net-scenarios/20-rival-manager.sh b/scripts/testing/net-scenarios/20-rival-manager.sh new file mode 100644 index 0000000..0d7836b --- /dev/null +++ b/scripts/testing/net-scenarios/20-rival-manager.sh @@ -0,0 +1,27 @@ +# shellcheck shell=bash disable=SC2034 # sourced by run-net-scenarios.sh +# Scenario: a rival network manager active alongside NetworkManager is disabled. +# +# dhcpcd (or systemd-networkd / iwd) running next to NM fights it for the link. +# Break by enabling dhcpcd; the fix disables it, and the chain resets to +# reconnect. Requires dhcpcd installed in the target. +SCENARIO_DESC="an active rival manager (dhcpcd) is disabled" +SCENARIO_GROUP="control-plane" + +scenario_break() { + nexec "systemctl enable --now dhcpcd" +} + +scenario_diagnose_expect() { + # The verdict fires only when the link is also failing; in a fresh target + # with no configured uplink that holds, so the rival should be named. + ndoctor_json ".report.control_plane.rivals | index(\"dhcpcd\")" \ + | grep -vqx null +} + +scenario_fix() { + nfix +} + +scenario_assert() { + ! nexec "systemctl is-active dhcpcd >/dev/null 2>&1" +} diff --git a/scripts/testing/net-scenarios/30-keyfile-perms.sh b/scripts/testing/net-scenarios/30-keyfile-perms.sh new file mode 100644 index 0000000..8149f87 --- /dev/null +++ b/scripts/testing/net-scenarios/30-keyfile-perms.sh @@ -0,0 +1,30 @@ +# shellcheck shell=bash disable=SC2034 # sourced by run-net-scenarios.sh +# Scenario: a profile keyfile with unsafe permissions is set back to 0600 root. +# +# NetworkManager silently ignores a world-readable keyfile, so the profile +# never activates. Break by chmod 0644 on an existing profile's keyfile; the +# fix chmods it back to 0600 root. The doctor must run as root to read the +# keyfile dir, so this scenario runs the doctor with sudo. Requires at least +# one saved profile in the target (NET_SCENARIO_PROFILE names it). +SCENARIO_DESC="an unsafe-perms profile keyfile is restored to 0600 root" +SCENARIO_GROUP="control-plane" + +_keyfile() { + echo "/etc/NetworkManager/system-connections/${NET_SCENARIO_PROFILE:?set NET_SCENARIO_PROFILE to a saved profile name}.nmconnection" +} + +scenario_break() { + nexec "chmod 0644 '$(_keyfile)'" +} + +scenario_diagnose_expect() { + ndoctor_json ".report.control_plane.keyfile.perms_ok" | grep -qx false +} + +scenario_fix() { + nfix +} + +scenario_assert() { + nexec "[ \"\$(stat -c '%a %U' '$(_keyfile)')\" = '600 root' ]" +} diff --git a/scripts/testing/run-net-scenarios.sh b/scripts/testing/run-net-scenarios.sh new file mode 100755 index 0000000..7197e37 --- /dev/null +++ b/scripts/testing/run-net-scenarios.sh @@ -0,0 +1,115 @@ +#!/bin/bash +# SPDX-License-Identifier: GPL-3.0-or-later +# Run net-doctor control-plane repair scenarios against a booted VM. +# Author: Craig Jennings <craigmartinjennings@gmail.com> +# License: GNU GPLv3 +# +# Live verification for net Phase 1's three privileged fixes (unmask-nm, +# disable-rival, chmod-keyfile). Each scenario breaks the target in a known +# way, runs the real `net doctor --fix` inside it, and asserts the post-state. +# These states are dangerous on a daily driver (masking NM kills the network), +# so they run against a DISPOSABLE VM you can revert, never ratio/velox. +# +# The target must be a booted VM (a real kernel + network stack — NetworkManager +# does not run reliably in an nspawn container) reachable over ssh as root, with +# NetworkManager and dhcpcd installed. The runner rsyncs the net + panelkit +# source trees in and runs the doctor from them via a small wrapper (no install +# needed), matching the stowed shim. jq is used host-side to read the JSON. +# +# STATUS: first-draft harness. The scenario break/fix/assert logic is the +# reviewed part; the ssh transport plumbing has not been exercised against a +# live target yet and may need a shakeout on the first real run. +# +# Usage: run-net-scenarios.sh --target root@HOST [--list] [--profile NAME] +# --target ssh destination of the booted VM (required unless --list) +# --profile a saved NM profile name for the keyfile scenario +# (else the keyfile scenario is skipped) +# --list print the scenario plan and exit +# +# Reverting the VM to a clean snapshot between runs is the caller's job; each +# scenario also restores what it broke on the way out. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCENARIO_DIR="${NET_SCENARIO_DIR:-$SCRIPT_DIR/net-scenarios}" +DOTFILES="${DOTFILES:-$HOME/.dotfiles}" +T_NET="/root/net-scenario-tree" # where the source trees land in the target + +TARGET="" +LIST_ONLY=false +export NET_SCENARIO_PROFILE="${NET_SCENARIO_PROFILE:-}" + +while [[ $# -gt 0 ]]; do + case $1 in + --target) TARGET="$2"; shift 2 ;; + --profile) NET_SCENARIO_PROFILE="$2"; shift 2 ;; + --list) LIST_ONLY=true; shift ;; + *) echo "usage: $0 --target root@HOST [--list] [--profile NAME]" >&2; exit 1 ;; + esac +done + +mapfile -t S_FILES < <(find "$SCENARIO_DIR" -maxdepth 1 -name '*.sh' | sort) + +if [ "$LIST_ONLY" = true ]; then + echo "net doctor scenario plan:" + for f in "${S_FILES[@]}"; do + ( . "$f"; printf ' %-24s %s\n' "$(basename "$f" .sh)" "$SCENARIO_DESC" ) + done + exit 0 +fi + +[ -n "$TARGET" ] || { echo "--target root@HOST is required" >&2; exit 1; } +command -v jq >/dev/null || { echo "jq is required host-side" >&2; exit 1; } + +SSH() { ssh -o BatchMode=yes "$TARGET" "$@"; } + +# ── target helpers (available to the scenario scripts) ─────────────────── +# nexec runs a payload in the target as root; the doctor runs through the +# /root/net-run wrapper synced below, so no fragile inline quoting is needed. +nexec() { SSH "bash -lc $(printf '%q' "$*")"; } +nfix() { nexec "/root/net-run doctor --fix --json"; } +ndoctor_json() { nexec "/root/net-run doctor --json" | jq -er "$1"; } + +info() { printf ' %s\n' "$*"; } +pass() { printf ' PASS %s\n' "$*"; } +fail() { printf ' FAIL %s\n' "$*"; } + +# ── sync the source trees + the doctor wrapper into the target ─────────── +echo "==> syncing net + panelkit into $TARGET:$T_NET" +SSH "mkdir -p $T_NET/net $T_NET/panelkit" +rsync -a -e "ssh -o BatchMode=yes" "$DOTFILES/net/src" "$TARGET:$T_NET/net/" +rsync -a -e "ssh -o BatchMode=yes" "$DOTFILES/panelkit/src" "$TARGET:$T_NET/panelkit/" +# The wrapper: run as root, so NET_SUDO empty (commands run directly) and +# PANELKIT_SUDO=sudo (root's `sudo -n true` succeeds -> the model resolves RUN). +SSH "cat > /root/net-run" <<EOF +#!/bin/bash +exec env PYTHONPATH=$T_NET/net/src:$T_NET/panelkit/src NET_SUDO= PANELKIT_SUDO=sudo \\ + python3 -c 'import sys; from net.cli import main; sys.exit(main(sys.argv[1:]))' "\$@" +EOF +SSH "chmod +x /root/net-run" + +# ── run each scenario ──────────────────────────────────────────────────── +fails=0 +for f in "${S_FILES[@]}"; do + name="$(basename "$f" .sh)" + # shellcheck disable=SC1090 + ( . "$f" + case "$name" in + *keyfile*) [ -n "$NET_SCENARIO_PROFILE" ] || { info "skip $name (no --profile)"; exit 0; } ;; + esac + echo "== $name — $SCENARIO_DESC" + scenario_break || { fail "$name: break failed"; exit 1; } + if declare -F scenario_diagnose_expect >/dev/null; then + if scenario_diagnose_expect; then pass "$name: diagnose named it" + else fail "$name: diagnose did NOT name it (inspect net doctor --json)"; fi + fi + scenario_fix || info "$name: net doctor --fix returned non-zero" + if scenario_assert; then pass "$name: repaired" + else fail "$name: NOT repaired"; exit 1; fi + ) || fails=$((fails + 1)) +done + +echo +[ "$fails" -eq 0 ] && echo "all scenarios passed" || echo "$fails scenario(s) failed" +exit "$fails" |
