Protect your Linux Server with a Port Knocker and UFW
I was running the SSH server on an internet facing linux machine for a while (the one you are reading this post on...) on the standard port and was, as to be expected, bombarded with bogus login requests.
To protect the server, I decided to first move ssh to a different port and then hide it behind a port knocker.
The port knocker is very simple and works in the following way.
- Your ssh port is by default denied by the firewall.
- If 3 predefined TCP ports get "knocked" in sequence within a short period of time (a simple request by telnet, nc or ssh), the port knocker daemon will:
- Enable a firewall rule that allows ssh traffic to the port predefined from the IP that "knocked".
- Wait a short time
- Disable the firewall rule again.
Instead of a firewall, you can also use iptables to enable/disable traffic, but I like the clean approach that ufw allows.
The following steps show how to set things up on Ubuntu 22.04 Server but should be very similar on other distros.
Installing the UFW firewall
Setting up the UFW firewall is really easy. Install it using:
sudo apt install ufw
Now, when running
sudo ufw status
You will see that the status is inactive.
The default firewall policy works well for servers and desktops. Close all ports by default and only open required TCP or UDP ports.
sudo ufw default allow outgoing
sudo ufw default deny incoming
Now start opening the ports you need to be accessible in the following way.
sudo ufw allow 80/tcp comment 'Allow HTTP'
sudo ufw allow 443/tcp comment 'Allow SSL'
sudo ufw allow 1122/udp comment 'Allow custom UDP port'
Now you can enable UFW with
sudo ufw enable
and query it's status with
sudo ufw status
You can also get the status list numbered with
sudo ufw status numbered
which will output something like
[1] 80/tcp ALLOW IN Anywhere # Allow HTTP
[2] 443/tcp ALLOW IN Anywhere # Allow HTTPS
[3] 80/tcp (v6) ALLOW IN Anywhere (v6) # Allow HTTP
[4] 443/tcp (v6) ALLOW IN Anywhere (v6) # Allow HTTPS
You can then easily remove rules with their number
sudo ufw delete 1
Installing a Port Knocker daemon
Next, you need a port knocker daemon. I recommend trying knockd, which is very simple to install and configure. Note that simple port knockers may be vulnerable because a sniffer may recover the port sequence that was used. More complex types use an encrypted string as a knock which is more secure.
To install knockd, run
sudo apt install knockd
Now edit the configuration file /etc/knockd.conf. Here is an example that uses ports 7000, 8000 and 9000, has a 5 second timeout for a knock, will enable port 22 (ssh) for the IP that knocked and after 10 seconds, disable it again.
[options]
UseSyslog
[SSH]
sequence = 7000,8000,9000
seq_timeout = 5
command = ufw allow from %IP% to any port 22
tcpflags = syn
cmd_timeout = 10
stop_command = ufw delete allow from %IP% to any port 22
The beauty is that if you log in to the server using SSH after knocking, you will not be disconnected when UFW blocks the port again. Only new connections can't be established anymore then.
Also configure the file /etc/default/knockd in the following way so it starts on init and only listens on the internet facing network adapter (in this case eth0)
################################################
#
# knockd's default file, for generic sys config
#
################################################
# control if we start knockd at init or not
# 1 = start
# anything else = don't start
START_KNOCKD=1
# command line options
KNOCKD_OPTS="-i eth0"
You now need to add the knock ports to UFW:
sudo ufw allow 7000/tcp comment 'Allow knockd on 7000'
sudo ufw allow 8000/tcp comment 'Allow knockd on 8000'
sudo ufw allow 9000/tcp comment 'Allow knockd on 9000'
You don't add 22/tcp to UFW, knockd will manage that for you.
Now start the Knocker daemon using
sudo systemctl knockd enable
sudo systemctl knockd start
Knock Client
To initiate a knock, you can install a knock client. Knockd has a client "knock" already installed.
On MacOS for example, you can install it using:
brew install knock
Alternatively, you can use the following Python script which I have adapted from this repo.
#!/usr/bin/env python
import argparse
import socket
import sys
import time
parser = argparse.ArgumentParser()
parser.add_argument('host', metavar='HOST', type=str,
help='Hostname to knock at')
parser.add_argument('ports', metavar='PORT', type=int, nargs='+',
help='Port(s) to use, in order specified')
parser.add_argument('-t', '--timeout', type=int,
help='Timeout for connection attempt (seconds), default 10')
parser.add_argument('-v', '--verbose', action="store_true",
help='Show detailed information')
parser.add_argument('-w', '--wait', type=float,
help='Time to wait between knocks (seconds), default 1.0')
parser.set_defaults(timeout=2)
parser.set_defaults(wait=0.5)
args = parser.parse_args()
CONN_REFUSED_MESSAGES = ["connection refused", "no connection could be made because the target machine actively refused it"]
TCP_IP = args.host
TIMEOUT = args.timeout
VERBOSE = args.verbose
WAIT = args.wait
ports_failed = []
if len(args.ports) > 1 and VERBOSE:
format_wait = str(WAIT).rstrip('0').rstrip('.')
sys.stdout.write("Waiting %ss between each connection attempt\n" % format_wait)
sys.stdout.flush()
for i, TCP_PORT in enumerate(args.ports):
if VERBOSE:
sys.stdout.write("Knocking on port ")
sys.stdout.write('{0: <10}'.format(str(TCP_PORT) + '...'))
sys.stdout.flush()
sock_msg, sock_ok = None, True
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(TIMEOUT)
s.connect((TCP_IP, TCP_PORT))
sock_msg = "open"
s.close()
# timeouts are knocks, too
except socket.timeout:
sock_msg = "no answer"
except socket.error as e:
if any(substring in str(e).lower() for substring in CONN_REFUSED_MESSAGES):
sock_ok = True
else:
ports_failed.append(TCP_PORT)
sock_msg = e
sock_ok = False
if VERBOSE:
if sock_ok:
sys.stdout.write("OK")
else:
sys.stdout.write("FAILED")
if sock_msg:
sys.stdout.write(f" ({sock_msg})")
sys.stdout.write("\n")
sys.stdout.flush()
if i+1 < len(args.ports):
time.sleep(WAIT)
if len(ports_failed):
s_ports = ", ".join([str(p) for p in ports_failed])
sys.stdout.write(f"\nFailed ports: {s_ports}")
sys.stdout.flush()
sys.exit(1)
sys.stdout.flush()
sys.exit(0)
On linux or Mac OS, just name the file knack
, make it executable and put it somewhere in the path. You can then just call it using
knack -v server.domain.com 7000 8000 9000
On Windows, this works as well. Name the file knack.py and put it in your path. Now add .PY
to the existing PATHEXT
environment variable and associate the .py
file extension with C:\Windows\py.exe
, the Python launcher.
You can now also call it with the same command as on linux / Mac OS.
knack -v server.domain.com 7000 8000 9000
Thanks for setting me on this path, Skylar! 😸♥️