A small cross-platform tray client for FortiGate SSL-VPN gateways that authenticate with SAML/SSO. It keeps the tunnel up, reconnects on its own, connects at login, and has a native Settings window — the things the free FortiClient tier does not do.
OpenFortiTray is the convenience layer around a FortiGate SSL-VPN, not a reimplementation:
openconnect --protocol=fortinet moves the packets, your IdP handles credentials, and
OpenFortiTray drives the SAML handshake, supervises the process, and gives it a UI.
- tray icon with tunnel state + Connect / Disconnect
- Status window with live state, the assigned IP, a session clock and a history of the last dozen state changes — the tray menu is a native OS menu and cannot repaint while it is open, so this is where a state change is visible as it happens
- auto-connect at login and auto-reconnect (backoff 15 s → 2 min)
- SAML login in your system browser — silent while your IdP session is alive
- native Settings window: multiple profiles, Basic/Advanced fields, no JSON editing
- light and dark themes that follow your OS setting
- one binary per platform; the only runtime dependency is
openconnect(≥ 8.10, 9.x recommended), which every installer pulls in for you
Authentication: SAML/SSO via external browser is the only method wired in today, and the default for new profiles. Username/password and client-cert appear in Settings but are gated ("not yet supported — use SAML/SSO").
brew install --cask savvaskoualis/tap/openfortitrayThen launch OpenFortiTray, open Settings…, set your gateway host and port, and
Connect. On the first Connect the app asks for your Mac admin password once to
install a small root-owned helper that brings the tunnel up — no separate step. Homebrew
also installs openconnect and clears the download quarantine, so there is no Gatekeeper
prompt.
Download OpenFortiTray-<version>.dmg from Releases, mount it, drag
OpenFortiTray.app to /Applications, then clear the quarantine (the app is ad-hoc
signed but not notarized):
xattr -dr com.apple.quarantine /Applications/OpenFortiTray.app
brew install openconnect # the .dmg does not bundle itFirst Connect installs the helper via the same one-time admin prompt.
From a checkout of this repo:
OPENFORTITRAY_GATEWAY=vpn.example.com:10443 bash scripts/install.shRun as your normal user (it calls sudo itself). It installs openconnect (apt/dnf/
pacman) if missing, the binary to /usr/local/bin/openfortitray, the root-owned helper +
a scoped sudoers rule, and a starter config.json. Then openfortitray &. Needs a
StatusNotifierItem-capable tray and webkit2gtk (4.1 on current distros, 4.0 on older
ones) + GTK3, which render the app's window.
Download OpenFortiTray-<version>-Setup.exe from Releases and run it. SmartScreen
(unsigned) → More info → Run anyway; approve the UAC prompt. The wizard installs to
%ProgramFiles%\openfortitray, adds a Start-menu entry and an elevated logon task, and
bundles openconnect (with its DLLs and wintun.dll) in %ProgramFiles%\openfortitray\openconnect,
so nothing else is needed — Windows has no reliable way to install openconnect otherwise.
Every launch shows a UAC prompt — the app must be elevated to create the wintun adapter.
The login task launches it elevated without a prompt. Open Settings… to set your
gateway. The app uses the bundled openconnect automatically; to point it at a different
build, set "openconnect_path" to its full path in %APPDATA%\openfortitray\config.json.
The app's window renders inside the Microsoft Edge WebView2 Runtime, which ships by
default on Windows 11 and most updated Windows 10 machines. The Setup.exe installs it
for you if it is missing (via Microsoft's own WebView2 bootstrapper); if you run the bare
openfortitray-windows-amd64.exe on a machine without WebView2, install it yourself from
Microsoft's WebView2 page.
Before you connect (all platforms): quit FortiClient — or any other client for this gateway — and disable its login item. FortiGate allows only one SSL-VPN session per user; two clients fight over the routes and it looks like a flaky network.
The tray menu has the current status, Connect / Disconnect, Auto-connect at login, Settings…, View logs, Check for Updates…, and Quit. Icon colour tracks state: grey disconnected, yellow connecting, green connected, red error.
The first connect opens your browser for SAML; later connects reuse the IdP session and are usually silent. If the active profile is not ready to dial (no gateway, bad port, an unsupported auth method), Connect opens Settings on the offending field instead of throwing a cryptic error.
Updates. OpenFortiTray checks GitHub for newer releases in the background. When one
exists the tray shows Update to vX.Y.Z & Restart; clicking it upgrades in place
(brew upgrade on macOS, the verified Setup.exe on Windows) and relaunches.
The Settings window is the primary way to configure; you do not need to edit JSON.
For reference, config.json (schema v2, multi-profile) and openfortitray.log live in:
| OS | directory |
|---|---|
| macOS | ~/Library/Application Support/openfortitray/ |
| Linux | ~/.config/openfortitray/ |
| Windows | %APPDATA%\openfortitray\ |
Key profile fields: gateway (host, no scheme/port — no default), port (10443),
saml_port (8020), auth.method (saml), realm, dtls (true; off →
--no-dtls), dual_stack (false; off → --disable-ipv6), server_cert.mode
(warn/trust/pin, with a sha256 pin), split_dns, remember_session
(true). A minimal config:
{
"schemaVersion": 2,
"activeProfile": "Default",
"profiles": [
{ "name": "Default", "gateway": "vpn.example.com", "port": 10443,
"auth": { "method": "saml" }, "dtls": true, "server_cert": { "mode": "warn" } }
],
"autostart": true
}With remember_session on (the default, "Reuse session to avoid re-login" under
Settings → Advanced), the SSL-VPN session cookie is kept in platform-native,
user-scoped secret storage — the macOS login keychain, a DPAPI-encrypted file on
Windows, a 0600 file on Linux — and reused on reconnect and after a restart, so
the SAML browser flow runs only once per session lifetime instead of on every
connect. The cookie never goes into config.json or the log. Turning the toggle
off, or changing a profile's gateway or auth method, deletes the stored cookie.
On Linux the cookie is protected by file permissions alone (there is no
libsecret dependency).
Split-DNS domains are captured and validated but not yet installed as a scoped resolver —
running alongside Tailscale/another VPN may need a manual /etc/resolver entry until that
automation ships.
openconnect must run as root to create the tunnel and edit routes. On macOS/Linux
OpenFortiTray installs a root-owned helper (/usr/local/libexec/openfortitray-tunnel)
reached through a sudoers rule scoped to that one path. The helper takes only
start <host:port> [flag…] and stop; the gateway is pattern-validated and extra flags
are checked against an exact allowlist (--no-dtls, --disable-ipv6,
--servercert <fingerprint>), so the passwordless rule cannot be turned into arbitrary
root. Full threat model in the header comments of
scripts/openfortitray-tunnel and
scripts/install.sh. On Windows there is no helper — the app runs
elevated and calls openconnect directly.
Start with View logs. Common cases:
- Connect keeps opening Settings — the active profile is not ready; the flagged field names the fix.
- Helper not effective (macOS/Linux) —
sudo -n /usr/local/libexec/openfortitray-tunnel stopmust print nothing and exit 0. If it prompts, re-runscripts/install.shas your normal user (not undersudo). - Routing left broken after a disconnect —
sudo /usr/local/libexec/openfortitray-tunnel stop(idempotent) clears it. On Windows a reconnect/reboot restores it. - Port 8020 in use — free it or change the profile's SAML port (your gateway must allow the new redirect port).
Quit the app first.
macOS
launchctl bootout gui/$(id -u)/io.github.savvaskoualis.openfortitray 2>/dev/null
brew uninstall --cask openfortitray 2>/dev/null || sudo rm -rf /Applications/OpenFortiTray.app
sudo rm -f /usr/local/libexec/openfortitray-tunnel /etc/sudoers.d/openfortitray
rm -rf ~/Library/Application\ Support/openfortitray
# The reused session cookie lives in the login keychain, not in that directory:
security delete-generic-password -s openfortitray 2>/dev/null(Repeat the security delete-generic-password line once per gateway you used —
each gateway stores its own item under the openfortitray service. On Linux and
Windows the stored session lives inside the config directory removed above.)
Linux
sudo rm -f /usr/local/bin/openfortitray /usr/local/libexec/openfortitray-tunnel /etc/sudoers.d/openfortitray
rm -rf ~/.config/openfortitray ~/.config/autostart/openfortitray.desktopWindows (elevated PowerShell)
schtasks /Delete /TN "OpenFortiTray" /F
Remove-Item -Force "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\OpenFortiTray.lnk"
Remove-Item -Recurse -Force "$env:ProgramFiles\openfortitray","$env:APPDATA\openfortitray"The UI (Wails v2) renders through cgo + a native webview on macOS and Linux, so those two
build on their own native toolchain (macOS: Xcode CLT; Linux: gcc + libgtk-3-dev +
libwebkit2gtk-4.0-dev, Wails' default). Windows needs no cgo at all (its webview binding
and the tray backend are pure Go) and builds with CGO_ENABLED=0. CI builds the full
three-OS release matrix; a v* tag publishes signed-per-runner SHA256SUMS, the .dmg,
and the Setup.exe.
make build # go build -o openfortitray ./cmd/openfortitray
make test # go vet ./... && go test -race ./...
make app # macOS: assemble dist/OpenFortiTray.app
make dmg # macOS: build the drag-to-Applications .dmgOn distros that no longer ship libwebkit2gtk-4.0-dev (Ubuntu 24.04+ and others), install
libwebkit2gtk-4.1-dev instead and pass -tags webkit2_41 yourself — make build/make test do not add it automatically: go build -tags webkit2_41 ./cmd/openfortitray, or
GOFLAGS='-tags=webkit2_41' make build / GOFLAGS='-tags=webkit2_41' make test.
MIT. See LICENSE.