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 --jsonIt does not store passwords. Instead, sudo is configured to allow only the exact Tailscale on/off commands without a password.
- 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.
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.
Install and authenticate Tailscale first:
sudo apt update
sudo apt install tailscale
sudo tailscale upConfirm Tailscale works:
tailscale status
tailscale status --jsonConfirm GNOME Shell version:
gnome-shell --versionSet 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/"
}
EOFIf 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;
}
}
EOFFind your username:
whoamiCreate 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-sudoersExample 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-sudoersInstall it:
sudo install -m 0440 /tmp/tailscale-toggle-sudoers /etc/sudoers.d/tailscale-toggleTest passwordless commands:
tailscale status --json
sudo -n tailscale down
sudo -n tailscale upImportant: the extension itself uses tailscale status --json without sudo, and only uses sudo for up and down.
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@localCheck status:
gnome-extensions info tailscale-toggle@localOpen the top-right GNOME menu. You should see a Tailscale toggle in Quick Settings.
After editing extension.js or metadata.json, reload GNOME Shell:
gnome-extensions disable tailscale-toggle@local
gnome-extensions enable tailscale-toggle@localIf changes do not appear, log out and log back in.
Disable the extension:
gnome-extensions disable tailscale-toggle@localRemove the extension files:
rm -rf "$HOME/.local/share/gnome-shell/extensions/tailscale-toggle@local"Remove the sudoers rule:
sudo rm /etc/sudoers.d/tailscale-toggleLog out and back in.
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@localCheck Tailscale:
command -v tailscale
tailscale status --jsonIf tailscale status --json fails, fix Tailscale before debugging the extension.
Check the sudoers rule:
sudo visudo -cf /etc/sudoers.d/tailscale-toggle
sudo -n tailscale down
sudo -n tailscale upIf sudo -n fails, the extension cannot toggle Tailscale because GNOME Shell extensions cannot show a terminal password prompt.
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=trueThen 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']Use journal logs:
journalctl --user -f /usr/bin/gnome-shellThen disable and re-enable the extension in another terminal:
gnome-extensions disable tailscale-toggle@local
gnome-extensions enable tailscale-toggle@localUse this checklist when implementing or repairing this extension on a user machine.
- Check the environment first:
gnome-shell --version
command -v tailscale
tailscale version
tailscale status --json- Use the user extension directory:
$HOME/.local/share/gnome-shell/extensions/tailscale-toggle@local- Match
metadata.jsonuuidexactly to the folder name:
tailscale-toggle@local
-
Match
shell-versionto the major GNOME version fromgnome-shell --version. -
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';-
Do not run blocking shell commands from the UI thread. Use
Gio.Subprocesswithcommunicate_utf8_async. -
Do not hardcode passwords. Use a least-privilege sudoers rule for only
/usr/bin/tailscale upand/usr/bin/tailscale down. -
Use
sudo -nin the extension so it fails fast instead of hanging on a password prompt. -
Validate sudoers with
visudo -cfbefore installing. -
Tell the user to log out and back in after creating a new extension directory, especially on Wayland.
-
If
tailscale upneeds flags, update the sudoers rule and JavaScript command together. Sudoers command matching is exact. -
Verify with:
gnome-extensions list --user
gnome-extensions info tailscale-toggle@local
tailscale status --json
sudo -n tailscale down
sudo -n tailscale up-
Avoid destructive changes. Do not remove other extensions. Do not modify unrelated sudoers files.
-
If publishing as a Gist, avoid including real usernames, passwords, tailnet names, node keys, or private status JSON.
Install and authenticate GitHub CLI:
sudo apt install gh
gh auth loginCreate 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"