fwknop port-knocking for SSH: making port 22 invisible from the internet without locking yourself out

The default state of 22/tcp on a public IP is “constantly probed by every botnet on the planet.” Even with key-only auth and fail2ban, the noise floor of failed SSH attempts is real — my auth.log picks up 3,000–5,000 hostile connection attempts a day on a server that’s just sitting there. Most of those are wasted CPU cycles for both sides.

Port-knocking with fwknop closes that down hard. Port 22 stays firewalled-deny-all by default; you send a single encrypted UDP packet to a different port (the “knock”), and the firewall opens 22 to your source IP for 30 seconds. Botnets see 22 as filtered. You see 22 as accessible. That’s the entire trick.

This post is the no-lockout setup, the trade-offs, and the one thing that almost bricked my access the first time I tried it.

Why fwknop, not classic port-knock sequences

Old-school port knocking sends a sequence of TCP SYNs to predetermined ports — the daemon watches for the pattern and opens up. The problem: any passive observer (your ISP, a packet capture en route, a compromised mid-box) sees the sequence and can replay it. It’s security through obscurity in the cryptographic sense.

fwknop uses Single Packet Authorization (SPA): one UDP packet, encrypted with AES (or a GPG key), containing a timestamp and the requested IP/port to open. The server decrypts it, checks the timestamp window, and acts. Replay is mathematically prevented; observation gives the attacker no usable information.

The setup

On Ubuntu/Debian:

sudo apt install fwknop-server fwknop-client

Server config in /etc/fwknop/access.conf:

SOURCE              ANY
OPEN_PORTS          tcp/22
KEY_BASE64          <output of: fwknop --key-gen>
HMAC_KEY_BASE64     <ditto>
FW_ACCESS_TIMEOUT   30
REQUIRE_SOURCE_ADDRESS  Y

fwknop --key-gen emits a fresh AES key + an HMAC key. Save those; you’ll paste them into the client config too. FW_ACCESS_TIMEOUT 30 means the firewall hole stays open for 30 seconds after the knock — long enough to start an SSH session, short enough that the hole isn’t reusable by anyone else who later sees your IP.

fwknopd config in /etc/fwknop/fwknopd.conf needs:

PCAP_INTF       eth0
ENABLE_IPT_FORWARDING   N
IPT_INPUT_ACCESS        ACCEPT, filter, INPUT, 1, FWKNOP_INPUT, 1

Then iptables (or your firewall of choice) needs to default-deny 22/tcp:

iptables -A INPUT -p tcp --dport 22 -j DROP

fwknopd will dynamically insert ACCEPT rules above the DROP for each authorized knock. systemctl enable --now fwknopd and the server is listening for SPA packets.

The client config and the knock

On your laptop, ~/.fwknoprc:

[my-server]
ACCESS                  tcp/22
SPA_SERVER              my-server.example.com
KEY_BASE64              <same key as server>
HMAC_KEY_BASE64         <same hmac as server>
USE_HMAC                Y
ALLOW_IP                resolve

ALLOW_IP resolve tells the client to ask the SPA server for “what IP did you see this packet come from?” before sending — important for clients behind NAT.

Then to SSH:

fwknop -n my-server && sleep 1 && ssh user@my-server.example.com

Wrap it as an SSH ProxyCommand for true zero-touch:

# ~/.ssh/config
Host my-server
    HostName my-server.example.com
    ProxyCommand sh -c 'fwknop -n %h && sleep 1 && nc %h %p'

Now ssh my-server sends the knock, waits a beat, and connects. The user experience is identical to vanilla SSH; the firewall surface is invisible to anyone without the key.

The thing that almost bricked me

I set this up over an existing SSH session. I was careful: tested the knock from a second client, watched the firewall accept the rule, confirmed I could re-SSH. Then I closed the original session.

The next morning, my home IP had rotated (DHCP lease). The fwknop client knew this; it did ALLOW_IP resolve and sent the knock with the new IP. But my router’s UDP egress was being blocked by an aggressive carrier-grade NAT. The knock packet never arrived. Port 22 was silent. I had no recourse short of physical access to the box.

The fix is to always have a fallback: a second SSH port (not 22) that’s open to a specific allowlisted IP (your VPN exit node, your office), independent of the fwknop layer. If the knock fails, you SSH in via the fallback, debug fwknop, and re-arm. I now run port 47322 open to my Tailscale IP only — no knocking required from inside the tailnet, and I’m never one CGNAT hiccup away from being locked out.

Was it worth it?

For my Oracle box: yes. SSH brute-force noise dropped to zero overnight. fail2ban’s logs went quiet. nmap from the public internet now reports 22/tcp filtered, which is genuinely satisfying.

For a small homelab where you have console access and IP allowlisting works fine: probably overkill. The Tailscale-only approach gets you 90% of the same benefit with 10% of the moving parts. fwknop earns its keep when you genuinely need the public server to be reachable from anywhere — but only by you.

Cover photo: Ltf0graphy on Pexels.

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.