A tray + window GUI for ziti-edge-tunnel (the OpenZiti tunneler) on Linux. Start/stop the tunneler service, enable/disable each identity, enroll new identities from a JWT, and browse every Ziti service with its intercept addresses — inspired by the OpenZiti Desktop Edge app from the macOS tunneler guide, rebuilt for Linux.
Pure Go, no interpreters embedded: the window is Gio
(native Wayland and X11), the tray is fyne.io/systray (StatusNotifierItem
over D-Bus), file dialogs use zenity with xdg-desktop-portal as fallback.
Window (opens on start; re-opening focuses the existing one):
- header with a live status dot and Start / Stop / Restart / Enroll JWT… buttons;
- identity cards on the left with on/off switches (persisted across restarts), per-identity status, service count, MFA/auth warnings;
- the selected identity's services on the right: name, all intercept addresses with ports, dial/bind mode. Click any address to copy the server name to the clipboard (if a service has several addresses, click the one you want);
- footer with versions, IPC state and a service-journal button.
Tray (StatusNotifierItem): colored icon (green = running, orange = running but IPC unreachable, yellow = starting, gray = stopped, red = failed), the same controls, per-identity submenus, diagnostics, journal, quit.
Under the hood
- identities are toggled live via the tunneler IPC (
on_off_identity) and the choice is persisted, so a service restart re-applies it; - contexts that fail to initialize at service start (e.g. the network wasn't ready yet) are detected automatically and re-initialized ~20 s later;
- single instance: launching the binary again focuses the already open window;
- every action is logged to the journal.
- Linux,
systemd(the tunneler is expected to run as a system service — that's how the officialziti-edge-tunnel.deb/.rpm packages install it); - Go 1.25+ to build;
- build deps for Gio (cgo bindings):
gcc,pkg-configand the libraries below (runtime libs are almost always already installed);
| Debian / Ubuntu | Fedora | Arch |
|---|---|---|
libxkbcommon-dev libxkbcommon-x11-dev libx11-dev libx11-xcb-dev libxcursor-dev libxfixes-dev libwayland-dev libegl-dev |
libxkbcommon-devel libxkbcommon-x11-devel libX11-devel libxcb-devel libXcursor-devel libXfixes-devel wayland-devel mesa-libEGL-devel |
libxkbcommon libxkbcommon-x11 libx11 libxcb libxcursor libxfixes wayland mesa |
- at runtime:
polkit(+ an authentication agent) and eitherzenityorxdg-desktop-portalfor the JWT file dialog; a tray host (GNOME/KDE panel, waybar, DankMaterialShell, …) to see the icon; wl-clipboardis optional (extra clipboard fallback under Wayland).
go build -o ziti-gui .
./ziti-gui # tray + window
./ziti-gui --once # one status snapshot as JSON (handy for debugging)
./ziti-gui --identity-dir ~/.ziti/identities --service ziti-edge-tunnel.service
./ziti-gui --versionThe GUI assumes the tunneler runs as a systemd system service (default
ziti-edge-tunnel.service, package layout: binary in /opt/openziti/bin,
identities in /opt/openziti/etc/identities, IPC socket in /tmp/.ziti/).
Everything is auto-detected and can be overridden with flags or
~/.config/ziti-gui/config.json:
{
"identity_dir": "/opt/openziti/etc/identities",
"service": "ziti-edge-tunnel.service",
"ziti_bin": "",
"poll_sec": 3,
"disabled": {}
}- service control —
pkexec systemctl start/stop/restart …; - statuses — the tunneler's own
dumpcommand over its IPC socket, parsed tolerantly (current text-report format of v1.18.x plus older JSON layouts); - enable/disable —
on_off_identitylive + persisted in the config file (written only after a successful call), re-applied after service restarts and late-loading contexts; - enrollment —
add(enroll + load into the running tunneler, token passed as content); when the service is stopped —enrollinto a temp file, then a one-shotpkexec installinto the identities directory and an automatic service restart; - delete — IPC
delete+ removing the identity file viapkexec(two-click confirmation).
Out of the box the tunneler's IPC and its identity directory belong to the
service user (ziti), so the GUI can't see them. The tray/window will offer
“Set up access (password once)”, which runs a single pkexec script that:
- adds your user to the
zitigroup (fully effective after re-login); - grants ACLs on
/tmp/.ziti, its sockets and the identities directory for the current session; - installs a polkit rule
(
/etc/polkit-1/rules.d/50-ziti-gui.rules) allowing start/stop/restart of the tunneler service without a password.
After that the tray is fully self-service: no prompts on everyday actions.
Recommended: a systemd user unit (see deploy/ziti-gui.service, adjust the
binary path):
mkdir -p ~/.config/systemd/user
cp deploy/ziti-gui.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now ziti-gui.service
journalctl --user -u ziti-gui -f # GUI logsThis way the GUI starts with the session, survives crashes
(Restart=always), doesn't inherit no_new_privs from sandboxed shells (a
setuid pkexec would refuse to work — the GUI will tell you if that happens),
and the tray's Quit item stops the unit so it isn't resurrected.
spawn-at-startup in niri's config.kdl or a DE autostart entry work too.
The tunneler intercepts DNS for Ziti names (default range 100.64.0.0/10).
Two pitfalls worth knowing:
-
it needs a real upstream for everything else; if your
resolv.confonly points at the interceptor (some tools do that), name resolution loops and dies. Fix: add--dns-upstream=<your LAN DNS>to the service, e.g. a drop-in:# /etc/systemd/system/ziti-edge-tunnel.service.d/dns.conf [Service] ExecStart= ExecStart=/opt/openziti/bin/ziti-edge-tunnel run --verbose=info \ --dns-ip-range=100.64.0.1/10 --dns-upstream=192.168.1.1 \ --identity-dir=/opt/openziti/etc/identities
-
tools that force their own nameserver into NetworkManager's
[global-dns-domain-*](e.g. a leftover from another VPN client) can point the whole system at the interceptor address; when the tunneler is stopped, DNS dies with it. Listing your LAN DNS as a second server there restores a fallback.
- No tray icon — your bar must support StatusNotifierItem (waybar's
traymodule, GNOME/KDE panels, DankMaterialShell, …). A stale duplicate icon after an unclean kill is shell-side cache: restart your bar (dms restart,pkill waybar && waybar &, …). wl_display.connect failed— the GUI started before the graphical session. The app discovers the Wayland socket itself and retries; on X11 it just usesDISPLAY.- A context didn't come up after a restart — the GUI re-initializes wedged contexts automatically (~20 s); a manual off/on toggle on its card does the same.
- “IPC unreachable” — run Set up access; it's a permissions issue
(group
ziti+ ACLs), not a tunnel fault.
./ziti-gui --once # status snapshot, no tray
go test ./... # parser tests
journalctl --user -u ziti-gui -f # every GUI step is logged
go run ./porttest # raw xdg-desktop-portal FileChooser probemain.go flags, config, single-instance lock
tray.go tray menu and actions
window.go Gio window (identities, services, copy-to-clipboard)
state.go status model, dump parsing, IPC actions
ziti.go systemd / ziti-cli wrappers
portal.go D-Bus: file chooser, notifications
setup.go first-run access setup, diagnostics
icons.go tray icons drawn in code
deploy/ systemd user unit
porttest/ portal debug probe (not needed at runtime)