diff --git a/vpn-manager/README.md b/vpn-manager/README.md new file mode 100644 index 00000000..d7f78898 --- /dev/null +++ b/vpn-manager/README.md @@ -0,0 +1,170 @@ +# VPN Manager + +A NetworkManager VPN front end for Noctalia: connect, disconnect, import and +auto-switch VPN connections of any protocol NetworkManager supports, from a +bar widget and panel, the same way `nm-applet`/`nmtui` do. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `andrewdems/vpn-manager` | +| Entries | Service: `service`; bar widget: `bar`; panel: `panel`; shortcut: `toggle`; launcher: `provider` | +| Launcher Prefix | `/vpn` | + +## Requirements + +NetworkManager, providing `nmcli` on `PATH`. Any VPN protocol you want to use +also needs its NetworkManager VPN plugin installed - see **How it works** below. + +**Edit connection** opens `nm-connection-editor` when it is installed, and falls +back to `nmtui-edit` in a terminal otherwise. Neither is required: without them +the button just reports that no editor was found. + +The file picker used by **Add VPN** is `kdialog`, falling back to `zenity`. +Neither is required either - without them you can still type a path or paste the +configuration text. + +## How it works + +This plugin does not implement any VPN protocol itself. It drives `nmcli`, +so it supports whatever protocols your installed NetworkManager VPN plugins +support: OpenVPN, WireGuard (built into NetworkManager), IPsec/IKEv2 +(strongSwan), L2TP/IPsec, PPTP, SSTP, Cisco AnyConnect (OpenConnect) and +Cisco legacy VPN (vpnc). Any VPN connection already configured with +`nmtui`, `nm-connection-editor` or `nmcli` shows up in the panel +automatically. + +Install the matching package to add a protocol, e.g. on Arch: + +```sh +sudo pacman -S networkmanager-openvpn # OpenVPN +sudo pacman -S networkmanager-strongswan # IPsec / IKEv2 +sudo pacman -S networkmanager-l2tp # L2TP/IPsec +sudo pacman -S networkmanager-pptp # PPTP +sudo pacman -S networkmanager-openconnect # Cisco AnyConnect +sudo pacman -S networkmanager-vpnc # Cisco legacy VPN +sudo pacman -S networkmanager-sstp # SSTP +``` + +WireGuard needs nothing extra; NetworkManager 1.16+ supports it natively. + +## Usage + +Add the **VPN Manager** widget under Settings → Bar (or add +`andrewdems/vpn-manager:bar` to a bar's widget list in `config.toml`), and/or +add the `toggle` shortcut to the control center. Click the bar widget to +open the panel. + +In the panel: + +- **Connections** - toggle to connect/disconnect, star to mark as the + auto-connect default, the info button to expand the connection's details, and + trash to delete (which asks first). +- **Connection details** - expanding an active connection shows its state, + device, IPv4/IPv6 address and gateway, read live from `nmcli`. An inactive + connection has none of that yet, so it says so. **Edit connection** opens the + connection in a full editor. +- **Add VPN** - import a `.ovpn`/WireGuard/vpnc/OpenConnect config. The folder + button opens a graphical file picker (`kdialog`, or `zenity`) and imports + whatever you choose; you can also type a path or paste the config text. PPTP, + L2TP/IPsec and IKEv2 generally need fields typed in rather than a file - + create those with `nmtui` and they'll appear in the list automatically. + + Opening the picker closes the panel. That is deliberate: the picker is a + separate window, and a panel left open underneath it holds a layer-shell grab + that would leave the picker visible but unclickable. The file you choose is + imported on its own, and appears in the connection list next time you open + the panel. If the import fails, the panel reopens on this form with the + reason and the file that failed, so you can pick a protocol explicitly and + retry. +- **Trusted networks** - list of Wi-Fi SSIDs / Ethernet connection names + where the VPN should stay off. One-click `Trust ''` buttons appear + for whatever network you're currently on. + +Open or close the panel with: + +```sh +noctalia msg panel-toggle andrewdems/vpn-manager:panel +``` + +Typing `/vpn` in the launcher offers "Open VPN Manager" plus a +connect/disconnect entry for every configured connection. + +## Auto-connect on untrusted networks + +Turn on **Auto-connect on untrusted networks** in the plugin's settings +(gear icon in the panel, or Settings → Plugins → VPN Manager) and star a +connection to make it the default. Whenever the active Wi-Fi/Ethernet +network isn't on your trusted list for `stable_checks` consecutive polls, +that VPN is connected automatically; when you're back on a trusted network, +it's disconnected automatically (only if this plugin was the one that +started it - a VPN you connected by hand is left alone). + +Wired Ethernet is trusted by default (**Treat any wired connection as +trusted**); turn that off if you plug into untrusted wired networks too. + +## Settings + +| Setting | Type | Default | Description | +| --- | --- | --- | --- | +| `auto_connect_enabled` | `bool` | `false` | Auto-connect the default VPN on untrusted networks. | +| `auto_disconnect_on_trusted` | `bool` | `true` | Auto-disconnect a plugin-started VPN on trusted networks. | +| `trust_all_ethernet` | `bool` | `true` | Treat any wired connection as trusted. | +| `notify_on_autoswitch` | `bool` | `true` | Notify on automatic connect/disconnect. | +| `poll_interval_seconds` | `int` | `5` | How often to check VPN/network status. | +| `stable_checks` | `int` | `3` | Consecutive checks required before switching (debounce). | + +## IPC + +```sh +noctalia msg panel-toggle andrewdems/vpn-manager:panel # open or close the panel +noctalia msg plugin andrewdems/vpn-manager:service all refresh # re-poll NetworkManager now +noctalia msg plugin andrewdems/vpn-manager:service all status # notify with the active VPNs +noctalia msg plugin andrewdems/vpn-manager:service all connect # connect one VPN by UUID +noctalia msg plugin andrewdems/vpn-manager:service all disconnect # disconnect one VPN by UUID +noctalia msg plugin andrewdems/vpn-manager:service all import # import a config file +noctalia msg plugin andrewdems/vpn-manager:service all pick # open a file picker and import what is chosen +noctalia msg plugin andrewdems/vpn-manager:service all dismiss-error # clear a stuck import-failure banner +noctalia msg plugin andrewdems/vpn-manager:service all list # log the known connections +``` + +UUIDs are the ones `nmcli connection show` prints. + +## Notes + +- Secrets (passwords, PSKs) are never touched by this plugin - it only + passes UUIDs to `nmcli`. A connection whose password is marked *agent-owned* + (`password-flags = 1`) needs a NetworkManager **secret agent** to supply it; + without one, NetworkManager reports `No valid secrets` and activation fails + before the VPN process starts. Desktop environments ship an agent + (`nm-applet`, GNOME Shell, plasma-nm); a bare Wayland session often has none. + + When a connect fails that way, this plugin reruns it as + `nmcli --ask connection up uuid ` in a terminal so you can type the + password. Auto-connect never does this - it will not seize a terminal + unprompted - and reports the problem instead. + + To stop being asked at all, store the password in the connection: + + ```sh + nmcli connection modify vpn.secrets password= vpn.data password-flags=0 + ``` + + NetworkManager then keeps it in the connection file (root-readable only) and + the VPN connects headlessly from then on. +- Pasted/imported config text is written to a temporary file under the + plugin's data directory only for the duration of the `nmcli connection + import` call, then deleted immediately (success or failure) so secrets + don't linger on disk outside NetworkManager's own connection storage. +- Newly imported connections have NetworkManager's own `autoconnect` turned + off, since this plugin (when enabled) is what decides when the VPN comes + up, based on trusted networks rather than "device available". +- Trusted networks, the default VPN, and which VPN this plugin auto-started + are stored under `noctalia.pluginDataDir()`, not in `plugin.toml` settings, + since they're runtime lists edited from the panel. + +## AI assistance + +This plugin, and this README, were written with AI assistance. I run it on my +own desktop and tested what shipped. diff --git a/vpn-manager/bar.luau b/vpn-manager/bar.luau new file mode 100644 index 00000000..211fded7 --- /dev/null +++ b/vpn-manager/bar.luau @@ -0,0 +1,37 @@ +--!nonstrict +-- VPN Manager bar widget: shield glyph that reflects connection state, click +-- opens the panel. + +local connected = false +local activeNames = {} + +local function render() + barWidget.setGlyph(connected and "shield-lock-filled" or "shield-lock") + if connected then + barWidget.setColor("primary") + else + barWidget.setColor("on_surface_variant") + end + if connected and #activeNames > 0 then + barWidget.setTooltip(noctalia.tr("tooltip.connected", { names = table.concat(activeNames, ", ") })) + else + barWidget.setTooltip(noctalia.tr("tooltip.disconnected")) + end +end + +noctalia.state.watch("vpn.status", function(status) + if type(status) == "table" then + connected = status.anyActive == true + activeNames = type(status.activeNames) == "table" and status.activeNames or {} + else + connected = false + activeNames = {} + end + render() +end) + +function onClick() + noctalia.togglePanel("andrewdems/vpn-manager:panel") +end + +render() diff --git a/vpn-manager/launcher.luau b/vpn-manager/launcher.luau new file mode 100644 index 00000000..69420091 --- /dev/null +++ b/vpn-manager/launcher.luau @@ -0,0 +1,78 @@ +--!nonstrict +-- VPN Manager launcher provider ("/vpn"): opens the panel, or jumps straight +-- to connecting/disconnecting a VPN. Read-only like the panel/bar - it only +-- reads vpn.connections and, to act, writes the same vpn.cmd command channel +-- service.luau already handles. + +local OPEN_ID = "__open" + +local function findRow(connections, uuid) + for _, row in ipairs(connections) do + if row.uuid == uuid then + return row + end + end + return nil +end + +local function buildResults(query) + local connections = noctalia.state.get("vpn.connections") or {} + local results = {} + + local openScore = 0 + if query ~= "" then + openScore = noctalia.fuzzyScore(query, noctalia.tr("launcher.open_title")) + end + if openScore ~= nil then + table.insert(results, { + id = OPEN_ID, + title = noctalia.tr("launcher.open_title"), + subtitle = noctalia.tr("launcher.open_subtitle"), + glyph = "shield-lock", + score = openScore, + }) + end + + for _, row in ipairs(connections) do + local score = 0 + if query ~= "" then + score = noctalia.fuzzyScore(query, row.name) + end + if score ~= nil then + table.insert(results, { + id = row.uuid, + title = row.active and noctalia.tr("launcher.disconnect_title", { name = row.name }) + or noctalia.tr("launcher.connect_title", { name = row.name }), + subtitle = row.protocol, + glyph = row.active and "plug-off" or "plug", + score = score, + }) + end + end + + table.sort(results, function(a, b) + return (a.score or 0) > (b.score or 0) + end) + return results +end + +function onQuery(query) + query = noctalia.string.trim(query) + launcher.setResults(query, buildResults(query)) +end + +function onActivate(id) + if id == "" then + return + end + if id == OPEN_ID then + noctalia.togglePanel("andrewdems/vpn-manager:panel") + return + end + local connections = noctalia.state.get("vpn.connections") or {} + local row = findRow(connections, id) + if row == nil then + return + end + noctalia.state.set("vpn.cmd", { op = row.active and "disconnect" or "connect", uuid = id }) +end diff --git a/vpn-manager/panel.luau b/vpn-manager/panel.luau new file mode 100644 index 00000000..825a30fc --- /dev/null +++ b/vpn-manager/panel.luau @@ -0,0 +1,599 @@ +--!nonstrict +-- VPN Manager panel: connection list (connect/disconnect/star/delete), +-- import form (file path or pasted config), and trusted-network management. + +local connections = {} +local trustedNetworks = {} +local currentNetworks = {} +local defaultVpnUuid = "" +local availableProtocols = {} + +local pendingDeleteVpn = nil +local expandedUuid = nil +local details = { uuid = "", rows = {} } + +local showAdd = false +local IMPORT_TYPES = { + { label = noctalia.tr("panel.auto_detect"), value = "" }, + { label = "OpenVPN (.ovpn)", value = "openvpn" }, + { label = "WireGuard (.conf)", value = "wireguard" }, + { label = "Cisco AnyConnect (openconnect)", value = "openconnect" }, + { label = "Cisco legacy VPN (vpnc)", value = "vpnc" }, +} +local importTypeIndex = 0 +local pathDraft = "" +local picking = false +local lastImport = { ok = true, reason = "", path = "", at = 0 } +local pathKey = 0 +local pasteDraft = "" +local pasteKey = 0 + +local showTrust = false +local trustDraft = "" +local trustKey = 0 + +local function importTypeLabels() + local labels = {} + for _, t in ipairs(IMPORT_TYPES) do + table.insert(labels, t.label) + end + return labels +end + +local function statusDot(active, busy) + return ui.box({ + width = 8, + height = 8, + radius = 4, + fill = if busy then "on_surface_variant" elseif active then "primary" else "outline", + }) +end + +local function protocolBadge(available) + return ui.glyph({ + name = if available then "check" else "x", + size = 12, + color = if available then "primary" else "on_surface_variant", + }) +end + +local function detailsBlock(uuid, row) + local lines = {} + if not row.active then + table.insert(lines, ui.label({ + text = noctalia.tr("panel.details_inactive"), + fontSize = 11, + color = "on_surface_variant", + maxLines = 2, + })) + elseif details.uuid ~= uuid or #details.rows == 0 then + table.insert(lines, ui.label({ + text = noctalia.tr("panel.details_loading"), + fontSize = 11, + color = "on_surface_variant", + })) + else + for _, d in ipairs(details.rows) do + table.insert(lines, ui.row({ key = `detail-{uuid}-{d.label}`, gap = 6 }, { + ui.label({ text = d.label, fontSize = 11, color = "on_surface_variant", width = 70 }), + ui.label({ text = d.value, fontSize = 11, flexGrow = 1, maxLines = 1 }), + })) + end + end + + table.insert(lines, ui.button({ + key = "edit-" .. uuid, + text = noctalia.tr("panel.edit"), + glyph = "pencil", + variant = "outline", + controlSize = "sm", + onClick = function() + noctalia.state.set("vpn.cmd", { op = "edit", uuid = uuid }) + end, + })) + + return ui.column({ gap = 4, paddingH = 4 }, lines) +end + +local function connectionRow(row) + local uuid = row.uuid + local deleting = pendingDeleteVpn == uuid + + local trailing + if deleting then + trailing = ui.row({ gap = 4, align = "center" }, { + ui.button({ + glyph = "check", + variant = "destructive", + controlSize = "sm", + tooltip = noctalia.tr("panel.delete_confirm", { name = row.name }), + onClick = function() + pendingDeleteVpn = nil + noctalia.state.set("vpn.cmd", { op = "delete", uuid = uuid }) + end, + }), + ui.button({ + glyph = "x", + variant = "ghost", + controlSize = "sm", + onClick = function() + pendingDeleteVpn = nil + render() + end, + }), + }) + else + trailing = ui.row({ gap = 2, align = "center" }, { + ui.button({ + glyph = if row.isDefault then "star-filled" else "star", + variant = "ghost", + controlSize = "sm", + tooltip = noctalia.tr("panel.use_as_default"), + onClick = function() + noctalia.state.set("vpn.cmd", { op = "setDefault", uuid = if row.isDefault then "" else uuid }) + end, + }), + ui.toggle({ + checked = row.active, + enabled = not row.busy, + onChange = function(_value) + if row.active then + noctalia.state.set("vpn.cmd", { op = "disconnect", uuid = uuid }) + else + noctalia.state.set("vpn.cmd", { op = "connect", uuid = uuid }) + end + end, + }), + ui.button({ + key = "info-" .. uuid, + glyph = expandedUuid == uuid and "chevron-up" or "info-circle", + variant = "ghost", + controlSize = "sm", + tooltip = noctalia.tr("panel.details"), + onClick = function() + if expandedUuid == uuid then + expandedUuid = nil + else + expandedUuid = uuid + details = { uuid = uuid, rows = {} } + noctalia.state.set("vpn.cmd", { op = "details", uuid = uuid }) + end + render() + end, + }), + ui.button({ + glyph = "trash", + variant = "ghost", + controlSize = "sm", + tooltip = noctalia.tr("panel.delete"), + onClick = function() + pendingDeleteVpn = uuid + render() + end, + }), + }) + end + + local header = ui.row({ + gap = 8, + align = "center", + }, { + statusDot(row.active, row.busy), + ui.column({ flexGrow = 1, gap = 1 }, { + ui.label({ text = row.name, fontWeight = "bold", fontSize = 13 }), + ui.label({ text = row.protocol, color = "on_surface_variant", fontSize = 10 }), + }), + trailing, + }) + + local children = { header } + if expandedUuid == uuid then + table.insert(children, detailsBlock(uuid, row)) + end + + return ui.column({ + key = "vpn-" .. uuid, + gap = 6, + paddingV = 6, + paddingH = 8, + fill = "surface_variant/0.35", + radius = 8, + }, children) +end + +local function connectionsSection() + local rows = {} + if #connections == 0 then + table.insert(rows, ui.label({ + text = noctalia.tr("panel.no_connections"), + color = "on_surface_variant", + fontSize = 12, + maxLines = 3, + })) + else + for _, row in ipairs(connections) do + table.insert(rows, connectionRow(row)) + end + end + return ui.column({ gap = 6 }, rows) +end + +local function addSection() + if not showAdd then + return ui.button({ + text = noctalia.tr("panel.add_vpn"), + glyph = "plus", + variant = "outline", + onClick = function() + showAdd = true + render() + end, + }) + end + + local protoNote = {} + for _, t in ipairs(IMPORT_TYPES) do + if t.value ~= "" then + table.insert(protoNote, ui.row({ gap = 4, align = "center" }, { + protocolBadge(availableProtocols[t.value] == true), + ui.label({ text = t.label, fontSize = 10, color = "on_surface_variant" }), + })) + end + end + -- no wrap support on ui.row: lay badges out two per row instead of one long strip + local protoRows = {} + for i = 1, #protoNote, 2 do + local rowItems = { protoNote[i] } + if protoNote[i + 1] ~= nil then + table.insert(rowItems, protoNote[i + 1]) + end + table.insert(protoRows, ui.row({ gap = 14 }, rowItems)) + end + + local banner = {} + if lastImport.ok == false and lastImport.reason ~= "" then + table.insert(banner, ui.column({ gap = 2, padding = 6, fill = "error/0.15", radius = 6 }, { + ui.row({ gap = 6, align = "center" }, { + ui.glyph({ name = "alert-triangle", size = 14, color = "error" }), + ui.label({ + text = noctalia.tr("panel.import_failed"), + fontSize = 12, + fontWeight = "bold", + color = "error", + flexGrow = 1, + }), + ui.button({ + glyph = "x", + variant = "ghost", + controlSize = "sm", + tooltip = noctalia.tr("panel.dismiss"), + onClick = function() + -- Clear the shared record too, or onOpen reads the failure straight + -- back out of the service state and the banner returns. + lastImport = { ok = true, reason = "", path = "", at = 0 } + noctalia.state.set("vpn.cmd", { op = "dismissImportError" }) + showAdd = false + render() + end, + }), + }), + ui.label({ text = lastImport.reason, fontSize = 11, color = "on_surface_variant", maxLines = 4 }), + ui.label({ + text = lastImport.path ~= "" and lastImport.path or "", + fontSize = 10, + color = "on_surface_variant", + maxLines = 1, + visible = lastImport.path ~= "", + }), + })) + end + + local body = { + ui.row({ align = "center", justify = "space_between" }, { + ui.label({ text = noctalia.tr("panel.add_vpn"), fontWeight = "bold", fontSize = 13, flexGrow = 1 }), + ui.button({ + glyph = "x", + variant = "ghost", + controlSize = "sm", + onClick = function() + showAdd = false + render() + end, + }), + }), + ui.column({ gap = 4 }, protoRows), + ui.label({ text = noctalia.tr("panel.protocol"), fontSize = 11, color = "on_surface_variant" }), + ui.select({ + key = "import-type", + options = importTypeLabels(), + selectedIndex = importTypeIndex, + onChange = function(index, _text) + importTypeIndex = tonumber(index) or 0 + render() + end, + }), + ui.label({ text = noctalia.tr("panel.import_from_file"), fontSize = 11, color = "on_surface_variant" }), + ui.row({ gap = 6, align = "center" }, { + ui.input({ + key = "path-" .. pathKey, + value = pathDraft, + placeholder = noctalia.tr("panel.path_placeholder"), + flexGrow = 1, + onChange = function(value) + pathDraft = value + end, + onSubmit = "onImportPath", + }), + ui.button({ + glyph = "folder-open", + variant = "outline", + tooltip = noctalia.tr("panel.browse"), + enabled = not picking, + onClick = function() + noctalia.state.set("vpn.cmd", { op = "pick" }) + -- The picker is a separate window that takes focus; a panel left open + -- underneath it holds a layer-shell grab and the picker cannot be + -- clicked. Close deliberately, as other plugins do when they launch + -- something on top. The chosen file imports on its own. + panel.close() + end, + }), + ui.button({ text = noctalia.tr("panel.import"), onClick = "onImportPath" }), + }), + ui.label({ text = noctalia.tr("panel.or_paste"), fontSize = 11, color = "on_surface_variant" }), + ui.input({ + key = "paste-" .. pasteKey, + value = pasteDraft, + placeholder = noctalia.tr("panel.paste_placeholder"), + multiline = true, + height = 90, + onChange = function(value) + pasteDraft = value + end, + }), + ui.button({ text = noctalia.tr("panel.import_pasted"), variant = "primary", onClick = "onImportPaste" }), + ui.label({ + text = noctalia.tr("panel.manual_note"), + fontSize = 10, + color = "on_surface_variant", + maxLines = 4, + }), + } + + -- A failed import is shown above the form, since the picker closes the panel + -- and the toast is long gone by the time the panel is reopened. + for _, node in ipairs(body) do + table.insert(banner, node) + end + + return ui.column({ gap = 8, padding = 8, fill = "surface_variant/0.25", radius = 8 }, banner) +end + +local function trustSection() + local header = ui.row({ align = "center", justify = "space_between" }, { + ui.label({ text = noctalia.tr("panel.trusted_networks"), fontWeight = "bold", fontSize = 13, flexGrow = 1 }), + ui.button({ + glyph = if showTrust then "chevron-up" else "chevron-down", + variant = "ghost", + controlSize = "sm", + tooltip = noctalia.tr(showTrust and "panel.collapse" or "panel.expand"), + onClick = function() + showTrust = not showTrust + render() + end, + }), + }) + + if not showTrust then + return header + end + + local rows = { header } + if #trustedNetworks == 0 then + table.insert(rows, ui.label({ + text = noctalia.tr("panel.trusted_none"), + color = "on_surface_variant", + fontSize = 11, + maxLines = 3, + })) + else + for _, name in ipairs(trustedNetworks) do + table.insert(rows, ui.row({ + key = "trust-" .. name, + gap = 6, + align = "center", + paddingV = 3, + paddingH = 6, + fill = "surface_variant/0.25", + radius = 6, + }, { + ui.glyph({ name = "wifi", size = 12, color = "on_surface_variant" }), + ui.label({ text = name, fontSize = 12, flexGrow = 1 }), + ui.button({ + glyph = "trash", + variant = "ghost", + controlSize = "sm", + onClick = function() + noctalia.state.set("vpn.cmd", { op = "untrust", name = name }) + end, + }), + })) + end + end + + local untrustedCurrent = {} + for _, net in ipairs(currentNetworks) do + if not net.trusted then + table.insert(untrustedCurrent, net) + end + end + for _, net in ipairs(untrustedCurrent) do + table.insert(rows, ui.button({ + key = "add-current-" .. net.key, + text = noctalia.tr("panel.trust_current", { name = net.key }), + variant = "outline", + controlSize = "sm", + onClick = function() + noctalia.state.set("vpn.cmd", { op = "trust", name = net.key }) + end, + })) + end + + table.insert(rows, ui.row({ gap = 6, align = "center" }, { + ui.input({ + key = "trust-add-" .. trustKey, + value = trustDraft, + placeholder = noctalia.tr("panel.trust_placeholder"), + flexGrow = 1, + onChange = function(value) + trustDraft = value + end, + onSubmit = "onTrustAdd", + }), + ui.button({ glyph = "plus", tooltip = noctalia.tr("panel.trust_add"), onClick = "onTrustAdd" }), + })) + + return ui.column({ gap = 6 }, rows) +end + +function render() + panel.render(ui.column({ flexGrow = 1, gap = 10, padding = 10 }, { + ui.row({ align = "center", justify = "space_between" }, { + ui.label({ text = noctalia.tr("title"), fontSize = 16, fontWeight = "bold", flexGrow = 1 }), + ui.button({ + glyph = "refresh", + variant = "ghost", + controlSize = "sm", + tooltip = noctalia.tr("panel.refresh"), + onClick = function() + noctalia.state.set("vpn.cmd", { op = "refresh" }) + end, + }), + ui.button({ + glyph = "settings", + variant = "ghost", + controlSize = "sm", + tooltip = noctalia.tr("panel.plugin_settings"), + onClick = function() + noctalia.openSettings() + end, + }), + ui.button({ + glyph = "x", + variant = "ghost", + controlSize = "sm", + tooltip = noctalia.tr("panel.close"), + onClick = function() + panel.close() + end, + }), + }), + ui.scroll({ flexGrow = 1, gap = 12 }, { + connectionsSection(), + addSection(), + ui.separator({}), + trustSection(), + }), + })) +end + +-- The select reports its own index; guard the lookup anyway, because a nil +-- here would throw inside a UI callback and take the panel down with it. +local function selectedImportType() + local entry = IMPORT_TYPES[importTypeIndex + 1] + local value = entry ~= nil and entry.value or "" + return value ~= "" and value or nil +end + +function onImportPath() + local typeValue = selectedImportType() + noctalia.state.set("vpn.cmd", { op = "import", path = pathDraft, importType = typeValue }) + pathDraft = "" + pathKey += 1 + render() +end + +function onImportPaste() + local typeValue = selectedImportType() + noctalia.state.set("vpn.cmd", { op = "importText", text = pasteDraft, importType = typeValue }) + pasteDraft = "" + pasteKey += 1 + render() +end + +function onTrustAdd() + local name = noctalia.string.trim(trustDraft) + if name ~= "" then + noctalia.state.set("vpn.cmd", { op = "trust", name = name }) + end + trustDraft = "" + trustKey += 1 + render() +end + +function onOpen(_context) + pendingDeleteVpn = nil + expandedUuid = nil + details = { uuid = "", rows = {} } + lastImport = noctalia.state.get("vpn.lastImport") or lastImport + -- Land on the import form when the last attempt failed, so the reason is + -- visible rather than lost with the toast. + showAdd = lastImport.ok == false + connections = noctalia.state.get("vpn.connections") or {} + trustedNetworks = noctalia.state.get("vpn.trustedNetworks") or {} + currentNetworks = noctalia.state.get("vpn.currentNetworks") or {} + defaultVpnUuid = noctalia.state.get("vpn.defaultVpnUuid") or "" + availableProtocols = noctalia.state.get("vpn.availableProtocols") or {} + noctalia.state.set("vpn.cmd", { op = "refresh" }) + render() +end + +noctalia.state.watch("vpn.lastImport", function(value) + if type(value) == "table" then + lastImport = { + ok = value.ok ~= false, + reason = type(value.reason) == "string" and value.reason or "", + path = type(value.path) == "string" and value.path or "", + at = tonumber(value.at) or 0, + } + if not lastImport.ok then + showAdd = true + end + end + render() +end) + +noctalia.state.watch("vpn.picking", function(value) + picking = value == true + render() +end) + +noctalia.state.watch("vpn.details", function(value) + if type(value) == "table" then + details = { uuid = value.uuid or "", rows = type(value.rows) == "table" and value.rows or {} } + end + render() +end) + +noctalia.state.watch("vpn.connections", function(value) + connections = type(value) == "table" and value or {} + render() +end) + +noctalia.state.watch("vpn.trustedNetworks", function(value) + trustedNetworks = type(value) == "table" and value or {} + render() +end) + +noctalia.state.watch("vpn.currentNetworks", function(value) + currentNetworks = type(value) == "table" and value or {} + render() +end) + +noctalia.state.watch("vpn.defaultVpnUuid", function(value) + defaultVpnUuid = type(value) == "string" and value or "" +end) + +noctalia.state.watch("vpn.availableProtocols", function(value) + availableProtocols = type(value) == "table" and value or {} +end) diff --git a/vpn-manager/plugin.toml b/vpn-manager/plugin.toml new file mode 100644 index 00000000..48d0db4f --- /dev/null +++ b/vpn-manager/plugin.toml @@ -0,0 +1,91 @@ +# VPN Manager: list/connect/disconnect/import NetworkManager VPN connections +# of any protocol NM supports (whichever nm-*-service VPN plugins are +# installed: OpenVPN, WireGuard, IPsec/IKEv2, L2TP, PPTP, SSTP, Cisco +# AnyConnect, vpnc...), plus automatic connect/disconnect based on whether the +# current Wi-Fi/Ethernet network is on a trusted (e.g. home) list. + +id = "andrewdems/vpn-manager" +name = "VPN Manager" +version = "1.0.0" +plugin_api = 24 +author = "andrewdems" +license = "MIT" +icon = "shield-lock" +description = "Connect, disconnect and import NetworkManager VPNs of any protocol, with auto-connect off trusted networks." +tags = ["bar", "launcher", "panel", "service", "shortcut", "network", "privacy", "indicator"] +dependencies = ["nmcli", "nm-connection-editor", "nmtui-edit", "kdialog", "zenity"] + +[[setting]] +key = "auto_connect_enabled" +type = "bool" +label_key = "settings.auto_connect_enabled.label" +description_key = "settings.auto_connect_enabled.description" +default = false + +[[setting]] +key = "auto_disconnect_on_trusted" +type = "bool" +label_key = "settings.auto_disconnect_on_trusted.label" +description_key = "settings.auto_disconnect_on_trusted.description" +default = true + +[[setting]] +key = "trust_all_ethernet" +type = "bool" +label_key = "settings.trust_all_ethernet.label" +description_key = "settings.trust_all_ethernet.description" +default = true + +[[setting]] +key = "notify_on_autoswitch" +type = "bool" +label_key = "settings.notify_on_autoswitch.label" +description_key = "settings.notify_on_autoswitch.description" +default = true + +[[setting]] +key = "poll_interval_seconds" +type = "int" +label_key = "settings.poll_interval_seconds.label" +description_key = "settings.poll_interval_seconds.description" +default = 5 +min = 2 +max = 60 + +[[setting]] +key = "stable_checks" +type = "int" +label_key = "settings.stable_checks.label" +description_key = "settings.stable_checks.description" +default = 3 +min = 1 +max = 20 + +[[service]] +id = "service" +entry = "service.luau" + +[[widget]] +id = "bar" +entry = "bar.luau" + +[[panel]] +id = "panel" +entry = "panel.luau" +width = 400 +height = 520 +placement = "attached" +position = "auto" +open_near_click = true + +[[shortcut]] +id = "toggle" +entry = "shortcut.luau" + +[[launcher_provider]] +id = "provider" +entry = "launcher.luau" +prefix = "vpn" +glyph = "shield-lock" +include_in_global_search = false +debounce_ms = 100 diff --git a/vpn-manager/service.luau b/vpn-manager/service.luau new file mode 100644 index 00000000..7aac11d3 --- /dev/null +++ b/vpn-manager/service.luau @@ -0,0 +1,908 @@ +--!nonstrict +-- VPN Manager service: owns NetworkManager polling, VPN connect/disconnect/ +-- import/delete, trusted-network tracking and the auto-connect/disconnect +-- decision. Every other entry (bar, panel, shortcut) only reads published +-- state and sends commands through noctalia.state.set("vpn.cmd", ...). + +noctalia.setUpdateInterval(1000) + +-- ── constants ───────────────────────────────────────────────────────────── + +local SERVICE_TYPE_LABELS = { + ["org.freedesktop.NetworkManager.openvpn"] = "OpenVPN", + ["org.freedesktop.NetworkManager.openconnect"] = "Cisco AnyConnect (OpenConnect)", + ["org.freedesktop.NetworkManager.vpnc"] = "Cisco VPN (vpnc)", + ["org.freedesktop.NetworkManager.l2tp"] = "L2TP/IPsec", + ["org.freedesktop.NetworkManager.pptp"] = "PPTP", + ["org.freedesktop.NetworkManager.strongswan"] = "IPsec/IKEv2 (strongSwan)", + ["org.freedesktop.NetworkManager.sstp"] = "SSTP", + ["org.freedesktop.NetworkManager.fortisslvpn"] = "Fortinet SSL VPN", + ["org.freedesktop.NetworkManager.ssh"] = "SSH", +} + +-- service-type suffix -> Arch package name, for "install this to add support" hints. +local PACKAGE_HINTS = { + openvpn = "networkmanager-openvpn", + openconnect = "networkmanager-openconnect", + vpnc = "networkmanager-vpnc", + l2tp = "networkmanager-l2tp", + pptp = "networkmanager-pptp", + strongswan = "networkmanager-strongswan", + sstp = "networkmanager-sstp", + fortisslvpn = "networkmanager-fortisslvpn", +} + +local VPN_PLUGIN_DIRS = { "/usr/lib64/NetworkManager/VPN", "/usr/lib/NetworkManager/VPN" } + +-- ── small helpers ──────────────────────────────────────────────────────── + +local function log(msg) + noctalia.log(`vpn-manager: {msg}`) +end + +local function trim(s) + return noctalia.string.trim(s or "") +end + +local function firstLine(s) + s = trim(s) + local nl = s:find("\n") + if nl ~= nil then + return s:sub(1, nl - 1) + end + return s +end + +local function clampInt(v, lo, hi, default) + v = tonumber(v) + if v == nil then + return default + end + v = math.floor(v) + if v < lo then + return lo + end + if v > hi then + return hi + end + return v +end + +local function cfgBool(key, default) + local v = noctalia.getConfig(key) + if type(v) == "boolean" then + return v + end + return default +end + +local function cfgInt(key, lo, hi, default) + return clampInt(noctalia.getConfig(key), lo, hi, default) +end + +local function run(argv, onDone, timeoutMs) + local accepted = noctalia.runAsync(argv, onDone, timeoutMs or 15000) + if not accepted then + log("failed to start: " .. table.concat(argv, " ")) + if onDone ~= nil then + onDone({ exitCode = -1, stdout = "", stderr = "failed to start process", timedOut = false }) + end + end +end + +-- Sequentially maps items through an async fn(item, done), then calls onDone(results). +local function mapAsync(items, fn, onDone) + local results = {} + local i = 0 + local function step() + i += 1 + if i > #items then + onDone(results) + return + end + fn(items[i], function(result) + results[i] = result + step() + end) + end + step() +end + +local function friendlyProtocol(ctype, serviceType) + if ctype == "wireguard" then + return "WireGuard" + end + if type(serviceType) == "string" and SERVICE_TYPE_LABELS[serviceType] ~= nil then + return SERVICE_TYPE_LABELS[serviceType] + end + if type(serviceType) == "string" and serviceType ~= "" then + return (serviceType:gsub("^org%.freedesktop%.NetworkManager%.", "")) + end + return "VPN" +end + +local function packageHintFor(typeArg) + local hint = PACKAGE_HINTS[typeArg] + if hint ~= nil then + return " " .. noctalia.tr("notifications.install_hint", { package = hint }) + end + return "" +end + +local function detectImportType(content) + if type(content) ~= "string" then + return nil + end + if content:match("%[Interface%]") and content:match("%[Peer%]") then + return "wireguard" + end + if content:match("^%s*client") or content:match("\nremote%s+%S") or content:match("") or content:match("%-%-%-%-%-BEGIN CERTIFICATE") then + return "openvpn" + end + return nil +end + +-- ── persistent data (trusted networks, default VPN, auto-started tracking) ── + +local data = { + trustedNetworks = {}, + defaultVpnUuid = "", + autoStartedUuid = "", +} + +local function dataPath() + local dir = noctalia.pluginDataDir() + if dir == nil then + return nil + end + return dir .. "/data.json" +end + +local function saveData() + local path = dataPath() + if path == nil then + return + end + local encoded = noctalia.json.encode(data) + if encoded ~= nil then + noctalia.writeFile(path, encoded) + end +end + +local function loadData() + local path = dataPath() + if path == nil then + return + end + local raw = noctalia.readFile(path) + if raw == nil then + return + end + local decoded = noctalia.json.decode(raw) + if type(decoded) ~= "table" then + return + end + if type(decoded.trustedNetworks) == "table" then + data.trustedNetworks = decoded.trustedNetworks + end + if type(decoded.defaultVpnUuid) == "string" then + data.defaultVpnUuid = decoded.defaultVpnUuid + end + if type(decoded.autoStartedUuid) == "string" then + data.autoStartedUuid = decoded.autoStartedUuid + end +end + +local function isTrusted(name) + for _, n in ipairs(data.trustedNetworks) do + if n == name then + return true + end + end + return false +end + +-- ── installed VPN plugin detection ────────────────────────────────────── + +local function publishAvailableProtocols() + local available = { wireguard = true } -- always native/built in + for _, dir in ipairs(VPN_PLUGIN_DIRS) do + local entries = noctalia.listDir(dir) + if entries ~= nil then + for _, entry in ipairs(entries) do + local key = entry:match("^nm%-(.+)%-service%.name$") + if key ~= nil then + available[key] = true + end + end + end + end + noctalia.state.set("vpn.availableProtocols", available) +end + +-- ── live state (last refresh results) ─────────────────────────────────── + +local lastVpnRows = {} +local refreshInFlight = false +local opInFlight = {} -- uuid -> true while a connect/disconnect/delete is running +local untrustedStreak = 0 +local trustedStreak = 0 +-- Auto-connect re-evaluates on every poll, so a VPN that cannot authenticate +-- would be retried every few seconds and notify each time. A failed automatic +-- attempt backs off until this timestamp. +local autoConnectBlockedUntil = 0 +local AUTO_RETRY_BACKOFF_SECONDS = 300 +local tickCount = 0 + +-- NetworkManager UUIDs are hex and dashes. Validated before a UUID is ever +-- interpolated into a terminal command, the only place this plugin builds a +-- shell string rather than an argv list. +local function isUuid(value) + return type(value) == "string" and value:match("^[0-9a-fA-F%-]+$") ~= nil and #value >= 8 +end + +-- NetworkManager asks a secret agent for a password whose flags mark it +-- agent-owned. A bare shell has no agent, so activation fails before OpenVPN +-- even starts; nmcli --ask asks on the terminal instead. +local function needsSecrets(stderr) + local text = tostring(stderr or ""):lower() + return text:find("no valid secrets") ~= nil + or text:find("secrets were required") ~= nil + or text:find("no agents were available") ~= nil +end + +local function promptForSecrets(uuid) + if not isUuid(uuid) then + return false + end + return noctalia.runInTerminal(`nmcli --ask connection up uuid {uuid}`) +end + +local function nameForUuid(uuid) + for _, row in ipairs(lastVpnRows) do + if row.uuid == uuid then + return row.name + end + end + return nil +end + +local function publishConnections() + local rows = {} + local activeNames = {} + for _, row in ipairs(lastVpnRows) do + table.insert(rows, { + uuid = row.uuid, + name = row.name, + protocol = row.protocol, + ctype = row.ctype, + active = row.active, + isDefault = row.uuid == data.defaultVpnUuid, + busy = opInFlight[row.uuid] == true, + }) + if row.active then + table.insert(activeNames, row.name) + end + end + table.sort(rows, function(a, b) + return a.name:lower() < b.name:lower() + end) + noctalia.state.set("vpn.connections", rows) + noctalia.state.set("vpn.status", { + anyActive = #activeNames > 0, + activeNames = activeNames, + }) +end + +local function publishTrust() + noctalia.state.set("vpn.trustedNetworks", data.trustedNetworks) + noctalia.state.set("vpn.defaultVpnUuid", data.defaultVpnUuid) +end + +-- ── connect / disconnect / delete / import ────────────────────────────── + +local function refreshAll() + -- fwd declared below; assigned once defined +end + +local function connectUuid(uuid, isAuto) + if uuid == nil or uuid == "" or opInFlight[uuid] then + return + end + opInFlight[uuid] = true + publishConnections() + run({ "nmcli", "connection", "up", "uuid", uuid }, function(result) + opInFlight[uuid] = nil + if result.exitCode == 0 then + autoConnectBlockedUntil = 0 + if isAuto then + data.autoStartedUuid = uuid + saveData() + if cfgBool("notify_on_autoswitch", true) then + noctalia.notify(noctalia.tr("title"), noctalia.tr("notifications.connected_untrusted", { name = nameForUuid(uuid) or noctalia.tr("title") })) + end + end + else + if isAuto then + autoConnectBlockedUntil = os.time() + AUTO_RETRY_BACKOFF_SECONDS + end + local name = nameForUuid(uuid) or noctalia.tr("title") + if needsSecrets(result.stderr) then + -- Auto-connect must not seize a terminal behind the user's back, so it + -- only says what is wrong; a deliberate connect gets the prompt. + if isAuto or not promptForSecrets(uuid) then + noctalia.notifyError( + noctalia.tr("notifications.connect_failed"), + noctalia.tr("notifications.needs_secrets", { name = name }) + ) + else + noctalia.notify(noctalia.tr("title"), noctalia.tr("notifications.asking_secrets", { name = name })) + end + else + noctalia.notifyError( + noctalia.tr(isAuto and "notifications.auto_connect_failed" or "notifications.connect_failed"), + firstLine(result.stderr) + ) + end + end + refreshAll() + end, 25000) +end + +local function disconnectUuid(uuid, isAuto) + if uuid == nil or uuid == "" or opInFlight[uuid] then + return + end + opInFlight[uuid] = true + publishConnections() + run({ "nmcli", "connection", "down", "uuid", uuid }, function(result) + opInFlight[uuid] = nil + if uuid == data.autoStartedUuid then + data.autoStartedUuid = "" + saveData() + end + if result.exitCode == 0 then + if isAuto and cfgBool("notify_on_autoswitch", true) then + noctalia.notify(noctalia.tr("title"), noctalia.tr("notifications.disconnected_trusted")) + end + else + noctalia.notifyError(noctalia.tr(isAuto and "notifications.auto_disconnect_failed" or "notifications.disconnect_failed"), firstLine(result.stderr)) + end + refreshAll() + end, 25000) +end + +local function deleteUuid(uuid) + if uuid == nil or uuid == "" then + return + end + run({ "nmcli", "connection", "delete", "uuid", uuid }, function(result) + if result.exitCode ~= 0 then + noctalia.notifyError(noctalia.tr("notifications.delete_failed"), firstLine(result.stderr)) + else + if data.defaultVpnUuid == uuid then + data.defaultVpnUuid = "" + end + if data.autoStartedUuid == uuid then + data.autoStartedUuid = "" + end + saveData() + end + refreshAll() + end) +end + +-- An import can fail after the panel has closed (the picker closes it), so a +-- toast is the only thing the user sees and it is gone in seconds. Record the +-- failure too, and the panel shows it the next time it is opened. +local function importFailed(reason, path) + noctalia.state.set("vpn.lastImport", { + ok = false, + reason = reason, + path = path or "", + at = os.time(), + }) + noctalia.notifyError(noctalia.tr("notifications.import_failed"), reason) +end + +local function finishImport(path, typeArg, isTemp) + local function cleanup() + if isTemp then + noctalia.removeFile(path) + end + end + + local function doImport(resolvedType) + if resolvedType == nil then + importFailed(noctalia.tr("notifications.detect_failed"), path) + cleanup() + return + end + run({ "nmcli", "connection", "import", "type", resolvedType, "file", path }, function(result) + cleanup() + log(`import result exitCode={result.exitCode} stdout={result.stdout:gsub("\n", "\\n")} stderr={result.stderr:gsub("\n", "\\n")}`) + if result.exitCode ~= 0 then + importFailed(firstLine(result.stderr) .. packageHintFor(resolvedType), path) + return + end + -- Newly imported connections default to autoconnect=yes (NM connects them at + -- boot / whenever the device is available); this plugin drives activation + -- itself, so turn that off to avoid two different things deciding when the + -- VPN comes up. + local newName = result.stdout:match("Connection '([^']+)'") + local newUuid = result.stdout:match("%(([%w%-]+)%)") + if newUuid ~= nil then + run({ "nmcli", "connection", "modify", "uuid", newUuid, "connection.autoconnect", "no" }, function() end) + end + noctalia.state.set("vpn.lastImport", { ok = true, reason = "", path = "", at = os.time() }) + noctalia.notify(noctalia.tr("title"), noctalia.tr("notifications.imported", { name = newName or noctalia.tr("notifications.a_connection") })) + refreshAll() + end, 20000) + end + + if typeArg ~= nil and typeArg ~= "" then + doImport(typeArg) + else + local content = noctalia.readFile(path) + doImport(detectImportType(content)) + end +end + +local function importFromPath(path, typeArg) + path = trim(path) + if path == "" then + importFailed(noctalia.tr("notifications.no_path"), "") + return + end + path = noctalia.expandPath(path) + if not noctalia.fileExists(path) then + importFailed(noctalia.tr("notifications.file_not_found", { path = path }), path) + return + end + finishImport(path, typeArg, false) +end + +local function importFromText(text, typeArg) + if type(text) ~= "string" or trim(text) == "" then + importFailed(noctalia.tr("notifications.no_paste"), "") + return + end + -- Deliberately not logged: a pasted config carries private keys, PSKs and + -- certificates, and the shell log is a world-readable file on disk. + log(`importFromText received {#text} bytes`) + local dir = noctalia.pluginDataDir() + if dir == nil then + importFailed(noctalia.tr("notifications.no_data_dir"), "") + return + end + local tmpPath = dir .. "/import-" .. tostring(math.floor(noctalia.nowMs())) .. ".tmp" + local ok = noctalia.writeFile(tmpPath, text) + if not ok then + importFailed(noctalia.tr("notifications.write_failed"), "") + return + end + finishImport(tmpPath, typeArg, true) +end + +-- ── trust evaluation / auto-connect decision ──────────────────────────── + +local function maybeAutoConnect() + if not cfgBool("auto_connect_enabled", false) then + return + end + if os.time() < autoConnectBlockedUntil then + return + end + local uuid = data.defaultVpnUuid + if uuid == nil or uuid == "" then + return + end + for _, row in ipairs(lastVpnRows) do + if row.uuid == uuid then + if row.active then + return + end + connectUuid(uuid, true) + return + end + end + -- default VPN no longer exists + data.defaultVpnUuid = "" + saveData() +end + +local function maybeAutoDisconnect() + if not cfgBool("auto_disconnect_on_trusted", true) then + return + end + local uuid = data.autoStartedUuid + if uuid == nil or uuid == "" then + return + end + + -- The VPN may already be down: it dropped, or it was disconnected outside + -- this plugin. Tearing down a connection that is not up makes nmcli fail and + -- would report an auto-disconnect failure that never happened, so just drop + -- the marker instead. + local active = false + for _, row in ipairs(lastVpnRows) do + if row.uuid == uuid then + active = row.active == true + end + end + if not active then + data.autoStartedUuid = "" + saveData() + return + end + + disconnectUuid(uuid, true) +end + +local function evaluateTrust(physicalRows) + if #physicalRows == 0 then + untrustedStreak = 0 + trustedStreak = 0 + return + end + local trustAllEthernet = cfgBool("trust_all_ethernet", true) + local anyTrusted = false + for _, row in ipairs(physicalRows) do + if row.ctype == "802-3-ethernet" and trustAllEthernet then + anyTrusted = true + end + if isTrusted(row.key) then + anyTrusted = true + end + end + + local threshold = cfgInt("stable_checks", 1, 20, 3) + if anyTrusted then + trustedStreak += 1 + untrustedStreak = 0 + -- Back on a trusted network: the next untrusted one deserves a fresh try. + autoConnectBlockedUntil = 0 + if trustedStreak >= threshold then + maybeAutoDisconnect() + end + else + untrustedStreak += 1 + trustedStreak = 0 + if untrustedStreak >= threshold then + maybeAutoConnect() + end + end +end + +-- ── full refresh: connections + network trust in one nmcli round trip ────── + +refreshAll = function() + if refreshInFlight then + return + end + refreshInFlight = true + + run({ "nmcli", "-t", "-f", "NAME,TYPE,UUID,AUTOCONNECT,ACTIVE,DEVICE", "--escape", "no", "connection", "show" }, function(result) + if result.exitCode ~= 0 then + log("connection show failed: " .. firstLine(result.stderr)) + refreshInFlight = false + return + end + + local vpnRows, physicalRows = {}, {} + for line in result.stdout:gmatch("[^\n]+") do + local name, ctype, uuid, autoconnect, active, device = line:match("^(.*):([^:]*):([^:]*):([^:]*):([^:]*):([^:]*)$") + if uuid ~= nil and uuid ~= "" then + local row = { + name = name, + ctype = ctype, + uuid = uuid, + autoconnect = autoconnect == "yes", + active = active == "yes", + device = device, + } + if ctype == "vpn" or ctype == "wireguard" then + table.insert(vpnRows, row) + elseif row.active and (ctype == "802-11-wireless" or ctype == "802-3-ethernet") then + table.insert(physicalRows, row) + end + end + end + + mapAsync(vpnRows, function(row, done) + if row.ctype == "wireguard" then + row.protocol = "WireGuard" + done(row) + else + run({ "nmcli", "-g", "vpn.service-type", "connection", "show", row.uuid }, function(r2) + local svc = r2.exitCode == 0 and trim(r2.stdout) or "" + row.protocol = friendlyProtocol(row.ctype, svc) + done(row) + end) + end + end, function(enrichedVpnRows) + mapAsync(physicalRows, function(row, done) + if row.ctype == "802-11-wireless" then + run({ "nmcli", "-g", "802-11-wireless.ssid", "connection", "show", row.uuid }, function(r2) + local ssid = r2.exitCode == 0 and trim(r2.stdout) or "" + row.key = ssid ~= "" and ssid or row.name + done(row) + end) + else + row.key = row.name + done(row) + end + end, function(enrichedPhysicalRows) + lastVpnRows = enrichedVpnRows + publishConnections() + + local currentNetworks = {} + for _, row in ipairs(enrichedPhysicalRows) do + table.insert(currentNetworks, { + key = row.key, + kind = row.ctype == "802-11-wireless" and "wifi" or "ethernet", + trusted = isTrusted(row.key) or (row.ctype == "802-3-ethernet" and cfgBool("trust_all_ethernet", true)), + }) + end + noctalia.state.set("vpn.currentNetworks", currentNetworks) + + evaluateTrust(enrichedPhysicalRows) + refreshInFlight = false + end) + end) + end) +end + +-- ── commands from panel / bar / shortcut ──────────────────────────────── + +-- ── connection details and editing ─────────────────────────────────────── + +local DETAIL_FIELDS = "GENERAL.STATE,GENERAL.DEVICES,VPN.VPN-STATE,IP4.ADDRESS,IP4.GATEWAY,IP6.ADDRESS" + +-- nmcli returns indexed keys for repeatable fields ("IP4.ADDRESS[1]"), so the +-- index is stripped before the label lookup. +local DETAIL_LABELS = { + ["GENERAL.STATE"] = "state", + ["GENERAL.DEVICES"] = "device", + ["VPN.VPN-STATE"] = "vpn_state", + ["IP4.ADDRESS"] = "ipv4", + ["IP4.GATEWAY"] = "gateway", + ["IP6.ADDRESS"] = "ipv6", +} + +local function fetchDetails(uuid) + if not isUuid(uuid) then + return + end + run({ "nmcli", "-t", "-f", DETAIL_FIELDS, "connection", "show", "--active", uuid }, function(result) + local rows = {} + for _, line in ipairs(splitLines(result.stdout)) do + local key, value = line:match("^([^:]+):(.*)$") + if key ~= nil then + local bare = key:gsub("%[%d+%]$", "") + local labelKey = DETAIL_LABELS[bare] + value = trim(value) + if labelKey ~= nil and value ~= "" then + table.insert(rows, { label = noctalia.tr(`panel.detail.{labelKey}`), value = value }) + end + end + end + noctalia.state.set("vpn.details", { uuid = uuid, rows = rows }) + end) +end + +-- nm-connection-editor is the graphical editor; where it is not installed, fall +-- back to nmtui-edit in a terminal. Neither is required for the plugin to work, +-- so a box with neither just gets told so. +local function editConnection(uuid) + if not isUuid(uuid) then + return + end + if noctalia.commandExists("nm-connection-editor") then + run({ "nm-connection-editor", "--edit=" .. uuid }) + elseif noctalia.commandExists("nmtui-edit") then + noctalia.runInTerminal("nmtui-edit " .. uuid) + else + noctalia.notifyError(noctalia.tr("title"), noctalia.tr("notifications.no_editor")) + end +end + +-- ── file picker ────────────────────────────────────────────────────────── + +-- A GUI picker outlives the runAsync call that spawns it, so it is detached +-- with `&` and writes the chosen path to a temp file that update() consumes. +-- This mirrors what other Noctalia plugins do for file selection. +local pendingPick = nil -- { path = , started = } + +local function shellQuote(value) + return "'" .. tostring(value):gsub("'", "'\\''") .. "'" +end + +local function startFilePicker() + if pendingPick ~= nil then + noctalia.notifyError(noctalia.tr("title"), noctalia.tr("notifications.pick_in_progress")) + return + end + + local picker, pickerBinary, pickerNeedle + if noctalia.commandExists("kdialog") then + picker = "kdialog --getopenfilename ~ '*.ovpn *.conf *.zip|VPN configurations'" + pickerBinary, pickerNeedle = "kdialog", "--getopenfilename" + elseif noctalia.commandExists("zenity") then + picker = "zenity --file-selection --title='Select a VPN configuration'" + pickerBinary, pickerNeedle = "zenity", "--file-selection" + else + noctalia.notifyError(noctalia.tr("notifications.import_failed"), noctalia.tr("notifications.no_picker")) + return + end + + local dir = noctalia.pluginDataDir() + if dir == nil then + noctalia.notifyError(noctalia.tr("notifications.import_failed"), noctalia.tr("notifications.no_data_dir")) + return + end + + local tmp = `{dir}/pick.tmp` + noctalia.removeFile(tmp) + pendingPick = { path = tmp, started = os.time(), binary = pickerBinary, needle = pickerNeedle, checking = false } + noctalia.state.set("vpn.picking", true) + noctalia.runAsync(`{picker} > {shellQuote(tmp)} 2>/dev/null &`) +end + +-- Called from update(). The chosen file is imported straight away rather than +-- being written back into the path field: one action, and the connection list +-- updates in place. A cancelled picker writes nothing, so the wait is bounded +-- either way. +local function clearPick() + if pendingPick ~= nil then + noctalia.removeFile(pendingPick.path) + pendingPick = nil + end + noctalia.state.set("vpn.picking", false) +end + +local function pollFilePicker() + if pendingPick == nil then + return + end + + local raw = noctalia.readFile(pendingPick.path) + if raw ~= nil and trim(raw) ~= "" then + local path = trim(raw) + clearPick() + importFromPath(path, nil) + return + end + + -- Cancelling writes nothing, so waiting for output alone would leave the + -- picker "in progress" until the timeout. Watch for the process going away + -- instead, and treat that as a cancel. + if not pendingPick.checking and os.time() - pendingPick.started >= 2 then + pendingPick.checking = true + local binary, needle = pendingPick.binary, pendingPick.needle + noctalia.processMatches(function(stillRunning) + if pendingPick ~= nil then + if stillRunning then + pendingPick.checking = false + else + clearPick() + end + end + end, binary, needle) + end + + if os.time() - pendingPick.started > 120 then + clearPick() + end +end + +local function handleCommand(cmd) + if type(cmd) ~= "table" then + return + end + local op = cmd.op + if op == "connect" then + connectUuid(cmd.uuid, false) + elseif op == "disconnect" then + disconnectUuid(cmd.uuid, false) + elseif op == "delete" then + deleteUuid(cmd.uuid) + elseif op == "setDefault" then + data.defaultVpnUuid = cmd.uuid or "" + saveData() + publishTrust() + publishConnections() + elseif op == "trust" then + local name = trim(cmd.name) + if name ~= "" and not isTrusted(name) then + table.insert(data.trustedNetworks, name) + saveData() + publishTrust() + untrustedStreak = 0 + end + elseif op == "untrust" then + local name = trim(cmd.name) + for i, n in ipairs(data.trustedNetworks) do + if n == name then + table.remove(data.trustedNetworks, i) + break + end + end + saveData() + publishTrust() + elseif op == "import" then + importFromPath(cmd.path, cmd.importType) + elseif op == "importText" then + importFromText(cmd.text, cmd.importType) + elseif op == "dismissImportError" then + noctalia.state.set("vpn.lastImport", { ok = true, reason = "", path = "", at = 0 }) + elseif op == "pick" then + startFilePicker() + elseif op == "details" then + fetchDetails(cmd.uuid) + elseif op == "edit" then + editConnection(cmd.uuid) + elseif op == "refresh" then + refreshAll() + end +end + +noctalia.state.watch("vpn.cmd", function(cmd) + if cmd ~= nil then + handleCommand(cmd) + noctalia.state.set("vpn.cmd", nil) + end +end) + +function onIpc(event, payload) + if event == "refresh" then + refreshAll() + elseif event == "connect" then + connectUuid(payload, false) + elseif event == "disconnect" then + disconnectUuid(payload, false) + elseif event == "status" then + local names = {} + for _, row in ipairs(lastVpnRows) do + if row.active then + table.insert(names, row.name) + end + end + noctalia.notify(noctalia.tr("title"), #names > 0 and table.concat(names, ", ") or noctalia.tr("notifications.status_none")) + elseif event == "list" then + for _, row in ipairs(lastVpnRows) do + log(`conn name={row.name} type={row.ctype} protocol={row.protocol} uuid={row.uuid} active={tostring(row.active)}`) + end + log(`trusted={table.concat(data.trustedNetworks, ",")} default={data.defaultVpnUuid} autoStarted={data.autoStartedUuid}`) + elseif event == "import" then + importFromPath(payload, nil) + elseif event == "pick" then + startFilePicker() + elseif event == "dismiss-error" then + noctalia.state.set("vpn.lastImport", { ok = true, reason = "", path = "", at = 0 }) + end +end + +function onEnable() + refreshAll() +end + +function update() + tickCount += 1 + pollFilePicker() + local interval = cfgInt("poll_interval_seconds", 2, 60, 5) + if tickCount % interval == 0 then + refreshAll() + end +end + +loadData() +publishTrust() +publishAvailableProtocols() +noctalia.state.set("vpn.connections", {}) +noctalia.state.set("vpn.status", { anyActive = false, activeNames = {} }) +noctalia.state.set("vpn.currentNetworks", {}) +noctalia.state.set("vpn.details", { uuid = "", rows = {} }) +noctalia.state.set("vpn.picking", false) +noctalia.state.set("vpn.lastImport", { ok = true, reason = "", path = "", at = 0 }) +refreshAll() diff --git a/vpn-manager/shortcut.luau b/vpn-manager/shortcut.luau new file mode 100644 index 00000000..8885fb7e --- /dev/null +++ b/vpn-manager/shortcut.luau @@ -0,0 +1,36 @@ +--!nonstrict +-- VPN Manager control-center shortcut: toggles the default VPN connection. + +local connected = false +local defaultUuid = "" + +local function render() + shortcut.setLabel(noctalia.tr("title")) + shortcut.setIcon("shield-lock-filled", "shield-lock") + shortcut.setActive(connected) + shortcut.setEnabled(true) +end + +noctalia.state.watch("vpn.status", function(status) + connected = type(status) == "table" and status.anyActive == true + render() +end) + +noctalia.state.watch("vpn.defaultVpnUuid", function(uuid) + defaultUuid = type(uuid) == "string" and uuid or "" +end) + +function onClick() + if defaultUuid == "" then + noctalia.notifyError(noctalia.tr("title"), noctalia.tr("notifications.no_default")) + noctalia.togglePanel("andrewdems/vpn-manager:panel") + return + end + if connected then + noctalia.state.set("vpn.cmd", { op = "disconnect", uuid = defaultUuid }) + else + noctalia.state.set("vpn.cmd", { op = "connect", uuid = defaultUuid }) + end +end + +render() diff --git a/vpn-manager/thumbnail.webp b/vpn-manager/thumbnail.webp new file mode 100644 index 00000000..a1ee0a24 Binary files /dev/null and b/vpn-manager/thumbnail.webp differ diff --git a/vpn-manager/translations/en.json b/vpn-manager/translations/en.json new file mode 100644 index 00000000..a1dfaa61 --- /dev/null +++ b/vpn-manager/translations/en.json @@ -0,0 +1,107 @@ +{ + "title": "VPN", + "tooltip": { + "connected": "VPN: {names}", + "disconnected": "VPN: disconnected" + }, + "settings": { + "auto_connect_enabled": { + "label": "Auto-connect on untrusted networks", + "description": "When enabled, connects your default VPN whenever you join a Wi-Fi or Ethernet network that isn't in your trusted list." + }, + "auto_disconnect_on_trusted": { + "label": "Auto-disconnect on trusted networks", + "description": "When enabled, disconnects a VPN this plugin auto-started once you're back on a trusted network. Never touches a VPN you started manually." + }, + "trust_all_ethernet": { + "label": "Treat any wired connection as trusted", + "description": "Wired Ethernet is treated as trusted even if its connection name isn't in your trusted list. Turn this off if you plug into untrusted wired networks." + }, + "notify_on_autoswitch": { + "label": "Notify on automatic connect/disconnect", + "description": "Show a notification whenever the VPN is automatically connected or disconnected." + }, + "poll_interval_seconds": { + "label": "Check interval (seconds)", + "description": "How often to check VPN and network status." + }, + "stable_checks": { + "label": "Stable checks before switching", + "description": "How many consecutive checks a network change must persist before auto-connect/disconnect reacts. Higher values avoid flapping during Wi-Fi roaming." + } + }, + "panel": { + "add_vpn": "Add VPN", + "auto_detect": "Auto-detect", + "protocol": "Protocol", + "import_from_file": "Import from file", + "path_placeholder": "~/Downloads/client.ovpn", + "import": "Import", + "or_paste": "or paste a config", + "paste_placeholder": "[Interface] / [Peer] for WireGuard, or full .ovpn contents", + "import_pasted": "Import Pasted Config", + "manual_note": "PPTP, L2TP/IPsec and IKEv2 usually need fields typed in rather than a file - create those with nmtui or nm-connection-editor and they'll appear above automatically.", + "no_connections": "No VPN connections yet. Import one below, or create one with nmtui and it will show up here.", + "use_as_default": "Use as auto-connect default", + "trusted_networks": "Trusted networks", + "trusted_none": "None yet. VPN auto-connect (if enabled in Settings) treats every network as untrusted until you add one.", + "trust_current": "+ Trust '{name}'", + "trust_placeholder": "SSID or Ethernet connection name", + "plugin_settings": "Plugin settings", + "details": "Connection details", + "details_loading": "Reading connection details…", + "details_inactive": "Connect this VPN to see its address and device.", + "edit": "Edit connection", + "detail": { + "state": "State", + "device": "Device", + "vpn_state": "VPN state", + "ipv4": "IPv4", + "gateway": "Gateway", + "ipv6": "IPv6" + }, + "delete": "Delete connection", + "delete_confirm": "Confirm deleting {name}", + "keep": "Keep it", + "refresh": "Refresh now", + "close": "Close", + "trust_add": "Add this network to the trusted list", + "expand": "Show trusted networks", + "collapse": "Hide trusted networks", + "browse": "Choose a file and import it", + "import_failed": "Import failed", + "dismiss": "Dismiss" + }, + "launcher": { + "open_title": "Open VPN Manager", + "open_subtitle": "Manage VPN connections", + "connect_title": "Connect to {name}", + "disconnect_title": "Disconnect {name}" + }, + "notifications": { + "connected_untrusted": "Untrusted network detected - connected {name}.", + "connect_failed": "VPN connect failed", + "auto_connect_failed": "VPN auto-connect failed", + "disconnected_trusted": "Back on a trusted network - disconnected VPN.", + "disconnect_failed": "VPN disconnect failed", + "auto_disconnect_failed": "VPN auto-disconnect failed", + "delete_failed": "Could not delete VPN", + "import_failed": "Could not import VPN", + "detect_failed": "Couldn't detect the protocol. Pick one explicitly (OpenVPN, WireGuard, OpenConnect, vpnc) and try again.", + "imported": "Imported {name}.", + "a_connection": "connection", + "no_path": "Enter a file path first.", + "file_not_found": "File not found: {path}", + "no_paste": "Paste a config first.", + "no_data_dir": "No writable plugin data directory.", + "write_failed": "Failed to write temporary file.", + "install_hint": "Install '{package}' and try again.", + "no_default": "No default VPN set. Open the VPN panel and tap the star on a connection first.", + "status_none": "No VPN connected", + "no_editor": "No connection editor found. Install nm-connection-editor or nmtui.", + "needs_secrets": "{name} needs a password and no secret agent is running. Save it with 'nmcli connection modify {name} vpn.secrets password=… vpn.data password-flags=0', or connect it by hand once.", + "asking_secrets": "{name} needs a password - asking for it in a terminal.", + "pick_in_progress": "A file picker is already open.", + "no_picker": "No file picker found. Install kdialog or zenity, or type the path." + } +}