- provision-hetzner.sh — idempotent hcloud wrapper; create/destroy/status/list * HCLOUD_TOKEN from ~/.claude/secrets/.env (RULE 0.8) - provision-vultr.sh — idempotent vultr-cli wrapper; Vultr resolves IP async * VULTR_API_KEY from ~/.claude/secrets/.env (RULE 0.8) - harden-base.sh — Debian/Ubuntu baseline; apt → ssh → ufw → fail2ban → auditd → unattended-upgrades; idempotent; ports generic patterns from vortex/control/setup/setup.sh:13-53 (no Xray/sing-box/WG steps) All three reject unsupported platforms early; harden-base.sh never auto-reboots (surfaces needrestart hints only). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
240 lines
8.5 KiB
Bash
Executable file
240 lines
8.5 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# harden-base.sh — idempotent Debian/Ubuntu baseline hardening.
|
|
# Runs ON THE TARGET VPS (not the local workstation). Ports generic
|
|
# patterns from ~/Projects/vortex/control/setup/setup.sh:13-53 — strips
|
|
# Vortex-specific Xray/sing-box/Wireguard steps.
|
|
#
|
|
# USAGE
|
|
# curl -fsSL <your-raw-url>/harden-base.sh | sudo bash -s -- [options]
|
|
# OR
|
|
# scp harden-base.sh keiadmin@host:/tmp/
|
|
# ssh keiadmin@host "sudo bash /tmp/harden-base.sh"
|
|
#
|
|
# OPTIONS
|
|
# --admin-user <name> default: keiadmin
|
|
# --ssh-port <n> default: 22 (opens in ufw + enforces in sshd drop-in)
|
|
# --allow-port <n/proto> repeatable. e.g. --allow-port 443/tcp --allow-port 80/tcp
|
|
# --no-caddy skip Caddy install (default: skip — install via its own block)
|
|
# --no-reboot default (never reboots; surfaces needrestart hints only)
|
|
# --skip <step> repeatable; known steps: apt, ssh, ufw, fail2ban, auditd, unattended
|
|
#
|
|
# IDEMPOTENCY
|
|
# Every step is `test → configure → reload`. Re-run = diff-and-apply.
|
|
# Detects the sshd_config.d/99-kei.conf + audit rules files; overwrites with
|
|
# known-good content; never destroys /etc/ssh/sshd_config itself.
|
|
#
|
|
# ENV
|
|
# No secrets read. SECRETS SINGLE SOURCE (RULE 0.8) → harden-base does not
|
|
# touch tokens/keys.
|
|
#
|
|
# EXIT
|
|
# 0 ok
|
|
# 1 usage / platform not supported
|
|
# 2 hardening step failed (stderr)
|
|
|
|
set -euo pipefail
|
|
|
|
log() { printf '[%s] [harden-base] %s\n' "$(date '+%H:%M:%S')" "$*" >&2; }
|
|
die() { log "ERROR: $*"; exit "${2:-2}"; }
|
|
|
|
# ------------------------------------------------------------------ args
|
|
ADMIN_USER="keiadmin"
|
|
SSH_PORT="22"
|
|
ALLOW_PORTS=()
|
|
SKIPS=()
|
|
NO_CADDY=1 # default on; Caddy install is its own block
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--admin-user) ADMIN_USER="$2"; shift 2 ;;
|
|
--ssh-port) SSH_PORT="$2"; shift 2 ;;
|
|
--allow-port) ALLOW_PORTS+=("$2"); shift 2 ;;
|
|
--no-caddy) NO_CADDY=1; shift ;;
|
|
--no-reboot) shift ;; # accepted for clarity; we never auto-reboot
|
|
--skip) SKIPS+=("$2"); shift 2 ;;
|
|
-h|--help) cat <<EOF >&2
|
|
harden-base.sh — Debian/Ubuntu baseline hardening.
|
|
OPTIONS
|
|
--admin-user <name> default: keiadmin
|
|
--ssh-port <n> default: 22
|
|
--allow-port <n/proto> repeatable (e.g. 443/tcp, 80/tcp)
|
|
--no-caddy (default) skip Caddy install
|
|
--skip <step> apt|ssh|ufw|fail2ban|auditd|unattended
|
|
EOF
|
|
exit 0 ;;
|
|
*) die "unknown flag '$1'" 1 ;;
|
|
esac
|
|
done
|
|
|
|
# ------------------------------------------------------------------ guards
|
|
[ "$(id -u)" -eq 0 ] || die "must run as root (sudo)." 1
|
|
. /etc/os-release 2>/dev/null || die "cannot read /etc/os-release" 1
|
|
case "${ID:-}" in
|
|
debian|ubuntu) : ;;
|
|
*) die "only Debian/Ubuntu supported (got ID=${ID:-unknown})" 1 ;;
|
|
esac
|
|
|
|
skipped() {
|
|
local step="$1"
|
|
for s in "${SKIPS[@]}"; do [ "$s" = "$step" ] && return 0; done
|
|
return 1
|
|
}
|
|
|
|
# ------------------------------------------------------------------ step: apt
|
|
step_apt() {
|
|
skipped apt && { log "skip apt"; return; }
|
|
log "apt update + base packages…"
|
|
export DEBIAN_FRONTEND=noninteractive
|
|
apt-get update -qq
|
|
apt-get install -y -qq \
|
|
ufw fail2ban unattended-upgrades needrestart auditd audispd-plugins \
|
|
curl wget jq ca-certificates openssh-server
|
|
}
|
|
|
|
# ------------------------------------------------------------------ step: admin user
|
|
step_admin_user() {
|
|
if id "$ADMIN_USER" >/dev/null 2>&1; then
|
|
log "user '$ADMIN_USER' exists"
|
|
else
|
|
log "creating '$ADMIN_USER' (sudo, bash, NOPASSWD)"
|
|
useradd -m -s /bin/bash -G sudo "$ADMIN_USER"
|
|
install -d -m 0700 -o "$ADMIN_USER" -g "$ADMIN_USER" "/home/$ADMIN_USER/.ssh"
|
|
fi
|
|
install -d -m 0755 /etc/sudoers.d
|
|
cat >/etc/sudoers.d/90-keiadmin <<EOF
|
|
$ADMIN_USER ALL=(ALL) NOPASSWD:ALL
|
|
EOF
|
|
chmod 0440 /etc/sudoers.d/90-keiadmin
|
|
visudo -cf /etc/sudoers.d/90-keiadmin >/dev/null
|
|
}
|
|
|
|
# ------------------------------------------------------------------ step: ssh
|
|
step_ssh() {
|
|
skipped ssh && { log "skip ssh"; return; }
|
|
log "ssh: writing /etc/ssh/sshd_config.d/99-kei.conf (port=$SSH_PORT, user=$ADMIN_USER)…"
|
|
install -d -m 0755 /etc/ssh/sshd_config.d
|
|
cat >/etc/ssh/sshd_config.d/99-kei.conf <<EOF
|
|
# GENERATED by harden-base.sh — idempotent. Edit intent, re-run the script.
|
|
Port $SSH_PORT
|
|
Protocol 2
|
|
PasswordAuthentication no
|
|
ChallengeResponseAuthentication no
|
|
KbdInteractiveAuthentication no
|
|
PermitRootLogin prohibit-password
|
|
PermitEmptyPasswords no
|
|
UsePAM yes
|
|
MaxAuthTries 3
|
|
MaxSessions 4
|
|
LoginGraceTime 20
|
|
AllowUsers $ADMIN_USER
|
|
AllowTcpForwarding no
|
|
X11Forwarding no
|
|
PermitTunnel no
|
|
ClientAliveInterval 120
|
|
ClientAliveCountMax 2
|
|
LogLevel VERBOSE
|
|
KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,sntrup761x25519-sha512@openssh.com
|
|
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com
|
|
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com
|
|
HostKeyAlgorithms ssh-ed25519,rsa-sha2-512,rsa-sha2-256
|
|
EOF
|
|
sshd -t
|
|
systemctl reload ssh 2>/dev/null || systemctl reload sshd
|
|
}
|
|
|
|
# ------------------------------------------------------------------ step: ufw
|
|
step_ufw() {
|
|
skipped ufw && { log "skip ufw"; return; }
|
|
log "ufw: default-deny-in + allow-out + ssh rate-limit…"
|
|
ufw --force reset >/dev/null
|
|
ufw default deny incoming
|
|
ufw default allow outgoing
|
|
ufw default deny routed
|
|
ufw limit "$SSH_PORT/tcp" comment "ssh (rate-limited)"
|
|
for p in "${ALLOW_PORTS[@]}"; do
|
|
log " allow $p"
|
|
ufw allow "$p"
|
|
done
|
|
ufw logging medium
|
|
ufw --force enable
|
|
}
|
|
|
|
# ------------------------------------------------------------------ step: fail2ban
|
|
step_fail2ban() {
|
|
skipped fail2ban && { log "skip fail2ban"; return; }
|
|
log "fail2ban: writing /etc/fail2ban/jail.local (sshd jail)…"
|
|
cat >/etc/fail2ban/jail.local <<EOF
|
|
[DEFAULT]
|
|
bantime = 3600
|
|
findtime = 600
|
|
maxretry = 5
|
|
backend = systemd
|
|
|
|
[sshd]
|
|
enabled = true
|
|
port = $SSH_PORT
|
|
EOF
|
|
systemctl enable --now fail2ban
|
|
systemctl restart fail2ban
|
|
}
|
|
|
|
# ------------------------------------------------------------------ step: auditd
|
|
step_auditd() {
|
|
skipped auditd && { log "skip auditd"; return; }
|
|
log "auditd: writing /etc/audit/rules.d/99-kei.rules…"
|
|
install -d -m 0750 /etc/audit/rules.d
|
|
cat >/etc/audit/rules.d/99-kei.rules <<'EOF'
|
|
# GENERATED by harden-base.sh — idempotent baseline.
|
|
-w /etc/ssh/sshd_config -p wa -k sshd_config
|
|
-w /etc/ssh/sshd_config.d/ -p wa -k sshd_config
|
|
-w /root/.ssh/ -p wa -k ssh_keys_root
|
|
-w /etc/sudoers -p wa -k sudoers
|
|
-w /etc/sudoers.d/ -p wa -k sudoers
|
|
-a always,exit -F arch=b64 -S execve -F euid=0 -F auid>=1000 -F auid!=unset -k sudo_root
|
|
-w /etc/passwd -p wa -k identity
|
|
-w /etc/group -p wa -k identity
|
|
-w /etc/shadow -p wa -k identity
|
|
-w /etc/gshadow -p wa -k identity
|
|
-a always,exit -F arch=b64 -S init_module -S finit_module -S delete_module -k module
|
|
-a always,exit -F arch=b64 -S adjtimex -S settimeofday -S clock_settime -k time
|
|
-w /etc/localtime -p wa -k time
|
|
-e 2
|
|
EOF
|
|
augenrules --load >/dev/null
|
|
systemctl enable --now auditd
|
|
}
|
|
|
|
# ------------------------------------------------------------------ step: unattended-upgrades + needrestart
|
|
step_unattended() {
|
|
skipped unattended && { log "skip unattended"; return; }
|
|
log "unattended-upgrades + needrestart (list-only)…"
|
|
cat >/etc/apt/apt.conf.d/20auto-upgrades <<'EOF'
|
|
APT::Periodic::Update-Package-Lists "1";
|
|
APT::Periodic::Unattended-Upgrade "1";
|
|
APT::Periodic::AutocleanInterval "7";
|
|
EOF
|
|
cat >/etc/apt/apt.conf.d/50unattended-upgrades.kei <<'EOF'
|
|
Unattended-Upgrade::Origins-Pattern {
|
|
"origin=Debian,codename=${distro_codename}-security";
|
|
"origin=Debian,codename=${distro_codename}-updates";
|
|
"origin=Ubuntu,archive=${distro_codename}-security";
|
|
};
|
|
Unattended-Upgrade::Automatic-Reboot "false";
|
|
Unattended-Upgrade::MailReport "on-change";
|
|
EOF
|
|
# needrestart: list services, suppress TTY prompts (non-TTY cron safe).
|
|
if [ -f /etc/needrestart/needrestart.conf ]; then
|
|
sed -i "s/^#\?\$nrconf{restart}.*/\$nrconf{restart} = 'l';/" /etc/needrestart/needrestart.conf
|
|
sed -i "s/^#\?\$nrconf{kernelhints}.*/\$nrconf{kernelhints} = -1;/" /etc/needrestart/needrestart.conf
|
|
fi
|
|
}
|
|
|
|
# ------------------------------------------------------------------ main
|
|
log "start: admin=$ADMIN_USER ssh=$SSH_PORT extra-ports=${ALLOW_PORTS[*]:-none}"
|
|
step_apt
|
|
step_admin_user
|
|
step_ssh
|
|
step_ufw
|
|
step_fail2ban
|
|
step_auditd
|
|
step_unattended
|
|
log "done. Next: verify via _primitives/_rust/ssh-check + firewall-diff."
|