VPN ProxyJump
Table of Contents
1. About
A container image combining a VPN client (OpenConnect or OpenFortiVPN) with an OpenSSH server, designed as an SSH ProxyJump host through a VPN tunnel.
Everything is configured at runtime via environment variables. No secrets or SSH keys baked into the image.
local machine
-> ssh -J jumpuser@localhost:2200 internal-host
-> container (VPN + sshd, port 2200)
-> VPN tunnel to vpn.example.com
-> internal-host.corp.example.com
2. Tutorial
2.1. What you will produce
A running container that connects to your VPN and lets you ssh internal-host transparently from your terminal.
2.2. Prerequisites
- Docker or Podman
- An SSH key pair (
~/.ssh/id_ed25519and.pub) - VPN credentials (server hostname, username, password)
2.3. Steps
Build the image:
git clone https://github.com/HaoZeke/vpn-proxyjump.git cd vpn-proxyjump docker build -t vpn-jumphost .
Start the container (interactive, prompts for password):
docker run --rm -it \ --name vpn-jump \ -e VPN_SERVER="vpn.example.com" \ -e VPN_USER="your_username" \ -e SSH_USER_NAME="jumpuser" \ -e SSH_AUTHORIZED_KEY="$(cat ~/.ssh/id_ed25519.pub)" \ --cap-add=NET_ADMIN \ --device=/dev/net/tun:/dev/net/tun \ -p 127.0.0.1:2200:22 \ vpn-jumphost # Type your password when prompted, then TOTP if needed
Add these lines to ~/.ssh/config:
Host vpn-jump
HostName 127.0.0.1
Port 2200
User jumpuser # matches SSH_USER_NAME above
IdentitiesOnly yes
IdentityFile ~/.ssh/id_ed25519
Host internal-host
HostName internal-host.corp.example.com
User your_corp_username
ProxyJump vpn-jump
Connect:
ssh internal-host
That is it. SSH traffic routes through the container's VPN tunnel.
3. How-to guides
3.1. Non-interactive login (password + TOTP)
Pass VPN_PASSWORD and VPN_TOTP as environment variables. Both are piped to OpenConnect via --passwd-on-stdin. Disable reconnection since TOTP codes expire after 30 seconds.
docker run --rm -d \ --name vpn-jump \ -e VPN_SERVER="vpn.example.com" \ -e VPN_USER="your_username" \ -e VPN_PASSWORD="your_password" \ -e VPN_TOTP="123456" \ -e VPN_RECONNECT="false" \ -e SSH_USER_NAME="jumpuser" \ -e SSH_AUTHORIZED_KEY="$(cat ~/.ssh/id_ed25519.pub)" \ -e OPENCONNECT_EXTRA_ARGS="--useragent=AnyConnect" \ --cap-add=NET_ADMIN \ --device=/dev/net/tun:/dev/net/tun \ -p 127.0.0.1:2200:22 \ vpn-jumphost
3.2. File-based password (Docker secrets)
echo "my_vpn_password" > ./secrets/vpn_password docker run --rm -it \ -v ./secrets/vpn_password:/run/secrets/vpn_password:ro \ -e VPN_SERVER="vpn.example.com" \ -e VPN_USER="your_username" \ -e SSH_USER_NAME="jumpuser" \ -e SSH_AUTHORIZED_KEY="$(cat ~/.ssh/id_ed25519.pub)" \ --cap-add=NET_ADMIN \ --device=/dev/net/tun:/dev/net/tun \ -p 127.0.0.1:2200:22 \ vpn-jumphost
Password resolution order: (1) /run/secrets/vpn_password file, (2) VPN_PASSWORD env var, (3) interactive prompt.
3.3. OpenFortiVPN backend
docker run --rm -it \ -e VPN_TYPE="openfortivpn" \ -e VPN_SERVER="vpn.example.com:10443" \ -e VPN_USER="your_username" \ -e SSH_USER_NAME="jumpuser" \ -e SSH_AUTHORIZED_KEY="$(cat ~/.ssh/id_ed25519.pub)" \ --cap-add=NET_ADMIN \ --device=/dev/net/tun:/dev/net/tun \ -p 127.0.0.1:2200:22 \ vpn-jumphost
3.4. TCP port forwarding with socat
Expose an internal web service through the VPN tunnel:
# Start container with extra port mapping docker run --rm -d \ -p 127.0.0.1:2200:22 \ -p 127.0.0.1:8080:8080 \ ... # VPN env vars as above vpn-jumphost # Forward traffic docker exec vpn-jump \ socat TCP-LISTEN:8080,fork,reuseaddr TCP:internal-host:80
Access at http://localhost:8080.
3.5. mosh (mobile shell)
docker run --rm -it \ -p 127.0.0.1:2200:22 \ -p 60000-60010:60000-60010/udp \ ... # VPN env vars as above vpn-jumphost mosh --ssh="ssh -p 2200" jumpuser@localhost
3.6. Persistent SSH host keys
By default the container generates SSH host keys on first start. Mount a named volume to persist them across container restarts and image rebuilds, avoiding known_hosts mismatches:
docker run --rm -d \ -v ssh-host-keys:/etc/ssh/host_keys \ -e VPN_SERVER="vpn.example.com" \ -e VPN_USER="your_username" \ -e SSH_USER_NAME="jumpuser" \ -e SSH_AUTHORIZED_KEY="$(cat ~/.ssh/id_ed25519.pub)" \ --cap-add=NET_ADMIN \ --device=/dev/net/tun:/dev/net/tun \ -p 127.0.0.1:2200:22 \ ghcr.io/haozeke/vpn-proxyjump:main
Keys are generated once on first run and reused on subsequent starts. To force new keys, remove the volume: docker volume rm ssh-host-keys.
3.7. docker-compose
Copy docker-compose.yml, set SSH_AUTHORIZED_KEY in your environment:
export SSH_AUTHORIZED_KEY="$(cat ~/.ssh/id_ed25519.pub)" docker compose up --build
3.8. Pre-built images
docker pull ghcr.io/haozeke/vpn-proxyjump:latest # or docker pull haozeke/vpn-proxyjump:latest docker run --rm -it \ -e SSH_USER_NAME="jumpuser" \ -e SSH_AUTHORIZED_KEY="$(cat ~/.ssh/id_ed25519.pub)" \ -e VPN_SERVER="vpn.example.com" \ -e VPN_USER="your_username" \ --cap-add=NET_ADMIN \ --device=/dev/net/tun:/dev/net/tun \ -p 127.0.0.1:2200:22 \ ghcr.io/haozeke/vpn-proxyjump:latest
4. Reference
4.1. Environment variables
| Variable | Default | Description |
|---|---|---|
VPN_SERVER |
(required) | VPN server hostname (with port for openfortivpn) |
VPN_USER |
(required) | VPN username |
VPN_TYPE |
openconnect |
VPN backend: openconnect or openfortivpn |
VPN_PASSWORD |
(empty) | VPN password (or use secrets file, or interactive) |
VPN_TOTP |
(empty) | TOTP code piped after password for two-factor auth |
SSH_USER_NAME |
jumpuser |
SSH login username (created at runtime if missing) |
SSH_AUTHORIZED_KEY |
(empty) | SSH public key content for authentication |
OPENCONNECT_EXTRA_ARGS |
(empty) | Additional flags passed to openconnect |
FORTIGATE_EXTRA_ARGS |
(empty) | Additional flags passed to openfortivpn |
VPN_RECONNECT |
true |
Auto-reconnect when VPN drops |
VPN_RECONNECT_DELAY |
5 |
Seconds between reconnection attempts |
4.2. Container capabilities
--cap-add=NET_ADMIN and --device=/dev/net/tun are both required. The VPN client creates a tunnel interface and modifies the routing table.
4.3. Health check
Checks for tun0 (OpenConnect) or ppp0 (OpenFortiVPN) every 30 seconds with a 30-second startup grace period.
docker inspect --format='{{.State.Health.Status}}' vpn-jump
4.4. CI/CD
GitHub Actions workflow builds multi-arch images (linux/amd64, linux/arm64) on push to main and publishes to GHCR and Docker Hub on v* tags. Secrets needed: DOCKERHUB_USERNAME, DOCKERHUB_TOKEN.
5. Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| VPN connects, SSH rejected | Account locked (OpenSSH 10+) | Entrypoint unlocks automatically. Check passwd -S user |
| SSH host key changed | Container rebuilt without volume | Mount -v ssh-host-keys:/etc/ssh/host_keys for persistence |
| Too many auth failures | SSH agent offers many keys | Use IdentitiesOnly yes in SSH config |
| VPN "Login failed" | Credentials not piped correctly | Check podman logs for log lines in credential pipe |
| TOTP reconnect fails | Stale TOTP code | Set VPN_RECONNECT=false, restart with fresh code |
/dev/net/tun not found |
TUN module not loaded | sudo modprobe tun |
/proc/.../flush errors |
Read-only /proc in container | Harmless, does not affect VPN |
6. Changelog
6.1. v2.1.0
- SSH host keys generated at runtime instead of build time. Mount
/etc/ssh/host_keysas a named volume for persistence across image rebuilds. - Fixes
known_hostsmismatch every time the GHCR image is rebuilt by CI.
6.2. v2.0.0 (breaking)
- Runtime SSH user and public key via
SSH_USER_NAMEandSSH_AUTHORIZED_KEYenv vars. No more--build-arg USER_PUBLIC_KEY. VPN_TOTPenv var for non-interactive two-factor auth.- Fixed: OpenSSH 10 rejects pubkey auth for locked accounts (
passwd -uafteradduser). - Fixed:
log()wrote to stdout, corrupting credential pipes. Moved to stderr. - Replaced fragile
sedsshdconfig edits with a clean config write.
6.3. v1.0.0
- OpenConnect and OpenFortiVPN backends.
- Auto-reconnection, graceful shutdown, Docker
HEALTHCHECK. - Non-interactive password via env var or secrets file.
- GitHub Actions CI/CD for multi-arch builds.
socatandmoshincluded.
7. License
MIT. See LICENSE.