Files
linux-scripts/bastion-hardener.sh
T
chiefgeek a1a17e81a1 Sync all scripts from website downloads — 352 scripts total
Includes updated JS challenge scripts with Claude-User whitelist,
same-site referer bypass, Blackbox-Exporter allowed bot, and all
new exporters, cheat sheets, and automation scripts.
2026-05-25 03:31:08 +02:00

615 lines
24 KiB
Bash
Executable File

#!/usr/bin/env bash
#########################################################################################
#### bastion-hardener.sh — Harden SSH bastion/jump hosts with audit and rollback ####
#### Disables password auth, restricts ciphers, sets idle timeout, fail2ban config ####
#### Requires: bash 4+, root privileges ####
#### ####
#### Author: Phil Connor ####
#### Contact: contact@mylinux.work ####
#### License: MIT ####
#### Version 1.01 ####
#### ####
#### Usage: ####
#### sudo ./bastion-hardener.sh --audit ####
#### ####
#### See --help for all options. ####
#########################################################################################
# v1.01 changes:
# - Fixed: ((0++)) returns 1 under set -e; added || true guards
# - Fixed: grep in pipeline crashes under set -euo pipefail when no matches found. Added || true guard
#########################################################################################
set -euo pipefail
# ── Defaults ──────────────────────────────────────────────────────────
RUN_MODE=""
SSHD_CONFIG="${SSHD_CONFIG:-/etc/ssh/sshd_config}"
BACKUP_ROOT="${BACKUP_ROOT:-/etc/ssh}"
ALLOW_USERS="${ALLOW_USERS:-}"
ALLOW_GROUPS="${ALLOW_GROUPS:-}"
IDLE_TIMEOUT="${IDLE_TIMEOUT:-300}"
MAX_AUTH_TRIES="${MAX_AUTH_TRIES:-3}"
MAX_SESSIONS="${MAX_SESSIONS:-2}"
SESSION_LOG_DIR="${SESSION_LOG_DIR:-/var/log/bastion-sessions}"
FAIL2BAN_BANTIME="${FAIL2BAN_BANTIME:-3600}"
FAIL2BAN_MAXRETRY="${FAIL2BAN_MAXRETRY:-3}"
DRY_RUN="${DRY_RUN:-false}"
VERBOSE="${VERBOSE:-false}"
COLOR="${COLOR:-auto}"
ENABLE_SESSION_LOGGING="false"
CONFIGURE_FAIL2BAN="false"
ROLLBACK_DIR=""
# ── State ─────────────────────────────────────────────────────────────
SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_NAME
START_TIME=""
PASS_COUNT=0
FAIL_COUNT=0
WARN_COUNT=0
CHANGES=0
# ── Hardening settings ───────────────────────────────────────────────
readonly RECOMMENDED_CIPHERS="chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com"
readonly RECOMMENDED_MACS="hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com"
readonly RECOMMENDED_KEX="curve25519-sha256,curve25519-sha256@libssh.org"
# ── Colors ────────────────────────────────────────────────────────────
setup_colors() {
if [[ "$COLOR" == "never" ]]; then
RED="" GREEN="" YELLOW="" BLUE="" CYAN="" BOLD="" DIM="" RESET=""
return
fi
if [[ "$COLOR" == "always" ]] || [[ -t 1 ]]; then
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
# shellcheck disable=SC2034
CYAN='\033[0;36m'
BOLD='\033[1m'
DIM='\033[2m'
RESET='\033[0m'
else
# shellcheck disable=SC2034
RED="" GREEN="" YELLOW="" BLUE="" CYAN="" BOLD="" DIM="" RESET=""
fi
}
# ── Logging ───────────────────────────────────────────────────────────
log() { echo -e "${BLUE}[INFO]${RESET} $*"; }
warn() { echo -e "${YELLOW}[WARN]${RESET} $*" >&2; }
err() { echo -e "${RED}[ERROR]${RESET} $*" >&2; }
verbose() { if [[ "$VERBOSE" == "true" ]]; then echo -e "${DIM}[DEBUG]${RESET} $*"; fi; }
die() { err "$*"; exit 1; }
elapsed() {
local end_time
end_time=$(date +%s)
echo "$(( end_time - START_TIME ))s"
}
require_root() {
if [[ $EUID -ne 0 ]]; then
die "This operation requires root privileges. Run with sudo."
fi
}
# ── SSHD config helpers ──────────────────────────────────────────────
get_sshd_setting() {
local key="$1"
local val
val=$({ grep -i "^[[:space:]]*${key}[[:space:]]" "$SSHD_CONFIG" 2>/dev/null || true; } | tail -1 | awk '{print $2}')
if [[ -z "$val" ]]; then
echo "(not set)"
else
echo "$val"
fi
}
set_sshd_config() {
local key="$1"
local value="$2"
local file="$3"
if grep -qi "^[[:space:]]*${key}[[:space:]]" "$file" 2>/dev/null; then
sed -i "s|^[[:space:]]*${key}[[:space:]].*|${key} ${value}|i" "$file"
elif grep -qi "^[[:space:]]*#[[:space:]]*${key}[[:space:]]" "$file" 2>/dev/null; then
sed -i "s|^[[:space:]]*#[[:space:]]*${key}[[:space:]].*|${key} ${value}|i" "$file"
else
echo "${key} ${value}" >> "$file"
fi
verbose "Set ${key} = ${value}"
}
# ── Audit check helper ───────────────────────────────────────────────
check_setting() {
local name="$1"
local current="$2"
local recommended="$3"
local is_warn="${4:-false}"
local status_icon
if [[ "${current,,}" == "${recommended,,}" ]]; then
status_icon="${GREEN}✓ PASS${RESET}"
((PASS_COUNT++)) || true
elif [[ "$is_warn" == "true" ]]; then
status_icon="${YELLOW}! WARN${RESET}"
((WARN_COUNT++)) || true
else
status_icon="${RED}✗ FAIL${RESET}"
((FAIL_COUNT++)) || true
fi
printf " %-34s %-16s %-16s %b\n" "$name" "$current" "$recommended" "$status_icon"
}
# ══════════════════════════════════════════════════════════════════════
# AUDIT MODE
# ══════════════════════════════════════════════════════════════════════
do_audit() {
if [[ ! -f "$SSHD_CONFIG" ]]; then
die "sshd_config not found at ${SSHD_CONFIG}"
fi
log "Auditing SSH configuration..."
echo ""
echo -e " ${BOLD}SSH Configuration Audit${RESET}"
printf " ${BOLD}%-34s %-16s %-16s %s${RESET}\n" "SETTING" "CURRENT" "RECOMMENDED" "STATUS"
printf " %s\n" "$(printf '%.0s─' {1..78})"
# Core auth settings
check_setting "PermitRootLogin" "$(get_sshd_setting PermitRootLogin)" "no"
check_setting "PasswordAuthentication" "$(get_sshd_setting PasswordAuthentication)" "no"
check_setting "ChallengeResponseAuthentication" "$(get_sshd_setting ChallengeResponseAuthentication)" "no"
check_setting "PubkeyAuthentication" "$(get_sshd_setting PubkeyAuthentication)" "yes"
# Limits
check_setting "MaxAuthTries" "$(get_sshd_setting MaxAuthTries)" "$MAX_AUTH_TRIES"
check_setting "MaxSessions" "$(get_sshd_setting MaxSessions)" "$MAX_SESSIONS"
# Timeouts
check_setting "ClientAliveInterval" "$(get_sshd_setting ClientAliveInterval)" "$IDLE_TIMEOUT"
local cac_current
cac_current=$(get_sshd_setting ClientAliveCountMax)
if [[ "$cac_current" == "3" ]]; then
check_setting "ClientAliveCountMax" "$cac_current" "2" "true"
else
check_setting "ClientAliveCountMax" "$cac_current" "2"
fi
# Forwarding
check_setting "X11Forwarding" "$(get_sshd_setting X11Forwarding)" "no"
check_setting "AllowTcpForwarding" "$(get_sshd_setting AllowTcpForwarding)" "no"
check_setting "AllowAgentForwarding" "$(get_sshd_setting AllowAgentForwarding)" "no"
check_setting "PermitTunnel" "$(get_sshd_setting PermitTunnel)" "no"
# Crypto
local ciphers_current
ciphers_current=$(get_sshd_setting Ciphers)
if [[ "$ciphers_current" == "(not set)" ]]; then
check_setting "Ciphers" "(default)" "(restricted)"
elif [[ "$ciphers_current" == "$RECOMMENDED_CIPHERS" ]]; then
check_setting "Ciphers" "(restricted)" "(restricted)"
else
check_setting "Ciphers" "(custom)" "(restricted)"
fi
local macs_current
macs_current=$(get_sshd_setting MACs)
if [[ "$macs_current" == "(not set)" ]]; then
check_setting "MACs" "(default)" "(restricted)"
elif [[ "$macs_current" == "$RECOMMENDED_MACS" ]]; then
check_setting "MACs" "(restricted)" "(restricted)"
else
check_setting "MACs" "(custom)" "(restricted)"
fi
local kex_current
kex_current=$(get_sshd_setting KexAlgorithms)
if [[ "$kex_current" == "(not set)" ]]; then
check_setting "KexAlgorithms" "(default)" "(restricted)"
elif [[ "$kex_current" == "$RECOMMENDED_KEX" ]]; then
check_setting "KexAlgorithms" "(restricted)" "(restricted)"
else
check_setting "KexAlgorithms" "(custom)" "(restricted)"
fi
# Logging and misc
check_setting "LogLevel" "$(get_sshd_setting LogLevel)" "VERBOSE"
check_setting "LoginGraceTime" "$(get_sshd_setting LoginGraceTime)" "30"
# AllowUsers / AllowGroups (warn if not set)
local au_current ag_current
au_current=$(get_sshd_setting AllowUsers)
ag_current=$(get_sshd_setting AllowGroups)
if [[ "$au_current" == "(not set)" ]]; then
check_setting "AllowUsers" "(not set)" "(recommended)" "true"
else
check_setting "AllowUsers" "(configured)" "(recommended)"
fi
if [[ "$ag_current" == "(not set)" ]]; then
check_setting "AllowGroups" "(not set)" "(recommended)" "true"
else
check_setting "AllowGroups" "(configured)" "(recommended)"
fi
# Summary
local total_checks=$((PASS_COUNT + FAIL_COUNT + WARN_COUNT))
local score=0
if [[ "$total_checks" -gt 0 ]]; then
score=$(( PASS_COUNT * 100 / total_checks ))
fi
echo ""
echo -e " ${BOLD}Summary${RESET}"
echo " Total checks: ${total_checks}"
echo -e " Passed: ${GREEN}${PASS_COUNT}${RESET}"
echo -e " Failed: ${RED}${FAIL_COUNT}${RESET}"
echo -e " Warnings: ${YELLOW}${WARN_COUNT}${RESET}"
echo " Score: ${score} / 100"
# Extra warnings
echo ""
if ! command -v fail2ban-client &>/dev/null; then
warn "Fail2ban not installed — brute-force protection unavailable"
fi
if [[ "$au_current" == "(not set)" && "$ag_current" == "(not set)" ]]; then
warn "No AllowUsers/AllowGroups configured — all users can SSH in"
fi
log "Run with --apply to harden this host"
log "Completed in $(elapsed)"
}
# ══════════════════════════════════════════════════════════════════════
# APPLY MODE
# ══════════════════════════════════════════════════════════════════════
do_apply() {
require_root
if [[ ! -f "$SSHD_CONFIG" ]]; then
die "sshd_config not found at ${SSHD_CONFIG}"
fi
# Create backup
local backup_dir
backup_dir="${BACKUP_ROOT}/bastion-hardener-backup-$(date +%Y%m%d-%H%M%S)"
log "Backing up ${SSHD_CONFIG}${backup_dir}/sshd_config"
mkdir -p "$backup_dir"
cp -p "$SSHD_CONFIG" "${backup_dir}/sshd_config"
[[ -f /etc/ssh/banner.txt ]] && cp -p /etc/ssh/banner.txt "${backup_dir}/banner.txt"
if [[ "$DRY_RUN" == "true" ]]; then
log "${YELLOW}DRY RUN${RESET} — previewing changes (no files will be modified)"
local tmp_config
tmp_config=$(mktemp)
cp "$SSHD_CONFIG" "$tmp_config"
apply_settings "$tmp_config"
echo ""
log "Diff preview:"
diff "$SSHD_CONFIG" "$tmp_config" || true
rm -f "$tmp_config"
log "Run without --dry-run to apply changes"
return
fi
log "Applying SSH hardening..."
apply_settings "$SSHD_CONFIG"
# Create banner
if [[ ! -f /etc/ssh/banner.txt ]]; then
cat > /etc/ssh/banner.txt <<'BANNER'
***************************************************************************
* AUTHORIZED ACCESS ONLY *
* *
* This system is restricted to authorized users. All activities are *
* monitored and logged. Unauthorized access is prohibited and subject *
* to prosecution under applicable law. *
* *
* By proceeding, you acknowledge that you have read and agree to the *
* organization's acceptable use policies. *
***************************************************************************
BANNER
log "Created warning banner at /etc/ssh/banner.txt"
fi
# Session logging directory
if [[ "$ENABLE_SESSION_LOGGING" == "true" ]]; then
mkdir -p "$SESSION_LOG_DIR"
chmod 700 "$SESSION_LOG_DIR"
log "Session log directory: ${SESSION_LOG_DIR}"
fi
# Fail2ban configuration
if [[ "$CONFIGURE_FAIL2BAN" == "true" ]]; then
configure_fail2ban
fi
# Validate config
log "Validating sshd configuration..."
if sshd -t -f "$SSHD_CONFIG" 2>/dev/null; then
echo -e " ${GREEN}${RESET} sshd -t passed"
else
err "sshd -t validation failed — restoring backup"
cp -p "${backup_dir}/sshd_config" "$SSHD_CONFIG"
die "Config validation failed. Original config restored."
fi
# Restart sshd
log "Restarting sshd..."
if systemctl restart sshd 2>/dev/null || systemctl restart ssh 2>/dev/null; then
echo -e " ${GREEN}${RESET} sshd restarted successfully"
else
warn "Could not restart sshd — restart manually"
fi
# Write audit report
local report_file
report_file="/var/log/bastion-hardener-$(date +%Y%m%d-%H%M%S).log"
{
echo "Bastion Hardener — Apply Report"
echo "Time: $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
echo "Host: $(hostname -f 2>/dev/null || hostname)"
echo "Changes: ${CHANGES}"
echo "Backup: ${backup_dir}"
} > "$report_file" 2>/dev/null || true
log "Writing audit report → ${report_file}"
log "Changes applied: ${CHANGES}, skipped: 0"
log "Backup directory: ${backup_dir}"
log "To rollback: ./${SCRIPT_NAME} --rollback"
log "Completed in $(elapsed)"
}
apply_settings() {
local config_file="$1"
local settings=(
"PermitRootLogin no"
"PasswordAuthentication no"
"ChallengeResponseAuthentication no"
"KbdInteractiveAuthentication no"
"PubkeyAuthentication yes"
"MaxAuthTries ${MAX_AUTH_TRIES}"
"MaxSessions ${MAX_SESSIONS}"
"ClientAliveInterval ${IDLE_TIMEOUT}"
"ClientAliveCountMax 2"
"X11Forwarding no"
"AllowTcpForwarding no"
"AllowAgentForwarding no"
"PermitTunnel no"
"Ciphers ${RECOMMENDED_CIPHERS}"
"MACs ${RECOMMENDED_MACS}"
"KexAlgorithms ${RECOMMENDED_KEX}"
"LoginGraceTime 30"
"LogLevel VERBOSE"
"Banner /etc/ssh/banner.txt"
)
for setting in "${settings[@]}"; do
local key value
key="${setting%% *}"
value="${setting#* }"
set_sshd_config "$key" "$value" "$config_file"
echo -e " ${GREEN}${RESET} ${key}${value}"
((CHANGES++)) || true
done
# AllowUsers
if [[ -n "$ALLOW_USERS" ]]; then
local users_val="${ALLOW_USERS//,/ }"
set_sshd_config "AllowUsers" "$users_val" "$config_file"
echo -e " ${GREEN}${RESET} AllowUsers → ${users_val}"
((CHANGES++)) || true
fi
# AllowGroups
if [[ -n "$ALLOW_GROUPS" ]]; then
local groups_val="${ALLOW_GROUPS//,/ }"
set_sshd_config "AllowGroups" "$groups_val" "$config_file"
echo -e " ${GREEN}${RESET} AllowGroups → ${groups_val}"
((CHANGES++)) || true
fi
}
configure_fail2ban() {
if ! command -v fail2ban-client &>/dev/null; then
warn "fail2ban not installed — skipping jail configuration"
return
fi
local jail_file="/etc/fail2ban/jail.d/bastion-ssh.conf"
log "Configuring fail2ban SSH jail → ${jail_file}"
cat > "$jail_file" <<EOF
# Bastion Hardener — SSH jail configuration
# Generated: $(date -u '+%Y-%m-%dT%H:%M:%SZ')
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = ${FAIL2BAN_MAXRETRY}
bantime = ${FAIL2BAN_BANTIME}
findtime = 600
action = %(action_)s
EOF
if systemctl restart fail2ban 2>/dev/null; then
echo -e " ${GREEN}${RESET} fail2ban SSH jail configured and restarted"
else
warn "Could not restart fail2ban"
fi
}
# ══════════════════════════════════════════════════════════════════════
# ROLLBACK MODE
# ══════════════════════════════════════════════════════════════════════
do_rollback() {
require_root
local target_dir="$ROLLBACK_DIR"
if [[ -z "$target_dir" ]]; then
# Find most recent backup
target_dir=$(find "$BACKUP_ROOT" -maxdepth 1 -type d -name "bastion-hardener-backup-*" 2>/dev/null | sort -r | head -1)
if [[ -z "$target_dir" ]]; then
die "No backup directories found in ${BACKUP_ROOT}"
fi
fi
if [[ ! -d "$target_dir" ]]; then
die "Backup directory not found: ${target_dir}"
fi
log "Restoring from ${target_dir}..."
if [[ -f "${target_dir}/sshd_config" ]]; then
cp -p "${target_dir}/sshd_config" "$SSHD_CONFIG"
echo -e " ${GREEN}${RESET} Restored sshd_config"
else
die "No sshd_config found in backup directory"
fi
if [[ -f "${target_dir}/banner.txt" ]]; then
cp -p "${target_dir}/banner.txt" /etc/ssh/banner.txt
echo -e " ${GREEN}${RESET} Restored banner.txt"
fi
# Validate
log "Validating restored configuration..."
if sshd -t -f "$SSHD_CONFIG" 2>/dev/null; then
echo -e " ${GREEN}${RESET} sshd -t passed"
else
die "Restored config failed validation"
fi
# Restart
log "Restarting sshd..."
if systemctl restart sshd 2>/dev/null || systemctl restart ssh 2>/dev/null; then
echo -e " ${GREEN}${RESET} sshd restarted successfully"
else
warn "Could not restart sshd — restart manually"
fi
log "Rollback complete from ${target_dir}"
log "Completed in $(elapsed)"
}
# ══════════════════════════════════════════════════════════════════════
# HELP
# ══════════════════════════════════════════════════════════════════════
show_help() {
cat <<EOF
Usage: $SCRIPT_NAME [MODE] [OPTIONS]
Harden SSH bastion/jump hosts with audit, apply, and rollback.
MODES:
--audit Read-only check of current SSH configuration
--apply Apply hardening changes (backs up first)
--rollback Restore configuration from most recent backup
OPTIONS:
--dry-run Preview changes without writing to disk
--allow-users USERS Comma-separated list of allowed SSH users
--allow-groups GROUPS Comma-separated list of allowed SSH groups
--idle-timeout SECS Client alive interval in seconds (default: $IDLE_TIMEOUT)
--max-auth-tries N Maximum authentication attempts (default: $MAX_AUTH_TRIES)
--max-sessions N Maximum sessions per connection (default: $MAX_SESSIONS)
--session-logging Enable ForceCommand session recording
--configure-fail2ban Configure fail2ban SSH jail
--backup-dir DIR Specific backup directory for rollback
--verbose Debug output
--no-color Disable colored output
--help, -h Show this help
ENVIRONMENT VARIABLES:
SSHD_CONFIG Path to sshd_config (default: /etc/ssh/sshd_config)
BACKUP_ROOT Backup storage root (default: /etc/ssh)
ALLOW_USERS Comma-separated allowed users
ALLOW_GROUPS Comma-separated allowed groups
IDLE_TIMEOUT ClientAliveInterval in seconds (default: 300)
MAX_AUTH_TRIES Maximum auth attempts (default: 3)
MAX_SESSIONS Maximum sessions (default: 2)
SESSION_LOG_DIR Session recording directory
FAIL2BAN_BANTIME Ban duration in seconds (default: 3600)
FAIL2BAN_MAXRETRY Max retries before ban (default: 3)
DRY_RUN Preview without writing (default: false)
VERBOSE Debug output (default: false)
EXAMPLES:
# Audit current SSH configuration
sudo $SCRIPT_NAME --audit
# Preview what would change
sudo $SCRIPT_NAME --apply --dry-run
# Apply hardening with specific allowed users
sudo $SCRIPT_NAME --apply --allow-users "deployer,jumpuser"
# Apply with fail2ban
sudo $SCRIPT_NAME --apply --configure-fail2ban
# Rollback to previous config
sudo $SCRIPT_NAME --rollback
EOF
}
# ══════════════════════════════════════════════════════════════════════
# MAIN
# ══════════════════════════════════════════════════════════════════════
main() {
while [[ $# -gt 0 ]]; do
case "$1" in
--audit) RUN_MODE="audit"; shift ;;
--apply) RUN_MODE="apply"; shift ;;
--rollback) RUN_MODE="rollback"; shift ;;
--dry-run) DRY_RUN="true"; shift ;;
--allow-users) ALLOW_USERS="$2"; shift 2 ;;
--allow-groups) ALLOW_GROUPS="$2"; shift 2 ;;
--idle-timeout) IDLE_TIMEOUT="$2"; shift 2 ;;
--max-auth-tries) MAX_AUTH_TRIES="$2"; shift 2 ;;
--max-sessions) MAX_SESSIONS="$2"; shift 2 ;;
--session-logging) ENABLE_SESSION_LOGGING="true"; shift ;;
--configure-fail2ban) CONFIGURE_FAIL2BAN="true"; shift ;;
--backup-dir) ROLLBACK_DIR="$2"; shift 2 ;;
--verbose) VERBOSE="true"; shift ;;
--no-color) COLOR="never"; shift ;;
--help|-h) show_help; exit 0 ;;
*) die "Unknown option: $1 (see --help)" ;;
esac
done
setup_colors
if [[ -z "$RUN_MODE" ]]; then err "No mode specified"; echo ""; show_help; exit 1; fi
START_TIME=$(date +%s)
echo ""
echo -e "${BOLD}Bastion Hardener${RESET}"
echo "Host: $(hostname -f 2>/dev/null || hostname)"
echo "Mode: ${RUN_MODE}"
echo "Time: $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
echo ""
case "$RUN_MODE" in
audit) do_audit ;;
apply) do_apply ;;
rollback) do_rollback ;;
esac
}
main "$@"