SSH access ports to servers are under continuous attack from automated scanners. The recipe you find below provides a first level of defence, allowing you to keep the SSH port normally closed to all but the commonly trusted addresses (like your LAN, those of other selected institutes...) but still provide your remote users with an access path that does not require anything more than a browser and the SSH client, and so can be used also where VPN clients may not be available.
DISCLAIMER: the code below is released under the GNU Public License v3 or above. As you can read in the license, this means that nor I, nor my employer take any responsibility for it. It may or may not suit your needs. It is not meant as a main security line, its only role is to spare you from reading LogWatch reports containing hundreds of trivial automated login attempts, and concentrate on the actual threats. Make always sure that your users have non-trivial passwords (but don't force them to change too often, or they'll just write it on a sticky note under the keyboard) and also check that their SSH keys are not compromised. Apply the Defence in depth ideas as much as you can afford.
iptables setup
The system works by keeping the SSH port mostly closed in iptables, with one list of static exceptions, and one of dynamic exceptions:
Chain INPUT (policy DROP)
target prot opt source destination
ACCEPT all -- 0.0.0.0/0 0.0.0.0/0 state RELATED,ESTABLISHED
SSH tcp -- 0.0.0.0/0 myserver tcp dpt:22
Chain SSH (1 references)
target prot opt source destination
ACCEPT all -- mylan 0.0.0.0/0
ACCEPT all -- friendlan 0.0.0.0/0
SSH_DYN tcp -- 0.0.0.0/0 0.0.0.0/0
LOG all -- 0.0.0.0/0 0.0.0.0/0 limit: avg 10/sec burst 5 LOG flags 0 level 6 prefix `IPTABLES: DropSSH '
DROP all -- 0.0.0.0/0 0.0.0.0/0
Chain SSH_DYN (1 references)
target prot opt source destination
ACCEPT tcp -- 79.32.120.16 0.0.0.0/0
The choice of a separate SSH_DYN chain allows to easily add or remove rules for client IPs, without risks of disturbing the rest of the iptables configuration. The management of this chain is done by means of a PHP script in tandem with a small daemon.
A minimal iptables configuration for AutoGate may be created with the following iptables commands:
/sbin/iptables -N SSH
/sbin/iptables -N SSH_DYN
/sbin/iptables -I INPUT 1 -d myserver -p tcp --dport 22 -j SSH
/sbin/iptables -F SSH
/sbin/iptables -A SSH -s mylan -j ACCEPT
/sbin/iptables -A SSH -j SSH_DYN
/sbin/iptables -A SSH -m limit --limit 10/sec -j LOG --log-prefix "IPTABLES: DropSSH " --log-level 6
/sbin/iptables -A SSH -j REJECT
/sbin/iptables -F SSH_DYN
Note that this inserts the rule for SSH as first of the INPUT chain; you may want to place it elsewhere in the chain. Use iptables -L -vn --line-numbers to see the line number to use.
Don't forget to save permanently this configuration after testing it (service iptables save, on RedHat/Fedora distributions)
AutoGate web frontend
The PHP script is accessible via HTTP, simply requesting a chosen URL from a web browser or a command line HTTP client like wget or curl:
https://myserver.example.com/service/autogate.php
Of course, I would suggest you use a different URL on your server...
The PHP script does not need any elevated privileges to write, in a dedicated directory, files named as the client IP; it is sufficient for the directory to have the appropriate Unix permissions and (if you use SELinux) :
[sergio@myserver ~]$ ls -laZ /var/local/autogate
drwxr-x--- apache wheel user_u:object_r:httpd_sys_content_t .
drwxr-xr-x root root system_u:object_r:var_t ..
-rw-r--r-- apache apache user_u:object_r:httpd_sys_content_t 79.32.120.16
-rw-r--r-- root root user_u:object_r:httpd_sys_content_t update-timestamp
The client IP is provided by the Apache httpd itself, so it should be safe, but for good measure it is validated against a regexp. If the script identifies the IP as belonging to a private or local subnet (10.*.*.*,172.16.*.*,192.168.*.* or 127.*.*.*) it will assume that also the SSH connection will come from the same IP of the proxy.
It would be trivial to add the option for the client to specify the IP that should be added in the chain; but unless your users suffer from very special/obtuse networks in remote locations, I would suggest to avoid it.
The file is given a modification time in the future, which is used as an expiry time. After expiry time, the daemon will remove the file from the directory and the corresponding rule from the SSH_DYN chain. The default grace time is 30 minutes, but a different amount, up to a configurable maximum, can be specified by the user, appending a time=N HTTP GET parameter to the request URL, like
https://myserver.example.com/service/autogate.php?time=90
It would be easy to add a "password" parameter to the script. I would anyway suggest you don't do it, for a few reasons:
- many users store "web passwords" unprotected in their browser;
- a common password for all users would need to be changed for all if it's compromised even just for one user, so the user would be reluctant to report the incident to avoid disturbing everybody;
- to protect the password, you would need to deny plain HTTP access, and enforce HTTPS only; HTTPS may not be available to the remote users in some extreme cases;
This may not be too relevant because in these cases probably also SSH would not be available.
- It would give you a false sense of security; it should instead always be clear that AutoGate cannot ever be a primary defence measure.
Also using a simple user authentication is something to be avoided, for the reasons above, plus:
- if you use the system login password of the user, it compromise would be a much more serious problem
- if you give each a different password for this, you would have to maintain them, and this leads to more work
<?php
/**
* This writes a file named as the client's IP in a directory
* The directory is read by a daemon (autogate_daemon.sh)
* that updates a specific iptables chain.
* The daemon also creates the directory
* with the appropriate permissions.
*/
$dir="/var/local/autogate";
if (isset($_GET['time'])) {
$gracetime=$_GET['time'];
if ( $gracetime>24*60) $gracetime=24*60;
} else {
$gracetime=30;
}
if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$ProxyIP=$_SERVER['REMOTE_ADDR'];
$ClientIP=$_SERVER['HTTP_X_FORWARDED_FOR'];
//$ClientIP="127.0.0.1"; // for testing
//print "Proxy: ".$ProxyIP."\nClient: ".$ClientIP."\n";
// check and ignore private addresses
if (
strpos($ClientIP,"127.")===0
|| strpos($ClientIP,"10.")===0
|| strpos($ClientIP,"192.168.")===0
|| strpos($ClientIP,"172.")===0
&& preg_match('/^172\.(?:1[6-9]|2[0-9]|3[01])\./',$ClientIP) // 172.16/12
) {
$ClientIP=$ProxyIP;
}
} else {
$ClientIP=$_SERVER['REMOTE_ADDR'];
}
//$ClientIP="x.0.0.0"; // for testing
if ( preg_match('/^[\d]+\.[\d]+\.[\d]+\.[\d]+$/',$ClientIP)==0 ) {
print "Not a valid IP address: '".$ClientIP."'\n";
} else {
$FileName=$dir."/".$ClientIP."";
if ($gracetime<=0) {
unlink($FileName);
echo "Removed $ClientIP\n";
} else {
$F=fopen($FileName,"w");
if ($F!=false) {
fwrite($F,$ClientIP."\n");
fclose($F);
$time = time() + $gracetime*60;
touch ($FileName,$time,time());
echo "Added $ClientIP until ".strftime('%F %H:%M %Z',$time)."\n";
} else {
echo "Cannot record $ClientIP\n";
}
}
}
//phpinfo();
?>
AutoGate daemon
The AutoGate daemon is actually a shell script which may be started from rc.local or from inittab. It polls the chosen directory and, if the directory has a newer timestamp than the pid file, or if it's older than a chosen age (5 minutes) it processes its contents. First of all it will delete any file with a modification time older than the current system time; then it will flush the iptables chain and re-add all the surviving IPs.
At startup the daemon will take care of creating the directory with appropriate permissions, and the iptables chain.
This version of the daemon also support the usage of "AutoGate slave servers", which we'll discuss below.
#!/bin/bash
# generate an iptables chain from the file names = IPs in a directory
dir=/var/local/autogate
stamp=$dir/update-timestamp
pid=/var/run/autogate.pid
chain="SSH_DYN"
cleanmin=5 # cleanup every n minutes
slaves="" # slave autogate servers
slave_port=1111
DEBUG=false
#DEBUG=true
if [ "$EUID" != '0' ]; then
echo "$0 must be executed suid"
exit 0
fi
# kill other instances
#cat $pid
[ -s $pid ] && kill $(cat $pid)
if [ "$1" ]; then
MODE=$1
shift
else
MODE="master"
fi
if [ $MODE = "master" ]; then
mkdir -p $dir
chown apache:wheel $dir
chmod o-rwx $dir
chcon --no-dereference -R -t httpd_user_content_t $dir
fi
# spawn me with no console output
if [ "x$1" != "x--" ]; then
echo "Spawning $0"
$0 $MODE -- 1> /dev/null 2> /dev/null &
exit 0
fi
function tell_slaves {
local GRACE=$1
shift
for SLV in $slaves; do
for FILE in $*; do
IP=$(basename $FILE)
echo -e "$IP\n$GRACE" | nc -w5 $SLV $slave_port # > /dev/null
done
done
}
function cleanOldFiles() {
[ $MODE = "master" ] || return
[ $DEBUG = true ] && echo "Cleaning $chain on "$(date)
old=$(find $dir -mmin +0 -a -name "?*.?*.?*.?*")
if [ "$old" ]; then
[ $DEBUG = true ] && echo "Removing $old from $chain on "$(date)
logger -t $chain "Removing $old"
tell_slaves 0 $old
rm -f $old
touch $dir
fi
}
function updateChain() {
[ $DEBUG = true ] && echo "Updating $chain on "$(date)
echo $$ > $pid
iptables -F $chain
for f in $dir/*.*.*.*; do
ip=$(basename $f)
if [ "$ip" != "*.*.*.*" ]; then
[ $DEBUG = true ] && echo "Adding $ip to $chain"
logger -t $chain "Adding $ip"
#iptables -A "$chain" -p tcp -s "$ip" --dport ssh -j ACCEPT
iptables -A "$chain" -p tcp -s "$ip" -j ACCEPT
tell_slaves 1 $ip
fi
done
#iptables -L $chain -v
}
iptables -N $chain
tell_slaves RESET 0.0.0.0
cleanOldFiles
updateChain
#iptables -L SSH_DYN -vn
N=0
while true; do
#ls -lad --full-time $dir $pid $stamp
if [ $dir -nt $pid ]; then
cleanOldFiles
updateChain
#iptables -L SSH_DYN -vn
fi
sleep 1
if [ $[++N] -gt $[cleanmin*60] ]; then
cleanOldFiles
N=0
fi
done
User script
For command-line users on Apple OsX or Linux, this wrapper script may be a helpful replacement of ssh:
#!/bin/bash
curl http://myserver.example.com/service/autogate.php?time=$[2*60]
if [ "$@" ]; then
sleep 1
exec ssh $@
fi
AutoGate slave servers
If you want to use autogate to secure a single server, you can stop reading here, you already have all that you need to setup. If instead you have a single web server, and you want to allow your users to also access other local servers, without having to install httpd and open HTTP ports on each of them, then proceed below.
Please note that if you have servers which don't have other reasons to run an HTTP service, you may not want to install it just for this purpose because, unless you use a non-standard port, the existence of an open HTTP port tends to attract much undesired attention. And it's preferable to use standard ports because your users at a remote location may be denied connections to non-standard ports, and consequently be unable to use the AutoGate system.
For the reasons above, I enhanced my old single-server AutoGate to have a single web front-end, the "master" that can open SSH access for a user on a number of other servers, the "slaves".
Please note that this master-slave system is not meant to be used in an exposed network. Beyond the basic restriction on the master IP, no access control is provided to guarantee that the request comes from the master daemon and not from a rogue user on the master server, or from a spoofed IP.
The slave servers run an inetd (or xinetd, or equivalent) server, listening on a chosen TCP port. The autogate daemon running on the master uses NetCat (nc) to communicate to the service on the slaves, over a TCP socket. The autogate daemon needs to be configured with the list of slaves, and the port number.
This is the xinetd definition of the service:
# /etc/xinetd.d/autogate_slave
service autogate_slave
{
disable = no
only_from = masterserver_IP
type = UNLISTED
port = 1111
log_type = SYSLOG daemon
#log_on_success = HOST PID EXIT
log_on_failure = HOST
socket_type = stream
protocol = tcp
wait = no
user = root
server = /usr/local/sbin/autogate_slave.sh
}
And this below is the actual slave server, which is called by xinetd, and gets its stdin and stdout connected to the TCP stream. From the input stream it reads one line with the IP, and a "grace" time. If the grace time is zero, the IP is removed from the chain; if it is "RESET", the chain will be flushed and all IPs removed.
#!/bin/bash
chain=SSH_DYN
read ip
# sanitise input
IP=$(echo "$ip" | head -1 | egrep '^[0-9]?[0-9]?[0-9]\.[0-9]?[0-9]?[0-9]\.[0-9]?[0-9]?[0-9]\.[0-9]?[0-9]?[0-9]$')
if [ -z "$IP" ]; then
echo "FAIL Empty or bad IP $ip"
exit 1
fi
unset ip
#echo "IP: $IP"
read grace
# sanitise input
GRACE=$(echo "$grace" | head -1 | egrep '^RESET$|^[0-9]+$')
if [ -z "$GRACE" ]; then
echo "FAIL Empty or bad GRACETIME $grace"
exit 1
fi
unset grace
#echo "GRACETIME: $GRACE"
# rule number in the chain, if present
IPTN=$(/sbin/iptables -L SSH_DYN -n --line-numbers | grep $IP | cut -d" " -f1)
#logger -t $chain "Req $GRACE $IP @$IPTN"
if [ $GRACE = "RESET" ]; then
/sbin/iptables -N $chain 2>/dev/null
/sbin/iptables -F $chain
logger -t $chain "Resetting"
echo "OK reset"
else
if [ $GRACE -gt $[24*60] ]; then
GRACE=$[24*60]
fi
if [ $GRACE -le 0 ]; then
if [ "$IPTN" ]; then
/sbin/iptables -D $chain $IPTN
logger -t $chain "Removing $IP"
echo "OK rm"
else
echo "FAIL missing"
fi
#rm -f $DIR/$IP
else
if [ -z "$IPTN" ]; then
/sbin/iptables -A $chain -s "$IP" -j ACCEPT
logger -t $chain "Adding $IP"
echo "OK add"
else
echo "IGN present"
fi
#date --date "+$GRACE minutes"
#T1=$(date --date "+$GRACE minutes" +%Y%m%d%H%M.%S)
#echo "UNTIL: $T1"
#touch -t $T1 $DIR/$IP
fi
fi
#ls --full-time $DIR/$IP
#iptables -L SSH_DYN -n --line-numbers
I made an attempt at using lighter UDP sockets for this xinetd service, but the recipes I had found googling did not work; they provided sufficient information instead to make this TCP version.
Please note that the slave daemon does not create the SSH_DYN chain, so it must be configured manually.
Author
For all questions, please ask Sergio