diff --git a/modules/home/desktop/gnome/default.nix b/modules/home/desktop/gnome/default.nix index bde069f..35b2c6c 100644 --- a/modules/home/desktop/gnome/default.nix +++ b/modules/home/desktop/gnome/default.nix @@ -33,6 +33,7 @@ in gnomeExtensions.boatman-winboat-monitor papirus-icon-theme pop-gtk-theme + pkgs.mjallen.gnome-nebula-vpn ]; dconf = { @@ -68,6 +69,7 @@ in "dash-to-dock@micxgx.gmail.com" "BingWallpaper@ineffable-gmail.com" "gsconnect@andyholmes.github.io" + "nebula-vpn-status@mjallen" ]; "org/gnome/shell/extensions/bingwallpaper" = { override-lockscreen-blur = true; diff --git a/modules/nixos/services/nebula/default.nix b/modules/nixos/services/nebula/default.nix index 6fa0db3..fad2403 100644 --- a/modules/nixos/services/nebula/default.nix +++ b/modules/nixos/services/nebula/default.nix @@ -89,6 +89,21 @@ let moduleConfig = { environment.systemPackages = with pkgs; [ nebula ]; + # Allow users in the wheel group to start/stop the nebula service without + # a password prompt (used by the GNOME panel extension toggle). + security.polkit.extraConfig = '' + polkit.addRule(function(action, subject) { + if (action.id == "org.freedesktop.systemd1.manage-units" && + action.lookup("unit") == "nebula@${cfg.networkName}.service" && + (action.lookup("verb") == "start" || action.lookup("verb") == "stop") && + subject.local == true && + subject.active == true && + subject.isInGroup("wheel")) { + return polkit.Result.YES; + } + }); + ''; + services.nebula.networks.${cfg.networkName} = { enable = true; enableReload = true; diff --git a/packages/system/gnome-nebula-vpn/default.nix b/packages/system/gnome-nebula-vpn/default.nix new file mode 100644 index 0000000..f07b594 --- /dev/null +++ b/packages/system/gnome-nebula-vpn/default.nix @@ -0,0 +1,29 @@ +{ + stdenvNoCC, + lib, + ... +}: + +stdenvNoCC.mkDerivation { + pname = "gnome-nebula-vpn"; + version = "1.0.0"; + + src = ./extension; + + installPhase = '' + runHook preInstall + + uuid="nebula-vpn-status@mjallen" + dest="$out/share/gnome-shell/extensions/$uuid" + mkdir -p "$dest" + cp -r . "$dest/" + + runHook postInstall + ''; + + meta = { + description = "GNOME Shell extension showing Nebula VPN status with interface info and a toggle"; + license = lib.licenses.mit; + platforms = lib.platforms.linux; + }; +} diff --git a/packages/system/gnome-nebula-vpn/extension/extension.js b/packages/system/gnome-nebula-vpn/extension/extension.js new file mode 100644 index 0000000..27c9e8a --- /dev/null +++ b/packages/system/gnome-nebula-vpn/extension/extension.js @@ -0,0 +1,203 @@ +import GLib from 'gi://GLib'; +import Gio from 'gi://Gio'; +import GObject from 'gi://GObject'; +import St from 'gi://St'; +import Clutter from 'gi://Clutter'; + +import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; +import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; + +// ── constants ──────────────────────────────────────────────────────────────── + +const SERVICE_NAME = 'nebula@jallen-nebula.service'; +const IFACE_NAME = 'jallen-nebula'; + +const POLL_INTERVAL_SECS = 5; + +// ── helpers ────────────────────────────────────────────────────────────────── + +/** + * Run a command and return { success, stdout, stderr }. + * The command is passed as an argv array (no shell expansion). + */ +function runCommand(argv) { + try { + const [ok, stdout, stderr, exitCode] = GLib.spawn_sync( + null, // working dir + argv, + null, // inherit env + GLib.SpawnFlags.SEARCH_PATH, + null, + ); + return { + success: ok && exitCode === 0, + stdout: ok ? new TextDecoder().decode(stdout).trim() : '', + stderr: ok ? new TextDecoder().decode(stderr).trim() : '', + }; + } catch (_e) { + return {success: false, stdout: '', stderr: ''}; + } +} + +/** Return true if the systemd service is currently active. */ +function isServiceActive() { + const r = runCommand([ + 'systemctl', 'is-active', '--quiet', SERVICE_NAME, + ]); + return r.success; +} + +/** + * Get interface info for IFACE_NAME. + * Returns { found, ipv4, ipv6 } where ipv4/ipv6 are strings or null. + */ +function getIfaceInfo() { + // `ip -brief addr show ` output: + // jallen-nebula UP 10.1.1.3/24 fe80::…/64 + const r = runCommand(['ip', '-brief', 'addr', 'show', IFACE_NAME]); + if (!r.success || r.stdout === '') { + return {found: false, ipv4: null, ipv6: null}; + } + + const parts = r.stdout.split(/\s+/); + // parts[0] = iface, parts[1] = state, parts[2..] = addresses + const addrs = parts.slice(2); + const ipv4 = addrs.find(a => /^\d+\.\d+\.\d+\.\d+\//.test(a)) ?? null; + const ipv6 = addrs.find(a => a.includes(':') && !a.startsWith('fe80')) ?? null; + return {found: true, ipv4, ipv6}; +} + +/** Start or stop the systemd service (requires polkit / passwordless sudo). */ +function setServiceActive(enable) { + const action = enable ? 'start' : 'stop'; + runCommand(['systemctl', action, SERVICE_NAME]); +} + +// ── indicator ──────────────────────────────────────────────────────────────── + +const NebulaIndicator = GObject.registerClass( +class NebulaIndicator extends PanelMenu.Button { + _init(extensionPath) { + super._init(0.0, 'Nebula VPN Status'); + + this._extensionPath = extensionPath; + + // ── panel icon + label ── + const hbox = new St.BoxLayout({style_class: 'panel-status-menu-box'}); + + this._icon = new St.Icon({ + style_class: 'system-status-icon', + icon_size: 16, + }); + + this._label = new St.Label({ + text: '…', + y_align: Clutter.ActorAlign.CENTER, + style: 'margin-left: 4px; margin-right: 2px;', + }); + + hbox.add_child(this._icon); + hbox.add_child(this._label); + this.add_child(hbox); + + // ── dropdown menu ── + this._statusItem = new PopupMenu.PopupMenuItem('', {reactive: false}); + this._statusItem.label.set_style('font-weight: bold;'); + this.menu.addMenuItem(this._statusItem); + + this._ipv4Item = new PopupMenu.PopupMenuItem('', {reactive: false}); + this.menu.addMenuItem(this._ipv4Item); + + this._ipv6Item = new PopupMenu.PopupMenuItem('', {reactive: false}); + this.menu.addMenuItem(this._ipv6Item); + + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + + this._toggleItem = new PopupMenu.PopupMenuItem(''); + this._toggleItem.connect('activate', () => this._onToggle()); + this.menu.addMenuItem(this._toggleItem); + + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + + const refreshItem = new PopupMenu.PopupMenuItem('Refresh'); + refreshItem.connect('activate', () => this._refresh()); + this.menu.addMenuItem(refreshItem); + + // ── initial state + polling ── + this._active = false; + this._refresh(); + this._timerId = GLib.timeout_add_seconds( + GLib.PRIORITY_DEFAULT, + POLL_INTERVAL_SECS, + () => { this._refresh(); return GLib.SOURCE_CONTINUE; }, + ); + } + + // ── private ────────────────────────────────────────────────────────────── + + _refresh() { + this._active = isServiceActive(); + const iface = getIfaceInfo(); + this._updateUI(iface); + } + + _updateUI(iface) { + if (this._active) { + this._icon.icon_name = 'network-vpn-symbolic'; + this._label.text = 'VPN'; + this._label.style = 'margin-left: 4px; margin-right: 2px; color: #57e389;'; // green + this._statusItem.label.text = `Status: Connected`; + this._ipv4Item.label.text = iface.ipv4 ? `IPv4: ${iface.ipv4}` : 'IPv4: —'; + this._ipv6Item.label.text = iface.ipv6 ? `IPv6: ${iface.ipv6}` : 'IPv6: —'; + this._ipv4Item.visible = true; + this._ipv6Item.visible = true; + this._toggleItem.label.text = 'Disconnect'; + } else { + this._icon.icon_name = 'network-vpn-disabled-symbolic'; + this._label.text = 'VPN'; + this._label.style = 'margin-left: 4px; margin-right: 2px; color: #c01c28;'; // red + this._statusItem.label.text = `Status: Disconnected`; + this._ipv4Item.visible = false; + this._ipv6Item.visible = false; + this._toggleItem.label.text = 'Connect'; + } + } + + _onToggle() { + setServiceActive(!this._active); + // Optimistic update while systemd races + this._active = !this._active; + this._updateUI(getIfaceInfo()); + // Re-poll shortly after to confirm real state + GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 2, () => { + this._refresh(); + return GLib.SOURCE_REMOVE; + }); + } + + // ── cleanup ─────────────────────────────────────────────────────────────── + + destroy() { + if (this._timerId) { + GLib.source_remove(this._timerId); + this._timerId = null; + } + super.destroy(); + } +}); + +// ── extension lifecycle ─────────────────────────────────────────────────────── + +export default class NebulaVpnExtension extends Extension { + enable() { + this._indicator = new NebulaIndicator(this.path); + Main.panel.addToStatusArea(this.uuid, this._indicator); + } + + disable() { + this._indicator?.destroy(); + this._indicator = null; + } +} diff --git a/packages/system/gnome-nebula-vpn/extension/metadata.json b/packages/system/gnome-nebula-vpn/extension/metadata.json new file mode 100644 index 0000000..71561a9 --- /dev/null +++ b/packages/system/gnome-nebula-vpn/extension/metadata.json @@ -0,0 +1,7 @@ +{ + "name": "Nebula VPN Status", + "description": "Shows the status of the Nebula VPN in the GNOME panel with interface info and a toggle.", + "uuid": "nebula-vpn-status@mjallen", + "shell-version": ["45", "46", "47", "48"], + "version": 1 +}