feat(tailscale): operator-set login user — VPN menu skips the sudo terminal

nomarchy.services.tailscale now makes the login user the Tailscale
operator (services.tailscale.extraSetFlags = ["--operator=<username>"],
mkDefault), so `tailscale up/down/set` — and the System → VPN menu's
Tailscale controls — run without sudo. The user is already in wheel, so
the operator grant is no real new privilege, just skips the prompt.

nomarchy-vpn's Tailscale actions are now operator-aware: a ts_priv
helper runs the subcommand inline and only falls back to a sudo terminal
if the operator grant is absent (downstream dropped it). Connect brings
an authed-but-down node up inline; a node that still needs interactive
login goes to a terminal so the auth URL is visible.

Option description + template comment updated; ROADMAP follow-up closed.
System + home builds green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bernardo Magri
2026-06-23 11:26:15 +01:00
parent 47526ae6e2
commit 431af618cc
4 changed files with 36 additions and 16 deletions

View File

@@ -446,9 +446,10 @@ ${themeRows}
# 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.
# · Tailscale (self-gated on the CLI being present = nomarchy.services.tailscale,
# which makes the login user the operator): status is read-only; up/down/
# exit-node run inline without sudo thanks to the operator grant, falling
# back to a sudo terminal if it's absent. First login goes to a terminal.
# Secrets stay in NetworkManager's / Tailscale's own store — no new manager.
nomarchy-vpn = pkgs.writeShellScriptBin "nomarchy-vpn" ''
set -u
@@ -485,12 +486,19 @@ ${themeRows}
}
ts_state() { tailscale status --json 2>/dev/null | jq -r '.BackendState // "Unknown"'; }
# Run a privileged tailscale subcommand. nomarchy.services.tailscale makes
# the login user the operator, so this runs inline (no sudo); if the operator
# grant is absent (downstream dropped it) it falls back to a sudo terminal.
ts_priv() {
if tailscale "$@" >/dev/null 2>&1; then notify-send "Tailscale" "tailscale $*"
else ${cfg.terminal} -e sudo tailscale "$@"; fi
}
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"
ts_priv set --exit-node="$node"
}
tailscale_menu() {
while :; do
@@ -502,8 +510,10 @@ ${themeRows}
} | 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 ;;
# Authed-but-down bring it up inline; otherwise it needs an
# interactive login, so show it in a terminal (URL visible).
Connect) if [ "$st" = Stopped ]; then ts_priv up; else ${cfg.terminal} -e sudo tailscale up; fi ;;
Disconnect) ts_priv down ;;
"Exit node") ts_exit_node ;;
esac
done

View File

@@ -11,8 +11,9 @@ in
{
options.nomarchy.services = {
tailscale.enable = lib.mkEnableOption ''
Tailscale, the mesh VPN ships the daemon; authenticate once with
`sudo tailscale up`'';
Tailscale, the mesh VPN ships the daemon and makes the login user its
operator, so `tailscale up/down/set` and the System VPN menu work
without sudo. Authenticate once (the menu's Connect, or `tailscale up`)'';
syncthing.enable = lib.mkEnableOption ''
Syncthing continuous file sync, running as the login user add
@@ -125,6 +126,12 @@ in
config = lib.mkMerge [
(lib.mkIf cfg.tailscale.enable {
services.tailscale.enable = true;
# Let the login user drive tailscale (up/down/set — and so the VPN menu's
# Tailscale controls) without sudo. It's already in wheel, so the operator
# grant is no real new privilege, just skips the password prompt. The
# module's tailscaled-set unit applies this after the daemon starts.
# mkDefault so a downstream can drop or replace it (e.g. extraSetFlags = []).
services.tailscale.extraSetFlags = lib.mkDefault [ "--operator=${args.username}" ];
})
(lib.mkIf cfg.syncthing.enable {