ZAYEC77/wgmenu

★ 0Forks 0SwiftGitHub ↗Compare

README

WGMenu (wgm)

A very small native macOS menu bar client for WireGuard, built on the wireguard-tools CLI you already have. No NetworkExtension, no WireGuardKit, no VPN profiles — just wg-quick driven from the menu bar, with any number of tunnels up at the same time.

┌─────────────────────────────────┐
│ WireGuard             4 active  │
├─────────────────────────────────┤
│ ● office      10.10.0.2  [ ON ] │
│ ○ home        10.20.0.5  [OFF ] │
│ ● home-dev    10.20.1.5  [ ON ] │
│ ○ test        10.99.0.9  [OFF ] │
├─────────────────────────────────┤
│ Reload configs                  │
│ Open config folder              │
│ Settings…                       │
│ Quit                            │
└─────────────────────────────────┘

How it works

SwiftUI MenuBarExtra  (WGMenu.app, LSUIElement)
        │  sudo -n
        ▼
/usr/local/libexec/wgmenu-helper      up | down | remove | status | list | configs
        │  root
        ▼
wg-quick up/down <conf>   +   wg show <utun> dump

State is not guessed. wg-quick on Darwin writes the tunnel → device mapping itself:

/var/run/wireguard/office.name  -> "utun4"
/var/run/wireguard/utun4.sock

<name>.name exists + matching <utun>.sock exists ⇒ the tunnel is up. The .name files are 0400 root:daemon, so reading the utun device needs root, but the directory itself is world-readable — which means on/off state (all the menu needs) is detected with no privileges at all, on a 2 s timer. Only per-tunnel details (utun, endpoint, handshake, RX/TX) cost one sudo call, and they are fetched only for a row you actually expand.

Requirements

  • macOS 14+
  • brew install wireguard-tools (provides wg, wg-quick, wireguard-go)
  • Xcode command line tools (Swift 5.9+)

Build & run

make app            # -> dist/WGMenu.app
make run            # build + launch
make install        # copy to ~/Applications (INSTALL_DIR=/Applications to override)

Then install the privileged helper (asks for your password once):

make install-helper

That does two things:

  • installs helper/wgmenu-helper to /usr/local/libexec/wgmenu-helper, root:wheel 0755
  • writes /etc/sudoers.d/wgmenu (validated with visudo -c):
dmytro ALL=(root) NOPASSWD: /usr/local/libexec/wgmenu-helper

Check everything:

make doctor

Config discovery

The same search paths Darwin wg-quick uses, in the same order (first match wins):

/etc/wireguard
/opt/homebrew/etc/wireguard
/usr/local/etc/wireguard

The helper always passes the full path to wg-quick, so search-path behaviour never matters. Names must match ^[A-Za-z0-9_+=.-]{1,15}$ — wg-quick's own limit.

Security model

wg-quick runs PreUp / PostUp / PreDown / PostDown from a .conf as root. A NOPASSWD rule that could be pointed at an arbitrary config would therefore be an arbitrary-root-code-execution rule. So the helper:

  • accepts exactly up, down, remove, status, list, configs, version — nothing is forwarded to a shell
  • never accepts a pathname; it builds <dir>/<name>.conf from a fixed directory list
  • validates the tunnel name against wg-quick's character class and 15-char limit
  • refuses a config that is a symlink, not owned by root, or group/other-writable — and refuses the same for the directory containing it
  • clears WG_* and DYLD_* from the environment (WG_QUICK_USERSPACE_IMPLEMENTATION alone would otherwise be a root exec primitive) and sets its own PATH
  • never prints private or preshared keys (wg show dump line 1 field 1 is dropped), and configs returns only the Address lines of an [Interface] section — nothing else from the file can reach the GUI

To satisfy the ownership rule for existing configs:

make secure-configs     # chown root:wheel + chmod 600 on every *.conf

After that, editing a config needs sudo — that is the point.

Residual risk worth knowing: Homebrew's bin is user-writable, so root executing /opt/homebrew/bin/wg-quick trusts whatever is in that directory. That is inherent to the wg-quick approach (sudo wg-quick up … by hand has exactly the same property), not something WGMenu adds.

