Skip to content

Instantly share code, notes, and snippets.

@groundcat
Last active March 13, 2026 05:07
Show Gist options
  • Select an option

  • Save groundcat/112d0bbd8c47b41669037ae7338171a8 to your computer and use it in GitHub Desktop.

Select an option

Save groundcat/112d0bbd8c47b41669037ae7338171a8 to your computer and use it in GitHub Desktop.
Secure SSH for Debian and Ubuntu
#!/usr/bin/env bash
set -Eeuo pipefail
SSH_CONFIG="/etc/ssh/sshd_config"
AUTHORIZED_KEYS="/root/.ssh/authorized_keys"
F2B_JAIL_LOCAL="/etc/fail2ban/jail.local"
log() {
printf '[*] %s\n' "$*"
}
warn() {
printf '[!] %s\n' "$*" >&2
}
die() {
printf '[x] %s\n' "$*" >&2
exit 1
}
require_root() {
[[ "${EUID}" -eq 0 ]] || die "Run this script as root."
}
have_cmd() {
command -v "$1" >/dev/null 2>&1
}
backup_file_once() {
local file="$1"
local backup="${file}.bak.pre_hardening"
if [[ -f "$file" && ! -f "$backup" ]]; then
cp -a "$file" "$backup"
log "Backup created: $backup"
fi
}
trim() {
local s="$1"
s="${s#"${s%%[![:space:]]*}"}"
s="${s%"${s##*[![:space:]]}"}"
printf '%s' "$s"
}
ensure_root_ssh_paths() {
mkdir -p /root/.ssh
chmod 700 /root/.ssh
if [[ ! -e "$AUTHORIZED_KEYS" ]]; then
touch "$AUTHORIZED_KEYS"
fi
chmod 600 "$AUTHORIZED_KEYS"
}
count_valid_pubkeys() {
local count=0
local line tmp
[[ -f "$AUTHORIZED_KEYS" ]] || {
echo 0
return 0
}
while IFS= read -r line || [[ -n "$line" ]]; do
line="$(trim "$line")"
[[ -z "$line" ]] && continue
[[ "$line" =~ ^# ]] && continue
tmp="$(mktemp)"
printf '%s\n' "$line" > "$tmp"
if ssh-keygen -l -f "$tmp" >/dev/null 2>&1; then
count=$((count + 1))
fi
rm -f "$tmp"
done < "$AUTHORIZED_KEYS"
echo "$count"
}
require_manual_root_key_setup() {
local valid_count
ensure_root_ssh_paths
valid_count="$(count_valid_pubkeys)"
if (( valid_count > 0 )); then
log "Found $valid_count valid public key(s) in $AUTHORIZED_KEYS"
return 0
fi
echo
warn "No valid SSH public keys were found in $AUTHORIZED_KEYS"
echo
echo "Before SSH hardening continues, add at least one root public key manually."
echo
echo "Do this now:"
echo " mkdir -p /root/.ssh"
echo " chmod 700 /root/.ssh"
echo " nano $AUTHORIZED_KEYS"
echo " chmod 600 $AUTHORIZED_KEYS"
echo
echo "Paste one full public key line, for example:"
echo " ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... comment"
echo
echo "When done, save the file and come back here."
echo
while true; do
read -r -p "Type yes after you have manually added the key: " answer || true
answer="$(trim "$answer")"
case "${answer,,}" in
yes|y)
ensure_root_ssh_paths
valid_count="$(count_valid_pubkeys)"
if (( valid_count > 0 )); then
log "Confirmed: found $valid_count valid public key(s) in $AUTHORIZED_KEYS"
return 0
fi
warn "Still no valid public keys found in $AUTHORIZED_KEYS"
;;
*)
warn "Please type yes after adding at least one valid public key."
;;
esac
done
}
normalize_sshd_value() {
local key="$1"
local value="$2"
case "${key,,}" in
permitrootlogin)
case "${value,,}" in
without-password) echo "prohibit-password" ;;
*) echo "${value,,}" ;;
esac
;;
passwordauthentication|maxauthtries|logingracetime|x11forwarding|allowtcpforwarding|pubkeyauthentication|port)
echo "${value,,}"
;;
*)
echo "${value,,}"
;;
esac
}
current_sshd_value() {
local key="$1"
local out value
if out="$(sshd -T 2>/dev/null)"; then
value="$(awk -v k="${key,,}" '$1 == k {print $2; exit}' <<<"$out" || true)"
if [[ -n "${value:-}" ]]; then
normalize_sshd_value "$key" "$value"
return 0
fi
fi
value="$(awk -v k="$key" '
BEGIN { IGNORECASE=1 }
$0 ~ "^[[:space:]]*"k"[[:space:]]+" && $0 !~ "^[[:space:]]*#" {
v=$2
}
END {
if (v != "") print v
}
' "$SSH_CONFIG" 2>/dev/null || true)"
if [[ -n "${value:-}" ]]; then
normalize_sshd_value "$key" "$value"
fi
}
set_sshd_option_if_needed() {
local key="$1"
local desired="$2"
local desired_norm current
desired_norm="$(normalize_sshd_value "$key" "$desired")"
current="$(current_sshd_value "$key" || true)"
if [[ "$current" == "$desired_norm" ]]; then
log "SSH setting already correct: $key $desired"
return 1
fi
backup_file_once "$SSH_CONFIG"
if grep -Eqi "^[[:space:]]*#?[[:space:]]*${key}[[:space:]]+" "$SSH_CONFIG"; then
sed -i -E "s|^[[:space:]]*#?[[:space:]]*(${key})[[:space:]].*$|\1 ${desired}|I" "$SSH_CONFIG"
else
printf '\n%s %s\n' "$key" "$desired" >> "$SSH_CONFIG"
fi
log "SSH setting applied: $key $desired"
return 0
}
restart_ssh_service_if_needed() {
local service_name="ssh"
systemctl list-unit-files 2>/dev/null | grep -q '^sshd\.service' && service_name="sshd"
if [[ "${SSHD_CHANGED:-0}" -eq 1 ]]; then
if sshd -t; then
log "sshd configuration test passed."
systemctl restart "$service_name"
log "SSH service restarted: $service_name"
else
die "sshd configuration test failed. Review $SSH_CONFIG and restore from backup if needed."
fi
else
log "No SSH config changes were needed."
fi
}
prompt_fail2ban_email() {
while true; do
echo
read -r -p "Fail2Ban alert email (leave blank to skip destemail): " FAIL2BAN_EMAIL || true
FAIL2BAN_EMAIL="$(trim "${FAIL2BAN_EMAIL:-}")"
if [[ -z "$FAIL2BAN_EMAIL" ]]; then
log "No email provided. Fail2Ban will be configured without destemail."
break
fi
if [[ "$FAIL2BAN_EMAIL" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]]; then
break
fi
warn "That does not look like a valid email address."
done
}
apt_update_once() {
if [[ "${APT_UPDATED:-0}" -eq 0 ]]; then
log "Running apt update..."
apt-get update
APT_UPDATED=1
fi
}
upgrade_if_needed() {
apt_update_once
if apt-get -s upgrade | grep -qE '^[0-9]+ upgraded,'; then
log "Upgradeable packages found. Running apt upgrade -y..."
DEBIAN_FRONTEND=noninteractive apt-get upgrade -y
else
log "No package upgrades pending."
fi
}
install_fail2ban_if_needed() {
if dpkg -s fail2ban >/dev/null 2>&1; then
log "Fail2Ban is already installed."
else
apt_update_once
log "Installing Fail2Ban..."
DEBIAN_FRONTEND=noninteractive apt-get install -y fail2ban
fi
}
build_fail2ban_desired_config() {
if [[ -n "${FAIL2BAN_EMAIL:-}" ]]; then
cat <<EOF
[DEFAULT]
destemail = ${FAIL2BAN_EMAIL}
sendername = Fail2Ban
[sshd]
enabled = true
port = 22
mode = aggressive
EOF
else
cat <<'EOF'
[DEFAULT]
sendername = Fail2Ban
[sshd]
enabled = true
port = 22
mode = aggressive
EOF
fi
}
write_fail2ban_config_if_needed() {
local desired
desired="$(build_fail2ban_desired_config)"
backup_file_once "$F2B_JAIL_LOCAL"
if [[ -f "$F2B_JAIL_LOCAL" ]]; then
if cmp -s <(printf '%s\n' "$desired") "$F2B_JAIL_LOCAL"; then
log "Fail2Ban config already matches desired state."
return 1
fi
fi
printf '%s\n' "$desired" > "$F2B_JAIL_LOCAL"
log "Fail2Ban config written to $F2B_JAIL_LOCAL"
return 0
}
enable_and_restart_fail2ban_if_needed() {
local should_restart="${1:-0}"
systemctl enable fail2ban >/dev/null 2>&1 || true
if ! systemctl is-active --quiet fail2ban; then
systemctl start fail2ban
log "Fail2Ban started."
return 0
fi
if [[ "$should_restart" -eq 1 ]]; then
systemctl restart fail2ban
log "Fail2Ban restarted."
else
log "No Fail2Ban restart needed."
fi
}
main() {
require_root
have_cmd sshd || die "sshd not found. Is openssh-server installed?"
have_cmd ssh-keygen || die "ssh-keygen not found."
[[ -f "$SSH_CONFIG" ]] || die "SSH config not found: $SSH_CONFIG"
require_manual_root_key_setup
SSHD_CHANGED=0
set_sshd_option_if_needed "Port" "22" && SSHD_CHANGED=1 || true
set_sshd_option_if_needed "PermitRootLogin" "prohibit-password" && SSHD_CHANGED=1 || true
set_sshd_option_if_needed "PasswordAuthentication" "no" && SSHD_CHANGED=1 || true
set_sshd_option_if_needed "MaxAuthTries" "3" && SSHD_CHANGED=1 || true
set_sshd_option_if_needed "LoginGraceTime" "20" && SSHD_CHANGED=1 || true
set_sshd_option_if_needed "X11Forwarding" "no" && SSHD_CHANGED=1 || true
set_sshd_option_if_needed "AllowTcpForwarding" "no" && SSHD_CHANGED=1 || true
set_sshd_option_if_needed "PubkeyAuthentication" "yes" && SSHD_CHANGED=1 || true
restart_ssh_service_if_needed
prompt_fail2ban_email
upgrade_if_needed
install_fail2ban_if_needed
F2B_CHANGED=0
write_fail2ban_config_if_needed && F2B_CHANGED=1 || true
enable_and_restart_fail2ban_if_needed "$F2B_CHANGED"
echo
log "Done."
log "At least one valid root public key exists in $AUTHORIZED_KEYS."
log "SSH hardening settings are in place."
log "Fail2Ban is installed/configured."
log "Open a second SSH session and verify key login before closing your current session."
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment