Give the VPN a toggle, an indicator, and a way back
Turning on a WireGuard profile whose server was unreachable used to cost the whole network stack, and the only way out was nmcli typed into a terminal. Quickshell's Networking module has no VPN surface, so this arrives as the one sanctioned nmcli exception: a helper that lists, raises and lowers profiles, a service that watches NetworkManager for changes made anywhere, a quick settings tile (left-click toggles the most recently used profile, right-click picks among them), and a bar glyph while a tunnel is up. The safety property is in the helper, where it cannot be skipped: activation waits a bounded 25 seconds, and a failure is rolled back down and reported instead of leaving a black-hole default route. The contract pins exactly that, against a stateful stub NetworkManager. Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
This commit is contained in:
@@ -148,6 +148,7 @@ done
|
||||
qml_package() {
|
||||
case "$1" in
|
||||
hyprctl) printf 'hyprland' ;;
|
||||
nmcli) printf 'NetworkManager' ;;
|
||||
wl-copy|wl-paste) printf 'wl-clipboard' ;;
|
||||
xdg-open) printf 'xdg-utils' ;;
|
||||
pw-dump|pw-play) printf 'pipewire-utils' ;;
|
||||
|
||||
Executable
+159
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# The VPN toggle, end to end minus NetworkManager.
|
||||
#
|
||||
# The property under test is the one that motivated the feature: a person
|
||||
# turned on a tunnel whose server was unreachable and had no way back short of
|
||||
# nmcli in a terminal. So beyond parsing and wiring, this pins the safety
|
||||
# behavior — a failed activation is rolled back down and reported, never left
|
||||
# half-up as a black-hole default route.
|
||||
#
|
||||
# nmcli is a stateful stub on PATH: fixtures decide what profiles exist, an
|
||||
# `active` file tracks what is up, and every invocation is recorded. The real
|
||||
# helper and the real service run against it, so the parse, the recency pick,
|
||||
# and the rollback are all the shipped code paths.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
harness="$repo_dir/config/dot/quickshell/vpn-harness.qml"
|
||||
service="$repo_dir/config/dot/quickshell/services/Vpn.qml"
|
||||
helper="$repo_dir/config/dot/quickshell/scripts/panama-vpn"
|
||||
panel="$repo_dir/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml"
|
||||
vpn_list="$repo_dir/config/dot/quickshell/modules/quicksettings/VpnList.qml"
|
||||
cluster="$repo_dir/config/dot/quickshell/modules/bar/StatusCluster.qml"
|
||||
|
||||
fail() {
|
||||
printf 'vpn contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── Wiring that must not silently disappear ──────────────────────────────────
|
||||
|
||||
rg -Fq 'visible: Vpn.available' "$panel" \
|
||||
|| fail 'the VPN tile is not gated on a profile existing'
|
||||
rg -Fq 'onToggled: Vpn.toggle()' "$panel" \
|
||||
|| fail 'the VPN tile does not drive Vpn.toggle()'
|
||||
rg -Fq 'root.expand("vpn")' "$panel" \
|
||||
|| fail 'the VPN tile cannot open its detail list'
|
||||
rg -Fq 'VpnList {' "$panel" \
|
||||
|| fail 'the VPN detail list is not mounted in the panel'
|
||||
rg -Fq 'visible: Vpn.anyActive' "$cluster" \
|
||||
|| fail 'the bar glyph is not gated on an active tunnel'
|
||||
rg -Fq 'onClicked: Vpn.setActive(modelData.uuid, !modelData.active)' "$vpn_list" \
|
||||
|| fail 'a profile row does not flip that profile'
|
||||
rg -Fq 'visible: Vpn.lastError !== ""' "$vpn_list" \
|
||||
|| fail 'activation failures have nowhere to surface'
|
||||
# The bound and the rollback are what make the toggle safe on a dead server.
|
||||
rg -Fq 'nmcli -w 25 connection up uuid' "$helper" \
|
||||
|| fail 'activation is unbounded -- a dead server hangs the toggle for 90s'
|
||||
rg -q 'nmcli connection down uuid .* 2>&1\|\|nmcli connection down uuid' "$helper" \
|
||||
|| rg -Fq 'nmcli connection down uuid "$uuid" >/dev/null 2>&1 || true' "$helper" \
|
||||
|| fail 'a failed activation is not rolled back down'
|
||||
rg -Fq '"nmcli", "monitor"' "$service" \
|
||||
|| fail 'outside changes to NetworkManager state are never noticed'
|
||||
|
||||
# ── The stub NetworkManager ──────────────────────────────────────────────────
|
||||
|
||||
stub_dir="$(mktemp -d)"
|
||||
state_dir="$(mktemp -d)"
|
||||
config_home="$(mktemp -d)"
|
||||
: >"$state_dir/active"
|
||||
: >"$state_dir/log"
|
||||
|
||||
cat >"$stub_dir/nmcli" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
state="$state_dir"
|
||||
echo "\$*" >>"\$state/log"
|
||||
case "\$*" in
|
||||
"monitor")
|
||||
exec sleep 45 ;;
|
||||
"-t -f NAME,UUID,TYPE,TIMESTAMP connection show")
|
||||
printf 'Home:uuid-wg-home:wireguard:200\n'
|
||||
printf 'Office\\\\: Berlin:uuid-vpn-office:vpn:100\n'
|
||||
printf "Gib's iPhone:uuid-wifi:802-11-wireless:300\n" ;;
|
||||
"-t -f UUID connection show --active")
|
||||
cat "\$state/active" ;;
|
||||
"-w 25 connection up uuid "*)
|
||||
uuid="\${!#}"
|
||||
if [[ -e "\$state/fail-up" ]]; then
|
||||
echo "Error: Connection activation failed: the server did not respond." >&2
|
||||
exit 4
|
||||
fi
|
||||
echo "\$uuid" >>"\$state/active" ;;
|
||||
"connection down uuid "*)
|
||||
uuid="\${!#}"
|
||||
grep -v "^\$uuid\$" "\$state/active" >"\$state/active.next" || true
|
||||
mv "\$state/active.next" "\$state/active" ;;
|
||||
*)
|
||||
echo "stub nmcli: unexpected: \$*" >&2
|
||||
exit 9 ;;
|
||||
esac
|
||||
STUB
|
||||
chmod +x "$stub_dir/nmcli"
|
||||
|
||||
stop_harness() {
|
||||
[[ -n "${harness_pid:-}" ]] && kill "$harness_pid" >/dev/null 2>&1 || true
|
||||
rm -rf "$stub_dir" "$state_dir" "$config_home"
|
||||
}
|
||||
trap stop_harness EXIT
|
||||
|
||||
run() { XDG_CONFIG_HOME="$config_home" PATH="$stub_dir:$PATH" qs -p "$harness" "$@"; }
|
||||
status() { run ipc call vpn-test status; }
|
||||
|
||||
PATH="$stub_dir:$PATH" XDG_CONFIG_HOME="$config_home" qs -p "$harness" --daemonize >/dev/null
|
||||
for _ in $(seq 1 40); do
|
||||
run ipc show 2>/dev/null | rg -q '^target vpn-test$' && break
|
||||
sleep 0.1
|
||||
done
|
||||
run ipc show 2>/dev/null | rg -q '^target vpn-test$' || fail 'test IPC target did not start'
|
||||
harness_pid="$(run list | awk '/Process ID:/ { print $3; exit }')"
|
||||
|
||||
settle() {
|
||||
local want="$1" tries="${2:-50}"
|
||||
for _ in $(seq 1 "$tries"); do
|
||||
if jq -e "$want" <<<"$(status)" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Only VPN kinds are listed, names unescape, recency is read ───────────────
|
||||
|
||||
settle '.scanned and (.busy | not)' || fail 'the service never finished its first scan'
|
||||
listing="$(status)"
|
||||
jq -e '.connections | length == 2' <<<"$listing" >/dev/null \
|
||||
|| fail "the wifi profile leaked into the VPN list: $listing"
|
||||
jq -e '.connections | map(.name) | sort == ["Home", "Office: Berlin"]' <<<"$listing" >/dev/null \
|
||||
|| fail "names did not parse (escaped colon?): $listing"
|
||||
jq -e '.available and (.anyActive | not)' <<<"$listing" >/dev/null \
|
||||
|| fail "profiles exist but the tile would not show: $listing"
|
||||
|
||||
# ── Toggle up picks the most recently used profile ───────────────────────────
|
||||
|
||||
run ipc call vpn-test toggle >/dev/null
|
||||
settle '.anyActive and (.busy | not)' || fail 'toggling up never activated anything'
|
||||
jq -e '.activeSummary == "Home"' <<<"$(status)" >/dev/null \
|
||||
|| fail "toggle did not pick the most recently used profile: $(status)"
|
||||
rg -Fq -- '-w 25 connection up uuid uuid-wg-home' "$state_dir/log" \
|
||||
|| fail 'the most recently used uuid was not the one activated'
|
||||
|
||||
# ── Toggle down takes the active tunnel down ─────────────────────────────────
|
||||
|
||||
run ipc call vpn-test toggle >/dev/null
|
||||
settle '(.anyActive | not) and (.busy | not)' || fail 'toggling down left the tunnel up'
|
||||
rg -Fq 'connection down uuid uuid-wg-home' "$state_dir/log" \
|
||||
|| fail 'deactivation never reached nmcli'
|
||||
|
||||
# ── A dead server: bounded, rolled back, and reported ────────────────────────
|
||||
|
||||
touch "$state_dir/fail-up"
|
||||
run ipc call vpn-test setActive uuid-vpn-office true >/dev/null
|
||||
settle '(.busy | not) and .lastError != ""' || fail 'a failed activation reported nothing'
|
||||
settle '.anyActive | not' 5 || fail 'a failed activation stayed half-up'
|
||||
rg -Fq 'connection down uuid uuid-vpn-office' "$state_dir/log" \
|
||||
|| fail 'a failed activation was not rolled back down'
|
||||
|
||||
exit 0
|
||||
Reference in New Issue
Block a user