#!/usr/bin/env bash # # harden-jumphost.sh # # Hardens a box for use as an SSH jump host (bastion) on Alpine, Debian, or # Alma Linux. Layered on the same PQ-hybrid posture as harden-ssh.sh, plus # jump-host specifics. All distro differences go through scripts/oslib.sh. # # Two groups, two privilege levels: # ssh-admins -- full TTY shell on the jump host (maintenance only). No # forwarding. For fixing the box, not reaching elsewhere. # ssh-jumpers -- ProxyJump ONLY. No TTY, no shell, no SFTP, no agent # forwarding. Only direct-tcpip to whitelisted targets. # # How the restriction works: # - Global default: DisableForwarding yes, PermitTTY no, ForceCommand # . A user in neither group can do nothing. # - Match Group ssh-admins: re-enables PermitTTY, clears ForceCommand. # - Match Group ssh-jumpers: enables AllowTcpForwarding + a PermitOpen # whitelist, but keeps PermitTTY no + ForceCommand . ProxyJump # (direct-tcpip) works because it never opens a session channel, so # ForceCommand never fires. # # Usage: # bash harden-jumphost.sh # SSH_PORT=2222 bash harden-jumphost.sh # JUMP_TARGETS="10.0.0.5:22 10.0.0.6:22" bash harden-jumphost.sh # ALLOWED_IP=1.2.3.4 bash harden-jumphost.sh # FORCE=1 bash harden-jumphost.sh set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=scripts/oslib.sh . "$SCRIPT_DIR/oslib.sh" # ============================================================================ # CONFIG # ============================================================================ : "${SSH_PORT:=22}" : "${ALLOWED_IP:=}" : "${FORCE:=0}" # Space-separated host:port list jumpers can reach via ProxyJump. Empty means # deny-all. e.g. "10.0.0.5:22 10.0.0.6:22". : "${JUMP_TARGETS:=}" : "${KEY_COMMENT:=root@$(hostname)-$(date +%Y%m%d)}" log() { _log "$@"; } warn() { _warn "$@"; } die() { _die "$@"; } [[ $EUID -eq 0 ]] || die "Run as root." os_detect log "Detected OS: ${OS_ID} (family ${OS_FAMILY}, init ${INIT_SYSTEM})" # ---------------------------------------------------------------------------- # 1. Packages # ---------------------------------------------------------------------------- log "Installing OpenSSH + sshguard + iptables..." install_openssh || die "OpenSSH packages failed to install; cannot harden. Fix the package error above, then re-run." # sshguard is best-effort (see harden-ssh.sh): never let a missing brute-force # package abort the whole bastion hardening. install_bruteforce_protection \ || warn "sshguard not installed; brute-force protection is OFF. Add it later with: dnf install -y epel-release sshguard. Continuing with the rest of the hardening." ensure_gum || warn "gum not installed; sshuser will use its CLI mode." SFTP_PATH="$(sftp_server_path)" NOLOGIN="$(nologin_path)" # /sbin/nologin (Alpine/Alma) or /usr/sbin/nologin (Debian) # Install the sshuser tool alongside this script if present. if [[ -f "$SCRIPT_DIR/sshuser.sh" ]]; then install -d -m 0755 /usr/local/bin install -m 0755 "$SCRIPT_DIR/sshuser.sh" /usr/local/bin/sshuser log "Installed /usr/local/bin/sshuser" fi # ---------------------------------------------------------------------------- # 2. PQ KEX detection # ---------------------------------------------------------------------------- log "Checking which key exchange methods this OpenSSH supports..." # Cosmetic only -- every decision below comes from `ssh -Q kex`, not this. A build # whose banner does not match (OpenSSH_for_Windows_9.5p2, vendor forks) must not # abort the run: without the guard, grep's non-match fails the pipeline under # pipefail and set -e kills the script here with no message at all. SSH_VER=$(ssh -V 2>&1 | grep -oE 'OpenSSH_[0-9]+[.][0-9]+' | head -1 | sed 's/OpenSSH_//' || true) # Ask the binary what it supports rather than deriving it from the version -- the # same algorithm has two spellings and guessing wrong either breaks sshd_config or # locks out clients that implement it under the other name. See oslib. KEX_PQ="$(ssh_kex_pq_list)" KEX_LIST="$(ssh_kex_list)" if [[ -n "$KEX_PQ" ]]; then if [[ "${SSH_ALLOW_CLASSIC_KEX:-0}" == "1" ]]; then KEX_NOTE='# --- Key exchange: post-quantum hybrid + classical fallback --- # curve25519-sha256 is offered for clients too old for any PQ method # (SSH_ALLOW_CLASSIC_KEX=1). A session that negotiates it has NO store-now- # decrypt-later protection -- drop the fallback once those clients are gone.' warn "SSH_ALLOW_CLASSIC_KEX=1 -- offering curve25519-sha256 next to the PQ methods." warn " Admits clients with no PQ KEX at all (Windows in-box ssh.exe), at the cost of" warn " store-now-decrypt-later protection for any session that negotiates it." else KEX_NOTE='# --- Key exchange: post-quantum hybrid only --- # Every classical-only method is rejected, which is what protects the session key # against "store now, decrypt later". Clients with no PQ KEX cannot connect -- # re-run with SSH_ALLOW_CLASSIC_KEX=1 to also offer curve25519-sha256.' fi else # No post-quantum method on this host at all. Decide on THAT, not on an empty # list: without the opt-in the classical names are never collected, so an empty # list here would otherwise be misreported as "no usable KEX". [[ "${SSH_ALLOW_CLASSIC_KEX:-0}" == "1" ]] \ || die "OpenSSH ${SSH_VER:-?} has no post-quantum KEX (needs >= 8.5 built with sntrup761). Re-run with SSH_ALLOW_CLASSIC_KEX=1 to accept classical-only." [[ -n "$KEX_LIST" ]] \ || die "OpenSSH ${SSH_VER:-?} reports no usable key exchange method at all ('ssh -Q kex' returned nothing)." KEX_NOTE='# --- Key exchange: CLASSICAL ONLY --- # This OpenSSH has no post-quantum method. No store-now-decrypt-later protection.' warn "OpenSSH ${SSH_VER:-?} has no PQ KEX -- classical curve25519 only." fi log "OpenSSH ${SSH_VER:-?}: KexAlgorithms ${KEX_LIST}" # ---------------------------------------------------------------------------- # 3. Host keys (Ed25519 only) # ---------------------------------------------------------------------------- log "Regenerating host keys (Ed25519 only)..." rm -f /etc/ssh/ssh_host_rsa_key* /etc/ssh/ssh_host_ecdsa_key* /etc/ssh/ssh_host_dsa_key* if [[ ! -f /etc/ssh/ssh_host_ed25519_key ]]; then ssh-keygen -q -t ed25519 -f /etc/ssh/ssh_host_ed25519_key -N "" \ -C "host@$(hostname)-$(date +%Y%m%d)" fi chmod 600 /etc/ssh/ssh_host_ed25519_key chmod 644 /etc/ssh/ssh_host_ed25519_key.pub log "Host key fingerprint:" ssh-keygen -l -f /etc/ssh/ssh_host_ed25519_key.pub | sed 's/^/ /' sshd_disable_keygen # Alpine-only; no-op on systemd # ---------------------------------------------------------------------------- # 4. Groups # ---------------------------------------------------------------------------- log "Ensuring groups ssh-admins and ssh-jumpers exist..." group_add_system ssh-admins group_add_system ssh-jumpers # ---------------------------------------------------------------------------- # 5. Root keypair (for ssh-admins maintenance access) # ---------------------------------------------------------------------------- log "Generating Ed25519 keypair for root..." mkdir -p /root/.ssh chmod 700 /root/.ssh touch /root/.ssh/authorized_keys chmod 600 /root/.ssh/authorized_keys user_add_to_group root ssh-admins TMP_KEY=$(mktemp -u /tmp/root_ed25519.XXXXXX) ssh-keygen -q -t ed25519 -f "$TMP_KEY" -N "" -C "$KEY_COMMENT" ROOT_PUB=$(cat "${TMP_KEY}.pub") ROOT_PRIV=$(cat "$TMP_KEY") grep -qxF "$ROOT_PUB" /root/.ssh/authorized_keys || echo "$ROOT_PUB" >> /root/.ssh/authorized_keys # Seed root (ssh-admins) with the shared admin keys from globals/ so the # bastion has a known, secure default login. Best-effort. if [[ "${SEED_KEYS:-1}" == "1" && -f "$SCRIPT_DIR/lib.sh" ]]; then # shellcheck source=scripts/lib.sh . "$SCRIPT_DIR/lib.sh" load_globals if declare -f resolve_ssh_keys >/dev/null 2>&1; then SEEDED=0 while IFS= read -r k; do [[ -n "$k" ]] || continue grep -qxF "$k" /root/.ssh/authorized_keys || { echo "$k" >> /root/.ssh/authorized_keys; SEEDED=$((SEEDED+1)); } done <<< "$(resolve_ssh_keys 2>/dev/null || true)" [[ "$SEEDED" -gt 0 ]] && log "Seeded ${SEEDED} admin key(s) into /root/.ssh/authorized_keys from globals." fi fi # ---------------------------------------------------------------------------- # 6. PermitOpen line from JUMP_TARGETS (space-separated -> comma-separated) # ---------------------------------------------------------------------------- PERMIT_OPEN_LINE="none" [[ -n "$JUMP_TARGETS" ]] && PERMIT_OPEN_LINE=$(echo "$JUMP_TARGETS" | tr -s ' ' ',' | sed 's/^,//;s/,$//') # ---------------------------------------------------------------------------- # 7. sshd_config # ---------------------------------------------------------------------------- log "Writing /etc/ssh/sshd_config..." [[ -f /etc/ssh/sshd_config.orig ]] || cp /etc/ssh/sshd_config /etc/ssh/sshd_config.orig cat > /etc/ssh/sshd_config </tmp/sshd-test.err; then cat /tmp/sshd-test.err >&2 cp /etc/ssh/sshd_config.orig /etc/ssh/sshd_config die "sshd config invalid; restored original. NOT reloading." fi rm -f /tmp/sshd-test.err # ---------------------------------------------------------------------------- # 9. sshguard # ---------------------------------------------------------------------------- log "Configuring sshguard..." mkdir -p /etc/sshguard WHITELIST=/etc/sshguard/whitelist { echo "127.0.0.1"; echo "::1" [[ -n "$ALLOWED_IP" ]] && echo "$ALLOWED_IP" } > "$WHITELIST" SSHGUARD_BACKEND="$(sshguard_backend)" SSHGUARD_LOGREADER="$(sshguard_logreader)" [[ -x "${SSHGUARD_BACKEND}" ]] || warn "sshguard backend not found at ${SSHGUARD_BACKEND}; brute-force blocking may be inactive." cat > /etc/sshguard/sshguard.conf < sshguard jump. When the host firewall (harden-firewall.sh) is enabled # it owns the whole INPUT chain -- including this jump -- and persists it via the # distro's native iptables package, so we install the firewall and skip the # standalone boot hook. Otherwise fall back to the minimal init-agnostic boot # hook that just (re)inserts the jump at every boot. : "${ENABLE_FIREWALL:=1}" # Proxmox (VE/PMG) is Debian underneath, but pve-firewall already owns the host # ruleset -- harden-firewall.sh skips those hosts, so fall through to the boot # hook and keep sshguard's jump. That jump is safe there: pve-firewall restores # with --noflush and only ever flushes its own PVEFW-* chains, and the hook it # adds to INPUT is APPENDED -- so a jump inserted with -I sits ahead of it and # keeps getting first look at NEW connections, firewall enabled or not. # FW_IGNORE_PVE=1 forces our firewall anyway. if [[ "$ENABLE_FIREWALL" == "1" && "${FW_IGNORE_PVE:-0}" != "1" ]] && is_proxmox; then warn "Proxmox detected -- pve-firewall owns the host firewall; installing only the sshguard jump hook." # A host hardened before the skip existed still has a live DROP chain that only # accepts the OLD port -- changing SSH_PORT here would lock you out of it. [[ -x /usr/local/sbin/firewall-apply || -d /etc/firewall ]] && warn " This host still has an older harden-firewall.sh ruleset. Run 'harden-firewall.sh disable' FIRST, especially if you are changing SSH_PORT." ENABLE_FIREWALL=0 fi if [[ "$ENABLE_FIREWALL" == "1" && -f "$SCRIPT_DIR/harden-firewall.sh" ]]; then log "Installing host firewall (deny-by-default INPUT; carries the sshguard jump)..." SSH_PORT="$SSH_PORT" OPEN_PORTS="${OPEN_PORTS:-}" \ FW_SSH_SOURCE="${FW_SSH_SOURCE:-}" FW_ALLOW_PING="${FW_ALLOW_PING:-1}" \ FORCE=1 bash "$SCRIPT_DIR/harden-firewall.sh" apply \ || warn "harden-firewall.sh failed; INPUT left unfiltered. Re-run it manually." else HOOK=$(mktemp) cat > "$HOOK" <<'EOF' #!/bin/sh SSH_PORT=$(awk '/^Port / {print $2; exit}' /etc/ssh/sshd_config) SSH_PORT=${SSH_PORT:-22} for ipt in iptables ip6tables; do command -v "$ipt" >/dev/null 2>&1 || continue $ipt -N sshguard 2>/dev/null || true $ipt -C INPUT -p tcp --dport "$SSH_PORT" -j sshguard 2>/dev/null \ || $ipt -I INPUT -p tcp --dport "$SSH_PORT" -j sshguard done EOF install_boot_hook sshguard-iptables "$HOOK" rm -f "$HOOK" fi svc_enable_start sshguard || warn "Could not start sshguard; check sshguard.conf on this distro." # ---------------------------------------------------------------------------- # 9b. Optional: SSH login notifier (pam_exec -> ntfy) # ---------------------------------------------------------------------------- # Enabled when NTFY_URL is provided. On a bastion we default to notifying for # the two SSH groups and tag the alert with this host's region. if [[ -n "${NTFY_URL:-}" ]]; then : "${NOTIFY_GROUPS:=ssh-admins ssh-jumpers}" : "${NTFY_REGION:=$(host_region)}" log "Installing SSH login notifier (ntfy)..." install_login_notifier "$SCRIPT_DIR/ntfy-ssh-login.sh" || warn "Notifier install had issues." fi # ---------------------------------------------------------------------------- # 9c. Daily unattended updates (default ON -- recommended for an SSH-only # bastion; set AUTO_UPDATE=0 to skip). New Alpine *branches* are reported, not # auto-applied. # ---------------------------------------------------------------------------- if [[ "${AUTO_UPDATE:-1}" == "1" && -f "$SCRIPT_DIR/auto-update.sh" ]]; then log "Scheduling daily auto-update (reboot only when idle)..." AUTO_REBOOT="${AUTO_REBOOT:-idle}" \ ALLOW_RELEASE_UPGRADE="${ALLOW_RELEASE_UPGRADE:-0}" \ NOTIFY="${NOTIFY:-1}" \ bash "$SCRIPT_DIR/auto-update.sh" install || warn "Could not schedule auto-update." fi # ---------------------------------------------------------------------------- # 10. Enable sshd # ---------------------------------------------------------------------------- SSHD_SVC="$(sshd_service)" log "Enabling ${SSHD_SVC} at boot..." svc_enable "$SSHD_SVC" cat < ----- BEGIN ROOT PRIVATE KEY (Ed25519) ----- ${ROOT_PRIV} ----- END ROOT PRIVATE KEY ----- Public key (already in /root/.ssh/authorized_keys): ${ROOT_PUB} Host fingerprint: $(ssh-keygen -l -f /etc/ssh/ssh_host_ed25519_key.pub) Client usage examples: # Admin shell on the jump host: ssh -i ~/.ssh/id_ed25519_jump -p ${SSH_PORT} root@ # ProxyJump through to an internal target: ssh -J root@:${SSH_PORT} -i ~/.ssh/id_ed25519_target user@ ================================================================ EOF shred -u "$TMP_KEY" "${TMP_KEY}.pub" 2>/dev/null || rm -f "$TMP_KEY" "${TMP_KEY}.pub" if [[ "$FORCE" != "1" ]]; then cat < -p ${SSH_PORT} root@ Reload sshd now? [y/N] EOF read -r ans if [[ "${ans,,}" != "y" && "${ans,,}" != "yes" ]]; then warn "Skipping reload. Reload ${SSHD_SVC} manually when ready." exit 0 fi fi log "Reloading ${SSHD_SVC}..." # Reload -- and on Alpine swap in the PAM sshd build if the running one # predates this config (oslib explains why that can happen). sshd_apply_config log "Done."