A firewall that admits only the VPN port is not one rule. It is three separate decisions that happen to involve the same port number: the input chain decides what may reach a local process, the forward chain decides what may pass through the host on behalf of tunnel clients, and the NAT table rewrites the source address on the way out. Get one of them wrong and the result is rarely a clean outage. You get a host that accepts clients but loads nothing, or a host that looks locked down until someone reads the chain order.

The one port that cannot be closed

The VPN listener has to accept connections from addresses you cannot predict, so it is a public service by definition. That is the single exception the design is built around: one UDP port for OpenVPN, one UDP port for WireGuard, one TCP port for an SSL VPN. Everything else belongs behind the tunnel, which means application services should bind to the tunnel address or to loopback rather than to every interface.

Binding is not enforcement, though. A process bound to 0.0.0.0 is reachable only if a rule admits it, and the default policy is the fallback for traffic no rule matched, not the rule that governs the port. An accept placed above your drop is still an accept, and moving a service off the public address is an independent improvement rather than a substitute for the chain.

Input, forward and NAT answer different questions

Input traffic is destined to the host itself, so it is where the VPN listener and the administrative path live. Forward traffic passes through the host, and tunnel clients need it plus net.ipv4.ip_forward=1. The classic half-broken configuration allows the outbound flow and forgets the return direction, so the client completes its handshake and then nothing loads; a conntrack rule for established and related traffic covers that direction.

NAT is the third question. Without a postrouting rewrite, forwarded packets leave with the private tunnel source and are dropped upstream, because no network beyond the host knows how to route the pool back. The source prefix in that rule has to match the VPN pool, not the host's own address, and the same is true of the forward rule that names the client subnet.

IPv6 is a second policy, not an afterthought. A host with a strict IPv4 chain and an open ip6tables policy has not closed anything; the services are still reachable over the other family. Either mirror the rule set with ip6tables or disable IPv6 on the public interface deliberately, and write down which one you chose.

A baseline you can read in one pass

Start from drop, then add only what the host needs. Loopback and replies first, then the administrative path, then the tunnel listener, then the forwarding policy for clients:

iptables -P INPUT DROP
iptables -P FORWARD DROP
iptables -A INPUT -i lo -j ACCEPT
iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A INPUT -p tcp --dport 22 -m conntrack --ctstate NEW -j ACCEPT
iptables -A INPUT -p udp --dport 1194 -m conntrack --ctstate NEW -j ACCEPT
sysctl -w net.ipv4.ip_forward=1
iptables -A FORWARD -i tun0 -o eth0 -s 10.8.0.0/24 -j ACCEPT
iptables -A FORWARD -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -t nat -A POSTROUTING -s 10.8.0.0/24 -o eth0 -j MASQUERADE

The interface names, the port and the pool are placeholders; read them from the server instead of copying them. Saving the result matters as much as writing it, because a rule set that lives only in memory disappears at the next reboot and quietly reopens the host. Persist it with the distribution's mechanism, and keep a copy outside the machine for the day the machine will not boot.

Closing the last port without losing the host

There is a catch in the design. If SSH exists only through the tunnel, a failed VPN service takes SSH with it, and the only way back may be a console you have never opened. Before you close the last public port, confirm that console exists and that you can actually use it, or keep one narrow administrative path that does not depend on the tunnel: SSH from a fixed address, keys only, password authentication off.

Apply the change from a session that is already established, not through the connection you are about to filter. Schedule a rollback so a mistake expires instead of becoming permanent, for example a systemd-run --on-active=10min job that restores the previous rule set, and keep a second SSH session open while you test the first one. Then verify the rollback job actually fires, because untested recovery is the same as no recovery.

Verify the path, not the script

Read the loaded rules, not the file that was supposed to load them. From an authorized external host, confirm that only the intended ports answer; from inside the tunnel, confirm that the services you meant to publish are reachable. A deny-by-default policy that also blocks tunnel traffic looks identical to a working lockdown until somebody tries to use it.

iptables -S
iptables -t nat -S
ss -lntup
ip -4 addr show

Record the allowed port, the client pool, the tunnel interface, the administrative source and the console procedure next to the configuration. The next change to the pool or the interface name has to be matched in both the filter and the NAT rule, or the host returns to a half-open state without anyone noticing.

One habit is worth more than the rule set: after every change, prove the forward path with a client request and prove the recovery path with a second session. A lockdown you have not used is a hypothesis.

Skyldar greinar