One route out of this workstation is a SOCKS proxy, and OpenVPN behind it reports a connect timeout with nothing useful attached. A browser on the same machine works through that proxy, the credentials are right, and the port answers. Three things differ often enough to check first: the SOCKS version the proxy speaks, the transport the client chose, and where name resolution happens. Confirm each of those before deciding the firewall is at fault.

Match the SOCKS Version Before the Firewall

Community OpenVPN speaks SOCKS5 for socks-proxy. Point it at a proxy that only offers SOCKS4 and the TCP connection opens, then stalls, because the client waits for a reply to a greeting the other end never parses. From outside, that stall looks exactly like a filtered port, which is why the version question comes before any firewall rule. A SOCKS-aware fetch settles it in one command.

curl --socks5-hostname 127.0.0.1:9050 gw.lab.example

A completed request proves two things at once: the proxy accepted the SOCKS request and it resolved the name. No URL scheme is needed, because curl treats a bare host as HTTP, which is enough for this test.

That fetch also rules out the failure the client log hides. A proxy can permit the browser and still refuse the VPN destination, in which case the rejection happens at CONNECT, before any VPN authentication. OpenVPN reports only a timeout, so the proxy's own answer is the evidence you need.

The Transport Has to Be TCP on Both Sides

SOCKS5 carries a TCP stream here, so the tunnel itself needs proto tcp-client. A profile still set to UDP will not work through this path, because the client does not use the protocol's UDP association mode. Flipping the profile is a genuine change rather than a formality: the far end has to be listening on TCP at the same port. The payload then becomes TCP inside TCP, where a lost segment is retransmitted twice and pacing degrades, which interactive sessions notice far more than large transfers do.

proto tcp-client
remote gw.lab.example 8443
socks-proxy 127.0.0.1 9050

The directive takes the proxy host, an optional port and an optional credentials file. Put those in the profile itself so a copy behaves identically elsewhere.

Confine the Whole Chain to One Container

Doing the chain on the host means taking over its default route and leaving the resolver in a state nobody can explain later. A namespace or a container scopes it instead, so the host keeps its own path while the SOCKS hop and the tunnel live together. Nothing comes up inside it without a tunnel device, the rights to configure that device, and a reachable proxy. A Compose service gets that with cap_add: NET_ADMIN and a bind-mounted /dev/net/tun.

cap_add:
  - NET_ADMIN
devices:
  - /dev/net/tun:/dev/net/tun

That pair is narrower than running the container privileged, and it is the whole of the device access needed. A Tor client or a separate proxy container can supply the listener. In either case bind it to loopback, and reach it from the namespace through the host instead of exposing the proxy to the network.

Name Resolution Is Where the Isolation Fails

A tunnel that carries packets while asking the host's resolver has isolated nothing. Two things make the isolation real: the namespace has a resolver of its own, and its queries travel through the proxy or the tunnel, never over the physical interface. Use two names for the test: one that exists only behind the tunnel, and a public one that ought to follow the same path.

docker compose exec vpn cat /etc/resolv.conf
docker compose exec vpn getent hosts internal.lab.example

If the far-side name resolves and the public one does not, the chain is selective rather than broken, and the difference tells you which path each query took. If both resolve from the host but neither resolves inside the container, the container is still borrowing the host's resolver. Record the resolver file you found, because that is the setting that has to change.

Prove Each Hop in Order

The list is short. A SOCKS5 request gets an answer, the tunnel reaches its listener through the proxy, and packets leaving the namespace carry the address the far side expects. Confirm each one at a time instead of layering on more configuration. When the client says connected and nothing moves, the break is almost always in the second or third fact.

curl --socks5-hostname 127.0.0.1:9050 gw.lab.example
docker compose exec vpn ip route get 203.0.113.10
docker compose exec vpn getent hosts internal.lab.example

Write down the combination that worked: SOCKS version, transport, resolver and namespace settings. Solving this chain a second time from memory costs more than the note. Keep those four facts in the ticket rather than in a chat thread.

A chain that reports connected and passes nothing has usually failed at the hop you have not proven yet. Test that hop before adding another layer.

Makomi ya ndenge moko