aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-02 21:57:39 -0400
committerCraig Jennings <c@cjennings.net>2026-07-02 21:57:39 -0400
commit2e40781ebf91fa0f9dc67f4381a8d3784cda8872 (patch)
treed84d14c48100de722b0da204305054a103d1c5f3
parent03897904c3270c07f2a5e8d3cf0457895dbe0e4f (diff)
downloadarchsetup-2e40781ebf91fa0f9dc67f4381a8d3784cda8872.tar.gz
archsetup-2e40781ebf91fa0f9dc67f4381a8d3784cda8872.zip
feat(vpn): wireguard config import for the NM migration
scripts/import-wireguard-configs.sh imports the seven Proton configs into NetworkManager with autoconnect forced off. Each config stages through a wgpvpn.conf temp copy (NM's import name must be a valid interface name; several config names exceed the 15-char limit) and is renamed by the UUID parsed from the import output, so a stray same-named connection can't be hit. A leftover wgpvpn connection — a run that died between import and rename, autoconnect still armed — makes the script refuse to run. 10 tests over a fake nmcli; velox migration verified (all seven wireguard, autoconnect no). The tunnels spec is implemented: all six phases shipped.
-rw-r--r--docs/design/2026-07-02-net-panel-other-interfaces-spec.org12
-rwxr-xr-xscripts/import-wireguard-configs.sh59
-rw-r--r--tests/import-wireguard-configs/fake-nmcli45
-rw-r--r--tests/import-wireguard-configs/test_import_wireguard_configs.py167
-rw-r--r--todo.org32
5 files changed, 306 insertions, 9 deletions
diff --git a/docs/design/2026-07-02-net-panel-other-interfaces-spec.org b/docs/design/2026-07-02-net-panel-other-interfaces-spec.org
index 3ec9520..6b0a72d 100644
--- a/docs/design/2026-07-02-net-panel-other-interfaces-spec.org
+++ b/docs/design/2026-07-02-net-panel-other-interfaces-spec.org
@@ -4,10 +4,18 @@
#+TODO: TODO | DONE
#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED
-* DOING Status
+* IMPLEMENTED Status
:PROPERTIES:
:ID: 79a1075a-4b56-4f25-a861-b69f120a636a
:END:
+- [2026-07-02 Thu] IMPLEMENTED — all six phases shipped (dotfiles 2d9d060,
+ 21db05a, 31ba056, b4010bf, b5c8442; archsetup 0389790 + the wireguard
+ import script): probes, panel Tunnels view, diagnose/doctor route
+ awareness, bar badge, installer swap + operator, velox config migration.
+ Residual human steps filed under todo.org "Manual testing and
+ validation": proton CLI sign-in (per machine) and the first live
+ badge/tunnel round-trip. Ratio picks up the import + package swap on its
+ trip.
- [2026-07-02 Thu] DOING — decomposed into six build phases under the
todo.org parent (:SPEC_ID: bound); build started same evening per Craig
("tunnels build now + audio-panel spec alongside").
@@ -24,7 +32,7 @@
| Field | Value |
|--------+---------------------------------------------------|
-| Status | doing |
+| Status | implemented |
|--------+---------------------------------------------------|
| Owner | Craig Jennings |
|--------+---------------------------------------------------|
diff --git a/scripts/import-wireguard-configs.sh b/scripts/import-wireguard-configs.sh
new file mode 100755
index 0000000..ae6ca7e
--- /dev/null
+++ b/scripts/import-wireguard-configs.sh
@@ -0,0 +1,59 @@
+#!/bin/bash
+# Import the assets/wireguard-config Proton configs into NetworkManager as
+# wireguard connections with autoconnect off. Two NM quirks handled here:
+#
+# - The import filename must be a valid interface name (<= 15 chars), and
+# several config names are longer — so every file imports through a temp
+# copy named wgpvpn.conf and the connection is renamed to the real config
+# name right after (by the UUID parsed from the import output, so a stray
+# same-named connection can't be hit). All profiles share the wgpvpn
+# interface, which is fine (they're mutually exclusive full-tunnel
+# configs), and the wg prefix keeps the net doctor's tunnel-down repair
+# on the NM path.
+# - Imports default to autoconnect yes, and these are full-tunnel
+# (AllowedIPs 0.0.0.0/0) — a VPN that arms itself on boot is not a default
+# anyone chose, so the modify runs immediately after each import.
+#
+# A connection still literally named wgpvpn means an earlier run died
+# between import and rename — and it still has autoconnect on. The script
+# refuses to run until that's cleaned up rather than guessing.
+#
+# Idempotent: already-imported names skip.
+#
+# Usage: import-wireguard-configs.sh [config-dir]
+set -euo pipefail
+
+dir="${1:-$(cd "$(dirname "$0")/.." && pwd)/assets/wireguard-config}"
+[ -d "$dir" ] || { echo "no such config dir: $dir" >&2; exit 1; }
+
+if nmcli -t -f NAME connection show | grep -Fxq "wgpvpn"; then
+ echo "stale 'wgpvpn' connection found (an earlier run died mid-import; it has autoconnect ON)" >&2
+ echo "inspect and remove it first: nmcli connection delete wgpvpn" >&2
+ exit 1
+fi
+
+tmp="$(mktemp -d)"
+trap 'rm -rf "$tmp"' EXIT
+
+shopt -s nullglob
+found=0
+for conf in "$dir"/*.conf; do
+ found=1
+ name="$(basename "$conf" .conf)"
+ if nmcli -t -f NAME connection show | grep -Fxq "$name"; then
+ echo "skip: $name (already imported)"
+ continue
+ fi
+ cp "$conf" "$tmp/wgpvpn.conf"
+ out="$(nmcli connection import type wireguard file "$tmp/wgpvpn.conf")"
+ uuid="$(grep -oE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' <<<"$out" | head -1 || true)"
+ if [ -z "$uuid" ]; then
+ echo "could not parse a UUID from the import output for $name:" >&2
+ echo " $out" >&2
+ exit 1
+ fi
+ nmcli connection modify "$uuid" connection.id "$name" \
+ connection.autoconnect no
+ echo "imported: $name (autoconnect off, iface wgpvpn)"
+done
+[ "$found" = 1 ] || { echo "no .conf files in $dir" >&2; exit 1; }
diff --git a/tests/import-wireguard-configs/fake-nmcli b/tests/import-wireguard-configs/fake-nmcli
new file mode 100644
index 0000000..45b88cd
--- /dev/null
+++ b/tests/import-wireguard-configs/fake-nmcli
@@ -0,0 +1,45 @@
+#!/bin/bash
+# Fake nmcli for the import-wireguard-configs tests.
+#
+# Behavior is driven by env vars set by the test harness:
+# FAKE_NMCLI_LOG file every invocation's args are appended to (one line
+# per call; for imports the staged file's basename and
+# content hash context are visible in the args)
+# FAKE_NMCLI_NAMES newline-separated connection names returned by
+# `nmcli -t -f NAME connection show`
+# FAKE_NMCLI_IMPORT_OUT override for the import command's stdout
+# (default: the real NM success line with a per-call
+# deterministic UUID)
+# FAKE_NMCLI_MODIFY_RC exit code for `nmcli connection modify` (default 0)
+#
+# Import calls also copy the staged file into $FAKE_NMCLI_LOG.d/ so tests can
+# assert the temp copy was named wgpvpn.conf and carried the right content.
+set -euo pipefail
+
+echo "$*" >>"$FAKE_NMCLI_LOG"
+
+case "$1 $2" in
+"-t -f")
+ # nmcli -t -f NAME connection show
+ printf '%s\n' "${FAKE_NMCLI_NAMES:-}"
+ ;;
+"connection import")
+ # nmcli connection import type wireguard file <path>
+ file="${6:?}"
+ mkdir -p "$FAKE_NMCLI_LOG.d"
+ n=$(find "$FAKE_NMCLI_LOG.d" -type f | wc -l)
+ cp "$file" "$FAKE_NMCLI_LOG.d/import-$n-$(basename "$file")"
+ if [ -n "${FAKE_NMCLI_IMPORT_OUT:-}" ]; then
+ echo "$FAKE_NMCLI_IMPORT_OUT"
+ else
+ printf "Connection 'wgpvpn' (%08d-aaaa-bbbb-cccc-dddddddddddd) successfully added.\n" "$n"
+ fi
+ ;;
+"connection modify")
+ exit "${FAKE_NMCLI_MODIFY_RC:-0}"
+ ;;
+*)
+ echo "fake-nmcli: unexpected args: $*" >&2
+ exit 99
+ ;;
+esac
diff --git a/tests/import-wireguard-configs/test_import_wireguard_configs.py b/tests/import-wireguard-configs/test_import_wireguard_configs.py
new file mode 100644
index 0000000..0307041
--- /dev/null
+++ b/tests/import-wireguard-configs/test_import_wireguard_configs.py
@@ -0,0 +1,167 @@
+"""Tests for the import-wireguard-configs.sh one-time migration script.
+
+The script imports every assets/wireguard-config/*.conf into NetworkManager
+as a wireguard connection with autoconnect forced off. NM quirks under test:
+the import filename must be a valid interface name (<= 15 chars), so every
+config stages through a temp copy named wgpvpn.conf and is renamed to the
+real config name immediately after import — by the UUID parsed from the
+import output, never by the transient wgpvpn name. A leftover connection
+literally named wgpvpn (an earlier run died between import and rename, so
+it still has autoconnect on) makes the script refuse to run.
+
+nmcli is faked via a stub on PATH (fake-nmcli in this directory) that logs
+every invocation and snapshots the staged import file.
+
+Run from repo root:
+ python3 -m unittest tests.import-wireguard-configs.test_import_wireguard_configs
+"""
+
+import os
+import shutil
+import subprocess
+import tempfile
+import unittest
+
+
+REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
+SCRIPT = os.path.join(REPO_ROOT, "scripts", "import-wireguard-configs.sh")
+FAKE_NMCLI = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fake-nmcli")
+
+
+class ImportWireguardConfigs(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp(prefix="import-wg-test-")
+ self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True)
+ self.confdir = os.path.join(self.tmp, "configs")
+ os.mkdir(self.confdir)
+ self.bindir = os.path.join(self.tmp, "bin")
+ os.mkdir(self.bindir)
+ shutil.copy(FAKE_NMCLI, os.path.join(self.bindir, "nmcli"))
+ os.chmod(os.path.join(self.bindir, "nmcli"), 0o755)
+ self.log = os.path.join(self.tmp, "nmcli.log")
+
+ def write_conf(self, name, body="[Interface]\nPrivateKey = k\n"):
+ path = os.path.join(self.confdir, name + ".conf")
+ with open(path, "w") as f:
+ f.write(body)
+ return path
+
+ def run_script(self, confdir=None, names="", env_extra=None):
+ env = dict(os.environ)
+ env["PATH"] = self.bindir + os.pathsep + env["PATH"]
+ env["FAKE_NMCLI_LOG"] = self.log
+ env["FAKE_NMCLI_NAMES"] = names
+ if env_extra:
+ env.update(env_extra)
+ return subprocess.run(
+ ["bash", SCRIPT, confdir or self.confdir],
+ capture_output=True, text=True, timeout=10, env=env,
+ )
+
+ def log_lines(self):
+ if not os.path.exists(self.log):
+ return []
+ with open(self.log) as f:
+ return [ln.strip() for ln in f if ln.strip()]
+
+ # --- Normal cases ----------------------------------------------------
+
+ def test_imports_every_conf_with_autoconnect_off(self):
+ self.write_conf("USNY")
+ self.write_conf("USDC")
+ r = self.run_script()
+ self.assertEqual(r.returncode, 0, r.stderr)
+ modifies = [ln for ln in self.log_lines() if ln.startswith("connection modify")]
+ self.assertEqual(len(modifies), 2)
+ for ln in modifies:
+ self.assertIn("connection.autoconnect no", ln)
+ self.assertIn("imported: USDC", r.stdout)
+ self.assertIn("imported: USNY", r.stdout)
+
+ def test_renames_by_uuid_from_import_output_not_by_name(self):
+ self.write_conf("USNY")
+ r = self.run_script()
+ self.assertEqual(r.returncode, 0, r.stderr)
+ modify = [ln for ln in self.log_lines() if ln.startswith("connection modify")][0]
+ # The modify targets the UUID the import printed, and never the
+ # transient wgpvpn name.
+ self.assertIn("00000000-aaaa-bbbb-cccc-dddddddddddd", modify)
+ self.assertIn("connection.id USNY", modify)
+ self.assertNotIn("modify wgpvpn", modify)
+
+ def test_long_name_stages_through_wgpvpn_temp_copy(self):
+ # switzerlan-zurich1 is 18 chars — over NM's 15-char interface-name
+ # limit, the reason the staging copy exists at all.
+ body = "[Interface]\nPrivateKey = long-name-key\n"
+ self.write_conf("switzerlan-zurich1", body)
+ r = self.run_script()
+ self.assertEqual(r.returncode, 0, r.stderr)
+ staged = os.listdir(self.log + ".d")
+ self.assertEqual(len(staged), 1)
+ self.assertTrue(staged[0].endswith("wgpvpn.conf"), staged)
+ with open(os.path.join(self.log + ".d", staged[0])) as f:
+ self.assertEqual(f.read(), body)
+ self.assertIn("imported: switzerlan-zurich1", r.stdout)
+
+ # --- Idempotence -----------------------------------------------------
+
+ def test_already_imported_names_skip(self):
+ self.write_conf("USNY")
+ self.write_conf("USDC")
+ r = self.run_script(names="USNY\nsome-wifi")
+ self.assertEqual(r.returncode, 0, r.stderr)
+ self.assertIn("skip: USNY", r.stdout)
+ self.assertIn("imported: USDC", r.stdout)
+ modifies = [ln for ln in self.log_lines() if ln.startswith("connection modify")]
+ self.assertEqual(len(modifies), 1)
+
+ def test_all_imported_is_a_clean_noop(self):
+ self.write_conf("USNY")
+ r = self.run_script(names="USNY")
+ self.assertEqual(r.returncode, 0, r.stderr)
+ imports = [ln for ln in self.log_lines() if ln.startswith("connection import")]
+ self.assertEqual(imports, [])
+
+ # --- Boundary cases --------------------------------------------------
+
+ def test_empty_config_dir_fails_loudly(self):
+ r = self.run_script()
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("no .conf files", r.stderr)
+
+ def test_missing_config_dir_fails_loudly(self):
+ r = self.run_script(confdir=os.path.join(self.tmp, "nope"))
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("no such config dir", r.stderr)
+
+ # --- Error cases -----------------------------------------------------
+
+ def test_stale_wgpvpn_connection_refuses_to_run(self):
+ self.write_conf("USNY")
+ r = self.run_script(names="wgpvpn\nUSDC")
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("stale", r.stderr)
+ self.assertIn("nmcli connection delete wgpvpn", r.stderr)
+ imports = [ln for ln in self.log_lines() if ln.startswith("connection import")]
+ self.assertEqual(imports, [])
+
+ def test_unparseable_import_output_aborts(self):
+ self.write_conf("USNY")
+ r = self.run_script(env_extra={"FAKE_NMCLI_IMPORT_OUT": "something unexpected"})
+ self.assertEqual(r.returncode, 1)
+ self.assertIn("could not parse a UUID", r.stderr)
+ modifies = [ln for ln in self.log_lines() if ln.startswith("connection modify")]
+ self.assertEqual(modifies, [])
+
+ def test_modify_failure_aborts_the_run(self):
+ self.write_conf("USNY")
+ self.write_conf("USDC")
+ r = self.run_script(env_extra={"FAKE_NMCLI_MODIFY_RC": "4"})
+ self.assertNotEqual(r.returncode, 0)
+ # set -e stops at the first failed modify — only one import attempted.
+ imports = [ln for ln in self.log_lines() if ln.startswith("connection import")]
+ self.assertEqual(len(imports), 1)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/todo.org b/todo.org
index abbd4ed..9029b46 100644
--- a/todo.org
+++ b/todo.org
@@ -24,7 +24,8 @@ The vocabulary is open — topic tags are coined as needed — so these are conv
** TODO [#B] Audio panel spec :feature:waybar:audio:
Work Craig's ask (roam inbox, 2026-07-02) into a spec, net/bt-panel kin: an audio panel replacing the pypr audio scratchpad (Super+A) with the same functionality — change the default/active output (speaker) and input (mic), volume control for both. The one new capability: a push-to-talk mic mode for meetings — mic stays muted except while the space bar is held, releasing re-mutes. (Hold-to-talk under Wayland needs a global key grab — likely a hyprland bind pair on press/release or an evdev listener; feasibility research belongs in the spec.) Related current bindings: Super+M audio-cycle ring, Super+Shift+A mic-toggle.
-** DOING [#B] Network panel: other network interfaces (tailscale, VPNs, wireguard) :feature:waybar:network:
+** DONE [#B] Network panel: other network interfaces (tailscale, VPNs, wireguard) :feature:waybar:network:
+CLOSED: [2026-07-02 Thu]
:PROPERTIES:
:SPEC_ID: 79a1075a-4b56-4f25-a861-b69f120a636a
:END:
@@ -44,14 +45,14 @@ Connections gained a third sub-view (Available | Saved | Tunnels — a StackSwit
*** 2026-07-02 Thu @ 19:14:58 -0400 Shipped phase 4 — bar tunnel badge (dotfiles b4010bf)
=net status= carries =tunnel_route= ({dev, kind} via =overlays.default_route_owner=, exception-guarded like the overlays list, present on the no-device path too). The indicator appends a small nf-md-vpn badge after the state glyph, emits =["<state>", "tunnel"]= as a waybar class list (string class unchanged when no tunnel), and the tooltip names the owner ("Tunnel: default route via tailscale0 (tailscale)"). No css edit — presence is the signal, themes can hook the class later, and the waybar/style.css drift test stays untouched. 4 new tests; StatusHarness gained fake-ip so the machine's real route can't leak into assertions (462 net tests, 45 suites green). Live payload on velox verified badge-free (wlp170s0 owns the route — correct); a badge render awaits the first real tunnel-owned route (phase 6's wg import or a tailscale exit node).
-*** TODO Phase 5 — archsetup: operator flag + package swap :feature:
-=tailscale set --operator= in the tailscale step; proton-vpn-cli replaces proton-vpn-gtk-app; VM assertions.
+*** 2026-07-02 Thu @ 21:56:00 -0400 Shipped phase 5 — installer proton CLI swap + tailscale operator (archsetup 0389790); GTK app retired live on velox
+The feat commit landed at 19:16 (the session died before this close-out): installer enables tailscaled with =--now= and grants =tailscale set --operator= to the primary user (brief retry while the daemon's socket comes up), proton-vpn-cli replaces proton-vpn-gtk-app, VM asserts the vpn stack + the retirement + the OperatorUser pref (format verified against a live daemon). Live velox application finished 21:55: the =protonvpn-app --start-minimized= exec-once removed (dotfiles b5c8442 — nothing replaces it, the CLI is on-demand from the panel), the running app killed, =pacman -Rns proton-vpn-gtk-app= (proton-vpn-daemon stays — separate package the CLI uses). CLI verified unblocked: =protonvpn status= → "Status: Disconnected", =protonvpn info= → Account 'None' (sign-in is Craig's step, filed under Manual testing and validation).
-*** TODO Phase 6 — wireguard config migration (both machines) :chore:
-Import the seven assets/wireguard-config configs into NM with autoconnect off; scriptable; velox now, ratio on its trip.
+*** 2026-07-02 Thu @ 21:57:00 -0400 Shipped phase 6 — wireguard import script + velox migration (scripts/import-wireguard-configs.sh)
+The script stages each config through a =wgpvpn.conf= temp copy (NM's import name must be a valid <=15-char interface name; several config names are longer), renames by the UUID parsed from the import output (never by the transient name, so a stray same-named connection can't be hit), forces =autoconnect no= (full-tunnel AllowedIPs 0.0.0.0/0 must not arm itself at boot), skips already-imported names, and refuses to run past a stale =wgpvpn= connection (an earlier run that died between import and rename — it still has autoconnect on). =tests/import-wireguard-configs/=: 10 cases over a fake nmcli; writing them caught a real bug (under =set -e= the grep-for-UUID pipeline aborted before the error message printed). shellcheck clean; 11 unit suites green. Velox migration verified: the crashed session had already run the import, so tonight's run exercised the skip path live — all 7 connections confirmed wireguard type, autoconnect no, iface wgpvpn, no stale leftovers; =net status= overlays show tailscale + all 7 rows. Ratio runs the script on its trip (rides the archsetup pull).
-*** TODO Test surface :test:
-Probe suites over fake tailscale/nmcli/protonvpn, panel-model Tunnels coverage, diag overlay-ownership cases, badge suite, VM assertions for phase 5.
+*** 2026-07-02 Thu @ 21:58:00 -0400 Test surface complete across the phases
+Probe suites over fake tailscale/nmcli/protonvpn (19, phase 1), panel-model Tunnels coverage (22, phase 2), diag overlay-ownership cases (11, phase 3), badge suite (4, phase 4) — all in dotfiles; VM assertions for phase 5 in archsetup 0389790; the import-script suite (10, phase 6) closes the set.
** TODO [#B] File-manager swallow pattern :feature:hyprland:
When the file manager launches another app, it should hide to a special workspace (the "swallow" pattern) and return when that process ends, rather than vanishing. Today it disappears with no signal of whether it's coming back, so the user can't tell success from failure — they should quit explicitly instead. Origin: roam inbox capture.
@@ -778,6 +779,23 @@ Parse yay errors and provide specific, actionable fixes instead of generic error
Enhance existing indicators to show what's happening in real-time
** TODO Manual testing and validation
+*** Proton VPN CLI sign-in (velox now, ratio on its trip)
+What we're verifying: the proton CLI has its own account store (separate from the retired GTK app's), so the panel's proton rows can't toggle until you sign in once per machine.
+- Run in a terminal: protonvpn login <your-proton-username> (it prompts for the password).
+#+begin_src sh :results output
+protonvpn info
+#+end_src
+Expected: Account shows your Proton username instead of 'None'. After that, protonvpn status still says Disconnected — correct, nothing auto-connects.
+
+*** Tunnels round-trip: panel rows + bar badge (first real tunnel-owned route)
+What we're verifying: the panel's Tunnels tab drives a real wireguard tunnel up and down, and the bar indicator grows the vpn badge while the tunnel owns the default route (the badge has never rendered live — every prior check ran with the wlan owning the route).
+- Open the net panel (left-click the bar's net module), switch Connections to the Tunnels page.
+- Confirm the rows: tailscale (up), and the seven Proton configs (USNY, USDC, USCALA, USCASF, USGAAT, switzerlan-zurich1/2), all down.
+- Select USNY, press Bring Up, wait for the row to land.
+Expected: the bar's net glyph gains the small vpn badge; its tooltip names the owner ("Tunnel: default route via wgpvpn (wireguard)").
+- Press Bring Down on the same row.
+Expected: badge gone, tooltip back to normal, internet still works (the wlan owns the route again).
+
*** Screenshot View Image option
What we're verifying: the new post-capture menu entry opens the shot and puts its path on the clipboard (dispatch is unit-tested; this is the live end-to-end).
- Take a screenshot the usual way (region or fullscreen).