From 8d73d2a37eec5529519b2e7b4a7e6cca4b3d8ee5 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 19 Oct 2026 20:40:23 +0100 Subject: [PATCH] Feature: Add home UniFi WireGuard tunnel to VPN panel Co-Authored-By: Claude Opus 5.5 --- .../quickshell/config/VpnExclusivity.qml | 19 +- .../modules/bar/TailscaleIndicator.qml | 4 +- .../quickshell/modules/bar/VpnIndicator.qml | 179 ++++++++++++++++-- .../scripts/CheckQuickshellSetup.sh | 7 + .../scripts/ImportUnifiWireguard.sh | 64 +++++++ 5 files changed, 243 insertions(+), 30 deletions(-) create mode 100755 dotfiles/.config/quickshell/scripts/ImportUnifiWireguard.sh diff --git a/dotfiles/.config/quickshell/config/VpnExclusivity.qml b/dotfiles/.config/quickshell/config/VpnExclusivity.qml index 1859446..d39f4f0 100644 --- a/dotfiles/.config/quickshell/config/VpnExclusivity.qml +++ b/dotfiles/.config/quickshell/config/VpnExclusivity.qml @@ -1,16 +1,17 @@ pragma Singleton import QtQuick -// Tailscale, Mullvad, and ProtonVPN all fight over the default route/DNS if -// more than one is active at once (see VpnIndicator.qml's header comment) — -// only one of the three should ever actually be connected. This singleton -// is the shared signal between the two widget files: VpnIndicator.qml -// writes Mullvad/ProtonVPN's live connection state here as it polls them; -// TailscaleIndicator.qml reads it to back off (`tailscale down`) rather -// than stay up alongside either one, and to refuse turning Tailscale back -// on while one is still connected. +// Tailscale, Mullvad, ProtonVPN, and the home UniFi WireGuard tunnel all +// fight over the default route/DNS if more than one is active at once (see +// VpnIndicator.qml's header comment) — only one should ever actually be +// connected. This singleton is the shared signal between the two widget +// files: VpnIndicator.qml writes each tunnel's live connection state here +// as it polls them; TailscaleIndicator.qml reads it to back off (`tailscale +// down`) rather than stay up alongside one, and to refuse turning Tailscale +// back on while one is still connected. QtObject { property bool mullvadConnected: false property bool protonConnected: false - readonly property bool commercialVpnActive: mullvadConnected || protonConnected + property bool homeWgConnected: false + readonly property bool commercialVpnActive: mullvadConnected || protonConnected || homeWgConnected } diff --git a/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml b/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml index b39cd38..c49b4dd 100644 --- a/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml @@ -54,7 +54,7 @@ Item { // Set when the VPN-exclusivity guard (below) forces Tailscale down — // as opposed to you turning it off yourself — so it knows to bring - // Tailscale back up automatically once Mullvad/ProtonVPN disconnect, + // Tailscale back up automatically once the other VPN disconnects, // without overriding a deliberate manual "off". property bool _forcedDownByVpn: false @@ -204,7 +204,7 @@ Item { return } if (VpnExclusivity.commercialVpnActive) { - root.actionStatus = "Disconnect Mullvad/ProtonVPN first" + root.actionStatus = "Disconnect the active VPN first" actionStatusTimer.restart() return } diff --git a/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml b/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml index cc28131..7a0231e 100644 --- a/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml @@ -18,6 +18,10 @@ import "../../config" // - ProtonVPN: official `protonvpn` CLI (proton-vpn-cli package), plain- // text status only — parsed defensively since its exact wording isn't // guaranteed across versions. +// - Home (UniFi): the UniFi gateway's built-in WireGuard server, driven +// through a NetworkManager profile via `nmcli` (no sudo needed). Import +// the client .conf UniFi generates with scripts/ImportUnifiWireguard.sh, +// which names the profile to match homeWgConnection below. Item { id: root implicitWidth: label.implicitWidth + 4 @@ -39,9 +43,17 @@ Item { property string protonState: "disconnected" property string protonDetail: "" - readonly property bool anyConnected: mullvadState === "connected" || protonState === "connected" + // NetworkManager profile name for the home UniFi WireGuard tunnel. + // "Installed" here means the profile exists — `nmcli connection show` + // exits non-zero for an unknown name. + property string homeWgConnection: "unifi-home" + property bool homeWgInstalled: true + property string homeWgState: "disconnected" + property string homeWgDetail: "" - // Set while switching from one provider to the other: holds which one + readonly property bool anyConnected: mullvadState === "connected" || protonState === "connected" || homeWgState === "connected" + + // Set while switching from one provider to another: holds which one // to connect once the current one has finished disconnecting. property string pendingConnect: "" @@ -51,6 +63,7 @@ Item { // show up hanging under the other's row. property string mullvadActionStatus: "" property string protonActionStatus: "" + property string homeWgActionStatus: "" // --- Mullvad advanced options (auto-connect, relay location, multihop) -- // ProtonVPN's CLI is plain-text-only and far less predictable across @@ -114,6 +127,20 @@ Item { VpnExclusivity.protonConnected = root.protonState === "connected" } + // `nmcli -g GENERAL.STATE,IP4.ADDRESS connection show ` prints + // nothing for an inactive profile, otherwise "activated"/"activating"/ + // "deactivating" followed by the tunnel address(es). + function applyHomeWgStatus(text) { + const lines = String(text || "").trim().split("\n").map(function(l) { return l.trim() }).filter(Boolean) + const raw = (lines[0] || "").toLowerCase() + root.homeWgState = raw === "activated" ? "connected" + : raw === "activating" ? "connecting" + : raw === "deactivating" ? "disconnecting" + : "disconnected" + root.homeWgDetail = root.homeWgState === "connected" && lines[1] ? lines[1].split(" | ")[0] : "" + VpnExclusivity.homeWgConnected = root.homeWgState === "connected" + } + function applyMullvadAutoConnect(text) { const m = String(text || "").match(/autoconnect:\s*(on|off)/i) root.mullvadAutoConnect = !!m && m[1].toLowerCase() === "on" @@ -225,33 +252,61 @@ Item { } } + function refreshHomeWg() { + if (!homeWgStatusProc.running) homeWgStatusProc.running = true + } + function refresh() { root.refreshMullvad() root.refreshProton() + root.refreshHomeWg() + } + + // Disconnects every provider other than `keep` that isn't already + // down; returns whether it had to, so the caller can defer its own + // connect to root.resumePendingConnect() instead of racing it. + function stopOthers(keep) { + let stopping = false + if (keep !== "mullvad" && root.mullvadState !== "disconnected") { root.disconnectMullvad(); stopping = true } + if (keep !== "protonvpn" && root.protonState !== "disconnected") { root.disconnectProton(); stopping = true } + if (keep !== "homewg" && root.homeWgState !== "disconnected") { root.disconnectHomeWg(); stopping = true } + return stopping + } + + // Called from each disconnect's onExited. The provider that just went + // down is marked disconnected optimistically before this runs — its + // status poll is async, and without that the pending connect would see + // it still "connected" and try to disconnect it all over again. + function resumePendingConnect() { + const next = root.pendingConnect + root.pendingConnect = "" + if (next === "mullvad") root.connectMullvad() + else if (next === "protonvpn") root.connectProton() + else if (next === "homewg") root.connectHomeWg() } function connectMullvad() { if (mullvadConnectProc.running || mullvadDisconnectProc.running) return - if (root.protonState !== "disconnected") { - root.pendingConnect = "mullvad" - root.disconnectProton() - return - } + if (root.stopOthers("mullvad")) { root.pendingConnect = "mullvad"; return } root.pendingConnect = "" mullvadConnectProc.running = true } function connectProton() { if (protonConnectProc.running || protonDisconnectProc.running) return - if (root.mullvadState !== "disconnected") { - root.pendingConnect = "protonvpn" - root.disconnectMullvad() - return - } + if (root.stopOthers("protonvpn")) { root.pendingConnect = "protonvpn"; return } root.pendingConnect = "" protonConnectProc.running = true } + function connectHomeWg() { + if (homeWgConnectProc.running || homeWgDisconnectProc.running) return + if (root.stopOthers("homewg")) { root.pendingConnect = "homewg"; return } + root.pendingConnect = "" + root.homeWgState = "connecting" + homeWgConnectProc.running = true + } + function disconnectMullvad() { if (mullvadDisconnectProc.running) return mullvadDisconnectProc.running = true @@ -262,6 +317,12 @@ Item { protonDisconnectProc.running = true } + function disconnectHomeWg() { + if (homeWgDisconnectProc.running) return + root.homeWgState = "disconnecting" + homeWgDisconnectProc.running = true + } + function toggleMullvad() { if (mullvadConnectProc.running || mullvadDisconnectProc.running) return if (root.mullvadState === "connected") root.disconnectMullvad() @@ -274,7 +335,13 @@ Item { else root.connectProton() } - // Tailscale fights Mullvad/ProtonVPN for the default route/DNS, so fire + function toggleHomeWg() { + if (homeWgConnectProc.running || homeWgDisconnectProc.running) return + if (root.homeWgState === "connected") root.disconnectHomeWg() + else root.connectHomeWg() + } + + // Tailscale fights Mullvad/ProtonVPN/home WireGuard for the default route/DNS, so fire // this right after either one finishes connecting — a harmless no-op // if Tailscale was already down. TailscaleIndicator.qml also backs // itself off reactively (via VpnExclusivity) as a backstop, but doing @@ -336,10 +403,11 @@ Item { if (exitCode !== 0) { root.mullvadActionStatus = root.shortLine(mullvadDisconnectErr.text || "Disconnect failed") mullvadActionStatusTimer.restart() + } else { + root.mullvadState = "disconnected" } root.refreshMullvad() - if (root.pendingConnect === "mullvad") { root.pendingConnect = ""; root.connectMullvad() } - else if (root.pendingConnect === "protonvpn") { root.pendingConnect = ""; root.connectProton() } + root.resumePendingConnect() } } @@ -471,10 +539,11 @@ Item { if (exitCode !== 0) { root.protonActionStatus = root.shortLine(protonDisconnectErr.text || "Disconnect failed") protonActionStatusTimer.restart() + } else { + root.protonState = "disconnected" } root.refreshProton() - if (root.pendingConnect === "mullvad") { root.pendingConnect = ""; root.connectMullvad() } - else if (root.pendingConnect === "protonvpn") { root.pendingConnect = ""; root.connectProton() } + root.resumePendingConnect() } } @@ -485,6 +554,58 @@ Item { onTriggered: if (protonStatusProc.running) protonStatusProc.running = false } + // --- Home WireGuard (UniFi, via NetworkManager) ------------------------ + + Process { + id: homeWgStatusProc + command: ["nmcli", "-g", "GENERAL.STATE,IP4.ADDRESS", "connection", "show", root.homeWgConnection] + stdout: StdioCollector { + id: homeWgStatusCollector + onStreamFinished: root.applyHomeWgStatus(homeWgStatusCollector.text) + } + onExited: (exitCode, exitStatus) => { + root.homeWgInstalled = exitCode === 0 + if (!root.homeWgInstalled) root.applyHomeWgStatus("") + } + } + + // `connection up` blocks until NetworkManager reports the profile + // activated (or gives up), so its exit code is the real result. Note + // WireGuard "activated" only means the interface is configured — the + // handshake itself happens lazily on first traffic. + Process { + id: homeWgConnectProc + command: ["nmcli", "connection", "up", root.homeWgConnection] + stdout: StdioCollector {} + stderr: StdioCollector { id: homeWgConnectErr } + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0) { + root.homeWgActionStatus = root.shortLine(homeWgConnectErr.text || "Connect failed") + homeWgActionStatusTimer.restart() + } else if (!tailscaleDownProc.running) { + tailscaleDownProc.running = true + } + root.refreshHomeWg() + } + } + + Process { + id: homeWgDisconnectProc + command: ["nmcli", "connection", "down", root.homeWgConnection] + stdout: StdioCollector {} + stderr: StdioCollector { id: homeWgDisconnectErr } + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0) { + root.homeWgActionStatus = root.shortLine(homeWgDisconnectErr.text || "Disconnect failed") + homeWgActionStatusTimer.restart() + } else { + root.homeWgState = "disconnected" + } + root.refreshHomeWg() + root.resumePendingConnect() + } + } + // --- Shared timers ----------------------------------------------------- Timer { @@ -510,6 +631,13 @@ Item { onTriggered: root.protonActionStatus = "" } + Timer { + id: homeWgActionStatusTimer + interval: 4000 + repeat: false + onTriggered: root.homeWgActionStatus = "" + } + // --- Bar icon ------------------------------------------------------------ // A fixed, wallpaper-independent green rather than Colors.primary — the @@ -615,6 +743,7 @@ Item { property string detailText: "" property string state: "disconnected" property bool installed: true + property string missingText: "Not installed" // When true, shows a chevron that expands a provider-specific // options section below this row (used for Mullvad's advanced // options; ProtonVPN's row leaves this false). @@ -664,7 +793,7 @@ Item { } Text { Layout.fillWidth: true - text: !provRow.installed ? "Not installed" + text: !provRow.installed ? provRow.missingText : provRow.state === "connecting" ? "Connecting…" : provRow.state === "disconnecting" ? "Disconnecting…" : provRow.state === "connected" ? (provRow.detailText || "Connected") @@ -1052,11 +1181,23 @@ Item { } StatusLine { message: root.protonActionStatus } + + ProviderRow { + label: "Home (UniFi)" + subIcon: "home" + state: root.homeWgState + detailText: root.homeWgDetail + installed: root.homeWgInstalled + missingText: "Not set up — run ImportUnifiWireguard.sh" + onToggle: root.toggleHomeWg() + } + + StatusLine { message: root.homeWgActionStatus } } Text { width: parent.width - text: "Connecting one disconnects the other — only one VPN tunnel at a time." + text: "Connecting one disconnects the others — only one VPN tunnel at a time." font.family: Appearance.fontFamily font.pixelSize: Appearance.fontSizeSmall color: Colors.muted diff --git a/dotfiles/.config/quickshell/scripts/CheckQuickshellSetup.sh b/dotfiles/.config/quickshell/scripts/CheckQuickshellSetup.sh index 1c8f61c..90a22a0 100755 --- a/dotfiles/.config/quickshell/scripts/CheckQuickshellSetup.sh +++ b/dotfiles/.config/quickshell/scripts/CheckQuickshellSetup.sh @@ -159,6 +159,13 @@ if [ $have_mullvad -eq 0 ] && [ $have_proton -eq 0 ]; then warn "neither Mullvad nor ProtonVPN CLI is installed — VpnIndicator.qml will show both as 'Not installed'" fi +# Keep in sync with homeWgConnection in VpnIndicator.qml. +if nmcli -g connection.type connection show unifi-home >/dev/null 2>&1; then + pass "NetworkManager profile 'unifi-home' exists — used by VpnIndicator.qml (Home/UniFi row)" +else + warn "no NetworkManager profile 'unifi-home' — import one with scripts/ImportUnifiWireguard.sh (optional)" +fi + # --- 4. VPN daemons + functional status (Mullvad/ProtonVPN/Tailscale) ------- # The exclusivity logic (VpnExclusivity.qml backing off Tailscale when a # commercial VPN connects) is pure QML and ships with the dotfiles — it diff --git a/dotfiles/.config/quickshell/scripts/ImportUnifiWireguard.sh b/dotfiles/.config/quickshell/scripts/ImportUnifiWireguard.sh new file mode 100755 index 0000000..bc39205 --- /dev/null +++ b/dotfiles/.config/quickshell/scripts/ImportUnifiWireguard.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# Imports a UniFi WireGuard VPN Server client config into NetworkManager +# under the profile name VpnIndicator.qml expects (homeWgConnection, +# "unifi-home" by default), so the bar's VPN panel can toggle it. +# +# Get the .conf from UniFi Network: Settings → VPN → VPN Server → your +# WireGuard server → Add Client → Download. +# +# Usage: ./ImportUnifiWireguard.sh [profile-name] [--replace] +# +# NetworkManager names both the profile and the interface after the file's +# basename, so the config is copied to .conf first (interface +# names max out at 15 chars). The private key ends up in a root-only file +# under /etc/NetworkManager/system-connections — delete the downloaded +# .conf afterwards. + +set -euo pipefail + +replace=0 +args=() +for a in "$@"; do + case "$a" in + --replace) replace=1 ;; + -h|--help) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) args+=("$a") ;; + esac +done + +conf="${args[0]:-}" +name="${args[1]:-unifi-home}" + +if [ -z "$conf" ] || [ ! -f "$conf" ]; then + echo "Usage: $0 [profile-name] [--replace]" >&2 + exit 1 +fi +if ! [[ "$name" =~ ^[A-Za-z0-9_-]{1,15}$ ]]; then + echo "Profile name must be 1-15 chars of [A-Za-z0-9_-] (it doubles as the interface name)" >&2 + exit 1 +fi + +if nmcli -g connection.id connection show "$name" >/dev/null 2>&1; then + if [ $replace -eq 1 ]; then + nmcli connection delete "$name" >/dev/null + else + echo "A NetworkManager profile named '$name' already exists — rerun with --replace to overwrite it" >&2 + exit 1 + fi +fi + +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT +install -m 600 "$conf" "$tmpdir/$name.conf" + +nmcli connection import type wireguard file "$tmpdir/$name.conf" +# Only ever brought up deliberately from the bar — never on boot or on +# joining a network, where it'd silently fight Tailscale/Mullvad/ProtonVPN. +nmcli connection modify "$name" connection.autoconnect no +# Import itself defaults to autoconnect=yes, so NetworkManager may already +# have started bringing it up in the moment before the modify above. +nmcli connection down "$name" >/dev/null 2>&1 || true + +echo +echo "Imported as '$name'. Toggle it from the VPN panel, or: nmcli connection up $name" +echo "You can now delete $conf"