Feature: Add home UniFi WireGuard tunnel to VPN panel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,17 @@
|
|||||||
pragma Singleton
|
pragma Singleton
|
||||||
import QtQuick
|
import QtQuick
|
||||||
|
|
||||||
// Tailscale, Mullvad, and ProtonVPN all fight over the default route/DNS if
|
// Tailscale, Mullvad, ProtonVPN, and the home UniFi WireGuard tunnel all
|
||||||
// more than one is active at once (see VpnIndicator.qml's header comment) —
|
// fight over the default route/DNS if more than one is active at once (see
|
||||||
// only one of the three should ever actually be connected. This singleton
|
// VpnIndicator.qml's header comment) — only one should ever actually be
|
||||||
// is the shared signal between the two widget files: VpnIndicator.qml
|
// connected. This singleton is the shared signal between the two widget
|
||||||
// writes Mullvad/ProtonVPN's live connection state here as it polls them;
|
// files: VpnIndicator.qml writes each tunnel's live connection state here
|
||||||
// TailscaleIndicator.qml reads it to back off (`tailscale down`) rather
|
// as it polls them; TailscaleIndicator.qml reads it to back off (`tailscale
|
||||||
// than stay up alongside either one, and to refuse turning Tailscale back
|
// down`) rather than stay up alongside one, and to refuse turning Tailscale
|
||||||
// on while one is still connected.
|
// back on while one is still connected.
|
||||||
QtObject {
|
QtObject {
|
||||||
property bool mullvadConnected: false
|
property bool mullvadConnected: false
|
||||||
property bool protonConnected: false
|
property bool protonConnected: false
|
||||||
readonly property bool commercialVpnActive: mullvadConnected || protonConnected
|
property bool homeWgConnected: false
|
||||||
|
readonly property bool commercialVpnActive: mullvadConnected || protonConnected || homeWgConnected
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ Item {
|
|||||||
|
|
||||||
// Set when the VPN-exclusivity guard (below) forces Tailscale down —
|
// Set when the VPN-exclusivity guard (below) forces Tailscale down —
|
||||||
// as opposed to you turning it off yourself — so it knows to bring
|
// 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".
|
// without overriding a deliberate manual "off".
|
||||||
property bool _forcedDownByVpn: false
|
property bool _forcedDownByVpn: false
|
||||||
|
|
||||||
@@ -204,7 +204,7 @@ Item {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (VpnExclusivity.commercialVpnActive) {
|
if (VpnExclusivity.commercialVpnActive) {
|
||||||
root.actionStatus = "Disconnect Mullvad/ProtonVPN first"
|
root.actionStatus = "Disconnect the active VPN first"
|
||||||
actionStatusTimer.restart()
|
actionStatusTimer.restart()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ import "../../config"
|
|||||||
// - ProtonVPN: official `protonvpn` CLI (proton-vpn-cli package), plain-
|
// - ProtonVPN: official `protonvpn` CLI (proton-vpn-cli package), plain-
|
||||||
// text status only — parsed defensively since its exact wording isn't
|
// text status only — parsed defensively since its exact wording isn't
|
||||||
// guaranteed across versions.
|
// 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 {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
implicitWidth: label.implicitWidth + 4
|
implicitWidth: label.implicitWidth + 4
|
||||||
@@ -39,9 +43,17 @@ Item {
|
|||||||
property string protonState: "disconnected"
|
property string protonState: "disconnected"
|
||||||
property string protonDetail: ""
|
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.
|
// to connect once the current one has finished disconnecting.
|
||||||
property string pendingConnect: ""
|
property string pendingConnect: ""
|
||||||
|
|
||||||
@@ -51,6 +63,7 @@ Item {
|
|||||||
// show up hanging under the other's row.
|
// show up hanging under the other's row.
|
||||||
property string mullvadActionStatus: ""
|
property string mullvadActionStatus: ""
|
||||||
property string protonActionStatus: ""
|
property string protonActionStatus: ""
|
||||||
|
property string homeWgActionStatus: ""
|
||||||
|
|
||||||
// --- Mullvad advanced options (auto-connect, relay location, multihop) --
|
// --- Mullvad advanced options (auto-connect, relay location, multihop) --
|
||||||
// ProtonVPN's CLI is plain-text-only and far less predictable across
|
// ProtonVPN's CLI is plain-text-only and far less predictable across
|
||||||
@@ -114,6 +127,20 @@ Item {
|
|||||||
VpnExclusivity.protonConnected = root.protonState === "connected"
|
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) {
|
function applyMullvadAutoConnect(text) {
|
||||||
const m = String(text || "").match(/autoconnect:\s*(on|off)/i)
|
const m = String(text || "").match(/autoconnect:\s*(on|off)/i)
|
||||||
root.mullvadAutoConnect = !!m && m[1].toLowerCase() === "on"
|
root.mullvadAutoConnect = !!m && m[1].toLowerCase() === "on"
|
||||||
@@ -225,33 +252,61 @@ Item {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refreshHomeWg() {
|
||||||
|
if (!homeWgStatusProc.running) homeWgStatusProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
function refresh() {
|
function refresh() {
|
||||||
root.refreshMullvad()
|
root.refreshMullvad()
|
||||||
root.refreshProton()
|
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() {
|
function connectMullvad() {
|
||||||
if (mullvadConnectProc.running || mullvadDisconnectProc.running) return
|
if (mullvadConnectProc.running || mullvadDisconnectProc.running) return
|
||||||
if (root.protonState !== "disconnected") {
|
if (root.stopOthers("mullvad")) { root.pendingConnect = "mullvad"; return }
|
||||||
root.pendingConnect = "mullvad"
|
|
||||||
root.disconnectProton()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
root.pendingConnect = ""
|
root.pendingConnect = ""
|
||||||
mullvadConnectProc.running = true
|
mullvadConnectProc.running = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function connectProton() {
|
function connectProton() {
|
||||||
if (protonConnectProc.running || protonDisconnectProc.running) return
|
if (protonConnectProc.running || protonDisconnectProc.running) return
|
||||||
if (root.mullvadState !== "disconnected") {
|
if (root.stopOthers("protonvpn")) { root.pendingConnect = "protonvpn"; return }
|
||||||
root.pendingConnect = "protonvpn"
|
|
||||||
root.disconnectMullvad()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
root.pendingConnect = ""
|
root.pendingConnect = ""
|
||||||
protonConnectProc.running = true
|
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() {
|
function disconnectMullvad() {
|
||||||
if (mullvadDisconnectProc.running) return
|
if (mullvadDisconnectProc.running) return
|
||||||
mullvadDisconnectProc.running = true
|
mullvadDisconnectProc.running = true
|
||||||
@@ -262,6 +317,12 @@ Item {
|
|||||||
protonDisconnectProc.running = true
|
protonDisconnectProc.running = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function disconnectHomeWg() {
|
||||||
|
if (homeWgDisconnectProc.running) return
|
||||||
|
root.homeWgState = "disconnecting"
|
||||||
|
homeWgDisconnectProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
function toggleMullvad() {
|
function toggleMullvad() {
|
||||||
if (mullvadConnectProc.running || mullvadDisconnectProc.running) return
|
if (mullvadConnectProc.running || mullvadDisconnectProc.running) return
|
||||||
if (root.mullvadState === "connected") root.disconnectMullvad()
|
if (root.mullvadState === "connected") root.disconnectMullvad()
|
||||||
@@ -274,7 +335,13 @@ Item {
|
|||||||
else root.connectProton()
|
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
|
// this right after either one finishes connecting — a harmless no-op
|
||||||
// if Tailscale was already down. TailscaleIndicator.qml also backs
|
// if Tailscale was already down. TailscaleIndicator.qml also backs
|
||||||
// itself off reactively (via VpnExclusivity) as a backstop, but doing
|
// itself off reactively (via VpnExclusivity) as a backstop, but doing
|
||||||
@@ -336,10 +403,11 @@ Item {
|
|||||||
if (exitCode !== 0) {
|
if (exitCode !== 0) {
|
||||||
root.mullvadActionStatus = root.shortLine(mullvadDisconnectErr.text || "Disconnect failed")
|
root.mullvadActionStatus = root.shortLine(mullvadDisconnectErr.text || "Disconnect failed")
|
||||||
mullvadActionStatusTimer.restart()
|
mullvadActionStatusTimer.restart()
|
||||||
|
} else {
|
||||||
|
root.mullvadState = "disconnected"
|
||||||
}
|
}
|
||||||
root.refreshMullvad()
|
root.refreshMullvad()
|
||||||
if (root.pendingConnect === "mullvad") { root.pendingConnect = ""; root.connectMullvad() }
|
root.resumePendingConnect()
|
||||||
else if (root.pendingConnect === "protonvpn") { root.pendingConnect = ""; root.connectProton() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,10 +539,11 @@ Item {
|
|||||||
if (exitCode !== 0) {
|
if (exitCode !== 0) {
|
||||||
root.protonActionStatus = root.shortLine(protonDisconnectErr.text || "Disconnect failed")
|
root.protonActionStatus = root.shortLine(protonDisconnectErr.text || "Disconnect failed")
|
||||||
protonActionStatusTimer.restart()
|
protonActionStatusTimer.restart()
|
||||||
|
} else {
|
||||||
|
root.protonState = "disconnected"
|
||||||
}
|
}
|
||||||
root.refreshProton()
|
root.refreshProton()
|
||||||
if (root.pendingConnect === "mullvad") { root.pendingConnect = ""; root.connectMullvad() }
|
root.resumePendingConnect()
|
||||||
else if (root.pendingConnect === "protonvpn") { root.pendingConnect = ""; root.connectProton() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,6 +554,58 @@ Item {
|
|||||||
onTriggered: if (protonStatusProc.running) protonStatusProc.running = false
|
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 -----------------------------------------------------
|
// --- Shared timers -----------------------------------------------------
|
||||||
|
|
||||||
Timer {
|
Timer {
|
||||||
@@ -510,6 +631,13 @@ Item {
|
|||||||
onTriggered: root.protonActionStatus = ""
|
onTriggered: root.protonActionStatus = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: homeWgActionStatusTimer
|
||||||
|
interval: 4000
|
||||||
|
repeat: false
|
||||||
|
onTriggered: root.homeWgActionStatus = ""
|
||||||
|
}
|
||||||
|
|
||||||
// --- Bar icon ------------------------------------------------------------
|
// --- Bar icon ------------------------------------------------------------
|
||||||
|
|
||||||
// A fixed, wallpaper-independent green rather than Colors.primary — the
|
// A fixed, wallpaper-independent green rather than Colors.primary — the
|
||||||
@@ -615,6 +743,7 @@ Item {
|
|||||||
property string detailText: ""
|
property string detailText: ""
|
||||||
property string state: "disconnected"
|
property string state: "disconnected"
|
||||||
property bool installed: true
|
property bool installed: true
|
||||||
|
property string missingText: "Not installed"
|
||||||
// When true, shows a chevron that expands a provider-specific
|
// When true, shows a chevron that expands a provider-specific
|
||||||
// options section below this row (used for Mullvad's advanced
|
// options section below this row (used for Mullvad's advanced
|
||||||
// options; ProtonVPN's row leaves this false).
|
// options; ProtonVPN's row leaves this false).
|
||||||
@@ -664,7 +793,7 @@ Item {
|
|||||||
}
|
}
|
||||||
Text {
|
Text {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
text: !provRow.installed ? "Not installed"
|
text: !provRow.installed ? provRow.missingText
|
||||||
: provRow.state === "connecting" ? "Connecting…"
|
: provRow.state === "connecting" ? "Connecting…"
|
||||||
: provRow.state === "disconnecting" ? "Disconnecting…"
|
: provRow.state === "disconnecting" ? "Disconnecting…"
|
||||||
: provRow.state === "connected" ? (provRow.detailText || "Connected")
|
: provRow.state === "connected" ? (provRow.detailText || "Connected")
|
||||||
@@ -1052,11 +1181,23 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
StatusLine { message: root.protonActionStatus }
|
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 {
|
Text {
|
||||||
width: parent.width
|
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.family: Appearance.fontFamily
|
||||||
font.pixelSize: Appearance.fontSizeSmall
|
font.pixelSize: Appearance.fontSizeSmall
|
||||||
color: Colors.muted
|
color: Colors.muted
|
||||||
|
|||||||
@@ -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'"
|
warn "neither Mullvad nor ProtonVPN CLI is installed — VpnIndicator.qml will show both as 'Not installed'"
|
||||||
fi
|
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) -------
|
# --- 4. VPN daemons + functional status (Mullvad/ProtonVPN/Tailscale) -------
|
||||||
# The exclusivity logic (VpnExclusivity.qml backing off Tailscale when a
|
# The exclusivity logic (VpnExclusivity.qml backing off Tailscale when a
|
||||||
# commercial VPN connects) is pure QML and ships with the dotfiles — it
|
# commercial VPN connects) is pure QML and ships with the dotfiles — it
|
||||||
|
|||||||
64
dotfiles/.config/quickshell/scripts/ImportUnifiWireguard.sh
Executable file
64
dotfiles/.config/quickshell/scripts/ImportUnifiWireguard.sh
Executable 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"
|
||||||
Reference in New Issue
Block a user