Skip to content

Instantly share code, notes, and snippets.

@anxkhn
Created May 1, 2026 13:53
Show Gist options
  • Select an option

  • Save anxkhn/60274d70f000cfc328e2a07a7b98514c to your computer and use it in GitHub Desktop.

Select an option

Save anxkhn/60274d70f000cfc328e2a07a7b98514c to your computer and use it in GitHub Desktop.
GNOME Quick Settings Tailscale on/off toggle extension

GNOME Quick Settings Tailscale Toggle

This guide creates a small GNOME Shell extension that adds a Tailscale on/off button to the top-right Quick Settings menu, near Wi-Fi, Bluetooth, and VPN controls.

The toggle runs:

sudo -n tailscale up
sudo -n tailscale down
tailscale status --json

It does not store passwords. Instead, sudo is configured to allow only the exact Tailscale on/off commands without a password.

Tested Setup

  • Ubuntu GNOME
  • GNOME Shell 50.1
  • Tailscale 1.96.4
  • GNOME Shell extensions using the modern ES module API

This approach should also work on nearby GNOME versions that support QuickSettings.QuickToggle, but you may need to adjust shell-version in metadata.json.

Security Model

Do not hardcode a password into a GNOME extension.

GNOME extensions run inside the desktop shell process. Putting a password in extension JavaScript exposes it to local users, logs, backups, crash dumps, and accidental sharing.

The safer setup is a narrow sudoers rule:

YOUR_USERNAME ALL=(root) NOPASSWD: /usr/bin/tailscale up, /usr/bin/tailscale down

This allows only these exact commands to run as root without a password. It does not allow arbitrary sudo access.

Prerequisites

Install and authenticate Tailscale first:

sudo apt update
sudo apt install tailscale
sudo tailscale up

Confirm Tailscale works:

tailscale status
tailscale status --json

Confirm GNOME Shell version:

gnome-shell --version

Install The Extension

Set variables:

EXT_UUID="tailscale-toggle@local"
EXT_DIR="$HOME/.local/share/gnome-shell/extensions/$EXT_UUID"
mkdir -p "$EXT_DIR"

Create metadata.json:

cat > "$EXT_DIR/metadata.json" <<'EOF'
{
  "uuid": "tailscale-toggle@local",
  "name": "Tailscale Toggle",
  "description": "Adds a Quick Settings toggle for Tailscale VPN.",
  "shell-version": ["50"],
  "url": "https://tailscale.com/"
}
EOF

If your GNOME version is different, edit shell-version. For example, GNOME 46 usually needs:

"shell-version": ["46"]

Create extension.js:

cat > "$EXT_DIR/extension.js" <<'EOF'
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';

import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import * as QuickSettings from 'resource:///org/gnome/shell/ui/quickSettings.js';

function notify(message) {
    Main.notify('Tailscale', message);
}

function runCommand(argv, callback) {
    let proc;

    try {
        proc = Gio.Subprocess.new(
            argv,
            Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE
        );
    } catch (error) {
        callback(false, '', error.message);
        return;
    }

    proc.communicate_utf8_async(null, null, (source, result) => {
        try {
            const [, stdout, stderr] = source.communicate_utf8_finish(result);
            callback(source.get_successful(), stdout ?? '', stderr ?? '');
        } catch (error) {
            callback(false, '', error.message);
        }
    });
}

const TailscaleToggle = GObject.registerClass(
class TailscaleToggle extends QuickSettings.QuickToggle {
    _init() {
        super._init({
            title: 'Tailscale',
            iconName: 'network-vpn-symbolic',
            toggleMode: true,
        });

        this._busy = false;
        this._refreshTimeout = 0;

        this.connect('clicked', () => this._toggleVpn());
        this._refreshState();
    }

    destroy() {
        if (this._refreshTimeout) {
            GLib.Source.remove(this._refreshTimeout);
            this._refreshTimeout = 0;
        }

        super.destroy();
    }

    _scheduleRefresh() {
        if (this._refreshTimeout)
            GLib.Source.remove(this._refreshTimeout);

        this._refreshTimeout = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 5, () => {
            this._refreshTimeout = 0;
            this._refreshState();
            return GLib.SOURCE_REMOVE;
        });
    }

    _refreshState() {
        runCommand(['tailscale', 'status', '--json'], (ok, stdout) => {
            if (!ok) {
                this.checked = false;
                this.subtitle = 'Unavailable';
                this._scheduleRefresh();
                return;
            }

            try {
                const status = JSON.parse(stdout);
                const state = status.BackendState ?? 'Unknown';
                this.checked = state === 'Running';
                this.subtitle = state;
            } catch (error) {
                this.checked = false;
                this.subtitle = 'Unknown';
            }

            this._scheduleRefresh();
        });
    }

    _toggleVpn() {
        if (this._busy)
            return;

        this._busy = true;
        this.reactive = false;
        this.subtitle = this.checked ? 'Starting...' : 'Stopping...';

        const command = this.checked
            ? ['sudo', '-n', 'tailscale', 'up']
            : ['sudo', '-n', 'tailscale', 'down'];

        runCommand(command, (ok, stdout, stderr) => {
            this._busy = false;
            this.reactive = true;

            if (!ok) {
                const output = (stderr || stdout).trim();
                notify(output || 'Command failed');
            }

            this._refreshState();
        });
    }
});

