Two clients can both open the jump host and still have no business opening each other. Reaching a service is a forwarding decision, while reaching a peer is a policy decision, and most VPN stacks keep those in separate objects. Nothing you configure for the first one enforces the second. The job is therefore not to add a rule but to find the object that decides peer traffic on this server, write the deny there, and prove it from a client.

A route to the shared service is not a permit between peers

Advertising the client subnet tells a peer how to put a packet on the wire toward another client address. Whether that packet is delivered is settled afterwards, by whatever forwards it. On a Linux gateway that is the FORWARD chain; inside a userspace daemon it is the tunnel's own session table. A route answers reachability and leaves permission untouched.

That is why an isolation check which only reads routes proves nothing. A /24 can be pushed to every client while every peer-to-peer packet is dropped at the forward hook. The dangerous case is the reverse: no explicit rule, a permissive default, and isolation that exists only because nobody tested it.

Write the requirement as one line before you open a terminal. Reach the service block and the jump host, reach nothing else inside the client block.

Find out who actually forwards the peer packet

Two server behaviours look the same from the client side and need different rules. Either the kernel forwards the packet out of the tunnel interface again, or the daemon copies it between sessions and the kernel never sees it.

WireGuard belongs to the first group: it has no peer-to-peer switch, so a packet from 10.8.0.4 to 10.8.0.5 arrives on wg0, hits the forward hook, and is routed back out of wg0 when AllowedIPs covers both addresses. OpenVPN is the second group once client-to-client is set in the server config, because that directive hands the packet between sessions in userspace. Check the loaded config for that directive before writing anything. A kernel rule that never sees traffic is indistinguishable from a rule that works, which is how a deployment ends up with no isolation at all.

Read the effective configuration rather than the file you assume is loaded, since distro packages ship their own includes. If the daemon does the forwarding, the deny belongs in its own per-client controls, and a host firewall only filters traffic addressed to the host. If the kernel forwards, the forward hook is the only place that matters.

Routed and translated deployments need one more check: the source a far client sees may not be the one you expect. A rule matching the client subnet then matches nothing at all. Note the address that survives translation before writing the match, and prefer matching the interface pair over guessing at NAT addresses.

Give every address an identity you can revoke by itself

Isolation collapses as soon as two devices share one credential. A rule keyed to that identity cannot separate them, and revoking a lost laptop revokes the whole group. Per-device certificates or per-peer keys keep revocation local and keep the policy readable, because each address maps to exactly one person or machine. Where the design cannot do that, say so in the runbook instead of describing the rule as fine-grained.

Address allocation has to agree with the policy. Reserve a block for services, a block for clients, and hand out client addresses from a pool you control rather than a first-come range. If the server translates or bridges, keep the mapping table next to the config, because that table is what the next reader needs to re-derive the rule.

Put the deny where the packet crosses the tunnel

On a gateway that forwards WireGuard traffic, two rules cover the case: permit the service block, then drop the rest of the client block. The interface pair matters as much as the addresses, because matching only on destination also catches traffic arriving from the LAN. Load them in that order so the permit is evaluated first.

With nftables the pair goes into an existing inet filter table, permit first and drop second. Both matches pin the interface pair as well as the addresses.

nft add rule inet filter forward iifname "wg0" oifname "wg0" ip saddr 10.8.0.0/24 ip daddr 10.8.2.0/24 accept
nft add rule inet filter forward iifname "wg0" oifname "wg0" ip saddr 10.8.0.0/24 ip daddr 10.8.0.0/24 drop

Read the chain back after loading instead of trusting the exit status, and confirm both rules sit in the order you intended. Add matching ip6 rules for the IPv6 pool, or the v4 pair is decorative on a dual-stack tunnel. If the server already has a permissive forward policy, append the drop rather than replacing the chain wholesale.

When client-to-client is enabled on OpenVPN, these forward rules never match peer traffic and the deny has to live in the daemon's own controls. Keep the kernel rules for traffic addressed to the host, but do not count them as the isolation control.

Prove the deny from a client, not from the server

Testing from the gateway proves nothing, because locally generated traffic takes the output path and never crosses the forward hook. Use two real clients: one that should reach the service, one that should be refused. Run the allow case first, so a broken tunnel is not mistaken for a working policy.

From the allowed client, confirm the service answers on its port. From the same client, try the peer address and expect a silent drop rather than a refusal:

ping -c 3 -W 2 10.8.0.5
nc -z -w 2 10.8.0.5 22
nft list chain inet filter forward
iptables -vnL FORWARD
ping -6 -c 3 -W 2 fd00:8::5

A dropped packet and an offline peer both look like a timeout, so check the counters before concluding anything. Confirm the drop rule's counter increments while the peer is demonstrably up. Repeat over IPv6, where a missing ip6 rule leaves the v4 deny looking effective. Record the accepted and refused results in the runbook with the date, because the next migration will re-derive the rule.

Nuŋlɔɖi siwo ku ɖe eŋu