Subsections of deb13
banner
sudo apt-get update -qq
sudo apt-get install -y -qq git figlet bc
git clone -q https://github.com/xeylou/my-motd-s.git /tmp/my-motd-s
sudo cp /tmp/my-motd-s/gnu-linux/banner_4.txt /etc/ssh/banner
sudo tee /etc/ssh/sshd_config.d/02-banner.conf > /dev/null << 'EOF'
Banner /etc/ssh/banner
# PrintLastLog no
EOF
sudo sshd -t && sudo systemctl reload ssh
sudo cp /tmp/my-motd-s/gnu-linux/update-motd.d-en/* /etc/update-motd.d/
sudo chmod 755 /etc/update-motd.d/[0-3]*
sudo rm -f /etc/motd /tmp/my-motd-s
run-parts --lsbsysinit /etc/update-motd.ddocker
sudo apt-get remove $(dpkg --get-selections docker.io docker-compose docker-doc docker-buildx podman-docker containerd runc | cut -f1)
sudo apt-get update -qq
sudo apt-get install -y -qq ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/debian
Suites: $(. /etc/os-release && echo "$VERSION_CODENAME")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt-get update -qq
sudo apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker $USER
systemctl status --no-pager dockerhardening
based on sshaudit.com, updated for deb13 openssh 10 for the ssh part
keep a parallel ssh session open until the end
prep
safety
cat /home/$USER/.ssh/authorized_keysssh.socket
ssh.socket is active, so Port and AddressFamily
directives get ignored and systemctl reload ssh fails with
“Cannot bind any address”
# expected disabled for the socket && enabled for the service
systemctl is-enabled ssh.socket ssh.service
sudo systemctl disable --now ssh.socketssh
service
sudo rm /etc/ssh/ssh_host_*
sudo ssh-keygen -t ed25519 -f /etc/ssh/ssh_host_ed25519_key -N ""
# clients must clear the old one (ssh-keygen -R <ip>)
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
# wipes the hardening in 10 min unless cancelled
sudo systemd-run --on-active=10min --unit=sshd-rollback \
sh -c 'rm -f /etc/ssh/sshd_config.d/00-hardening.conf && systemctl reload ssh'
sudo tee /etc/ssh/sshd_config.d/00-hardening.conf > /dev/null << 'EOF'
HostKey /etc/ssh/ssh_host_ed25519_key
KexAlgorithms mlkem768x25519-sha256
Ciphers chacha20-poly1305@openssh.com
# kept in case ciphers get extended
MACs hmac-sha2-512-etm@openssh.com
HostKeyAlgorithms sk-ssh-ed25519-cert-v01@openssh.com,ssh-ed25519-cert-v01@openssh.com,sk-ssh-ed25519@openssh.com,ssh-ed25519
PubkeyAcceptedAlgorithms sk-ssh-ed25519-cert-v01@openssh.com,ssh-ed25519-cert-v01@openssh.com,sk-ssh-ed25519@openssh.com,ssh-ed25519
# inert w/o TrustedUserCAKeys, kept in case a ca shows up one day
CASignatureAlgorithms sk-ssh-ed25519@openssh.com,ssh-ed25519
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
X11Forwarding no
DebianBanner no
AllowUsers debian
AuthenticationMethods publickey
MaxAuthTries 3
LoginGraceTime 30
LogLevel VERBOSE
ClientAliveInterval 300
ClientAliveCountMax 2
EOF
sudo sshd -t && sudo systemctl reload ssh
sudo sshd -T | grep -iE 'passwordauthentication|kbdinteractive|permitrootlogin'
# from ANOTHER terminal, must succeed before going further:
# ssh -o IdentitiesOnly=yes -i <key> debian@<ip>
# then cancel the rollback
sudo systemctl stop sshd-rollback.timeripv4 only
if no use of ipv6 (see ipv6 below)
echo 'AddressFamily inet' | sudo tee /etc/ssh/sshd_config.d/10-ipv4only.conf
sudo sshd -t && sudo systemctl reload ssh
sudo ss -tlnp | grep sshdfirewall
single apply path for everything nft:
sudo nft -c -f /etc/nftables.conf && sudo systemctl reload nftables.
reloading replays flush ruleset, which also wipes fail2ban runtime tables →
always sudo systemctl restart fail2ban right after (once it is installed)
base
sudo apt-get update -qq
sudo apt-get install -y -qq nftables
sudo install -d -m 0755 /etc/nftables.d
sudo cp -a /etc/nftables.conf "/etc/nftables.conf.bak.$(date +%F-%H%M%S)"
sudo tee /etc/nftables.conf > /dev/null << 'EOF'
#!/usr/sbin/nft -f
# everything lives in /etc/nftables.d/, this file only orchestrates.
# reloading flushes fail2ban tables -> systemctl restart fail2ban after.
flush ruleset
define ssh_port = 22
include "/etc/nftables.d/*.nft"
EOF
sudo tee /etc/nftables.d/10-filter.nft > /dev/null << 'EOF'
#!/usr/sbin/nft -f
# main input chain, default drop.
# stack: 51-icmp (-10) and 50-ssh-ratelimit (-5) shave, fail2ban (-1) bans,
# this chain (0) decides.
table inet filter_main
delete table inet filter_main
table inet filter_main {
chain input {
type filter hook input priority filter; policy drop;
iif "lo" accept
ct state established,related accept
ct state invalid drop
# icmp needed for pmtud; fine-grained drops live in 51-icmp.nft
ip protocol icmp icmp type { echo-request, destination-unreachable, time-exceeded, parameter-problem } accept
# meta l4proto ipv6-icmp accept # uncomment if ipv6 comes back (ndp is vital)
# udp sport 67 udp dport 68 accept # uncomment if the vps gets its ip via dhcp
tcp dport $ssh_port ct state new accept
}
# forward/output: accept. docker will manage forward when it lands.
}
EOF
# rollback timer: opens everything back up in 5 min unless cancelled
sudo systemd-run --on-active=5min --unit=fw-rollback \
sh -c 'nft flush ruleset; systemctl try-restart fail2ban'
sudo nft -c -f /etc/nftables.conf
sudo systemctl enable --now nftables
sudo systemctl reload nftables
# from ANOTHER terminal: a new ssh session must work,
# and from outside `nc -vz <ip> 80` must time out. then:
sudo systemctl stop fw-rollback.timer
sudo nft list chain inet filter_main inputssh ratelimit
sudo tee /etc/nftables.d/50-ssh-ratelimit.nft > /dev/null << 'EOF'
#!/usr/sbin/nft -f
# $ssh_port comes from /etc/nftables.conf
table inet ssh_ratelimit
delete table inet ssh_ratelimit
table inet ssh_ratelimit {
set flood4 {
type ipv4_addr
flags dynamic
size 65536
timeout 10m
}
set flood6 {
type ipv6_addr
flags dynamic
size 65536
timeout 10m
}
chain input {
type filter hook input priority -5; policy accept;
tcp dport $ssh_port ct state new update @flood4 { ip saddr limit rate over 10/minute burst 5 packets } counter drop
tcp dport $ssh_port ct state new update @flood6 { ip6 saddr limit rate over 10/minute burst 5 packets } counter drop
}
}
EOF
sudo nft -c -f /etc/nftables.conf && sudo systemctl reload nftables
sudo nft list table inet ssh_ratelimiticmp
sudo tee /etc/nftables.d/51-icmp.nft > /dev/null << 'EOF'
#!/usr/sbin/nft -f
# icmp/icmpv6 hardening, drops only. accepts live in 10-filter.nft.
table inet icmp_hardening
delete table inet icmp_hardening
table inet icmp_hardening {
chain input_icmp {
type filter hook input priority filter - 10; policy accept;
meta l4proto icmp icmp type echo-request limit rate over 5/second burst 10 packets counter drop comment "icmp echo flood"
meta l4proto icmp icmp type { redirect, router-advertisement, router-solicitation, timestamp-request, timestamp-reply, info-request, info-reply, address-mask-request, address-mask-reply } counter drop comment "useless icmp types"
icmpv6 type echo-request limit rate over 5/second burst 10 packets counter drop comment "icmpv6 echo flood"
icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert, nd-router-advert, nd-router-solicit } ip6 hoplimit != 255 counter drop comment "ndp invalid hoplimit"
icmpv6 type nd-redirect counter drop comment "icmpv6 redirect"
}
}
EOF
sudo nft -c -f /etc/nftables.conf && sudo systemctl reload nftables
sudo nft list table inet icmp_hardeningfail2ban
sudo apt-get update -qq
sudo apt-get install -qq -y fail2ban python3-systemd
sudo tee /etc/fail2ban/jail.d/sshd-strict.local > /dev/null << 'EOF'
[DEFAULT]
banaction = nftables-multiport
banaction_allports = nftables-allports
bantime.increment = true
bantime.factor = 4
bantime.maxtime = 5w
# add your static ip here if you have one (maxretry 2 self-ban is easy)
ignoreip = 127.0.0.1/8
[sshd]
enabled = true
backend = systemd
mode = aggressive
port = 22
maxretry = 2
findtime = 6h
bantime = 24h
EOF
sudo systemctl enable --now fail2ban
sudo systemctl restart fail2ban
sleep 2
sudo fail2ban-client status sshd
sudo tail -n 50 /var/log/fail2ban.log
sudo fail2ban-client get sshd journalmatch
sudo fail2ban-client status sshd
# sudo fail2ban-client set sshd unbanip <ip>only if “Total failed” stays at 0 after a failed attempt, journalmatch may
target sshd.service / _COMM=sshd instead of debian’s ssh.service and
openssh 10’s sshd-session:
sudo tee /etc/fail2ban/jail.d/sshd-journalmatch.local > /dev/null << 'EOF'
[sshd]
journalmatch = _SYSTEMD_UNIT=ssh.service + _COMM=sshd-session
EOF
sudo systemctl restart fail2bansysctl
network
# file from a previous version of this doc
sudo tee /etc/sysctl.d/99-network-hardening.conf > /dev/null <<'EOF'
# icmp emission (defaults 1000/50)
net.ipv4.icmp_msgs_per_sec = 500
net.ipv4.icmp_msgs_burst = 25
# icmp redirects: kernel defaults are 1 on a host -> real effect
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.all.secure_redirects = 0
net.ipv4.conf.default.secure_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
# inert while ipv6 is disabled, kept in case it comes back
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.default.accept_redirects = 0
# already 0 on trixie ("all" is kernel default, "default" comes from systemd's
# /usr/lib/sysctl.d/50-default.conf), pinned so it survives vendor-file changes
net.ipv4.conf.default.accept_source_route = 0
# log impossible packets (observability, default 0)
net.ipv4.conf.all.log_martians = 1
net.ipv4.conf.default.log_martians = 1
# anti-spoofing (2 = loose): trixie already ships rp_filter=2 via systemd's
# 50-default.conf for "default" + existing interfaces, but not all
# pinned here, "all" added (max of all/interface wins)
net.ipv4.conf.all.rp_filter = 2
net.ipv4.conf.default.rp_filter = 2
EOF
# full path /usr/sbin is not in the user PATH
sudo /usr/sbin/sysctl --system
/usr/sbin/sysctl net.ipv4.conf.all.rp_filter net.ipv4.conf.all.log_martians net.ipv4.icmp_msgs_per_secleft out because already kernel defaults icmp_echo_ignore_broadcasts,
icmp_ignore_bogus_error_responses, icmp_ratelimit=1000, icmp_ratemask=6168,
net.ipv6.icmp.ratelimit=1000, conf.all.accept_source_route=0
ipv6
if no use or no filter of ipv6
sudo tee /etc/sysctl.d/99-ipv6.conf > /dev/null << 'EOF'
# all + default, covers existing && future interfaces
# also kills ::1 on lo, if something local needs ::1, drop the "all" line
# and list interfaces explicitly instead (eno1...)
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1
EOF
sudo /usr/sbin/sysctl --system
# must be empty
ip -6 addrNOT persistent alone bc sysctl runs early at boot, then the network flips disable_ipv6=0. check netplan
#example
dhcp6: false
accept-ra: false
set-name: "eno1"
link-local: []change ssh port
if you want to reduce fail2ban logs size lol (not security related)
NEWPORT=27520
# transition: listen on both
sudo tee /etc/ssh/sshd_config.d/01-port.conf > /dev/null << EOF
Port 22
Port $NEWPORT
EOF
sudo sshd -t && sudo systemctl reload ssh
sudo ss -tlnp | grep sshd
sudo sed -i "s|^define ssh_port = .*|define ssh_port = { 22, $NEWPORT }|" /etc/nftables.conf
sudo sed -i "s|^port = .*|port = 22,$NEWPORT|" /etc/fail2ban/jail.d/sshd-strict.local
sudo nft -c -f /etc/nftables.conf && sudo systemctl reload nftables
sudo systemctl restart fail2ban
# check NEWPORT access from outside
# then close port 22
sudo sed -i '/^Port 22$/d' /etc/ssh/sshd_config.d/01-port.conf
sudo sshd -t && sudo systemctl reload ssh
sudo sed -i "s|^define ssh_port = .*|define ssh_port = $NEWPORT|" /etc/nftables.conf
sudo sed -i "s|^port = .*|port = $NEWPORT|" /etc/fail2ban/jail.d/sshd-strict.local
sudo nft -c -f /etc/nftables.conf && sudo systemctl reload nftables
sudo systemctl restart fail2ban
sudo ss -tlnp | grep sshdllmnr
if systemd-resolved is installed
dpkg -l systemd-resolved || echo "not installed, skip this section"
sudo mkdir -p /etc/systemd/resolved.conf.d
sudo tee /etc/systemd/resolved.conf.d/99-hardening.conf > /dev/null << 'EOF'
[Resolve]
LLMNR=no
MulticastDNS=no
EOF
sudo systemctl restart systemd-resolvedauto updates
biggest security/effort ratio of the whole page
sudo apt-get install -qq -y unattended-upgrades needrestart
sudo dpkg-reconfigure -plow unattended-upgrades
sudo unattended-upgrade --dry-run --debug
# optional: version /etc
sudo apt-get install -qq -y etckeepercheck
create a report script checking everything this page applies. (AI generated)
sudo tee /usr/local/sbin/check-hardening > /dev/null << 'EOF'
#!/usr/bin/env bash
# report on everything the hardening page applies. run as root.
export PATH=/usr/local/sbin:/usr/sbin:/sbin:/usr/bin:/bin
pass=0; warn=0; fail=0
ok(){ echo " ok $*"; pass=$((pass+1)); }
wr(){ echo " warn $*"; warn=$((warn+1)); }
ko(){ echo " FAIL $*"; fail=$((fail+1)); }
hdr(){ printf '\n== %s\n' "$*"; }
[ "$(id -u)" -eq 0 ] || { echo "run as root: sudo check-hardening"; exit 1; }
hdr "ssh"
[ "$(systemctl is-enabled ssh.socket 2>/dev/null)" = enabled ] \
&& ko "ssh.socket enabled -> Port/AddressFamily ignored" \
|| ok "ssh.socket not enabled"
systemctl is-active ssh >/dev/null 2>&1 && ok "ssh.service active" || ko "ssh.service not active"
T=$(sshd -T 2>/dev/null) || ko "sshd -T failed"
chk(){ v=$(printf '%s\n' "$T" | awk -v k="$1" '$1==k{$1="";sub(/^ /,"");print;exit}')
[ "$v" = "$2" ] && ok "$1 = $2" || ko "$1 = '$v' (expected: $2)"; }
chk passwordauthentication no
chk kbdinteractiveauthentication no
chk permitrootlogin no
chk x11forwarding no
chk allowusers debian
chk authenticationmethods publickey
chk kexalgorithms mlkem768x25519-sha256
chk ciphers chacha20-poly1305@openssh.com
chk addressfamily inet
chk loglevel VERBOSE
printf '%s\n' "$T" | awk '$1=="banner"{print " info banner: " $2}'
extra=$(ls /etc/ssh/ssh_host_* 2>/dev/null | grep -v ed25519)
[ -z "$extra" ] && ok "only ed25519 host keys on disk" \
|| wr "extra host keys (unused, HostKey pins ed25519): $extra"
hdr "ssh port sources (must all match)"
p_sshd=$(printf '%s\n' "$T" | awk '$1=="port"{print $2}' | sort -n | xargs)
p_nft=$(grep '^define ssh_port' /etc/nftables.conf 2>/dev/null | grep -oE '[0-9]+' | sort -n | xargs)
p_f2b=$(grep '^port' /etc/fail2ban/jail.d/sshd-strict.local 2>/dev/null | grep -oE '[0-9]+' | sort -n | xargs)
echo " info sshd: ${p_sshd:-?} | nftables: ${p_nft:-?} | fail2ban: ${p_f2b:-?}"
{ [ -n "$p_sshd" ] && [ "$p_sshd" = "$p_nft" ] && [ "$p_sshd" = "$p_f2b" ]; } \
&& ok "ports in sync" || ko "ports out of sync"
ss -tlnp 2>/dev/null | awk '/sshd/{print " info listening on " $4}'
ss -tlnp 2>/dev/null | grep sshd | grep -q '\[::\]' \
&& wr "sshd still listens on ipv6" || ok "no ipv6 listener"
hdr "nftables"
[ "$(systemctl is-enabled nftables 2>/dev/null)" = enabled ] && ok "enabled at boot" || ko "not enabled at boot"
systemctl is-active nftables >/dev/null 2>&1 && ok "service active" || ko "service not active"
nft list chain inet filter_main input 2>/dev/null | grep -q 'policy drop' \
&& ok "filter_main input policy drop" || ko "no default-drop input chain"
for t in ssh_ratelimit icmp_hardening; do
nft list tables 2>/dev/null | grep -q "inet $t" && ok "table $t loaded" || ko "table $t missing"
done
hdr "fail2ban"
systemctl is-active fail2ban >/dev/null 2>&1 && ok "service active" || ko "service not active"
nft list tables 2>/dev/null | grep -q f2b \
&& ok "f2b tables present" \
|| wr "no f2b table: wiped by an nftables reload? -> systemctl restart fail2ban"
echo " info journalmatch: $(fail2ban-client get sshd journalmatch 2>/dev/null)"
tf=$(fail2ban-client status sshd 2>/dev/null | grep -i 'total failed' | grep -oE '[0-9]+' | tail -1)
case "${tf:-}" in
''|0) wr "total failed = ${tf:-?}: fresh install or broken journalmatch (see fail2ban section)" ;;
*) ok "journal matching works (total failed: $tf)" ;;
esac
hdr "sysctl"
sck(){ v=$(sysctl -n "$1" 2>/dev/null)
[ "$v" = "$2" ] && ok "$1 = $2" || ko "$1 = '$v' (expected: $2)"; }
sck net.ipv4.conf.all.accept_redirects 0
sck net.ipv4.conf.all.send_redirects 0
sck net.ipv4.conf.all.rp_filter 2
sck net.ipv4.conf.all.log_martians 1
sck net.ipv4.icmp_msgs_per_sec 500
if [ -d /proc/sys/net/ipv6 ]; then sck net.ipv6.conf.all.disable_ipv6 1
else ok "ipv6 disabled at kernel level (grub)"; fi
[ -e /etc/sysctl.d/99-icmp-hardening.conf ] \
&& wr "old 99-icmp-hardening.conf still present" || ok "old sysctl file gone"
hdr "ipv6"
n=$(ip -6 addr 2>/dev/null | grep -c inet6)
[ "$n" -eq 0 ] && ok "no ipv6 address" || wr "$n ipv6 address(es) left: ip -6 addr"
hdr "leftovers"
systemctl list-timers --all 2>/dev/null | grep -qE 'sshd-rollback|fw-rollback' \
&& wr "rollback timer still armed" || ok "no rollback timer armed"
if systemctl is-active systemd-resolved >/dev/null 2>&1; then
resolvectl status 2>/dev/null | grep -q '+LLMNR' && wr "llmnr still on" || ok "llmnr off"
fi
hdr "auto updates"
dpkg -s unattended-upgrades >/dev/null 2>&1 && ok "unattended-upgrades installed" || ko "unattended-upgrades missing"
dpkg -s needrestart >/dev/null 2>&1 && ok "needrestart installed" || wr "needrestart missing"
apt-config dump APT::Periodic::Unattended-Upgrade 2>/dev/null | grep -q '"1"' \
&& ok "periodic unattended-upgrade on" || ko "periodic unattended-upgrade off"
for t in apt-daily.timer apt-daily-upgrade.timer; do
systemctl is-active "$t" >/dev/null 2>&1 && ok "$t active" || ko "$t not active"
done
printf '\n== %s ok, %s warn, %s FAIL\n' "$pass" "$warn" "$fail"
[ "$fail" -eq 0 ]
EOF
sudo chmod 755 /usr/local/sbin/check-hardening
sudo check-hardening