Secure, scriptable command-line VPN client for Cisco AnyConnect and other SSL VPNs, built on OpenConnect — for macOS & Linux.
╔═════════════════════════════════════════════════════════════════╗
║ ║
║ ██╗ ██╗ ██████╗ ███╗ ██╗ ██╗ ██╗ ██████╗ ║
║ ██║ ██║ ██╔══██╗ ████╗ ██║ ██║ ██║ ██╔══██╗ ║
║ ██║ ██║ ██████╔╝ ██╔██╗ ██║ ███████ ██║ ██║ ██████╔╝ ║
║ ╚██╗ ██╔╝ ██╔═══╝ ██║╚██╗██║ ██║ ██║ ██╔═══╝ ║
║ ╚████╔╝ ██║ ██║ ╚████║ ╚██████╔╝ ██║ ║
║ ╚═══╝ ╚═╝ ╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ║
║ ║
║ F O R O P E N C O N N E C T ║
╚═════════════════════════════════════════════════════════════════╝
$ vpn-up start "Frankfurt VPN"
Starting the Frankfurt VPN on frankfurt.simplica.com using Cisco AnyConnect ...
Connecting with Two-Factor Authentication (2FA) from Duo (PUSH) ...
Connected to Frankfurt VPN
$ vpn-up status
VPN is running (PID: 88933)
Profile : Frankfurt VPN
Gateway : frankfurt.simplica.com
Since : 2026-06-12 22:49:14
Uptime : 08:47VPN Up is a secure, scriptable command-line VPN manager for OpenConnect. It connects macOS and Linux machines to Cisco AnyConnect, Palo Alto GlobalProtect, Pulse Secure, Juniper Network Connect, and ocserv gateways straight from the terminal — with named profiles, simultaneous tunnels, Duo 2FA, certificate pinning, secure secret storage, auto-reconnect, and shell completion.
It's built for developers, consultants, DevOps engineers, and remote workers who'd rather drive their VPN from the command line than a vendor GUI.
- Connect to Cisco AnyConnect-compatible VPNs without the official GUI client
- Manage multiple OpenConnect VPN profiles, connect by name, and run compatible profiles side by side (
vpn-up start "Work VPN") - Use Duo 2FA from the command line — push, phone, SMS, or one-time passcode
- Store VPN secrets in the macOS Keychain, Linux Secret Service, or an encrypted OpenSSL vault — never in plaintext config files
- Run VPN connections as launchd/systemd login services with auto-reconnect
- Script VPN startup into your development, automation, and remote-support workflows
VPN Up is useful if you:
- Use OpenConnect instead of vendor VPN clients
- Connect to Cisco AnyConnect, GlobalProtect, Pulse Secure, Juniper, or ocserv VPNs
- Want a terminal-first VPN workflow on macOS or Linux
- Juggle multiple client VPN profiles on one machine, including simultaneous tunnels when their routes can coexist
- Need Duo 2FA support from the command line
- Want secure password storage without plaintext config files
- Want VPN auto-reconnect at login via launchd or systemd
- Why use VPN Up?
- Who is this for?
- Key Features
- VPN Up vs. raw OpenConnect
- Common use cases
- Prerequisites & Installation
- Quick Start
- Usage
- Configuration
- Roadmap
- Related articles
- Contributing & License
- Four SSL VPN protocols via OpenConnect: Cisco AnyConnect (and ocserv), Juniper Network Connect, Palo Alto GlobalProtect, and Pulse Secure
- Multiple VPN profiles and tunnels — pick from an interactive menu or connect by name with zero prompts (
vpn-up start "Work VPN"), with full AuthGroup/realm support; compatible profiles can stay connected at the same time - Duo 2FA —
push,phone,sms, or one-time passcodes prompted at connect time; empty method lets the gateway auto-push - TOTP authenticator-app 2FA — store the seed once and
vpn-upgenerates the code at connect time (viaoathtool); fully non-interactive, so it works as an auto-reconnecting login service - SSO / external-browser login — for gateways that force a browser-based SAML/SSO flow (Okta, Azure AD, Ping Identity, often with an embedded Duo iframe), via OpenConnect's
--external-browser(needs openconnect ≥ 9.0). Because the login runs in your real browser, passkeys / FIDO2 / YubiKey-WebAuthn work automatically - Client-certificate authentication — an X.509 cert/key file or a PKCS#11 smartcard / YubiKey PIV, on its own or alongside a password/SSO; the key passphrase or PIN is kept out of the process table
- Background or foreground execution, per your config
- No plaintext passwords — secrets live in the macOS Keychain, Linux Secret Service, or an AES-256-CBC + PBKDF2 OpenSSL vault; legacy plaintext is migrated and scrubbed automatically, and secrets never appear on command lines or in child-process environments
- Fail-closed server identity —
pin-sha256certificate pinning with a one-command pin fetch, or strict system trust-store validation - The sudo password is never stored — interactive prompt by default, or a sudoers rule scoped to the one
openconnectbinary - Isolated user data — config, profiles, secrets, and logs live in
~/.config/vpn-upwith600/700permissions, untouched by updates or reinstalls
- Profile-aware lifecycle —
statusshows every running tunnel with profile, gateway, and uptime;stopandlogs -ftarget a specific connection - Login service with auto-reconnect — launchd (macOS) / systemd (Linux) supervision; reconnects when the tunnel drops
- Lifecycle hooks — run your own scripts on connect/disconnect (mount shares, switch proxies)
- Desktop notifications on connect/disconnect (Notification Center /
notify-send)
- Guided setup — a first-run wizard plus
add-profile/remove-profile, which handle XML, secrets, pins, and services in one step doctordiagnostics — environment, dependencies, secret backend, and config at a glance- Bash/zsh tab completion for commands and profile names
- Hardened & tested — 100+ tests on macOS + Ubuntu in CI, shellcheck-clean, secret scanning, modern Bash (≥ 4), no
eval
VPN Up is not a replacement for OpenConnect — it's a safer, more convenient wrapper around it for people who want profile management, secure secrets, 2FA prompts, certificate pinning, logs, completion, and service management instead of long ad-hoc command lines.
| Feature | Raw openconnect |
VPN Up |
|---|---|---|
| Multiple named profiles | manual | ✅ |
| Secure secret storage (Keychain/keyring/vault) | — | ✅ |
| Duo 2FA prompts (push/phone/sms/passcode) | manual | ✅ |
| Certificate-pinning helper | manual | ✅ |
| Auto-reconnect login service (launchd/systemd) | manual | ✅ |
Profile-aware status / logs / stop |
— | ✅ |
| Shell completion | — | ✅ |
| Connect/disconnect hooks & notifications | — | ✅ |
It works as a command-line alternative to vendor clients such as the Cisco AnyConnect Secure Mobility Client, GlobalProtect, Pulse Secure, or Juniper Network Connect whenever your gateway speaks an OpenConnect-compatible protocol.
- Connect to a Cisco AnyConnect VPN from the macOS or Linux terminal
- Use OpenConnect with Duo Push authentication
- Manage multiple client VPN profiles on one machine
- Start a VPN automatically at login on macOS (launchd) or Linux (systemd)
- Run OpenConnect with certificate pinning for a known gateway
- Store VPN credentials securely outside plaintext files
- Script VPN startup before running developer tools or remote-support tasks
brew tap sorinipate/vpn-up
brew install vpn-upInstalls the vpn-up command with all dependencies (modern Bash, openconnect, xmlstarlet) and bash completion. Your data stays in ~/.config/vpn-up either way.
macOS ships with Bash 3.2. Install modern Bash and the dependencies:
brew install bash openconnect xmlstarletEnsure Homebrew Bash is first in your PATH:
echo 'export PATH="/opt/homebrew/bin:$PATH"' >> ~/.zprofile
exec $SHELL -lThen clone and run:
git clone https://github.com/sorinipate/vpn-up-for-openconnect.git
cd vpn-up-for-openconnect
chmod +x vpn-up.command# Debian / Ubuntu
sudo apt install bash openconnect xmlstarlet openssl
# RHEL / CentOS
sudo yum install bash openconnect xmlstarlet openssl
# Fedora
sudo dnf install bash openconnect xmlstarlet opensslOptional (recommended): secret-tool for Secret Service keyring storage.
vpn-up setup # one-time configuration wizard
vpn-up add-profile # guided profile creation (+ password, + certificate pin)
vpn-up start # connect (interactive menu)Manual installs: use
./vpn-up.commandinstead ofvpn-upthroughout.
vpn-up start # interactive profile menu
vpn-up start "Frankfurt VPN" # connect directly (scriptable)
vpn-up list # list configured profiles
vpn-up add-profile # guided profile creation (+ secret, + pin)
vpn-up remove-profile "Old VPN" # remove profile + secret + logs + service
vpn-up status # all running profiles, gateways, uptime
vpn-up logs -f # follow the connection log
vpn-up stop # stop all VPNs (or: stop "Frankfurt VPN")Each profile keeps its own log and PID/state files under ~/.config/vpn-up, so status, stop, and logs are profile-aware. You can connect multiple different profiles at once; starting the same profile twice is refused. Whether two tunnels coexist cleanly still depends on the routes and DNS settings pushed by their gateways — split-tunnel or non-overlapping routes are the safest fit.
For gateways that prompt for a time-based one-time code (Google Authenticator, Authy, a hardware TOTP token), store the base32 seed once and vpn-up generates the current code at connect time:
vpn-up set-secret "Work VPN" token_secret # paste the base32 seed…then set <tokenMode>totp</tokenMode> on the profile (the add-profile wizard offers this as a 2FA choice). Requires oathtool (brew install oath-toolkit / apt install oathtool; shown by vpn-up doctor).
- The seed stays in your keychain — it's never passed to openconnect's argv or written to disk; only the short-lived 6-digit code is sent (on stdin), the same path as a Duo passcode.
- Because no interaction is needed, a TOTP profile is the one 2FA method that can run as a login service with auto-reconnect (Duo passcode and SSO can't).
- Security note: storing the TOTP seed beside the password in the same keychain is effectively "1.5-factor" — it's opt-in.
Some gateways don't accept a username/password on the command line at all — they force a real browser window for a SAML/SSO login (Okta, Azure AD, Ping Identity), often wrapping an embedded Duo iframe. vpn-up supports this via OpenConnect's external-browser mode.
Mark a profile as SSO — either answer yes to the "Use SSO / browser-based login?" prompt in vpn-up add-profile, or set <authMode>sso</authMode> in the profile XML:
<VPN>
<name>Work SSO</name>
<protocol>anyconnect</protocol> <!-- anyconnect or gp; not nc -->
<host>vpn.example.com</host>
<authMode>sso</authMode>
</VPN>Then connect normally:
vpn-up start "Work SSO"OpenConnect opens your browser to the identity provider; you complete the login (including Duo) there, and the tunnel comes up. Notes:
-
Requires openconnect ≥ 9.0 (when
--external-browserlanded).vpn-up doctorreports your version and whether SSO is available. -
Runs in the foreground —
BACKGROUNDis ignored, and an SSO profile cannot run as a login service (service installrefuses it) because it needs an interactive desktop session. No password is stored or piped for SSO profiles. -
Linux + sudo caveat: openconnect runs as root, so a root-spawned browser may not reach your desktop session. If the browser doesn't appear, point
vpn-upat a session-aware opener:# ~/bin/vpn-up-browser (chmod +x) #!/bin/sh exec sudo -u "$SUDO_USER" xdg-open "$@"
export VPN_UP_EXTERNAL_BROWSER="$HOME/bin/vpn-up-browser"
VPN_UP_EXTERNAL_BROWSERoverrides the opener command (default:openon macOS,xdg-openon Linux, or the bundledopenconnect-external-browserhelper if installed).
vpn-up service install "Frankfurt VPN" # connect at login, reconnect on drop
vpn-up service status
vpn-up service uninstall "Frankfurt VPN"Uses a launchd user agent on macOS and a systemd user unit on Linux; the service manager supervises openconnect in the foreground and relaunches it if the tunnel drops (30s throttle). Requirements:
- the passwordless sudoers rule — there is no TTY to type a sudo password into
- the profile's password stored in the secrets backend
- a non-interactive 2FA method (
push,phone,sms— notpasscode)
Drop executable scripts into ~/.config/vpn-up/hooks/connected.d/ or ~/.config/vpn-up/hooks/disconnected.d/ and they run (in name order) when a tunnel comes up or goes down. Hooks receive VPN_EVENT, VPN_NAME, and VPN_HOST in their environment (never the password). Because hooks are executable code, a hook is skipped unless it is owned by you and not group/world-writable — same rule as the config file. Hook failures are reported but never block the VPN.
mkdir -p ~/.config/vpn-up/hooks/connected.d
cat > ~/.config/vpn-up/hooks/connected.d/10-hello <<'EOF'
#!/bin/sh
echo "$(date): $VPN_NAME up ($VPN_HOST)" >> "$HOME/vpn-history.log"
EOF
chmod 700 ~/.config/vpn-up/hooks/connected.d/10-helloHomebrew installs completion automatically. For manual installs:
# bash (~/.bashrc)
source /path/to/vpn-up-for-openconnect/completions/vpn-up.bash
# zsh (~/.zshrc)
autoload -U +X bashcompinit && bashcompinit
source /path/to/vpn-up-for-openconnect/completions/vpn-up.bashCompletes commands and profile names (spaces handled).
vpn-up set-secret "Frankfurt VPN" password
vpn-up delete-secret "Frankfurt VPN" passwordPasswords are stored in the OS keychain/keyring (or an encrypted vault as fallback) — never in files, never in the process table, never exported to child processes.
openconnect needs root. By default you'll get the normal sudo prompt. Never store your sudo password anywhere; if you want non-interactive runs, grant passwordless sudo for openconnect only:
# macOS (Homebrew):
echo "$USER ALL=(root) NOPASSWD: /opt/homebrew/sbin/openconnect" | sudo tee /etc/sudoers.d/vpn-up
# Linux:
echo "$USER ALL=(root) NOPASSWD: /usr/sbin/openconnect" | sudo tee /etc/sudoers.d/vpn-up
sudo chmod 440 /etc/sudoers.d/vpn-up
sudo visudo -cf /etc/sudoers.d/vpn-up # validateVerify the
openconnectpath withcommand -v openconnect. Keep the rule scoped to the one binary — do not add broad commands likekillhere. Stopping the VPN will still ask for your sudo password; that's intentional.
vpn-up pin vpn.example.com # print the pin
vpn-up pin --save "Frankfurt VPN" # write it into the profilePrints the gateway's pin-sha256:... value for <serverCertificate>, or saves it into the profile directly with --save. If no pin is configured, the gateway's certificate must validate against the system trust store or the connection is refused (fail closed). Legacy SHA1 pins still work but print a deprecation warning. Verify any pin out-of-band with your VPN administrator before trusting it.
vpn-up doctorNeed an openconnect flag vpn-up doesn't model (--no-dtls, --os=win, --csd-wrapper, an HTTP/SOCKS proxy, MTU, --reconnect-timeout, …)? Set <extraArgs> on the profile (or answer the optional "Extra openconnect arguments" prompt in add-profile); the value is appended verbatim to the openconnect command, just before the gateway host:
<extraArgs>--no-dtls --reconnect-timeout 30</extraArgs>- Tokenized with
xargs, so quotes are respected ("--csd-wrapper=/path with space"stays one argument) — and nevereval'd. - Avoid flags
vpn-upalready manages —--protocol,--user,--passwd-on-stdin,--background,--servercert,--authgroup,--pid-file,--external-browser,--token-mode/--token-secret. Duplicating one prints a warning (it may conflict) but is still passed. ⚠️ openconnect runs as root; some flags execute programs as root (e.g.--csd-wrapper,--script). Only put flags here that you'd be comfortable running undersudoyourself.
Split-tunnel routing: to send only some subnets through the VPN (and the rest direct), set <extraArgs>--script "vpn-slice 10.0.0.0/8 host.corp"</extraArgs> — vpn-slice is a drop-in vpnc-script replacement. Full recipe (include/exclude syntax, hooks alternative, caveats): Split-tunnel routing.
Authenticate with an X.509 client certificate — a file or a PKCS#11 smartcard / YubiKey PIV — on its own (cert-only) or alongside a password/SSO. Set <clientCertificate> (and <clientKey> if the key is separate); each is a file path or a pkcs11: URI:
<clientCertificate>/etc/vpn/me.pem</clientCertificate>
<!-- or a smartcard / YubiKey PIV: -->
<clientCertificate>pkcs11:manufacturer=piv_II;id=%01;type=cert</clientCertificate>- The cert path/URI is not a secret (it stays in the XML); a key passphrase or PKCS#11 PIN is — it never goes on the command line. An encrypted key/token prompts interactively, or store a PKCS#11 PIN for unattended use:
vpn-up set-secret "Work" key_password(fed via a transientpin-sourcefile, never argv). - For a login service, use a PKCS#11 token with a stored PIN, or an unencrypted
0600key file (a passphrase-protected file key can't run unattended — there's no TTY to prompt on). vpn-up doctorreports PKCS#11 (p11-kit) availability. Full guide: docs/client-certificate-auth.
Reach the gateway through a proxy by setting <proxy> on the profile (or answer the optional proxy prompt in add-profile). It's passed to openconnect's --proxy:
<proxy>http://proxy.corp:8080</proxy>
<!-- or SOCKS: -->
<proxy>socks5://127.0.0.1:1080</proxy>- The proxy URL is an identifier, not a secret — it lives in the profile XML.
⚠️ Avoid embeddinguser:pass@in the URL: like any argument, it would be visible in the process table. Prefer a proxy that doesn't require inline credentials.
All user state (config, profiles, secrets vault, logs, PID files) lives in ~/.config/vpn-up (override with VPN_UP_HOME or XDG_CONFIG_HOME), so updating or deleting the program directory never touches your data. Legacy files from the old in-repo config/ directory are migrated automatically on first run.
readonly QUIET=TRUE # openconnect output verbosity
readonly BACKGROUND=TRUE
readonly SHOW_BANNER=TRUE # ASCII banner on start (independent of QUIET)
readonly NOTIFICATIONS=TRUE # desktop notification on connect/disconnect
readonly ENCRYPTION_ENABLED=TRUE
⚠️ ENCRYPTION_KEYis intentionally ignored. OS keychain/keyring is always preferred. The OpenSSL vault prompts for a passphrase when needed.
Seeded from config/vpn-up.command.profiles.default on first run, or created via vpn-up add-profile:
<VPNs>
<VPN>
<name>Frankfurt VPN</name>
<protocol>anyconnect</protocol>
<host>frankfurt.example.com</host>
<authGroup>SimplicaEmployees</authGroup>
<user>your.username</user>
<password></password>
<duo2FAMethod>push</duo2FAMethod>
<serverCertificate>pin-sha256:BASE64_HASH</serverCertificate>
<authMode>password</authMode>
<tokenMode>totp</tokenMode>
<extraArgs>--no-dtls</extraArgs>
<clientCertificate>/etc/vpn/me.pem</clientCertificate>
<clientKey></clientKey>
<proxy>http://proxy.corp:8080</proxy>
</VPN>
</VPNs>Supported tag aliases: username/user, group/authGroup, duoMethod/duo2FAMethod. The <password> field is deprecated: plaintext values are migrated to the secrets backend and blanked in the XML automatically on first use — prefer vpn-up set-secret. A duo2FAMethod of passcode prompts for the one-time code at connect time. <authMode> is password (default) or sso for browser-based SAML/SSO login. <tokenMode> is empty (default) or totp for authenticator-app 2FA. <extraArgs> passes extra openconnect flags verbatim — see Advanced: extra openconnect arguments. <clientCertificate>/<clientKey> enable client-certificate authentication — a file path or a PKCS#11 URI; a key passphrase/PIN goes in the secrets backend (key_password), never the XML. <proxy> routes the connection through an HTTP/SOCKS proxy.
Under consideration (open an issue if you need one of these):
- Yubikey OATH (HOTP) token support (TOTP authenticator codes are already supported — see TOTP authenticator-app 2FA — as is a Yubikey PIV client certificate via client-certificate authentication)
Explicitly out of scope: Windows support, GUI, and RSA SecurID — importing a SecurID token requires passing the token secret to openconnect on the command line (--token-secret), which would break VPN Up's rule of never putting secrets in argv (the same reason TOTP is fed on stdin rather than via --token-secret).
Known behavior: some AnyConnect gateways emit an initial Unexpected 404 result from server — this is benign if the connection proceeds successfully. Simultaneous full-tunnel VPNs can also fight over the default route or DNS. VPN Up tracks and controls each OpenConnect process independently, but OpenConnect and the VPN gateways still own route/DNS installation.
Background and design notes:
- A Safer OpenConnect Workflow for Cisco AnyConnect VPNs on macOS and Linux — Architegrity
- A Safer OpenConnect Workflow for Cisco AnyConnect VPNs on macOS and Linux — Dev.to
- A Safer OpenConnect Workflow for Cisco AnyConnect VPNs on macOS and Linux — Medium
Contributions are welcome — see CONTRIBUTING.md for the workflow (PRs only, shellcheck + test suite must pass on macOS and Ubuntu).
- 📖 Documentation site: sorinipate.github.io/vpn-up-for-openconnect
- 📐 Design docs: PRD.md (what & why) and ARCHITECTURE.md (how)
- 🔐 Security issues: please report privately via SECURITY.md, not public issues
- 📄 Full release history: CHANGELOG.md
- ⚖️ Licensed under the MIT License — © Sorin-Doru Ipate
- ☕ If it saves you time, you can buy me a coffee
Thanks to all contributors and users who helped test and refine this project.