Last active
March 13, 2026 05:07
-
-
Save groundcat/112d0bbd8c47b41669037ae7338171a8 to your computer and use it in GitHub Desktop.
Secure SSH for Debian and Ubuntu
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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