Astrixion Docs

Attach VPN Proxy to a container

Route a Linux container's IPv4 TCP and UDP traffic through an assigned VPN exit.

VPN Proxy can attach transparent egress to a Linux container without changing its image. A host-side forwarder creates a TUN device in the container's network namespace, replaces that namespace's default route, and carries packets over an authenticated WebSocket to the central proxy. The container does not receive VPN credentials and does not need HTTP_PROXY, an SDK, or a VPN client.

Current network constraint

The container image and application can be arbitrary, but the current forwarder is not yet network-topology agnostic. It expects an internal Docker network on 172.30.0.0/24 with gateway 172.30.0.1, and installs a bypass route for the host control-plane gateway at 172.17.0.1. Use the topology below until those addresses become configurable.

Before you start

You need:

  • a Linux Docker host with systemd, /dev/net/tun, nsenter, ip, and sysctl;
  • host root access, including permission to inspect the target container;
  • the VPN Proxy binary installed on the host;
  • a root-protected client configuration supplied by the proxy operator; and
  • a canonical, lowercase, non-zero UUID for the workload.

The workload UUID is routing identity, not a secret. Keep it stable when a logical workload should retain its assigned exit. Never reuse one UUID for two live containers: the server admits only one active tunnel per UUID.

The client configuration stays on the host and must never be mounted into the workload container:

{
  "url": "wss://PROXY_HOST/agent-network",
  "key": "OPERATOR_SUPPLIED_SHARED_KEY"
}

Store it as /etc/misanthropic-proxy/client.json, owned by root and mode 0600. The compatibility path and protocol headers still use the Misanthropic name. The shared key can assert any workload UUID, so treat it as a host credential rather than a per-container credential.

Prepare a fail-closed container network

Reuse misanthropic-agent-proxy when it already exists with the expected settings. Otherwise, create an internal network:

docker network create \
  --internal \
  --subnet 172.30.0.0/24 \
  --gateway 172.30.0.1 \
  vpn-proxy-egress

An internal network prevents the workload from bypassing the proxy before the forwarder is ready or after it disconnects. Do not attach the container to a second network with direct Internet access. Supply an external IPv4 resolver through a bind-mounted resolv.conf; Docker's embedded resolver would otherwise send DNS through the host rather than through the workload's tunnel.

install -d -m 0700 /etc/misanthropic-proxy /run/misanthropic-proxy
printf 'nameserver 1.1.1.1\noptions timeout:2 attempts:2\n' \
  > /etc/misanthropic-proxy/resolv.conf
chmod 0644 /etc/misanthropic-proxy/resolv.conf

Attach a container

Start the workload on only the internal network. This example uses a disposable shell container; replace the image and command with the real workload.

CONTAINER=my-workload
NETWORK=vpn-proxy-egress # or misanthropic-agent-proxy
WORKLOAD_ID=$(cat /proc/sys/kernel/random/uuid)
 
docker run -d --name "$CONTAINER" \
  --network "$NETWORK" \
  --mount type=bind,src=/etc/misanthropic-proxy/resolv.conf,dst=/etc/resolv.conf,readonly \
  YOUR_IMAGE:YOUR_TAG
 
PID=$(docker inspect --format '{{.State.Pid}}' "$CONTAINER")
test "$PID" -gt 0
NETNS="/proc/$PID/ns/net"
READY="/run/misanthropic-proxy/$WORKLOAD_ID.ready"

Run one host-side forwarder for that network namespace:

systemd-run \
  --unit="vpn-proxy-$WORKLOAD_ID" \
  --property=Restart=on-failure \
  --property=RestartSec=5s \
  /usr/local/lib/misanthropic-proxy/current/misanthropic-proxy forward \
    --config /etc/misanthropic-proxy/client.json \
    --netns "$NETNS" \
    --agent "$WORKLOAD_ID" \
    --ready-file "$READY"

The forwarder creates misproxy0, disables IPv6 in the namespace, and routes public IPv4 TCP and UDP through the central server. Private, loopback, link-local, multicast, documentation, and other non-public destinations are rejected by the central packet stack rather than sent through a VPN.

Wait for readiness

Do not mark the workload ready or give it jobs until the readiness file exists and identifies the current namespace:

for attempt in $(seq 1 60); do
  test -f "$READY" && test "$(cat "$READY")" = "$NETNS" && break
  sleep 1
done
test -f "$READY" && test "$(cat "$READY")" = "$NETNS"

The forwarder writes readiness only after the authenticated WebSocket and packet pumps are live. It removes the file whenever the connection drops. A missing file means egress is unavailable; it does not mean traffic has fallen back to Direct.

For workloads that must not start processing early, make the orchestrator's health/readiness gate depend on this check. The internal network still prevents direct egress while the tunnel is down.

Assign and verify the route

On first connection, the server persists a preferred VPN assignment using its available-route policy. It can temporarily use Direct when that VPN is unavailable. An operator can instead persist an explicit Direct or VPN assignment from the proxy server:

/usr/local/lib/misanthropic-proxy/current/misanthropic-proxy \
  admin assign "$WORKLOAD_ID" direct "my workload"
# Or choose an available configured VPN connection in the operator dashboard.

Verify the namespace and public exit:

docker exec "$CONTAINER" ip -4 route
docker exec "$CONTAINER" sh -c 'cat /proc/sys/net/ipv6/conf/all/disable_ipv6'
docker exec "$CONTAINER" curl -4fsS https://ipwho.is/ # or use the image's HTTP client
 
/usr/local/lib/misanthropic-proxy/current/misanthropic-proxy admin ip "$WORKLOAD_ID"
systemctl --no-pager --full status "vpn-proxy-$WORKLOAD_ID"

Expect the default IPv4 route to use misproxy0, IPv6 to report disabled, and the container's observed public IP to match the proxy server's admin ip result.

Stop cleanly

Stop the workload first so it cannot receive work after its tunnel starts shutting down, then stop the forwarder:

docker stop "$CONTAINER"
systemctl stop "vpn-proxy-$WORKLOAD_ID"
systemctl reset-failed "vpn-proxy-$WORKLOAD_ID" || true

The forwarder also watches the namespace inode and exits when the original container process ends or Docker reuses its PID. Always resolve the PID again after a container restart; never retain an old /proc/PID/ns/net path.

Integrate BBM workers through Nomad

BBM's current worker lifecycle has two owners: Sonar decides desired capacity, while Nomad places, restarts, drains, and removes worker allocations. VPN Proxy should follow that boundary:

  1. Install the forwarder and protected client configuration once when a Nomad client host enrolls. Do not add VPN software or credentials to worker images.
  2. Opt fixed worker pools into VPN egress in NomadJobSpecFactory; do not let task input or user-supplied worker tags change host networking.
  3. Add a Nomad-managed companion for each opted-in worker allocation. It resolves the worker's Docker PID from allocation metadata, starts forward on the host, and uses NOMAD_ALLOC_ID as the workload UUID.
  4. Put opted-in workers on the dedicated internal network and gate worker health on exact forwarder readiness. A worker must not pulse as available or consume RabbitMQ work before its tunnel is ready.
  5. During scale-down or a 135-minute BBM drain window, keep the companion alive until the worker finishes its active task. Stop the worker first and the forwarder second.

NOMAD_ALLOC_ID gives every worker incarnation a canonical UUID and avoids duplicate live tunnel identity during rolling replacement. If BBM later needs a worker to retain the same VPN assignment after Nomad replaces its allocation, introduce a separate durable routing UUID with explicit handoff; do not derive one from mutable tags or reuse it while an old allocation is live.

BBM integration is not implemented yet

Current BBM Nomad jobs do not add the forwarder companion, required internal network, or readiness gate. The generic host adapter must also replace Misanthropic-specific container discovery. Treat the steps above as the rollout contract, not as a claim that existing workers already use VPN Proxy.

Before enabling a BBM pool, verify one disposable allocation end to end: no pre-ready egress, correct assigned exit, TCP and UDP success, DNS through the tunnel, no IPv6 path, fail-closed behavior during a forced WebSocket disconnect, and clean Nomad drain/replacement without two live connections for one UUID.

Current limitations

  • Linux network namespaces only; the host-side forwarder must run as root.
  • IPv4 TCP and UDP only. IPv6 is disabled; ICMP and non-public destinations are not proxied.
  • The internal subnet, original host-gateway bypass, readiness directory, service paths, and protocol header names are currently fixed for compatibility.
  • A container restart creates a new network namespace even if its name is unchanged. Restart its forwarder with the new PID.
  • Client authentication is shared-key based, not workload scoped.
  • deploy/install.sh environment installs the existing Misanthropic lifecycle adapter. Other orchestrators should invoke forward directly and own discovery, readiness, and shutdown.

If setup fails, inspect the unit without printing the protected configuration:

journalctl -u "vpn-proxy-$WORKLOAD_ID" -n 100 --no-pager

Common causes are a reused workload UUID, a stale container PID, incorrect network subnet/gateway, missing /dev/net/tun, wrong permissions on /run/misanthropic-proxy, or an unreachable WSS endpoint from the host.

On this page