Port knocking protected SSH without pain
I will briefly show how to setup knockd on Linux server to protect ssh daemon, and how to automate port knocking on the client side when using ssh, scp and rsync.
First of all we need to generate random port sequence. A simple python script can handle it.
#!/usr/bin/env python
import random
port_min = 10000
port_max = 65535
count = 10
ports = []
for i in range(count):
ports.append('%d:%s' % (random.randint(port_min, port_max), random.choice(('tcp', 'udp'))))
print(','.join(ports))
print(' '.join(ports))
Run the script. It'll print something like this:
41373:tcp,17895:tcp,41647:tcp,18101:udp,52948:tcp,56049:tcp,36593:udp,42343:tcp,28832:udp,36191:udp
41373:tcp 17895:tcp 41647:tcp 18101:udp 52948:tcp 56049:tcp 36593:udp 42343:tcp 28832:udp 36191:udp
knockd.conf
(knockd configuration file) and knock
(client-side utility) are using different port sequence syntax, so the script prints both variants.
Firewall configuration
Allow localhost connections:
# iptables -A INPUT -i lo -j ACCEPT
Do not drop already established connections:
# iptables -A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
Don't forget to allow any other input connections you may need (e.g. to web server on ports 80 and 443, etc). Then drop everything else:
# iptables -A INPUT -j DROP
knockd configuration
knockd config is located at /etc/knockd.conf
.
[options]
UseSyslog
[opencloseSSH]
sequence = 41373:tcp,17895:tcp,41647:tcp,18101:udp,52948:tcp,56049:tcp,36593:udp,42343:tcp,28832:udp,36191:udp
seq_timeout = 10
tcpflags = syn
start_command = /sbin/iptables -I INPUT 1 -s %IP% -p tcp --dport 22 -j ACCEPT
cmd_timeout = 10
stop_command = /sbin/iptables -D INPUT -s %IP% -p tcp --dport 22 -j ACCEPT
The minimum configuration is finished and should work at this point. Start knockd. Note that it will write commands it executes (/sbin/iptables ...
in our case) to syslog.
Client side magic
To open a port, you can use the knock command-line utility (example.com is our example hostname):
$ knock -d 100 -v example.com 41373:tcp 17895:tcp 41647:tcp 18101:udp 52948:tcp 56049:tcp 36593:udp 42343:tcp 28832:udp 36191:udp
It works, but it's defenitely not convenient to run it manually every time. Don't know about you but I'd like it to just magically happen every time I use ssh. So let's write some bash.
Create associative arrays with port sequences and ssh port numbers for your servers:
declare -A MY_KNOCK_PORTS=(
[example.com]="41373:tcp 17895:tcp 41647:tcp 18101:udp 52948:tcp 56049:tcp 36593:udp 42343:tcp 28832:udp 36191:udp"
[other_hostname]="<other sequence here>"
)
declare -A MY_SSH_PORTS=(
[example.com]=22
[other_hostname]=2222
)
Write wrapper functions for ssh
, scp
and rsync
. Take care to preserve all arguments.
function my_ssh() {
local ssh_args=""
local user=""
local server_name=""
local port=""
# progress all arguments
for arg in "$@"; do
# match user@host:path
if [[ $arg =~ ^[[:alnum:]_\.-]+@[[:alnum:]_\.-]+ ]]; then
# parse username
local expl=(${arg//@/ })
user=${expl[0]}
# parse server_name
tmp=${expl[1]}
expl=(${tmp//:/ })
server_name=${expl[0]}
# find port
local port=${MY_SSH_PORTS[$server_name]}
if [ -z "$port" ]; then
port=22
fi
ssh_args="-p$port $ssh_args ${user}@${server_name}"
else
ssh_args="$ssh_args $arg"
fi
done
if [ -z "$server_name" ] || [ -z "$user" ]; then
echo "error: server_name or user is empty"
return 1
fi
if [ $# -lt 1 ]; then
echo "error: at least 1 argument expected"
return 1
fi
my_knock $server_name
# call ssh
ssh $ssh_args
}
function my_scp() {
local user=""
local server_name=""
local scp_args=""
local tmp=""
local port=""
# progress all arguments
for arg in "$@"; do
# match user@host:path
if [[ $arg =~ ^[[:alnum:]_\.-]+@[[:alnum:]_\.-]+: ]]; then
# parse username
local expl=(${arg//@/ })
user=${expl[0]}
# parser server_name
tmp=${expl[1]}
expl=(${tmp//:/ })
server_name=${expl[0]}
# find port
local port=${MY_SSH_PORTS[$server_name]}
if [ -z "$port" ]; then
port=22
fi
local pos=$(__strpos "$arg" :)
local len=$(__strlen "$arg")
# find position of the first colon (:)
pos=$(( $pos+1 ))
# parse path
local path=${arg:$pos:$len}
scp_args="-P$port $scp_args ${user}@${server_name}:${path}"
else
scp_args="$scp_args $arg"
fi
done
my_knock $server_name
# call scp
scp $scp_args
}
function my_rsync() {
local user=""
local server_name=""
local rsync_args=""
local tmp=""
local port=""
local _rsh_arg=0
# process all arguments
for arg in "$@"; do
# match user@host:path
if [ "$arg" == "-e" ]; then
_rsh_arg=1
continue
fi
if [ "$_rsh_arg" == "1" ]; then
_rsh_arg=0
continue
fi
if [[ $arg =~ ^[[:alnum:]_\.-]+@[[:alnum:]_\.-]+: ]]; then
# parse username
local expl=(${arg//@/ })
user=${expl[0]}
# parser server_name
tmp=${expl[1]}
expl=(${tmp//:/ })
server_name=${expl[0]}
# find port
local port=${MY_SSH_PORTS[$server_name]}
if [ -z "$port" ]; then
port=22
fi
local pos=$(__strpos "$arg" :)
local len=$(__strlen "$arg")
# find position of the first colon (:)
pos=$(( $pos+1 ))
# parse path
local path=${arg:$pos:$len}
rsync_args="$rsync_args ${user}@${server_name}:${path}"
else
rsync_args="$rsync_args $arg"
fi
done
my_knock $server_name
# call rsync
rsync -e "ssh -p${port}" $rsync_args
}
A function that knocks:
function my_knock() {
local server_name="$1"
local knock_ports=${MY_KNOCK_PORTS[$server_name]}
if [ ! -z "$knock_ports" ]; then
knock -d 100 -v "$1" $knock_ports
sleep 0.1
else
echo "nothing to knock"
fi
}
And some helper functions:
function __strpos() {
local x="${1%%$2*}"
[[ "$x" = "$1" ]] && echo -1 || echo "${#x}"
}
function __strlen() {
local x=${#1}
echo "$x"
}
Combine it all together and put into your .bashrc
.
Now you can just use my_ssh
, my_scp
and my_rsync
and your ports will be knocked and opened automatically. Just use it how you would normally use ssh, scp and rsync, with any needed arguments. Example:
$ my_ssh -v user@example.com
$ my_scp -l 512 src user@example.com:~/dst
$ my_rsync -aPv user@other_hostname:~/src dst