openvpn3-on-demand
A Claude Code and Codex plugin that brings an OpenVPN3 tunnel up and down on demand — no always-on VPN, no Bash(openvpn3 *) permission grants.
Linux only. Talks to openvpn3-linux over D-Bus via the openvpn3 Python module from openvpn3-client.
Quick start
/plugin marketplace add aeresov/claude-plugins
/plugin install openvpn3-on-demand@aeresov-claude-plugins
/openvpn3-on-demand:setup # writes .claude/openvpn3-on-demand.local.md + .gitignore
Then ask the assistant for something that hits a private resource; the skill connects first. See When does the tunnel come up?. /openvpn3-on-demand:doctor is a read-only health check.
Codex
Register this repository and install openvpn3-on-demand:
codex plugin marketplace add aeresov/claude-plugins
codex plugin add openvpn3-on-demand@aeresov-codex-plugins
Use $openvpn3-on-demand:openvpn3-on-demand-setup and $openvpn3-on-demand:openvpn3-on-demand-doctor in a Codex prompt. The main skill is $openvpn3-on-demand:vpn-on-demand. Both clients use the same skills, implementation and (where applicable) gitignored .claude/ project settings. These workflows need local execution with the prerequisites below. No global Codex configuration is changed by cloning this repository.
Prerequisites
openvpn3-client— D-Bus services and theopenvpn3Python module (Debian/Ubuntu:apt install openvpn3-client).python3-dbus— not pulled in byopenvpn3-client(Debian/Ubuntu:apt install python3-dbus).python3≥ 3.10 anduv(runs the MCP server) onPATH.- One-time netcfg init, so the tunnel's DNS reaches systemd-resolved:
sudo openvpn3-admin init-config --write-configs --force sudo openvpn3-admin netcfg-service --config-set systemd-resolved true sudo killall -INT openvpn3-service-netcfg - Programs resolving through systemd-resolved:
/etc/resolv.confis the stub (Ubuntu's default) orlibnss-resolveis installed. Tailscale, VPN clients and NetworkManager can take that file over — see Troubleshooting.
/openvpn3-on-demand:doctor checks all of this except the Python version. With the tunnel up, /openvpn3-on-demand:doctor <private-hostname> also compares what programs and systemd-resolved get for that host.
Modes
Set exactly one in .claude/openvpn3-on-demand.local.md (both or neither is an error; the skill then skips the VPN):
- BYO —
profile_namenames a profile you imported withopenvpn3 config-import --persistent. Before each start the plugin writes thedns-scope=tunnelbaseline and anyconfig_overridesinto that profile, so they also apply to your ownopenvpn3 session-start. Removing a key fromconfig_overridesdoesn't remove it from the profile: runopenvpn3 config-manage --config <profile_name> --unset-override <key>(openvpn3 config-manage --config <profile_name> --showlists what's set). - Ephemeral —
ovpn_provision_cmdprints the.ovpnbody on stdout. Each VPN-gated turn the plugin runs it, writes the output to a mode-600 file in a private per-user directory, imports it single-use asovpn3-od-<task-id>, connects, and deletes the file.
Commented templates: skills/vpn-on-demand/references/example-local-settings.md.
When does the tunnel come up?
The assistant decides per operation. Nothing in the plugin watches traffic or knows on its own what sits behind your VPN. It goes by:
- Built-in hints — private AWS endpoints (
*.rds.amazonaws.com,*.elasticache.amazonaws.com, …), hostnames ending in.internal,.corp,.privateor.vpc, and private IPs used withssh,kubectl,mysql,psql,curland similar. - Your project's
AGENTS.md/CLAUDE.md/ README — anything described as internal or VPN-only.
Many operations don't show their target in the command: a DB client reading its host from a config file or login path (like the mysql-client plugin), a kubeconfig context, an SSH alias, a make target or script, a secret-store CLI fetching from an internal Vault, any tool or plugin that reads its endpoint from its own settings. Describe those in the project instructions:
- In the project's
AGENTS.md(Codex) orCLAUDE.md(Claude) (best — read every session):The staging database, vault.corp.example and the Kubernetes API are only reachable over the VPN. - Or just ask — "connect the VPN first".
A timeout or "could not resolve host" on something private usually means the tunnel wasn't up; the skill suspects that first. If it was up, see Troubleshooting.
MCP tools
| Tool | Args | Returns |
|---|---|---|
vpn_status | — | {status: ok, session_count, sessions: [...]} |
vpn_connect | profile_name, overrides? | {status: connected | already_connected | error, ...} |
vpn_connect_ephemeral | ovpn_path, session_id, overrides? | {status: connected | already_connected | error, ...} |
vpn_disconnect | profile_name | {status: disconnected | not_connected | error, ...} |
vpn_connect is for imported BYO profiles; vpn_connect_ephemeral imports a fresh .ovpn single-use. The skill passes a retained task identifier as session_id: CLAUDE_CODE_SESSION_ID in Claude or CODEX_THREAD_ID in Codex when available, otherwise a generated UUID. The MCP server cannot infer the calling chat's identity. Both apply a dns-scope=tunnel baseline — split DNS: only the VPN's pushed domains go to its DNS, so inside systemd-resolved it coexists with Tailscale, mDNS and other VPNs. overrides override it; values the profile already holds aren't rewritten.
already_connected means the session is up; one still connecting is waited for. Any other state (paused, failed, dead backend) returns an error naming it and the command that clears it (openvpn3 session-manage --disconnect --config <name>, or openvpn3 session-manage --cleanup for a dead backend). The server never tears down a session it didn't start in that call.
Security
- Ephemeral mode writes the
.ovpnto a mode-600 file at the retained task-specific path —$XDG_RUNTIME_DIR/openvpn3-on-demand/(memory-backed, wiped at logout), or~/.cache/openvpn3-on-demand/when$XDG_RUNTIME_DIRisn't set — hands it to openvpn3 over D-Bus and deletes it. Its contents never enter the transcript. An interrupted turn can leave that file behind. Remove only its retained path; another chat may still be using files in the same directory. .claude/openvpn3-on-demand.local.mdmay hold internal hostnames and commands;/openvpn3-on-demand:setupadds it to.gitignore.- The MCP server exits 1 if
dbusoropenvpn3can't be imported. It installs nothing and never runs as root. - The skill disconnects the configured BYO profile or its own ephemeral profile returned by the connect tool. After a crash the tunnel stays up:
openvpn3 session-manage --disconnect --config <profile_name>(BYO) or… --config "ovpn3-od-<task-id>"(ephemeral).
Troubleshooting
- Anything looks broken —
/openvpn3-on-demand:doctortests every prerequisite and prints the fix. cannot import 'dbus' and/or 'openvpn3'— installopenvpn3-clientandpython3-dbus, restart the client.vpn_connectsays the config is unknown — BYO:openvpn3 config-import --name <profile_name> --persistent --config /path/to/file.ovpn.- Tunnel up, but private names fail, resolve to
0.0.0.0, or connections hang — below.
Settings-file edits apply immediately, except a key removed from config_overrides in BYO mode (see Modes). Refresh/reinstall the plugin after package changes; restart the client if its MCP configuration or skills are stale.
Tunnel up, but private names don't resolve
resolvectl query <host> returns the private address, yet programs get nothing or 0.0.0.0, or hang. Connecting to 0.0.0.0 reaches this machine, so a TLS client (redis-cli --tls, psql with sslmode=require) can hang against a local service with no DNS error.
Two layers have to work:
- The tunnel's DNS reaches systemd-resolved — the netcfg init in Prerequisites.
resolvectl dnslists the VPN's servers on the tunnel link,resolvectl domainits pushed domains. Underdns-scope=tunnelonly those domains go to the tunnel; a VPN that pushes none needsdhcp-option DOMAIN <domain>in its.ovpn. - Programs ask systemd-resolved. glibc programs (redis-cli, psql, mysql, curl, Python, Node's
dns.lookup, Java) followhosts:in/etc/nsswitch.conf:resolvegoes to systemd-resolved;dnsgoes to/etc/resolv.conf, which must be the127.0.0.53stub. If Tailscale, a VPN client or NetworkManager owns that file, programs ask a resolver that knows nothing of the split DNS. Public DNS returns private AWS names' private IPs, but resolvers with DNS-rebinding protection (NextDNS, many routers) answer0.0.0.0; private zones (*.compute.internal, Route 53 private zones) don't resolve at all.
Check with the tunnel up: /openvpn3-on-demand:doctor <private-hostname>, or compare resolvectl query --synthesize=no <host> (systemd-resolved, ignoring /etc/hosts) with getent ahosts <host> (what programs get). The skill runs this check for each private host after connecting and stops before the command if the check fails (programs get 0.0.0.0, nothing, or a different address for a name under the tunnel's domains).
Fixes — all need root; the plugin prints them, never runs them.
- Point
/etc/resolv.confat the stub (recommended; fixes every program on this host):If Tailscale had the file, alsosudo ln -sfn ../run/systemd/resolve/stub-resolv.conf /etc/resolv.confsudo rm -f /etc/resolv.pre-tailscale-backup.confandsudo systemctl restart tailscaled; it then configures its DNS inside systemd-resolved, MagicDNS keeps working, and its catch-all~.loses to the tunnel's more specific domains. If NetworkManager writes the file, first setdns=systemd-resolvedunder[main]in a file in/etc/NetworkManager/conf.d/and restart it. - Install nss-resolve —
sudo apt install libnss-resolveaddsresolve [!UNAVAIL=return]beforednsin/etc/nsswitch.conf(keepmdns4_minimal [NOTFOUND=return]ahead of it). Covers glibc programs only;dig,host,nslookup, cgo-less Go and Node'sdns.resolve*still read/etc/resolv.conf. A good fallback alongside the stub fix. - Containers get neither fix — Docker's default network copies the host's resolver list (the upstream servers when the host uses the stub) without per-link routing; run VPN-bound clients on the host.
- Not recommended: disabling rebinding protection or allowlisting AWS names at your DNS provider (leaks internal names, misses private zones);
tailscale set --accept-dns=false(drops MagicDNS, useless while Tailscale is stuck); a tailnet split-DNS route foramazonaws.com(affects every device, breaks AWS lookups when the tunnel is down).
Tailscale can get stuck. If tailscaled writes /etc/resolv.conf itself ("direct" mode — e.g. it restarted while a VPN client such as Cisco Secure Client had replaced the stub) and then stops uncleanly, it misreads its own leftover file on the next start and fails every DNS update: tailscale status shows running /usr/sbin/resolvconf -m 0 -x -a tailscale: Failed to resolve interface "tailscale". Restarts and reboots don't clear it (tailscale/tailscale#19062); the stub fix does.
/etc/hosts pins and resolver patches cover one name each and go stale when the endpoint moves; the doctor flags a pin for the host you give it. Remove them after the fix.
License
AGPL-3.0-only — the plugin links the AGPL openvpn3 Python module. See LICENSE.