Details for a tunnel that is down

A tunnel does not have to be up to be worth looking at, so the expanded row falls back to the config: endpoint, allowed IPs, persistent keepalive (per [Peer]), plus DNS, MTU and listen port. When the tunnel is up, the live wg show numbers replace the peer rows and DNS/MTU still come from the config.

configs returns a fixed whitelist of fields — Address, DNS, MTU, ListenPort, Endpoint, AllowedIPs, PersistentKeepalive — so PrivateKey and PresharedKey cannot reach the GUI even from a config that holds something unexpected.

Addresses in the row

Each row shows the tunnel's own IP in that network, taken from Address in the config's [Interface] section (IPv4 preferred, mask stripped; the full list, IPv6 included, is in the expanded row). The app reads the config directly when it can, and once make secure-configs has made the files root:wheel 0600 it falls back to a single wgmenu-helper configs call, cached per config mtime — so an edited config is picked up, and idle refreshes cost no sudo at all.

The filter field matches the name, any of the tunnel's addresses, or its utun device, so 10.42, fd42 and utun7 all narrow the list.

The address sits in a fixed-width right-aligned column, and the disclosure chevron has a pinned width too: chevron.right is 7 pt wide and chevron.down is 10 pt, so without that the address slid sideways by 3 pt every time a row was expanded.

Deleting a config

The expanded row has a Delete config button, which asks for confirmation inline (no modal that would dismiss the popover). Three guards, because a WireGuard config holds a private key that cannot be regenerated:

  • the helper refuses while the tunnel is up — its config is also what wg-quick down reads, so deleting it first would leave a tunnel nothing can cleanly stop. The row says "Turn the tunnel off to delete its config" instead of offering the button.
  • nothing is ever unlinked: the file is renamed to <name>.conf.deleted-YYYYmmdd-HHMMSS next to where it was, keeping its root ownership and mode. WGMenu and wg-quick both stop seeing it, and the menu reports the new path.
  • symlinks are refused, and the tunnel name is validated exactly as for up/down.

