🌐 English: this document · Español: README.es.md
One menu bar for every client's VPN — Tailscale accounts, WireGuard tunnels, native macOS VPNs, and anything else with a CLI.
If you juggle several clients' VPNs — a handful of Tailscale accounts, a few WireGuard tunnels, a corporate IKEv2 or two — you end up app-hopping: Tailscale's menu for tailnets, the WireGuard app for tunnels, System Settings for the rest. VPN Switcher puts them all in one menu bar, grouped by provider, one active at a time.
The trick is that it knows nothing about specific VPNs. Every profile is three
shell commands (activate / deactivate / status) in a declarative YAML file, so
it drives anything scriptable — Tailscale fast-switching, WireGuard via
scutil, native macOS VPNs, Mullvad, Cloudflare WARP, OpenVPN… Adding a client
is one block, not a new app. Global hotkeys and a Spotlight-style quick
switcher come on top.

Profiles grouped by provider, ✓ on the active one, hotkeys on the right.

The quick-switch panel (⌃⌥V): type to filter, ↑↓ to move, Enter to switch.
Demo data — fictional companies, not real clients.
VPN Switcher builds from source. A local build is ad-hoc signed and carries no quarantine flag, so it runs without Gatekeeper prompts — no "unidentified developer" wall. One command:
git clone https://github.com/albegosu/vpn-switcher.git
cd vpn-switcher
./install.shinstall.sh checks for Xcode, installs XcodeGen
via Homebrew if needed, builds the app, installs it to /Applications (or
~/Applications) and launches it. Then click the menu bar shield →
Create example config and edit ~/.config/vpn-switcher/profiles.yaml
(recipes in PROVIDERS.md).
Requirements: macOS 15+ and Xcode 16+. (XcodeGen is installed automatically if you have Homebrew.)
To update later, git pull and run ./install.sh again. To uninstall, quit
from the menu and delete VPNSwitcher.app from /Applications; remove
~/.config/vpn-switcher/ to drop your profiles.
Everything is declarative. The app reads
~/.config/vpn-switcher/profiles.yaml; each profile declares a name, a
provider (only used to group profiles in the menu), and three commands:
profiles:
- name: Client A
provider: tailscale
activate_cmd: tailscale switch client-a.example.ts.net ; tailscale up --accept-routes
deactivate_cmd: tailscale down
status_cmd: s=$(tailscale status --json); echo "$s" | grep -q '"BackendState": "Running"' && echo "$s" | grep -A1 '"CurrentTailnet"' | grep -q '"Name": "client-a.example.ts.net"'
- name: Client C
provider: wireguard
activate_cmd: scutil --nc start "client-c-tunnel"
deactivate_cmd: scutil --nc stop "client-c-tunnel"
status_cmd: scutil --nc status "client-c-tunnel" | head -1 | grep -qx ConnectedAdding a new client means adding a block — no code changes. The full template lives in profiles.example.yaml (the menu's "Create example config" button writes the same one), and PROVIDERS.md has ready-to-paste recipes for Tailscale, WireGuard, native macOS VPNs (IKEv2/IPsec/L2TP), Mullvad, Cloudflare WARP and OpenVPN.
status_cmdcontract: exit 0 = active, exit 1 = inactive, any other code = error (shown on the row). This is the natural convention ofgrep -qandtest.- Real status, not your last click: the app polls each
status_cmdeverypoll_intervalseconds (10 by default). Change a VPN from the terminal and the menu reflects it on the next poll. - Global exclusivity (one VPN at a time): activating a profile first runs
the
deactivate_cmdof every other active profile that declares one, regardless of provider. A profile without adeactivate_cmdis left alone. So bringing up a WireGuard tunnel drops the Tailscale tailnets and vice versa. (Switching between two tailnets then does a quickdown+up; drop thedeactivate_cmdif you prefer the mesh to coexist with WireGuard.) - Toggle: activating a profile that is already active deactivates it (runs
its
deactivate_cmd), so re-selecting the active VPN disconnects it. - Commands run through
/bin/sh -cwith the Homebrew paths prepended (/opt/homebrew/bin:/usr/local/bin), because an app launched from Finder does not inherit your shell'sPATH.
Also declarative. Each profile can declare a hotkey: that activates it from
anywhere, plus two top-level hotkeys:
disconnect_all_hotkey: ctrl+opt+0 # take down every active VPN
quick_switch_hotkey: ctrl+opt+v # open the quick switch panel
profiles:
- name: Client A
provider: tailscale
hotkey: ctrl+opt+1
...quick_switch_hotkey opens a floating quick-switch panel (Spotlight /
1Password Quick Access style): type to filter by name or provider, ↑/↓ to
move, Enter to activate the highlighted one (or deactivate it if it's
already active), Esc or a click outside to dismiss. The search field is
focused on open, so you can type immediately.
- Format: modifiers + key joined by
+. Modifiers:cmd,opt(oralt),ctrl,shift. Key: a letter, a digit,space, orf1–f12. At least one non-shift modifier is required. - Hotkeys appear in the menu next to each profile. If another program already
owns a global hotkey, the system refuses the registration and the menu
warns you — it is not stolen. Beware the opposite: a global hotkey that
registers successfully overrides that combo system-wide, including local
shortcuts in other apps (e.g.
cmd+shift+v= paste-without-formatting in many editors). Pick combos you don't use —ctrl+opt+<key>is usually free. - Built on
RegisterEventHotKey(Carbon): no dependencies and no Accessibility permission, unlike global event monitors.
Recommended: the official WireGuard app (App Store). Import the tunnel
there and let VPN Switcher drive it with scutil --nc — the headless
equivalent of the app's Activate button. It talks to the same Apple Network
Extension, so it needs no sudo, no wg-quick, no .conf handling, and
does not run the tunnel's PreUp/PostUp hooks. The name must match
scutil --nc list exactly. The same recipe drives any native macOS VPN
(IKEv2/IPsec/L2TP) from System Settings — see PROVIDERS.md.
The raw wg-quick alternative (only if you can't use the app) needs a scoped
NOPASSWD sudoers rule and runs the tunnel's hooks as root, so review the
.conf first. On Apple Silicon /opt/homebrew is user-writable, so pointing
NOPASSWD at a Homebrew binary would let anything running as you escalate to
root; fully closing that means copying wg-quick and its dependencies
(wg, wireguard-go) into a root-only directory. The official app is simpler
and safer. Always keep the .conf root-owned (chown root:wheel, chmod 600) and out of any repo.
Threat model in short: the app is not a security boundary — it's a wrapper at
the same trust level as your user account. With the recommended setup
(Tailscale via CLI + WireGuard via the official app) no command runs with
privileges: everything runs as you. sudo only appears if you choose the
wg-quick alternative, and there it is the single privilege boundary (review
it yourself).
- Client WireGuard private keys NEVER go in this repo. With the official
app they don't even pass through VPN Switcher (they live in the app's
keychain). With
wg-quick, the.confstays root-owned outside the repo..gitignorealso blocksprofiles.yamland*.confin case one lands here by mistake. The app reads no keys or credentials, makes no network calls of its own, and has no telemetry. profiles.yamlis code, not data: each profile is a command the app runs as you. The app creates it600(directory700) and refuses to load it if other users can write it. Whoever can write that file runs code as you — the same trust level as your.zshrc.- No sandbox, no hardened runtime, ad-hoc signed: deliberate (the app runs
external binaries); it's a personal utility with no distribution. Don't
install third-party builds — compile it yourself with
make build.
Requirements: macOS 15+, Xcode 16+, and
XcodeGen (brew install xcodegen).
make gen # regenerate VPNSwitcher.xcodeproj from project.yml
make build # build with xcodebuild
make run # build and launch the .app
make test # run the test suite
make typecheck # quick type-check without Xcode (Command Line Tools only)The .xcodeproj is not versioned: the project definition lives in
project.yml, so run make gen after cloning (the other targets do it for
you). See CONTRIBUTING.md for how to add a provider.
- SwiftUI
MenuBarExtra(window style), Swift 6 with strict concurrency, one actor (SwitchEngine) that polls and executes, and an@ObservableAppStateon the MainActor as the single source of truth for the UI. - Zero package dependencies: the YAML is parsed by a small purpose-built
parser for the subset used here (
ProfilesConfigParser, unit-tested). - No App Sandbox: the app runs external binaries and reads
~/.config. - A switch survives the menu closing: activation runs in an actor
Task, not the view's, so dismissing the menu mid-switch doesn't abort it. - Subset YAML parser: scalar top-level keys + a
profiles:list of flat maps, with comments and quotes. Unknown keys are ignored (forward compatibility). If full YAML is ever needed, only the parser changes.
- Menu grouped by provider, read from
profiles.yaml - Real-status polling with a ✓ on the active profile
- Tailscale switching end-to-end
- WireGuard / native macOS VPN via
scutil --nc - Global exclusivity (one VPN at a time) and toggle
- Global hotkeys + quick-switch panel
- Status-change notifications (a tunnel going up/down)
- Login item (start at login)
- Signed & notarized release for non-technical users
VPN Switcher's niche is orchestrating many different providers from one declarative config. If that's not your situation, one of these may fit better — and they're worth knowing:
- vpn-bar, vipinator (MIT, archived), and Timac's VPNStatus/vpnutil — native macOS menu bar togglers. Great if every VPN you use is configured in System Settings (IKEv2/IPsec/L2TP); they don't switch Tailscale accounts, drive WireGuard, or run custom commands.
- SwiftBar / xbar — run any shell script in the menu bar. Same "a VPN is just commands" idea, but a blank canvas: you build the grouping, exclusivity and UI yourself. VPN Switcher is that idea, purpose-built.
- Shimo, Viscosity — polished commercial multi-protocol clients. Paid and closed-source, protocol-based rather than command-based, and they allow simultaneous connections rather than one-at-a-time.
- Tailscale, WireGuard, Mullvad, Proton, etc. — each excellent for its own network. VPN Switcher doesn't replace them; it drives them from one place.
- TailMux (multiple tailnets simultaneously) and VPN-Bypass (split-tunnel routing) solve adjacent, different problems.
What's unique here: heterogeneous providers via arbitrary commands, a declarative config, cross-provider "one VPN at a time", and a quick-switch panel — open source and dependency-free.
Recipes for new providers are especially welcome — see PROVIDERS.md for the format. The config is declarative, so most providers need only a tested recipe, not code.
MIT.
