Put a TLS wrapper in front of OpenVPN and the deployment gains a second listener, only one of which should ever face the internet. When the tunnel works from the same machine and fails from outside, the forward is usually aimed at the wrong one of the two. What has to line up is a chain: the public rule reaches the wrapper, and the wrapper's own backend connection reaches the real VPN socket. Four hops carry that chain, and any of them can end the investigation early.

Two sockets, two owners, one of them public

The wrapper terminates TLS for the outer connection and passes the decrypted byte stream to a plain OpenVPN TCP listener. Those are separate sockets with separate configuration files, so editing one leaves the other untouched and still apparently healthy. It also means the two can drift apart whenever one is changed without the other.

Working from the machine itself therefore proves very little about the public path. A direct connection to the native port skips the wrapper, a connection to the wrapper on its LAN address skips translation, and only the full client-side arrangement exercises what an outside user does. Names need reading carefully as well, because localhost means the host running that particular component and not the host you happen to be typing on. Keep that distinction in your notes, since a test recorded without the hop it covered invites a comparison between two different things.

Where the forward actually lands

The chain is easier to hold as four hops than as three port numbers, because the same number can mean different things at each end. Read the table by row: every row is one socket that has to accept a connection and then open the next one. When a row is aimed at the wrong peer, the symptom in the last column is what the user reports.

HopListens onMust reachSymptom when it points elsewhere
Client machineA local plaintext portThe wrapper's public TLS addressPlain text offered to a TLS endpoint
Router forwardPublic TCP 443The wrapper host on its translated portWrapper records no connection at all
Wrapper, server modeIts accepted port, 8443 hereThe native VPN socket on loopbackHandshake completes, then silence
Native VPN listenerLoopback TCP portIts peer, over TCP onlyA UDP socket behind a byte-stream relay

A rule that targets the native VPN port instead of the wrapper skips TLS termination entirely, which is the most common version of this mistake: the client opens a handshake with a listener that expects OpenVPN framing, so the connection may open and then stall. Confirm the translated port against the wrapper's configuration file rather than against memory, since the next person to edit that file may have chosen a different number. The two commands below show both sides of that question at once.

grep -nE '^[[:space:]]*(accept|connect)' /etc/stunnel/stunnel.conf
ss -lntp | grep -E ':443|:8443|:1194'

The bind address that refuses you

An accept line bound to 127.0.0.1 answers only the host itself, so a forward arriving on the LAN address cannot connect and the client sees a refusal that reads like a packet-filter problem. Look at the socket before touching the firewall, because the socket is the cheaper answer. Either bind the wrapper to the interface the forward targets, or keep it on loopback and make the forward reach loopback through something that genuinely works. Those are two designs, and each one needs its own rule.

Mixing them is how a working wrapper ends up unreachable from outside while every file looks correct. The check below is one command, and it answers a question that a review of the firewall rules cannot. If the output shows a loopback address while the forward targets the LAN address, the fix belongs in the accept line and not in the packet filter.

ss -lntp '( sport = :8443 )'

What the local test actually proved

A success recorded without the hop it covered is not evidence about the other hops. Note which address the test used and which process answered it. Then repeat from outside the network rather than from a phone on the same Wi-Fi, since hairpin behaviour varies between routers and a connection that never left the LAN can satisfy a client without the forward being involved at all. Only that second attempt exercises the path a stranger takes.

When the attempt fails, follow a single connection across both logs: the wrapper's accepted and connected lines, then the VPN daemon's own session log. The first boundary that records nothing is where the next change belongs. Once OpenVPN is comparing peer options, judge those messages by that implementation's rules. A link-mtu inconsistency warning is not automatically fatal, pinning link-mtu to 1500 does not prove fragmentation has stopped, and cipher negotiation belongs to the inner OpenVPN configuration rather than to the router's forward.

nc -vz 127.0.0.1 1194
journalctl -u stunnel -n 50 --no-pager

Those probes cover the backend socket and the wrapper log, which separates a refused backend from a rejected handshake. Run them while a single attempt is in flight rather than afterwards. Timestamps from one attempt are what makes the comparison trustworthy.

Two identities, two failure orders

The outer certificate belongs to the wrapper and the inner identity belongs to OpenVPN, and they may come from different authorities. Disabling either check to make a path look healthy trades a real protection for a cosmetic result. Keep both verifications and debug them in order, because the sequence of messages names the layer: a wrapper certificate problem appears before any VPN option is negotiated, while an inner identity failure appears only once the stream is established.

The stunnel manual defines accept, connect and client mode precisely, and those sections answer the socket question faster than guessing does. When you still cannot tell which layer answered, read the certificate the public port presents with the command below. A wrapper certificate in the output means the forward reached the wrapper, and silence or a protocol error means it did not. That single distinction saves more time than any other check here.

openssl s_client -connect vpn.example:443 -servername vpn.example </dev/null 2>/dev/null | openssl x509 -noout -subject -issuer

ХӀокхунах тера йаззамаш