To reclaim the space for real: sudo rm /etc/wireguard/*.conf.deleted-*.

Pointer feedback

Everything clickable in the popover — the ON/OFF badge, the delete buttons, the footer items, the row itself — shows the link pointer on hover, via pointerStyle(.link) on macOS 15+. On macOS 14 it falls back to NSCursor.pointingHand.push(), which tracks its own push and pops on onDisappear: the list rebuilds every two seconds, so an unbalanced push would leave the cursor stuck as a hand system-wide. A busy (starting/stopping) toggle gets no pointer, since it is not clickable.

List height

The list is capped at what the screen can show rather than at a fixed number of rows: usable screen height minus the header, the footer, the filter field and any banner. With 39 configs at 13 pt that is 727 pt (~31 rows) on a 944 pt screen instead of the fixed 420 pt (~17 rows) it used to be, and on a taller external display the whole list fits without scrolling.

"The screen" is the one under the pointer, not NSScreen.main: the status item is clicked with the pointer, so that is where the popover opens, while NSScreen.main follows keyboard focus and even answers differently before and after AppKit starts up. An outer maxHeight keeps the window on that screen regardless of what the list cap computes.

The subtracted constants are measured, not guessed — WGMenu render --filter <no match> gives the chrome height at a given font size — and WGMenu doctor lays out the real popover in an NSHostingView and reports whether it fits:

✓ list height            up to 727 pt of 944 pt usable (~31 rows at 13 pt)
✓ popover fits           440 × 910 pt on a 944 pt screen

Those are ideal-size numbers. To check the window that actually opens, read it from CGWindowListCopyWindowInfo while the popover is up — a real measurement caught a regression that doctor could not see, where the list collapsed because the panel had a flexible height and the window squeezed it.

doctor takes two what-if overrides, so any display and text size can be checked from wherever you happen to be sitting:

./.build/release/WGMenu doctor -listFontSize 18 -screenHeight 1403

Text size

Settings has a List text size slider (11–18 pt, default 13). Everything in the popover — names, addresses, the ON/OFF badge, the detail rows, the footer — is derived from that one value. Names are monospaced like the addresses, and the panel's width comes from real font metrics for the worst row a config can produce — a 15-character name (wg-quick's own limit) beside a 15-character address such as 192.168.100.254 — so nothing truncates at any text size: 440 pt of content at 13 pt, 453 pt at 18 pt. The popover window adds about 16 pt of padding on each side.

Start at login

Settings has Start at login (an SMAppService login item) and, next to it, Say so when started at login. With the second one on, a launch WGMenu did not get from the user — the login item, or a saved-state resume — posts a "WGMenu is running" notification a second and a half later, pointing at the shield in the menu bar. A launch by hand, from Finder or open, stays silent: the user just clicked the app, so they know.

What tells the two apart is NSApplicationLaunchIsDefaultLaunchKey, which AppKit sets to false for exactly those automatic launches.

The banner goes through Notification Center, so the first automatic start asks for permission — flipping the toggle on in Settings asks right then instead, while you are looking at the window rather than in the middle of a login. If permission is refused, or Notification Center will not talk to the bundle at all, WGMenu draws its own small panel under the status item for six seconds instead; clicking it dismisses it. The same thing a banner would say, minus the permission.

Neither needs a logout to try out:

WGMENU_LAUNCH_NOTICE=1 dist/WGMenu.app/Contents/MacOS/WGMenu       # as if launched at login
WGMENU_LAUNCH_NOTICE=toast dist/WGMenu.app/Contents/MacOS/WGMenu   # the fallback panel
WGMENU_LAUNCH_NOTICE=0 dist/WGMenu.app/Contents/MacOS/WGMenu       # silent

If a menu bar manager is hiding the status item, the notification is the only sign the app came up at all — which is most of the reason it exists.

CLI

The same binary is a small CLI, which is also how the state logic is tested:

./.build/release/WGMenu state          # configs + ON/OFF, no root needed
./.build/release/WGMenu list           # details of running tunnels (via helper)
./.build/release/WGMenu doctor         # check install, sudo rule, config ownership
./.build/release/WGMenu up office      # same code path the menu uses
./.build/release/WGMenu down office
./.build/release/WGMenu remove office  # move its config aside (must be down)
./.build/release/WGMenu render out.png --font 16 --filter 10.42 \
                       --expand office --pending-delete office     # draw the menu to a PNG

v0.1 scope

Included: menu bar app, config auto-discovery, unlimited simultaneous tunnels, ON/OFF per tunnel, the tunnel's own address next to its name, starting/stopping states, real state from /var/run/wireguard/*.name, 2 s auto-refresh, wg-quick error text shown inline, per-tunnel details (interface, endpoint, last handshake, RX/TX), a filter field (name / IP / utun) once there are more than 12 configs, a configurable list text size, deleting a config (confirmed inline, moved aside not erased), Reload configs, Open config folder, Start at login with an optional "WGMenu is running" notification, Quit.

Deliberately not included: config editor, adding configs from the GUI, QR import, traffic graphs, routing/peer editing, WireGuardKit, NetworkExtension.

Troubleshooting

The menu bar icon is missing. If you run Bartender / Ice / another menu bar manager, it is probably hiding it — check its hidden-items list first. To confirm the status item exists at all, pgrep -x WGMenu and look for a WGMenu window in the status layer.

"Helper not installed" / "sudo needs a password". Run make install-helper, then make doctor.

must be owned by root. make secure-configs, or sudo chown root:wheel that one file.

"Helper is outdated". The helper reports a protocol version (wgmenu-helper version → wgmenu-helper 0.3.0 protocol 2) and the app refuses to guess when the installed copy is older than the output it parses: it says so in a banner rather than quietly showing fewer fields, and clicking the banner re-checks after make install-helper. An old helper still supplies what it can — addresses keep working, only the newer fields go missing.

Later: a real privileged helper

The sudoers wrapper can be replaced by an SMAppService-registered LaunchDaemon talking XPC (SMJobBless is deprecated), without changing anything above the WGService layer. That buys provisioning, signing and helper lifecycle work — and zero new VPN features — so it is not in v0.1.

Contributors

ZAYEC77

Issues