From 47526ae6e2492a789aaf15ae17ba0c4b6743292d Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Tue, 23 Jun 2026 11:13:03 +0100 Subject: [PATCH] feat(menu): VPN setup & management submenu (WireGuard/OpenVPN/Tailscale) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dedicated System → VPN flow (nomarchy-vpn) past the wifi picker: - NetworkManager VPN/WireGuard connections shown ● active / ○ inactive, toggled up/down on select (networkmanager-group users need no sudo). - Import a *.conf / *.ovpn via the fd picker → nmcli connection import, type chosen by extension. networkmanager-openvpn ships system-side (networking.networkmanager.plugins, mkDefault) so the openvpn type is available; WireGuard is native to NetworkManager. - Tailscale block, self-gated on the CLI (= nomarchy.services.tailscale): read-only status + up/down + exit-node; privileged actions go through a sudo terminal (the snapshot pattern), no operator wiring. Waybar custom/vpn shield (nomarchy-vpn-status): self-hides unless a NM tunnel or Tailscale is up; @good tone; click opens the submenu. Wired into the generated bar and both summer whole-swaps. Decided import-first; creation deferred to nm-connection-editor. ROADMAP marked shipped. Eval + home/system builds green; both scripts pass bash -n. Pending an on-machine check (live nmcli import/connect + Tailscale paths). Co-Authored-By: Claude Opus 4.8 --- docs/ROADMAP.md | 54 ++++++++++-------- modules/home/rofi.nix | 98 +++++++++++++++++++++++++++++++- modules/home/waybar.nix | 31 +++++++++- modules/nixos/default.nix | 5 ++ themes/summer-day/waybar.jsonc | 9 ++- themes/summer-night/waybar.jsonc | 9 ++- 6 files changed, 175 insertions(+), 31 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 56e3633..da96562 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -97,32 +97,36 @@ how to override it. Items marked ✓ are shipped. UI (an unthemed browser page). Validated: the generated menu script's `bash -n` build check passes, and an eval confirms printing-on puts system-config-printer in `systemPackages`. Pending an on-machine check. -- **VPN setup & management menu:** a dedicated **System → VPN** flow that goes - past today's Network module (`networkmanager_dmenu` only connects/disconnects - *existing* VPN connections) to a guided **setup + management** surface across - the three common kinds: +- ✓ **VPN setup & management menu:** a dedicated **System → VPN** flow + (`nomarchy-vpn`, `modules/home/rofi.nix`) that goes past the Network module + (`networkmanager_dmenu` only connects/disconnects *existing* VPNs) to a guided + setup + management surface across the three common kinds: - **WireGuard:** import a `.conf` into NetworkManager (`nmcli connection import - type wireguard file …` — NM handles wg tunnels natively, no extra plugin) - and toggle it up/down. - - **OpenVPN:** import an `.ovpn` (`nmcli connection import type openvpn file …`), - which needs the `networkmanager-openvpn` plugin shipped — gate the import - entry on it (the wireguard path has no such dependency). - - **Tailscale:** `tailscale up/down`, status, and **exit-node** selection — it - lives outside NetworkManager, so the menu drives the `tailscale` CLI - directly, self-gated on `nomarchy.services.tailscale` (already an opt-in - service) like the other hardware-gated System entries. - Shape: a `nomarchy-menu vpn` rofi submenu under **System** (Connect/Disconnect - with each connection's live up/down state like the wifi picker · Import config - via the existing Files/`fd` picker pattern · the Tailscale block when present), - ending in `↩ Back`. A self-gating Waybar `custom/vpn` indicator (shield glyph - while a tunnel or Tailscale is up) for parity with the other status modules, - added to the summer whole-swaps too. Secrets stay in the connection manager's - own store (NetworkManager / Tailscale state) — no new secret manager (cf. the - deferred rofi-rbw/pass note above). Open question: how much in-menu *creation* - (vs import) to support — a from-scratch WireGuard keypair/peer editor is a lot - of rofi surface, so likely import-first, deferring creation to - `nm-connection-editor`. Complements the existing Network item rather than - replacing it. + type wireguard file …` — NM handles wg tunnels natively, no plugin) and + toggle it up/down. + - **OpenVPN:** import an `.ovpn` (`nmcli connection import type openvpn file …`); + the `networkmanager-openvpn` plugin ships system-side + (`networking.networkmanager.plugins`, mkDefault) so the openvpn type is + available — import type is chosen by file extension. + - **Tailscale:** status (read-only, no privilege) + `up`/`down` + **exit-node** + selection. It lives outside NetworkManager, so the menu drives the + `tailscale` CLI directly, **self-gated** on the CLI being present + (= `nomarchy.services.tailscale`). Privileged actions go through a terminal + + `sudo` (the snapshot-tool pattern) — no operator wiring. + Shape: a `nomarchy-menu vpn` rofi submenu under **System** — NM + VPN/WireGuard connections shown ● active / ○ inactive and toggled on select + (networkmanager-group users need no sudo), Import via the Files/`fd` picker + (`*.conf`/`*.ovpn`), the Tailscale block when present — ending in `↩ Back`. A + self-gating Waybar **`custom/vpn`** shield (`nomarchy-vpn-status`: shown only + while a NM tunnel or Tailscale is up; `@good` tone; click opens the submenu), + wired into the generated bar **and the summer whole-swaps**. Secrets stay in + the connection manager's own store (NetworkManager / Tailscale) — no new secret + manager (cf. the deferred rofi-rbw/pass note above). **Decided: import-first** + — a from-scratch WireGuard keypair/peer editor is too much rofi surface, so + creation is deferred to `nm-connection-editor`. Eval + build green; **pending + an on-machine check** (the nmcli import/up-down + Tailscale paths need a live + session with real configs). Remaining (optional): operator-set Tailscale so its + toggles skip the sudo terminal; richer exit-node display (country/city). - **Theme parity with legacy:** summer-day/night now carry their legacy bar layouts as `waybar.jsonc` whole-swaps (adapted: dead legacy script modules dropped, Nerd-Fonts-v2 codepoints remapped to FontAwesome/v3, diff --git a/modules/home/rofi.nix b/modules/home/rofi.nix index 523879b..e6b6912 100644 --- a/modules/home/rofi.nix +++ b/modules/home/rofi.nix @@ -246,6 +246,11 @@ ${themeRows} # its config from xdg.configFile below (told to drive rofi). exec networkmanager_dmenu ;; + vpn) + # VPN setup + management (nomarchy-vpn): NM VPN/WireGuard connect + + # config import + Tailscale. Distinct from `network` (the wifi picker). + exec nomarchy-vpn ;; + bluetooth) # blueman-manager GUI (services.blueman.enable, system-side). exec blueman-manager ;; @@ -373,6 +378,7 @@ ${themeRows} bats=(/sys/class/power_supply/BAT*) choice=$( { row "Network" network-wireless + row "VPN" network-vpn row "Bluetooth" bluetooth [ -e "''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/pulse/native" ] \ && row "Audio" audio-volume-high @@ -395,6 +401,7 @@ ${themeRows} case "$choice" in "$BACK") exec "$0" ;; *Network*) exec "$0" network ;; + *VPN*) exec "$0" vpn ;; *Bluetooth*) exec "$0" bluetooth ;; *Audio*) exec "$0" audio ;; *Printers*) exec "$0" printers ;; @@ -427,15 +434,104 @@ ${themeRows} esac ;; *) - echo "usage: nomarchy-menu [tools|system|power|power-profile|theme|clipboard|calc|files|emoji|web|network|bluetooth|audio|printers|capture|keybinds|ask|dnd|nightlight|autotimezone|snapshot]" >&2 + echo "usage: nomarchy-menu [tools|system|power|power-profile|theme|clipboard|calc|files|emoji|web|network|bluetooth|audio|printers|capture|keybinds|ask|dnd|nightlight|autotimezone|vpn|snapshot]" >&2 exit 64 ;; esac ''; + + # VPN setup + management — a dedicated System submenu (and the custom/vpn + # Waybar indicator's click target). Goes past networkmanager_dmenu (which + # only connects existing VPNs) to importing configs and driving Tailscale: + # · NetworkManager VPN/WireGuard connections, ● active / ○ inactive, toggled + # up/down (networkmanager-group users need no sudo). + # · Import a WireGuard .conf or OpenVPN .ovpn (nmcli import; OpenVPN needs the + # networkmanager-openvpn plugin, shipped system-side). + # · Tailscale (self-gated on the CLI being present = nomarchy.services.tailscale): + # status is read-only; up/down/exit-node need root, so they go via a + # terminal + sudo (the snapshot-tool pattern), no operator wiring needed. + # Secrets stay in NetworkManager's / Tailscale's own store — no new manager. + nomarchy-vpn = pkgs.writeShellScriptBin "nomarchy-vpn" '' + set -u + BACK="↩ Back" + + conn_rows() { + nmcli -t -f NAME,TYPE,STATE connection show 2>/dev/null \ + | awk -F: '$2=="vpn"||$2=="wireguard"{ printf "%s %s\n", ($3=="activated"?"●":"○"), $1 }' + } + + toggle_conn() { + mark=''${1%% *}; name=''${1#* } + if [ "$mark" = "●" ]; then + nmcli connection down "$name" >/dev/null 2>&1 \ + && notify-send "VPN" "Disconnected $name" || notify-send "VPN" "Could not disconnect $name" + else + nmcli connection up "$name" >/dev/null 2>&1 \ + && notify-send "VPN" "Connected $name" || notify-send "VPN" "Could not connect $name (auth or bad config?)" + fi + } + + import_config() { + file=$( { fd -e conf -e ovpn . "$HOME" --type f --hidden \ + --exclude .git --exclude .cache 2>/dev/null \ + | sed "s|^$HOME/||"; printf '%s\n' "$BACK"; } \ + | rofi -dmenu -p "Import VPN config" ) || return + { [ -z "$file" ] || [ "$file" = "$BACK" ]; } && return + case "$file" in *.ovpn) type=openvpn ;; *) type=wireguard ;; esac + if nmcli connection import type "$type" file "$HOME/$file" >/dev/null 2>&1; then + notify-send "VPN" "Imported $type config: $(basename "$file")" + else + notify-send "VPN" "Import failed ($type plugin missing, or bad file?)" + fi + } + + ts_state() { tailscale status --json 2>/dev/null | jq -r '.BackendState // "Unknown"'; } + ts_exit_node() { + node=$( { printf 'none\n'; tailscale exit-node list 2>/dev/null | awk 'NR>1 && $2 {print $2}'; \ + printf '%s\n' "$BACK"; } | rofi -dmenu -p "Exit node" ) || return + { [ -z "$node" ] || [ "$node" = "$BACK" ]; } && return + [ "$node" = none ] && node="" + ${cfg.terminal} -e sudo tailscale set --exit-node="$node" + } + tailscale_menu() { + while :; do + st=$(ts_state) + choice=$( { + if [ "$st" = Running ]; then printf 'Disconnect\n'; else printf 'Connect\n'; fi + printf 'Exit node…\n' + printf '%s\n' "$BACK" + } | rofi -dmenu -p "Tailscale ($st)" ) || return + case "$choice" in + "$BACK"|"") return ;; + Connect) ${cfg.terminal} -e sudo tailscale up ;; + Disconnect) ${cfg.terminal} -e sudo tailscale down ;; + "Exit node…") ts_exit_node ;; + esac + done + } + + while :; do + have_ts="" + command -v tailscale >/dev/null 2>&1 && have_ts=1 + choice=$( { + conn_rows + printf 'Import config…\n' + [ -n "$have_ts" ] && printf 'Tailscale ›\n' + printf '%s\n' "$BACK" + } | rofi -dmenu -p VPN ) || exit 0 + case "$choice" in + "$BACK"|"") exec nomarchy-menu system ;; + "Import config…") import_config ;; + "Tailscale ›") tailscale_menu ;; + "● "*|"○ "*) toggle_conn "$choice" ;; + esac + done + ''; in { config = lib.mkIf cfg.rofi.enable { home.packages = [ nomarchy-menu + nomarchy-vpn # VPN submenu (NM connect/import + Tailscale) pkgs.fd # files module (fuzzy search over $HOME) pkgs.xdg-utils # xdg-open for the files + web modules pkgs.nodejs # npx, for the Ask Claude module (claude-code from npm) diff --git a/modules/home/waybar.nix b/modules/home/waybar.nix index c84d3de..e2b674e 100644 --- a/modules/home/waybar.nix +++ b/modules/home/waybar.nix @@ -51,6 +51,19 @@ let [ -n "$next" ] && powerprofilesctl set "$next" ''; + # VPN indicator — shows a shield when a NetworkManager VPN/WireGuard + # connection is active OR Tailscale is up; prints nothing otherwise so the + # module self-hides (like nightlight/updates). Click opens the VPN submenu. + vpnStatus = pkgs.writeShellScriptBin "nomarchy-vpn-status" '' + active=$(nmcli -t -f TYPE connection show --active 2>/dev/null | grep -Exm1 'vpn|wireguard') + ts="" + if command -v tailscale >/dev/null 2>&1; then + [ "$(tailscale status --json 2>/dev/null | jq -r '.BackendState // empty')" = Running ] && ts=1 + fi + [ -n "$active" ] || [ -n "$ts" ] || exit 0 + printf '{"text":"󰦝","tooltip":"VPN active (click to manage)","class":"on"}\n' + ''; + # Per-theme override probe. assetDir = config.nomarchy.themesDir + "/${t.slug}"; styleOverride = assetDir + "/waybar.css"; @@ -77,7 +90,7 @@ let modules-left = [ "hyprland/workspaces" "hyprland/window" ]; modules-center = [ "clock" ]; - modules-right = [ "tray" "pulseaudio" "cpu" "memory" "custom/powerprofile" "custom/nightlight" ] + modules-right = [ "tray" "custom/vpn" "pulseaudio" "cpu" "memory" "custom/powerprofile" "custom/nightlight" ] ++ lib.optional showLanguage "hyprland/language" ++ [ "battery" "custom/updates" "custom/notification" ]; @@ -131,6 +144,15 @@ let on-click = "nomarchy-powerprofile-cycle"; }; + # VPN shield — self-hides unless a NM VPN/WireGuard tunnel or Tailscale is + # up. Click opens the VPN submenu (nomarchy-vpn). 5s poll like powerprofile. + "custom/vpn" = { + exec = "nomarchy-vpn-status"; + return-type = "json"; + interval = 5; + on-click = "nomarchy-vpn"; + }; + # Night-light (hyprsunset) indicator. Self-gates: the moon shows only while # the schedule runs; otherwise the status helper prints nothing => hidden # (enable / re-enable from the System menu). Click toggles instantly — writes @@ -226,11 +248,14 @@ let font-weight: bold; } - #tray, #pulseaudio, #cpu, #memory, #custom-powerprofile, #custom-nightlight, #custom-updates, #language, #battery, #custom-notification { + #tray, #pulseaudio, #cpu, #memory, #custom-powerprofile, #custom-nightlight, #custom-updates, #custom-vpn, #language, #battery, #custom-notification { color: @subtext; padding: 0 10px; } + /* VPN active → accent green, reading as "connected / protected". */ + #custom-vpn.on { color: @good; } + /* Night-light active → warm tone, matching the filter it represents. */ #custom-nightlight.on { color: @warn; } @@ -285,5 +310,5 @@ in # The power-profile helpers on PATH, so both the generated bar and the # whole-swap themes' static waybar.jsonc can exec them by name. home.packages = lib.optionals config.nomarchy.waybar.enable - [ powerProfileStatus powerProfileCycle ]; + [ powerProfileStatus powerProfileCycle vpnStatus ]; } diff --git a/modules/nixos/default.nix b/modules/nixos/default.nix index 347c65a..f0f3046 100644 --- a/modules/nixos/default.nix +++ b/modules/nixos/default.nix @@ -127,6 +127,11 @@ in services.fwupd.enable = lib.mkDefault true; networking.networkmanager.enable = lib.mkDefault true; + # OpenVPN support for the VPN menu's import/connect flow (nomarchy-vpn). + # WireGuard needs no plugin (NetworkManager imports wg .conf natively); + # this adds the openvpn type so `nmcli connection import type openvpn` works. + # mkDefault so a downstream can drop it to slim the closure. + networking.networkmanager.plugins = lib.mkDefault [ pkgs.networkmanager-openvpn ]; # No double-unlock on hibernate. Locking the session before sleep is # right for suspend (resumes from RAM, no other gate), but an encrypted diff --git a/themes/summer-day/waybar.jsonc b/themes/summer-day/waybar.jsonc index 094ef74..03e021e 100644 --- a/themes/summer-day/waybar.jsonc +++ b/themes/summer-day/waybar.jsonc @@ -11,7 +11,7 @@ "modules-left": ["custom/launcher", "clock", "clock#date"], "modules-center": ["hyprland/workspaces"], - "modules-right": ["pulseaudio", "custom/powerprofile", "custom/nightlight", "custom/updates", "battery", "tray", "custom/notification", "custom/powermenu"], + "modules-right": ["pulseaudio", "custom/powerprofile", "custom/nightlight", "custom/updates", "battery", "tray", "custom/vpn", "custom/notification", "custom/powermenu"], "hyprland/workspaces": { "disable-scroll": true, @@ -79,6 +79,13 @@ "on-click": "nomarchy-powerprofile-cycle" }, + "custom/vpn": { + "return-type": "json", + "interval": 5, + "exec": "nomarchy-vpn-status", + "on-click": "nomarchy-vpn" + }, + "custom/nightlight": { "return-type": "json", "interval": 3, diff --git a/themes/summer-night/waybar.jsonc b/themes/summer-night/waybar.jsonc index 2b2f78f..d2ce19d 100644 --- a/themes/summer-night/waybar.jsonc +++ b/themes/summer-night/waybar.jsonc @@ -11,7 +11,7 @@ "modules-left": ["custom/nomarchy", "clock", "clock#date"], "modules-center": ["hyprland/workspaces"], - "modules-right": ["idle_inhibitor", "pulseaudio", "custom/powerprofile", "custom/nightlight", "custom/updates", "battery", "tray", "custom/notification", "custom/powermenu"], + "modules-right": ["idle_inhibitor", "pulseaudio", "custom/powerprofile", "custom/nightlight", "custom/updates", "battery", "tray", "custom/vpn", "custom/notification", "custom/powermenu"], "hyprland/workspaces": { "disable-scroll": true, @@ -86,6 +86,13 @@ "on-click": "nomarchy-powerprofile-cycle" }, + "custom/vpn": { + "return-type": "json", + "interval": 5, + "exec": "nomarchy-vpn-status", + "on-click": "nomarchy-vpn" + }, + "custom/nightlight": { "return-type": "json", "interval": 3,