Install CSF on a box that runs Tailscale and everything appears fine: the node shows up in the tailnet, SSH over it works, tailscale status is green. Nothing looks broken, so nobody looks. Meanwhile the firewall is dropping every direct UDP path and all of your traffic is being relayed — slower, and entirely dependent on a third party staying up. The failure is silent by design, because Tailscale falls back to DERP relay over TCP/443, which CSF allows.

This is on AlmaLinux 9 / Rocky Linux 9 with csf and lfd, but nothing here is distro-specific.

The symptom: UDP_OUT Blocked flooding the console

The first hint is noise, not breakage. The console fills with drop messages:

Firewall: *UDP_OUT Blocked* IN= OUT=eth0 SRC=192.168.10.20 DST=203.0.113.40 PROTO=UDP SPT=41641 DPT=3478
Firewall: *UDP_OUT Blocked* IN= OUT=eth0 SRC=192.168.10.20 DST=198.51.100.7 PROTO=UDP SPT=41641 DPT=41641

Two ports matter in those lines, and Tailscale documents both in its firewall ports reference:

  • 3478/udp — STUN. Tailscale uses it to discover what its own public address and NAT behaviour look like. Without it a node cannot learn how to be reached.
  • 41641/udp — the direct peer port. This is the actual WireGuard-style data path between two nodes.

Both are outbound to the internet, so they leave through eth0 and hit UDP_OUT. Neither is in CSF's default port list, because CSF predates Tailscale and ships a conservative set.

Prove it with tailscale netcheck

tailscale status will not tell you — it reports the tailnet as up, which it is. The command that shows the damage is netcheck:

tailscale netcheck

On a box where CSF is eating STUN:

Report:
	* UDP: true
	* IPv4: (no addr found)
	* IPv6: yes, [2001:db8::1]:39768
	* MappingVariesByDestIP:
	* PortMapping:

Read those three lines carefully, because they are the whole diagnosis:

  • IPv4: (no addr found) — the node could not complete a single STUN exchange over IPv4. It does not know its own public address.
  • IPv6: yes, … — IPv6 worked. That is what makes this so easy to miss: the node is not offline, it is half blind. If the box had no IPv6 at all you would probably have noticed sooner.
  • MappingVariesByDestIP: empty — it could not even run the probe that classifies the NAT.

A healthy node answers with an address and a verdict:

	* IPv4: yes, 203.0.113.55:51654
	* MappingVariesByDestIP: false

Fix it in csf.conf

Three changes in /etc/csf/csf.conf:

# CSF ignores the tailnet interface entirely
ETH_DEVICE_SKIP = "tailscale0"

# STUN + the direct peer port, outbound
UDP_OUT = "20,21,53,113,123,853,3478,41641"

# inbound direct peer attempts
UDP_IN = "20,21,53,80,443,41641"

Keep your existing port lists and add to them — the values above are illustrative, not a set to paste over yours.

Opening 41641/udp inbound is less alarming than it looks: the handshake behind it is WireGuard-style and authenticates peers by key, so this lets the coordination and data packets past the OS firewall — it does not expose anything to an unauthenticated caller.

ETH_DEVICE_SKIP = "tailscale0" tells CSF to leave the tailnet interface alone. That covers traffic inside the tunnel, but the tunnel's own underlay still leaves through eth0, so the two UDP ports are needed as well, even with the skip in place. Setting only one of the two is the usual half-fix.

Then reload:

csf -r

Use csf -r, not csf -ra. The -a form restarts lfd as well, which discards the failure counts it has accumulated toward a ban. Existing temporary bans survive — they live in /var/lib/csf/csf.tempban and are reloaded on start — but you rarely want to throw away in-progress tracking for a port list change.

Verify

Re-run the same check and compare against what you saw before:

tailscale netcheck
tailscale status

IPv4 should now report an address and MappingVariesByDestIP a real value. Then confirm the drops have actually stopped rather than merely scrolled off:

# count now, count again in half a minute - the number must not move
grep -c 'UDP_OUT Blocked' /var/log/messages

# and the interface really is exempt
iptables -S | grep -c tailscale0

A frozen counter plus a non-zero rule count is the proof. A netcheck that looks better on its own is not — results vary between runs.

Do this before you close port 22

The reason this matters beyond tidiness: on a box where Tailscale is becoming the only SSH path, CSF must not be able to fight it. With ETH_DEVICE_SKIP = "tailscale0" the management path cannot be cut by a firewall reload or by an LF_SSHD ban that catches your own address.

Set this up before you close public 22, not after. The order matters, and the failure mode of getting it wrong is locking yourself out of a box whose only remaining door you just removed.

While you are here: the console flood

Fixing the drops stops the noise from this source, but on many cloud images the console is unusable under any port-scan traffic, and it is worth understanding why.

Check the kernel's print level:

sysctl kernel.printk
kernel.printk = 7	4	1	7

The first field is console_loglevel, and 7 means every kernel message down to debug is printed to the VNC or serial console. CSF's drop messages are kern :warn : — level 4. A message is printed only when its level is strictly less than console_loglevel, so at 7 they all appear. Confirm which messages are actually flooding you before changing anything:

dmesg -x | awk '{print $2}' | sort | uniq -c | sort -rn | head

Setting the first field to 4 silences warnings on the console while keeping levels 0–3 — err, crit, alert, emerg. I/O errors, OOM kills and filesystem errors still reach it, which is the whole point of having a console.

echo 'kernel.printk = 4 4 1 7' > /etc/sysctl.d/99-quiet-console.conf
sysctl --system

Change only the first field; the other three are the default, the minimum and the boot-time value, and there is no reason to touch them here.

Nothing is lost by doing this. The messages still go to the ring buffer and to /var/log/messages — verify it yourself rather than taking my word for it:

wc -l /var/log/messages   # run twice, a minute apart: it must keep growing
dmesg -x | tail -5        # the entries are still there

This only changes what is printed to the console, not what is recorded.

Related

If CSF is dropping traffic you did not expect, two other posts on this blog cover adjacent traps: lfd not blocking SSH brute force on OpenSSH 9.8+, where the process name changed underneath CSF's regex, and IPV6 = 0 does not disable IPv6, which leaves ip6tables completely unfiltered rather than closed.

Leave a Reply