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_ed25519 and .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_keys as a named volume for persistence across image rebuilds.
  • Fixes known_hosts mismatch every time the GHCR image is rebuilt by CI.

6.2. v2.0.0 (breaking)

  • Runtime SSH user and public key via SSH_USER_NAME and SSH_AUTHORIZED_KEY env vars. No more --build-arg USER_PUBLIC_KEY.
  • VPN_TOTP env var for non-interactive two-factor auth.
  • Fixed: OpenSSH 10 rejects pubkey auth for locked accounts (passwd -u after adduser).
  • Fixed: log() wrote to stdout, corrupting credential pipes. Moved to stderr.
  • Replaced fragile sed sshdconfig 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.
  • socat and mosh included.

7. License

MIT. See LICENSE.

Analytics by Umami provided by TurtleTech ehf