OpenConnect VPN CLI for macOS with a rich terminal UI. Native AnyConnect-quality behaviour without the bloat: Keychain-backed credentials, Touch ID for sudo, auto-fill OTP codes, sane DNS handling, and zero leftover state when you disconnect.
$ occ connect work
π’ connected β work (vpn.example.com) q Β· Esc disconnect Tab logs
brew install openconnect # required runtime dependency
npm install -g @alexsarrell/occ
occ doctor # verify everything is wired upRequires macOS, Node.js β₯ 18, openconnect.
occ # first run: walks you through profile setup
occ connect # connect to default profile
occ connect work # connect to a named profile
occ stop # disconnectMultiple VPN endpoints, one default. Stored as plain JSON in ~/.occ/profiles.json,
passwords go to macOS Keychain only.
occ profiles add # interactive wizard
occ profiles list
occ profiles default britain # set defaultocc connect needs root to create the tunnel. Instead of typing the password every
time, enable Touch ID via PAM:
occ touchid enable # adds pam_tid.so to /etc/pam.d/sudo_local
# also offers to install pam_reattach so Touch ID
# works in iTerm / tmux / pty (not just Terminal)Once enabled, every sudo prompt β including occ connect β accepts the fingerprint
sensor. Password fallback still works.
If your VPN requires a TOTP second factor, import the secret once and occ connect
will generate codes automatically. No more juggling Google Authenticator on your
phone every time you connect.
# 1. Export from Google Authenticator on your phone:
# menu β Transfer accounts β Export β save the QR
# 2. Decode the QR (Photo Booth + zbar, or any QR reader)
occ totp import 'otpauth-migration://offline?data=...'
occ totp show work # current code (debug)
occ totp uri work --qr # render a QR to import the same secret into
# Apple Passwords / Authy / Raivo / 1PasswordBuilt on otpauth β supports SHA-1/256/512,
6/8 digits, arbitrary period.
Connect / disconnect VPN from anywhere via skhd:
occ hotkeys install # ββ₯βC connect, ββ₯βD disconnect, ββ₯βV menuThe bundled vpnc-script writes only to scutil's Dynamic Store
(State:/Network/Service/<utun>/...). Persistent network preferences in System
Settings are never touched. So even on an ungraceful exit (kernel panic, battery
loss, force-kill), your Wi-Fi DNS stays intact.
For older installs that hit DNS zombies before this design, there's a safety net:
occ heal # one-shot fix
occ heal install # LaunchAgent β runs on every login
occ clean # nuke DNS to DHCP defaults- TypeScript + Ink (React-in-terminal)
node-ptyfor openconnect interactionotpauthfor TOTP- macOS Keychain (via
securityCLI) for secrets - Bundled vpnc-script using
scutilDynamic Store for split-DNS without persistence
| Field | Required | Default | Purpose |
|---|---|---|---|
name |
yes | β | Identifier used as occ connect <name> |
server |
yes | β | VPN server URL, e.g. https://vpn.example.com |
username |
yes | β | VPN login |
keychainService |
yes | openconnect |
Keychain service holding the VPN password (account = username) |
noDtls |
no | true |
Disable DTLS/UDP. More stable on flaky networks; rarely worth turning off |
reconnectTimeout |
no | 300 |
Seconds before openconnect gives up on auto-reconnect |
useDefaultScript |
no | false |
Fall back to openconnect's stock vpnc-script. Enable only when the VPN server requires persistent system-DNS overrides |
totpKeychainService |
no | β | Keychain service holding the TOTP secret. When set, occ connect auto-fills the OTP code instead of prompting |
| Field | Purpose |
|---|---|
profiles |
Array of profile objects |
defaultProfile |
Profile name used when occ connect is invoked without arguments |
{
"profiles": [
{
"name": "work",
"server": "https://vpn.example.com",
"username": "alex",
"keychainService": "openconnect",
"noDtls": true,
"reconnectTimeout": 600,
"totpKeychainService": "occ-totp-work"
},
{
"name": "lab",
"server": "https://lab-vpn.example.com",
"username": "alex",
"keychainService": "openconnect-lab"
}
],
"defaultProfile": "work"
}| Path | Purpose |
|---|---|
~/.occ/profiles.json |
Profile definitions (plain JSON, safe to edit by hand) |
~/.occ/vpnc-script.log |
What the bundled vpnc-script did on connect / disconnect |
~/.occ/last-script-state |
Cached env from the last connect (used during disconnect cleanup) |
~/.occ/.caffeinate.pid |
PID of the active caffeinate keeping the Mac awake |
Keychain openconnect service |
VPN passwords (account = profile username) |
Keychain occ-totp-* services |
TOTP secrets stored as full otpauth:// URIs |
/etc/pam.d/sudo_local |
Touch ID for sudo (managed by occ touchid enable/disable) |
~/.config/skhd/skhdrc |
Global hotkeys (managed via occ hotkeys, only inside the # BEGIN occ-managed block β your other bindings stay intact) |
~/Library/LaunchAgents/com.occ.heal.plist |
Auto-heal LaunchAgent (occ heal install) |
| Variable | Default | Purpose |
|---|---|---|
OCC_CONFIG_DIR |
~/.occ |
Override the directory for profiles.json, logs, and caffeinate state. Useful for tests and dev sandboxes |
git clone https://github.com/alexsarrell/occ.git
cd occ
npm install
npm run build
npm link # use your local build as the global `occ`
npm test # vitest