const TailscaleIndicator = GObject.registerClass(
class TailscaleIndicator extends QuickSettings.SystemIndicator {
    _init() {
        super._init();

        this._indicator = this._addIndicator();
        this._indicator.icon_name = 'network-vpn-symbolic';

        this._toggle = new TailscaleToggle();
        this.quickSettingsItems.push(this._toggle);
    }

    destroy() {
        this.quickSettingsItems.forEach(item => item.destroy());
        super.destroy();
    }
});

export default class TailscaleToggleExtension extends Extension {
    enable() {
        this._indicator = new TailscaleIndicator();
        Main.panel.statusArea.quickSettings.addExternalIndicator(this._indicator);
    }

    disable() {
        this._indicator?.destroy();
        this._indicator = null;
    }
}
EOF

Configure Passwordless Tailscale On/Off

Find your username:

whoami

Create a temporary sudoers snippet:

printf '%s ALL=(root) NOPASSWD: /usr/bin/tailscale up, /usr/bin/tailscale down\n' "$(whoami)" > /tmp/tailscale-toggle-sudoers
chmod 0440 /tmp/tailscale-toggle-sudoers

Example for user alice:

alice ALL=(root) NOPASSWD: /usr/bin/tailscale up, /usr/bin/tailscale down

Validate it before installing:

sudo visudo -cf /tmp/tailscale-toggle-sudoers

Install it:

sudo install -m 0440 /tmp/tailscale-toggle-sudoers /etc/sudoers.d/tailscale-toggle

Test passwordless commands:

tailscale status --json
sudo -n tailscale down
sudo -n tailscale up

Important: the extension itself uses tailscale status --json without sudo, and only uses sudo for up and down.

Enable The Extension

Restart GNOME Shell discovery first. On Wayland, log out and log back in. On X11, Alt+F2, type r, press Enter.

Then run:

gnome-extensions enable tailscale-toggle@local

Check status:

gnome-extensions info tailscale-toggle@local

Open the top-right GNOME menu. You should see a Tailscale toggle in Quick Settings.

Updating The Extension

After editing extension.js or metadata.json, reload GNOME Shell:

gnome-extensions disable tailscale-toggle@local
gnome-extensions enable tailscale-toggle@local

If changes do not appear, log out and log back in.

Uninstall

Disable the extension:

gnome-extensions disable tailscale-toggle@local

Remove the extension files:

rm -rf "$HOME/.local/share/gnome-shell/extensions/tailscale-toggle@local"

Remove the sudoers rule:

sudo rm /etc/sudoers.d/tailscale-toggle

Log out and back in.

Troubleshooting

gnome-extensions enable says the extension does not exist

GNOME Shell has not discovered the new folder yet. Log out and log back in, then retry:

gnome-extensions list --user
gnome-extensions enable tailscale-toggle@local

Toggle says Unavailable

Check Tailscale:

command -v tailscale
tailscale status --json

If tailscale status --json fails, fix Tailscale before debugging the extension.

Toggle asks for password or fails silently

Check the sudoers rule:

sudo visudo -cf /etc/sudoers.d/tailscale-toggle
sudo -n tailscale down
sudo -n tailscale up

If sudo -n fails, the extension cannot toggle Tailscale because GNOME Shell extensions cannot show a terminal password prompt.

View GNOME Shell extension errors

Use journal logs:

journalctl --user -f /usr/bin/gnome-shell

Then disable and re-enable the extension in another terminal:

gnome-extensions disable tailscale-toggle@local
gnome-extensions enable tailscale-toggle@local

Notes For Coding Agents

Use this checklist when implementing or repairing this extension on a user machine.

  1. Check the environment first:
gnome-shell --version
command -v tailscale
tailscale version
tailscale status --json
  1. Use the user extension directory:
$HOME/.local/share/gnome-shell/extensions/tailscale-toggle@local
  1. Match metadata.json uuid exactly to the folder name:
tailscale-toggle@local
  1. Match shell-version to the major GNOME version from gnome-shell --version.

  2. Use GNOME Shell ES module imports for GNOME 45+:

import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import * as QuickSettings from 'resource:///org/gnome/shell/ui/quickSettings.js';
  1. Do not run blocking shell commands from the UI thread. Use Gio.Subprocess with communicate_utf8_async.

  2. Do not hardcode passwords. Use a least-privilege sudoers rule for only /usr/bin/tailscale up and /usr/bin/tailscale down.

  3. Use sudo -n in the extension so it fails fast instead of hanging on a password prompt.

  4. Validate sudoers with visudo -cf before installing.

  5. Tell the user to log out and back in after creating a new extension directory, especially on Wayland.

  6. Verify with:

gnome-extensions list --user
gnome-extensions info tailscale-toggle@local
tailscale status --json
sudo -n tailscale down
sudo -n tailscale up
  1. Avoid destructive changes. Do not remove other extensions. Do not modify unrelated sudoers files.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment