484 lines
16 KiB
QML
484 lines
16 KiB
QML
pragma Singleton
|
|
|
|
import QtQuick
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import Caelestia
|
|
import Caelestia.Config
|
|
|
|
Singleton {
|
|
id: root
|
|
|
|
property bool connected: false
|
|
property var status: ({
|
|
connected: false,
|
|
state: "disconnected",
|
|
reason: "",
|
|
authUrl: ""
|
|
})
|
|
|
|
readonly property bool connecting: connectProc.running || disconnectProc.running
|
|
readonly property bool enabled: GlobalConfig.utilities.vpn.provider.some(p => typeof p === "object" ? (p.enabled === true) : false)
|
|
readonly property var providerInput: {
|
|
const enabledProvider = GlobalConfig.utilities.vpn.provider.find(p => typeof p === "object" ? (p.enabled === true) : false);
|
|
return enabledProvider || "wireguard";
|
|
}
|
|
readonly property bool isCustomProvider: typeof providerInput === "object"
|
|
readonly property string providerName: isCustomProvider ? (providerInput.name || "custom") : String(providerInput)
|
|
readonly property string interfaceName: isCustomProvider ? (providerInput.interface || "") : ""
|
|
readonly property var currentConfig: {
|
|
const name = providerName;
|
|
const iface = interfaceName;
|
|
const defaults = getBuiltinDefaults(name, iface);
|
|
|
|
if (isCustomProvider) {
|
|
const custom = providerInput;
|
|
return {
|
|
connectCmd: custom.connectCmd || defaults.connectCmd,
|
|
disconnectCmd: custom.disconnectCmd || defaults.disconnectCmd,
|
|
interface: custom.interface || defaults.interface,
|
|
displayName: custom.displayName || defaults.displayName
|
|
};
|
|
}
|
|
|
|
return defaults;
|
|
}
|
|
|
|
function getBuiltinDefaults(name, iface) {
|
|
const builtins = {
|
|
"wireguard": {
|
|
connectCmd: ["pkexec", "wg-quick", "up", iface],
|
|
disconnectCmd: ["pkexec", "wg-quick", "down", iface],
|
|
interface: iface,
|
|
displayName: iface
|
|
},
|
|
"warp": {
|
|
connectCmd: ["warp-cli", "connect"],
|
|
disconnectCmd: ["warp-cli", "disconnect"],
|
|
interface: "CloudflareWARP",
|
|
displayName: "Warp"
|
|
},
|
|
"netbird": {
|
|
connectCmd: ["netbird", "up", "--no-browser"],
|
|
disconnectCmd: ["netbird", "down"],
|
|
interface: "wt0",
|
|
displayName: "NetBird"
|
|
},
|
|
"tailscale": {
|
|
connectCmd: ["tailscale", "up"],
|
|
disconnectCmd: ["tailscale", "down"],
|
|
interface: "tailscale0",
|
|
displayName: "Tailscale"
|
|
}
|
|
};
|
|
|
|
return builtins[name] || {
|
|
connectCmd: [name, "up"],
|
|
disconnectCmd: [name, "down"],
|
|
interface: iface || name,
|
|
displayName: name
|
|
};
|
|
}
|
|
|
|
function connect(): void {
|
|
if (status.state === "needs-auth" && status.authUrl) {
|
|
emitStatusToast(status);
|
|
return;
|
|
}
|
|
if (!connected && !connecting && root.currentConfig && root.currentConfig.connectCmd) {
|
|
connectProc.exec(root.currentConfig.connectCmd);
|
|
}
|
|
}
|
|
|
|
function disconnect(): void {
|
|
if (connected && !connecting && root.currentConfig && root.currentConfig.disconnectCmd) {
|
|
disconnectProc.exec(root.currentConfig.disconnectCmd);
|
|
}
|
|
}
|
|
|
|
function toggle(): void {
|
|
connected ? disconnect() : connect();
|
|
}
|
|
|
|
function checkStatus(): void {
|
|
if (root.enabled) {
|
|
statusProc.running = true;
|
|
}
|
|
}
|
|
|
|
function getStatusCommand(): var {
|
|
switch (providerName) {
|
|
case "tailscale":
|
|
return ["tailscale", "status", "--json"];
|
|
case "netbird":
|
|
return ["netbird", "status", "--json"];
|
|
case "warp":
|
|
return ["warp-cli", "status"];
|
|
case "wireguard":
|
|
return ["ip", "link", "show"];
|
|
default:
|
|
return ["ip", "link", "show"];
|
|
}
|
|
}
|
|
|
|
function parseTailscaleStatus(output: string): var {
|
|
const status = {
|
|
connected: false,
|
|
state: "disconnected",
|
|
reason: "",
|
|
authUrl: ""
|
|
};
|
|
|
|
// Handle empty or whitespace-only output
|
|
if (!output || output.trim().length === 0) {
|
|
return status;
|
|
}
|
|
|
|
// Check for common non-JSON states first
|
|
if (output.includes("Logged out") || output.includes("Stopped") || output.includes("not running") || output.includes("Tailscale is not running")) {
|
|
status.state = "disconnected";
|
|
return status;
|
|
}
|
|
|
|
// Try to parse as JSON
|
|
try {
|
|
const data = JSON.parse(output);
|
|
const backendState = data.BackendState || "";
|
|
|
|
if (backendState === "Running") {
|
|
status.connected = true;
|
|
status.state = "connected";
|
|
} else if (backendState === "Starting") {
|
|
status.state = "connecting";
|
|
} else if (backendState === "NeedsLogin" || backendState === "NeedsMachineAuth") {
|
|
status.state = "needs-auth";
|
|
status.reason = backendState === "NeedsLogin" ? "Login required" : "Machine authorization required";
|
|
status.authUrl = data.AuthURL || "";
|
|
}
|
|
} catch (e) {
|
|
// JSON parsing failed - treat as disconnected unless it looks like an error
|
|
if (output.includes("error") || output.includes("Error") || output.includes("failed")) {
|
|
status.state = "disconnected";
|
|
status.reason = "Tailscale may not be running";
|
|
} else {
|
|
status.state = "disconnected";
|
|
}
|
|
}
|
|
return status;
|
|
}
|
|
|
|
function parseNetBirdStatus(output: string): var {
|
|
const status = {
|
|
connected: false,
|
|
state: "disconnected",
|
|
reason: "",
|
|
authUrl: ""
|
|
};
|
|
try {
|
|
const data = JSON.parse(output);
|
|
const mgmtConnected = data.management?.connected;
|
|
const signalConnected = data.signal?.connected;
|
|
|
|
if (mgmtConnected && signalConnected) {
|
|
status.connected = true;
|
|
status.state = "connected";
|
|
} else if (data.management?.error) {
|
|
const error = data.management.error;
|
|
if (error.includes("auth") || error.includes("login")) {
|
|
status.state = "needs-auth";
|
|
status.reason = "Authentication required";
|
|
} else {
|
|
status.reason = error;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
status.state = "error";
|
|
status.reason = "Failed to parse status";
|
|
}
|
|
return status;
|
|
}
|
|
|
|
function parseWarpStatus(output: string): var {
|
|
const status = {
|
|
connected: false,
|
|
state: "disconnected",
|
|
reason: "",
|
|
authUrl: ""
|
|
};
|
|
|
|
if (output.includes("Connected")) {
|
|
status.connected = true;
|
|
status.state = "connected";
|
|
} else if (output.includes("Connecting")) {
|
|
status.state = "connecting";
|
|
} else if (output.includes("Unable") || output.includes("Registration Missing") || output.includes("registration") || output.includes("register")) {
|
|
status.state = "needs-auth";
|
|
status.reason = "WARP registration required";
|
|
} else if (!output.includes("Disconnected")) {
|
|
status.state = "error";
|
|
status.reason = "Unknown WARP status";
|
|
}
|
|
return status;
|
|
}
|
|
|
|
function parseWireGuardStatus(output: string): var {
|
|
const status = {
|
|
connected: false,
|
|
state: "disconnected",
|
|
reason: "",
|
|
authUrl: ""
|
|
};
|
|
const iface = root.currentConfig?.interface || "";
|
|
|
|
if (iface && output.includes(iface + ":")) {
|
|
status.connected = true;
|
|
status.state = "connected";
|
|
}
|
|
return status;
|
|
}
|
|
|
|
function parseStatusOutput(output: string): var {
|
|
switch (providerName) {
|
|
case "tailscale":
|
|
return parseTailscaleStatus(output);
|
|
case "netbird":
|
|
return parseNetBirdStatus(output);
|
|
case "warp":
|
|
return parseWarpStatus(output);
|
|
case "wireguard":
|
|
default:
|
|
return parseWireGuardStatus(output);
|
|
}
|
|
}
|
|
|
|
function extractAuthUrl(text: string): string {
|
|
const urlMatch = text.match(/(https?:\/\/[^\s]+)/);
|
|
return urlMatch ? urlMatch[1] : "";
|
|
}
|
|
|
|
function createAuthStatus(authUrl: string): var {
|
|
return {
|
|
connected: false,
|
|
state: "needs-auth",
|
|
reason: "Authentication required",
|
|
authUrl: authUrl
|
|
};
|
|
}
|
|
|
|
function updateStatus(newStatus: var): void {
|
|
const oldState = status.state;
|
|
if (newStatus.state === "needs-auth" && !newStatus.authUrl && status.authUrl) {
|
|
newStatus.authUrl = status.authUrl;
|
|
}
|
|
status = newStatus;
|
|
root.connected = newStatus.connected;
|
|
|
|
if (oldState !== newStatus.state) {
|
|
emitStatusToast(newStatus);
|
|
}
|
|
}
|
|
|
|
function emitStatusToast(statusObj: var): void {
|
|
if (!GlobalConfig.utilities.toasts.vpnChanged)
|
|
return;
|
|
|
|
const displayName = root.currentConfig ? (root.currentConfig.displayName || "VPN") : "VPN";
|
|
|
|
switch (statusObj.state) {
|
|
case "connected":
|
|
Toaster.toast(qsTr("VPN connected"), qsTr("Connected to %1").arg(displayName), "vpn_key");
|
|
break;
|
|
case "disconnected":
|
|
Toaster.toast(qsTr("VPN disconnected"), qsTr("Disconnected from %1").arg(displayName), "vpn_key_off");
|
|
break;
|
|
case "needs-auth":
|
|
const authMsg = statusObj.reason || "Authentication required";
|
|
Toaster.toast(qsTr("VPN authentication required"), qsTr("%1: %2").arg(displayName).arg(authMsg), "vpn_lock");
|
|
break;
|
|
case "error":
|
|
if (status.state === "connected" || status.state === "connecting" || status.state === "needs-auth") {
|
|
const errMsg = statusObj.reason || "Unknown error";
|
|
Toaster.toast(qsTr("VPN error"), qsTr("%1: %2").arg(displayName).arg(errMsg), "error");
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
onStatusChanged: {
|
|
if (providerName === "warp" && status.state === "needs-auth" && status.reason.includes("registration")) {
|
|
warpRegisterProc.exec(["warp-cli", "registration", "new"]);
|
|
}
|
|
}
|
|
|
|
onProviderNameChanged: {
|
|
status = {
|
|
connected: false,
|
|
state: "disconnected",
|
|
reason: "",
|
|
authUrl: ""
|
|
};
|
|
root.connected = false;
|
|
statusCheckTimer.start();
|
|
}
|
|
|
|
Component.onCompleted: root.enabled && statusCheckTimer.start()
|
|
|
|
Process {
|
|
id: nmMonitor
|
|
|
|
running: root.enabled
|
|
command: ["nmcli", "monitor"]
|
|
stdout: SplitParser {
|
|
onRead: statusCheckTimer.restart()
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: statusProc
|
|
|
|
command: root.getStatusCommand()
|
|
// qmllint disable incompatible-type
|
|
environment: ({
|
|
// qmllint enable incompatible-type
|
|
LANG: "C.UTF-8",
|
|
LC_ALL: "C.UTF-8"
|
|
})
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
const newStatus = root.parseStatusOutput(text);
|
|
root.updateStatus(newStatus);
|
|
}
|
|
}
|
|
stderr: StdioCollector {
|
|
onStreamFinished: {
|
|
if (text.trim().length > 0) {
|
|
if (text.includes("doesn't appear to be running") || text.includes("failed to connect to local tailscaled") || text.includes("daemon is not running") || text.includes("not running") && (text.includes("netbird") || text.includes("warp"))) {
|
|
let cmd = "sudo systemctl start ";
|
|
switch (root.providerName) {
|
|
case "tailscale":
|
|
cmd += "tailscaled";
|
|
break;
|
|
case "netbird":
|
|
cmd += "netbird";
|
|
break;
|
|
case "warp":
|
|
cmd += "warp-svc";
|
|
break;
|
|
default:
|
|
cmd += root.providerName + "d";
|
|
break;
|
|
}
|
|
const errorStatus = {
|
|
connected: false,
|
|
state: "disconnected",
|
|
reason: `Service not running (run: ${cmd})`,
|
|
authUrl: ""
|
|
};
|
|
root.updateStatus(errorStatus);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: connectProc
|
|
|
|
onExited: exitCode => { // qmllint disable signal-handler-parameters
|
|
if (exitCode !== 0) {
|
|
return;
|
|
}
|
|
|
|
if (root.providerName === "tailscale") {
|
|
Qt.callLater(() => {
|
|
if (root.status.state !== "needs-auth") {
|
|
statusCheckTimer.start();
|
|
}
|
|
});
|
|
} else if (root.status.state !== "needs-auth") {
|
|
statusCheckTimer.start();
|
|
}
|
|
}
|
|
stdout: SplitParser {
|
|
onRead: data => {
|
|
const authUrl = root.extractAuthUrl(data);
|
|
if (authUrl) {
|
|
root.updateStatus(root.createAuthStatus(authUrl));
|
|
}
|
|
}
|
|
}
|
|
stderr: StdioCollector {
|
|
onStreamFinished: {
|
|
const error = text.trim();
|
|
|
|
if (error.includes("Access denied") || error.includes("checkprefs access denied")) {
|
|
const errorStatus = {
|
|
connected: false,
|
|
state: "disconnected",
|
|
reason: "Permission denied. Run in terminal: sudo tailscale set --operator=$USER",
|
|
authUrl: ""
|
|
};
|
|
root.updateStatus(errorStatus);
|
|
return;
|
|
}
|
|
|
|
if (error.includes("Unknown device type") || error.includes("Protocol not supported")) {
|
|
const errorStatus = {
|
|
connected: false,
|
|
state: "disconnected",
|
|
reason: "WireGuard module not loaded. Run: sudo modprobe wireguard",
|
|
authUrl: ""
|
|
};
|
|
root.updateStatus(errorStatus);
|
|
return;
|
|
}
|
|
|
|
const authUrl = root.extractAuthUrl(error);
|
|
|
|
if (authUrl) {
|
|
root.updateStatus(root.createAuthStatus(authUrl));
|
|
} else if (error.includes("already exists")) {
|
|
root.connected = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: disconnectProc
|
|
|
|
onExited: statusCheckTimer.start() // qmllint disable signal-handler-parameters
|
|
stderr: StdioCollector {
|
|
onStreamFinished: {
|
|
const error = text.trim();
|
|
if (error && !error.includes("[#]")) {
|
|
console.warn(lc, "Disconnection error:", error);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: warpRegisterProc
|
|
|
|
onExited: exitCode => { // qmllint disable signal-handler-parameters
|
|
if (exitCode === 0) {
|
|
statusCheckTimer.start();
|
|
}
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
id: statusCheckTimer
|
|
|
|
interval: 500
|
|
onTriggered: root.checkStatus()
|
|
}
|
|
|
|
LoggingCategory {
|
|
id: lc
|
|
|
|
name: "caelestia.qml.services.vpn"
|
|
defaultLogLevel: LoggingCategory.Info
|
|
}
|
|
}
|