Feature: Add home UniFi WireGuard tunnel to VPN panel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 20:40:23 +01:00
parent 54b91a72e2
commit 8d73d2a37e
5 changed files with 243 additions and 30 deletions

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 <name>` 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

View File

@@ -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

View File

@@ -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 <client.conf> [profile-name] [--replace]
#
# NetworkManager names both the profile and the interface after the file's
# basename, so the config is copied to <profile-name>.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 <client.conf> [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"