Skip to content

Instantly share code, notes, and snippets.

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

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

Select an option

Save anxkhn/f8cae3b9e401178c2b4b2c489bbc263b 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.

tailscale up needs custom flags

Some Tailscale setups require exact flags, such as exit nodes, DNS options, or route settings. Test the command manually first:

sudo tailscale up --accept-dns=true

Then update both places:

/etc/sudoers.d/tailscale-toggle
extension.js command array

For example, if the command must be tailscale up --accept-dns=true, sudoers should allow exactly:

YOUR_USERNAME ALL=(root) NOPASSWD: /usr/bin/tailscale up --accept-dns=true, /usr/bin/tailscale down

And the JavaScript command should become:

['sudo', '-n', 'tailscale', 'up', '--accept-dns=true']

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. If tailscale up needs flags, update the sudoers rule and JavaScript command together. Sudoers command matching is exact.

  7. 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.

  2. If publishing as a Gist, avoid including real usernames, passwords, tailnet names, node keys, or private status JSON.

Publish As A GitHub Gist

Install and authenticate GitHub CLI:

sudo apt install gh
gh auth login

Create the Gist:

gh gist create tailscale-gnome-toggle-howto.md --public --desc "GNOME Quick Settings Tailscale on/off toggle extension"

Or create a private Gist:

gh gist create tailscale-gnome-toggle-howto.md --desc "GNOME Quick Settings Tailscale on/off toggle extension"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment