A clean OpenConnect wrapper for Fortinet VPN on macOS.
morti handles login, TOTP generation, reconnects, monitoring, and disconnect flow without the usual manual steps.
- Connect to a Fortinet VPN through OpenConnect
- Generate TOTP codes automatically from your secret
- Read passwords and TOTP secrets from Apple Keychain or 1Password
- Retry failed connections and monitor the tunnel in the background
- Restore your DNS settings on disconnect, so VPN DNS servers never stay behind
- Show clear status, logs, and notifications
- Keep the workflow down to explicit commands:
connect,disconnect,status
Install:
curl -fsSL https://raw.githubusercontent.com/arastu/morti/main/install.sh | bashThen:
nano ~/.morti.conf
source ~/.zshrc
morti connect| Command | Description |
|---|---|
morti connect |
Connect and start the background monitor |
morti disconnect |
Disconnect and stop the monitor |
morti reconnect |
Disconnect and connect again cleanly |
morti status |
Show VPN and monitor state |
morti debug |
Run OpenConnect in the foreground with verbose output |
morti logs |
Tail ~/.morti.log |
morti dns-restore |
Put your DNS settings back after a leftover VPN DNS |
morti setup |
Create a fresh ~/.morti.conf |
morti help |
Show the built-in help card |
curl -fsSL https://raw.githubusercontent.com/arastu/morti/main/install.sh | bashgit clone https://github.com/arastu/morti.git
cd morti
bash install.shbrew install openconnect terminal-notifier oath-toolkit
cp morti.sh ~/.morti.sh
cp morti.conf.example ~/.morti.conf
chmod 600 ~/.morti.conf
echo 'source ~/.morti.sh' >> ~/.zshrc
source ~/.zshrcYour config file lives at ~/.morti.conf.
# Required
MORTI_SERVER="vpn.yourcompany.com"
MORTI_USERNAME="your.username"
MORTI_PASSWORD="your-password"
# MORTI_PASSWORD_CMD="security find-generic-password -w -a your.username -s morti-vpn-password"
# MORTI_PASSWORD_CMD="op item get 'Fortinet VPN' --fields label=password"
# TOTP: choose one
MORTI_TOTP_SECRET="BASE32SECRETHERE"
# MORTI_TOTP_SECRET_CMD="security find-generic-password -w -a your.username -s morti-vpn-totp-secret"
# MORTI_TOTP_SECRET_CMD="op item get 'Fortinet VPN' --fields label=totp_secret"
# MORTI_TOTP_CMD="totp-cli p account"
# MORTI_TOTP_CMD="op item get 'Fortinet VPN' --otp"More options are documented in morti.conf.example, including:
MORTI_SERVERCERTMORTI_REALMMORTI_PING_HOSTMORTI_EXTRA_ARGS
You can keep secrets out of ~/.morti.conf and fetch them at runtime:
security add-generic-password -U -a your.username -s morti-vpn-password -w 'your-password'
security add-generic-password -U -a your.username -s morti-vpn-totp-secret -w 'BASE32SECRETHERE'Then in ~/.morti.conf:
MORTI_PASSWORD_CMD="security find-generic-password -w -a your.username -s morti-vpn-password"
MORTI_TOTP_SECRET_CMD="security find-generic-password -w -a your.username -s morti-vpn-totp-secret"If you use the op CLI:
MORTI_PASSWORD_CMD="op item get 'Fortinet VPN' --fields label=password"
MORTI_TOTP_CMD="op item get 'Fortinet VPN' --otp"OpenConnect needs root privileges to create and manage the tunnel. To avoid repeated password prompts:
sudo visudo -f /etc/sudoers.d/openconnectAdd:
yourusername ALL=(ALL) NOPASSWD: /opt/homebrew/bin/openconnect
yourusername ALL=(ALL) NOPASSWD: /bin/kill
morti connect
-> load ~/.morti.conf
-> generate a fresh OTP
-> clean up any morti-managed session
-> save your current DNS settings
-> start openconnect
-> verify tunnel health
-> start background monitor
morti disconnect
-> stop the monitor
-> ask openconnect to exit cleanly (SIGINT, up to MORTI_STOP_TIMEOUT)
-> restore your DNS settings if the VPN's are still there
If the tunnel drops, the monitor retries the connection automatically.
On macOS the standard vpnc-script does not only set tunnel DNS — it writes the VPN's DNS
servers into your active network service with networksetup -setdnsservers, which is a
persistent System Settings change. OpenConnect only undoes it when it exits gracefully and
re-runs the script with reason=disconnect. A force-kill, crash, sleep or reboot while
connected leaves those DNS servers configured on Wi-Fi or Ethernet long after the tunnel is
gone — and internal resolvers that are no longer reachable break name resolution.
morti closes that gap:
- Saves your per-service DNS settings to
~/.morti.dns.backupbefore connecting - Gives OpenConnect up to
MORTI_STOP_TIMEOUTseconds to run its own cleanup beforekill -9 - Restores the saved settings on disconnect, and again on the next connect, so leftovers from a crashed session heal themselves
- Clears orphaned
State:/Network/Service/utunN/*entries left inscutil - Flags leftovers in
morti status, fixable any time withmorti dns-restore
DNS you changed yourself is left alone. A service is only reset when it carries one of the
addresses the VPN actually pushed — recorded in ~/.morti.dns.vpn while the tunnel is up. With
no such record there is no way to tell a leak from a deliberate change, so morti does nothing.
The one exception is a service that is now empty but had DNS in the snapshot, which happens
because the vpnc-script disconnect hook resets DNS to empty rather than to the previous
value — silently dropping a manually configured resolver. morti only infers that immediately
after tearing down a tunnel, or when you ask for it with morti dns-restore; at any other
moment an empty service is just as likely to be your own switch back to DHCP.
Set MORTI_RESTORE_DNS=0 to turn the automatic restore off. morti dns-restore still works —
an explicit request overrides the setting, since opting out of the automatic behaviour is
exactly why you would run it by hand.
Set these before source ~/.morti.sh if you want to override defaults:
| Variable | Default | Purpose |
|---|---|---|
MORTI_CONFIG_FILE |
~/.morti.conf |
Config file path |
MORTI_LOG_FILE |
~/.morti.log |
Log file path |
MORTI_MAX_RETRIES |
5 |
Retry count |
MORTI_RETRY_DELAY |
3 |
Delay between retries |
MORTI_MONITOR_INTERVAL |
30 |
Health check interval |
MORTI_OPENCONNECT_PATH |
/opt/homebrew/bin/openconnect |
OpenConnect binary path |
MORTI_STOP_TIMEOUT |
10 |
Seconds to wait for a clean OpenConnect exit before kill -9 (minimum 2; anything lower or non-numeric falls back to the default) |
MORTI_RESTORE_DNS |
1 |
Save and restore DNS settings around the tunnel (1/0, true/false, yes/no, on/off) |
MORTI_PASSWORD_CMD |
unset | Command used to fetch the VPN password |
MORTI_TOTP_SECRET_CMD |
unset | Command used to fetch the raw TOTP secret |
MORTI_TOTP_CMD |
unset | Command used to fetch the current OTP directly |
Config file not found
Run:
morti setupor:
cp morti.conf.example ~/.morti.confFailed to generate OTP
Make sure oath-toolkit is installed, or verify your MORTI_TOTP_CMD.
Connection keeps failing
Run:
morti debugIf needed, try MORTI_EXTRA_ARGS="--no-dtls" or pin the server certificate with MORTI_SERVERCERT.
Tunnel is up but traffic is broken
Set MORTI_PING_HOST to an internal reachable IP so the monitor can detect a dead tunnel.
VPN reconnects by itself after morti disconnect
This was caused by orphaned monitors left behind when morti connect was run more than once (fixed — monitors now stop themselves when they lose ownership of ~/.morti.monitor.pid). If you still see it on an older version, find and kill the stray monitor shell, then update:
morti status
rm -f ~/.morti.monitor.pid # any leftover monitor exits on its next checkDNS servers from the VPN are still set after disconnecting
Check and fix:
morti status
morti dns-restoreThis happens when OpenConnect is killed without getting to run its cleanup hook (a crash, a
force-kill, sleep, or a reboot while connected). morti restores your DNS automatically on
morti disconnect and on the next morti connect; morti dns-restore does it on demand.
The baseline it restores lives in ~/.morti.dns.backup.
morti disconnect does not stop the monitor
Check:
morti status
rm -f ~/.morti.monitor.pidThe DNS and shutdown logic has tests that stub networksetup, scutil and ps, so they need
no VPN and never touch your real settings:
zsh tests/dns_test.zsh
zsh tests/stop_test.zshcurl -fsSL https://raw.githubusercontent.com/arastu/morti/main/uninstall.sh | bashOr:
bash uninstall.shMIT
