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
If you have any comments, contact me by email.