A modern, headless-friendly GlobalProtect VPN client for Linux, written in Rust.
pangolin (CLI binary pgn) connects to Palo Alto Networks
GlobalProtect VPN portals — including modern Prisma Access
deployments that use cloud authentication — without needing a desktop
environment, a graphical browser, or vpn-slice.
Status: early development. Phase 1 (auth → tunnel handshake) is verified end-to-end against a real Prisma Access portal. Phase 2 (routing, DNS, daemon mode, multi-portal management, HIP reports) is implemented and unit-tested; live verification against each feature on production portals is in progress. Windows / macOS support is the main Phase 3 item still outstanding. See Roadmap below.
There are two main open-source options today:
| openconnect + vpnc-script + gp-saml-gui | yuezk/GlobalProtect-openconnect | pangolin | |
|---|---|---|---|
| Tunnel | Native ESP/HTTPS | Native (via libopenconnect) | Native (via libopenconnect) |
| Single binary, no sidecar helpers, no GUI runtime | ❌ (openconnect + vpnc-script + gp-saml-gui Python) |
❌ (gpclient + gpauth helper + webkit2gtk runtime) |
✅ one pgn binary |
| Gateway-aware split tunnel out of the box | ❌ needs vpn-slice add-on, otherwise --only <gw-subnet> sends ESP probes into the tunnel and the session dies in ~20s |
❌ same, needs vpn-slice |
✅ gp-route installs a gateway /32 pin automatically (ports vpn-slice's VPNGATEWAY trick into Rust so the gateway always stays on the physical interface regardless of split-route coverage) |
Prisma Access cloud-auth (globalprotectcallback:) |
✅ via gp-saml-gui | ✅ via gpauth webview window |
✅ two headless modes: --auth-mode paste (browser-of-your-choice callback) and --auth-mode okta (direct Okta API, no browser at all) |
| Multi-instance parallel tunnels | ❌ | ❌ | ✅ pgn connect -i work + pgn connect -i client-a run side-by-side |
| HIP report (Windows / macOS / Linux) | partial (Windows only via script) | partial (Windows only, fixed template) | ✅ gp-hip — OS-aware, emits the category set each platform is expected to report |
| Prometheus metrics endpoint | ❌ | ❌ | ✅ --metrics-port 9100 |
| systemd integration out of the box | ❌ | partial (user-level GUI) | ✅ pangolin@.service template, one unit per saved profile |
| Written in | C + shell + Python | Rust + C | Rust |
-
Headless from the bottom up, by design. pangolin has no embedded browser.
pgn connect --auth-mode pastestarts a tiny local HTTP server on127.0.0.1:29999, prints a URL, and waits for you to complete SAML in whatever browser you already have open — your usual Firefox/Chrome, or a browser on a different machine reached viassh -L 29999:localhost:29999. Copy the finalglobalprotectcallback:URL out of the address bar, paste it back into the terminal, done. No webkit2gtk, no GTK, no X11, no Wayland.pgn --auth-mode oktagoes one step further and drives an Okta tenant's/api/v1/authndirectly, so even the browser step drops out. Thegpauthhelper fromyuezk/GlobalProtect-openconnectcannot do this: its auth window hard-links libwebkit2gtk and needs a display. -
Split tunnel that doesn't die at 20 seconds. If you tell
openconnectoryuezk/gpclientto only route129.94.0.0/16through the VPN, and the gateway's own IP lives inside that/16, the tunnel comes up cleanly and then dies 20 seconds later withGPST Dead Peer Detection detected dead peer!. The classic fix is the third-partyvpn-slicescript, which readsVPNGATEWAYfrom openconnect's environment and pins the gateway IP to the pre-tunnel default route before the split routes land. pangolin does that step natively —gp-route::apply()runsip -4 route get <gw>to resolve the pre-tunnel path, installs a/32host-route for the gateway, and restores whatever was there (or deletes the pin) on disconnect.--onlywith any subnet that contains the gateway Just Works, no extra packages. -
Multi-instance tunnels in parallel. Each
pgn connectis scoped by an--instance <name>flag and gets its own control socket, TUN device, route set, and DNS state. A consultant with three clients can hold three tunnels open at once; no other open-source GlobalProtect client supports that today. Combine with the systemd template unit for one-per-profile services. -
OS-consistent HIP.
gp-hipships plausible HIP profiles for Windows, macOS, and Linux, picked from the session'sclientosidentity so the HTTP header and the HIP XML never disagree. The Windows profile is structurally identical to openconnect's referencetrojans/hipreport.sh; the Linux profile omits the categories that make no sense on Linux (antivirus, anti-spyware, DLP) and reports iptables + nftables- cryptsetup instead.
pangolin used to ship a GTK+WebKit SAML window behind a feature flag. It was removed during the headless-first architecture cleanup for three reasons:
- The dep chain was an ongoing maintenance tax. gtk-rs
bindings for gtk3 are in maintenance mode and pinned to
glib 0.18, which has picked up a handful of
#[deprecated]soundness advisories that we have had to manually triage even when our code couldn't reach the unsafe paths (the most recent beingRUSTSEC-2024-0429). --auth-mode pastecovers the same UX. A desktop user already has a browser open; handing them a URL to click is almost indistinguishable from opening an embedded window, and it has the considerable upside that the user's password manager, bookmarks, and session cookies all work normally.- Deployment story consistency. pangolin targets SSH sessions, systemd units, containers, and hardened production hosts. Making the default build demand a 30-MB GUI runtime was contradicting the README's own pitch.
If you really need an in-process webview — kiosk mode, an IdP that rejects external redirects, a niche scenario we haven't thought of — open an issue with the concrete requirement.
You need:
- Rust 1.89+ (2021 edition) — pinned in
rust-toolchain.toml, sorustupwill install the right version automatically libopenconnect-dev≥ 8.20 (with--protocol=gpsupport)libclang-dev(forbindgen)libssl-dev,libdbus-1-dev
No GUI libraries — pangolin's auth flow runs entirely over stdin + a local HTTP callback, so there's nothing to link against webkit2gtk or GTK.
Debian / Ubuntu:
sudo apt install -y libopenconnect-dev libclang-dev libssl-dev \
libdbus-1-dev pkg-configFedora / RHEL:
sudo dnf install -y openconnect-devel clang-devel openssl-devel \
dbus-devel pkgconf-pkg-configThen:
git clone https://github.com/kyaky/pangolin
cd pangolin
cargo build --release
sudo install -m 0755 target/release/pgn /usr/local/bin/pgnThe resulting binary has ~65 entries in its ldd output and
no runtime dependency on GTK, WebKit, GDK, Soup, Cairo, Pango,
or JavaScriptCore — shrinking the footprint relative to any
GP client that embeds a browser by ~30 MB of shared libraries.
sudo -E pgn connect vpn.example.com --only 10.0.0.0/8pgn will print something like:
┌─ Pangolin — headless SAML authentication ─────────────────────────────────┐
│ Open this URL in any browser (any machine): │
│ http://127.0.0.1:29999/ │
│ │
│ Over SSH? Port-forward first: │
│ ssh -L 29999:localhost:29999 … │
│ │
│ After login, paste the `globalprotectcallback:…` URL here: │
└───────────────────────────────────────────────────────────────────────────┘
Open the printed URL on whatever machine has a browser, complete
your identity provider's flow (Azure AD, Okta, Shibboleth, …),
copy the final globalprotectcallback: URL out of the browser's
address bar and paste it back into the terminal. pgn turns TTY
echo off during the paste so the short-TTL JWT doesn't end up in
script(1) logs, tmux scrollback, or terminal history. The
tunnel comes up with only 10.0.0.0/8 routed through the VPN —
your SSH connection and the rest of your traffic keep their
normal path.
Works identically from a laptop desktop, an SSH session, a tmux pane, a systemd service, a distroless container — no display server required.
sudo -E pgn connect vpn.example.com \
--auth-mode okta \
--okta-url https://my-tenant.okta.com \
--user alicepgn drives Okta's /api/v1/authn transaction directly. Password
comes from --passwd-on-stdin; MFA prompts (TOTP, push, SMS)
are served inline in the terminal.
If you want every byte to go through the VPN, point at a real vpnc-script:
sudo -E pgn connect vpn.example.com \
--vpnc-script /etc/vpnc/vpnc-script(install the vpnc-scripts package first).
pgn connect [PORTAL] [OPTIONS]
Options:
-u, --user <USER> Username (rarely needed for SAML)
--passwd-on-stdin Read password from stdin (non-SAML auth)
--os <OS> Reported OS: win | mac | linux (default: linux)
--esp[=BOOL] Enable ESP/UDP transport (default: on; pass
`--esp=false` as an escape hatch when UDP 4501
is blocked end-to-end and you want CSTP-only)
--insecure Accept invalid TLS certificates
--vpnc-script <PATH> vpnc-compatible script for routes/DNS
--auth-mode <MODE> paste | okta (default: paste)
--okta-url <URL> Okta tenant base URL (required with
`--auth-mode okta`)
--saml-port <PORT> Local port for paste-mode HTTP server (29999)
--hip-script <PATH> Use an external HIP wrapper script instead of
pgn's built-in `hip-report` subcommand
--only <CIDR|IP|HOST> Comma-separated split-tunnel targets
--hip <MODE> HIP reporting: auto (default) | force | off
--reconnect[=BOOL] Keep tunnel alive across short network blips
(10-min libopenconnect reconnect budget)
-i, --instance <NAME> Instance name (drives the control socket
path and lets you run multiple tunnels
in parallel). Default: "default".
pgn status [-i NAME] [--all] Show running session(s). 0 live → disconnected.
1 live → full details. 2+ live → list view,
or pass -i/--all to pick.
pgn disconnect [-i NAME] [--all]
Tear down one or every running session.
Refuses to guess when 2+ are live.
pgn portal add <NAME> --url <URL> [FLAGS] Save a portal profile
pgn portal list List all saved profiles
pgn portal use <NAME> Set the default profile
pgn portal show <NAME> Show one profile's details
pgn portal rm <NAME> Remove a profile
Profiles live in ~/.config/pangolin/config.toml and store any of
the pgn connect flags. Once you've saved one and marked it as
the default, sudo pgn connect (no arguments) will pick it up.
CLI flags always override the profile's settings.
Each pgn connect is scoped by an instance name (defaults to
default). Every instance gets its own control socket at
/run/pangolin/<instance>.sock, its own TUN device, its own
routes, and its own DNS state, so you can run several tunnels
in parallel:
sudo pgn connect -i work work
sudo pgn connect -i client-a client-a
sudo pgn status --all # list every live instance
sudo pgn disconnect -i work # tear down just oneNo other open-source GlobalProtect client (openconnect, yuezk, the official Prisma Access Linux client) supports concurrent tunnels — for consultants / pentesters / migration scenarios, pangolin is the only option.
status and disconnect talk to the running pgn connect
process(es) over Unix control sockets in /run/pangolin/ (mode
0600, owner-only). Because the sockets are created by the
root-owned connect processes, those subcommands also need sudo:
sudo pgn status
sudo pgn disconnectBoth support --json for machine-readable output. Instance names
must match [A-Za-z0-9_-]{1,32}.
packaging/systemd/pangolin@.service is a template unit — one
instance per saved profile, and multiple units run in parallel
without collision.
sudo install -m 0644 packaging/systemd/pangolin@.service \
/etc/systemd/system/pangolin@.service
sudo systemctl daemon-reload
sudo systemctl enable --now pangolin@work.service
sudo systemctl enable --now pangolin@client-a.service # parallel, fully supported
sudo journalctl -u pangolin@work.service -fThe instance name (after the @) is a saved profile name — it
must match [A-Za-z0-9_-]{1,32}, so bare URLs are not supported
as instance names. Save the URL as a profile first. The unit
uses Restart=on-failure with a 15-second backoff, plumbs
stdout/stderr to journald, and relies on SIGTERM → cmd pipe
for clean shutdown (no racy ExecStop=pgn disconnect). See
packaging/systemd/README.md for
the full install + troubleshooting guide.
pangolin is a Cargo workspace. The interesting crates:
| crate | what it does |
|---|---|
gp-proto |
GlobalProtect XML protocol types (no I/O) |
gp-auth |
Authentication providers (Password, SamlPaste, Okta) plus the HTTP client for portal/gateway login. The paste provider turns off TTY echo during input so the short-TTL SAML JWT never ends up in script(1) logs, terminal scrollback, or tmux capture |
gp-tunnel |
Safe wrapper around libopenconnect. Owns the VPN session lifecycle, cancellation via openconnect_setup_cmd_pipe, and a C trampoline for libopenconnect's variadic progress callback (stable Rust can't define one) |
gp-openconnect-sys |
Raw bindgen FFI bindings + the C trampoline shim |
gp-route |
Native route / address / link management via ip(8). Installs and reverts split-tunnel routes after setup_tun_device returns — no shell script in the loop. Automatically installs a /32 host-route pin for the gateway IP before any split route lands (mirrors what vpn-slice does with $VPNGATEWAY), so --only lists that cover the gateway's own subnet don't trigger the 20-second ESP self-loop death that plagues the vanilla openconnect + split-tunnel setup. Saves and restores any pre-existing /32 so it never clobbers a foreign pin |
gp-dns |
Native DNS management. Per-interface resolvectl on systemd-resolved hosts; graceful no-op + warning elsewhere |
gp-ipc |
Unix control socket protocol (serde JSON) for pgn status / pgn disconnect |
gp-hip |
HIP (Host Information Profile) report XML generator. OS-aware: ships Windows / macOS / Linux profiles with plausible antivirus / firewall / disk-encryption / disk-backup entries, picked by the caller's --client-os choice so the HIP XML and the HTTP clientos header always agree. Submission happens via libopenconnect's csd-wrapper slot so the HIP client_ip always matches the gateway's view of the session |
gp-config |
~/.config/pangolin/config.toml schema and atomic load/save. Drives pgn portal add/rm/list/use/show |
bins/pgn |
The CLI, tokio-based |
Architecture rule of thumb: libopenconnect handles the tunnel,
Rust handles everything else. That includes authentication, portal
config, gateway selection, HIP, route installation, and reconnect
policy. We never reimplement ESP/UDP, never shell out to the
openconnect binary, and never run a Python helper script.
- Workspace scaffold + libopenconnect FFI
- GP protocol types and XML parsing
- Password + SAML paste-mode auth providers
- Prisma Access
globalprotectcallback:JWT capture pgn connectend-to-end: prelogin → SAML → portal config → gateway login → CSTP → TUN → DPD keepalives--onlyclient-controlled split tunnel, hostname + CIDR aware- Clean Ctrl-C cancellation via
openconnect_setup_cmd_pipe
Everything below is landed, unit-tested, and clippy-clean. Items marked with the footnote still need live verification against a production portal before they can be called production-ready.
pgn status/pgn disconnectvia unix control socket- Native route management (
gp-route) —ip(8)for now, rtnetlink later - Native DNS management (
gp-dns) — systemd-resolved backend; resolvconf / direct-resolv.conf later - HIP report generation (
gp-hip) — XML generator, submitted through libopenconnect's csd-wrapper slot (our ownpgnbinary is registered viaopenconnect_setup_csd, so the wrapper runs inside libopenconnect's session and inherits the liveclient_ip, avoiding the getconfig-round-trip mismatch that plagued the earlier HTTP-submission path) ¹ - Multi-portal profiles (
gp-config+pgn portal add/use/list/ show/rm,~/.config/pangolin/config.toml)
¹ Not yet exercised against a gateway that actually enforces HIP.
Application-level auto-reconnect state machine✅ When--reconnectis on, pangolin now retries the tunnel up to 10 times with exponential backoff (5s → 10s → ... → 5min cap), keeping the IPC control socket and metrics endpoint alive across retries. State flips toReconnectingduring backoff sopgn statusreflects reality. Re-auth on cookie expiry is the remaining sub-item (Phase 2c) — the current loop re-uses the existing gateway cookie, which covers the common case where libopenconnect's internal reconnect window was simply too short.systemd unit✅ (template atpackaging/systemd/pangolin@.service)Multi-instance parallel tunnels✅ (per-instance control sockets ingp-ipc,pgn connect --instance <name>,pgn status --all,pgn disconnect --all)Prometheus metrics endpoint✅ (pgn connect --metrics-port 9100exposespangolin_session_info,pangolin_session_state,pangolin_session_uptime_seconds,pangolin_reconnect_attempts_total,pangolin_tunnel_restarts_total, and more)
- Re-auth on cookie expiry for the auto-reconnect loop (gateway cookie re-issue without asking the user to re-do SAML when possible)
- Metrics endpoint TLS (rustls-based) for off-host scrapes
Okta headless auth (no browser, even for the IdP step)✅ Implemented as--auth-mode okta --okta-url https://tenant.okta.com. Drives/api/v1/authndirectly with full state-machine support for password, TOTP, SMS, push, andPASSWORD_WARNskip. Push factor polls until the user taps approve (or the device times out). Pure Okta state machine is unit-tested against canned API responses; the GP-portal SAML handoff (post-Okta sessionCookieRedirect → portal headers) still needs live verification against a real customer Okta+GP pairing — the code is there, the wire format is from_refs/pan-gp-oktaplus Okta API docs, but no integration test environment has been available. Webauthn / FIDO2 is deferred.- Client certificate auth (PEM / PKCS#12)
- FIDO2 / YubiKey
- macOS, Windows
- NetworkManager plugin
Issues and PRs welcome. Before sending a patch:
cargo fmt --all
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspaceThe project intentionally has very few dependencies — please justify new crates in the PR description.
Dual-licensed under either of:
- Apache License 2.0 (see LICENSE-APACHE)
- MIT License (see LICENSE-MIT)
at your option.
pangolin is not affiliated with, endorsed by, or sponsored by Palo
Alto Networks. "GlobalProtect" and "Prisma Access" are trademarks of
their respective owners.