diff --git a/_primitives/harden-base.sh b/_primitives/harden-base.sh new file mode 100755 index 0000000..472ca42 --- /dev/null +++ b/_primitives/harden-base.sh @@ -0,0 +1,240 @@ +#!/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 /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 default: keiadmin +# --ssh-port default: 22 (opens in ufw + enforces in sshd drop-in) +# --allow-port 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 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 <&2 +harden-base.sh — Debian/Ubuntu baseline hardening. +OPTIONS + --admin-user default: keiadmin + --ssh-port default: 22 + --allow-port repeatable (e.g. 443/tcp, 80/tcp) + --no-caddy (default) skip Caddy install + --skip 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 </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 </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 </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." diff --git a/_primitives/provision-hetzner.sh b/_primitives/provision-hetzner.sh new file mode 100755 index 0000000..a63c855 --- /dev/null +++ b/_primitives/provision-hetzner.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +# provision-hetzner.sh — idempotent Hetzner Cloud server provisioning. +# Wraps the `hcloud` CLI. Install path: +# $HOME/.claude/agents/_primitives/provision-hetzner.sh +# +# USAGE +# provision-hetzner.sh create [--type cx22|cax11] [--location fsn1] \ +# [--image debian-12] [--ssh-key ] \ +# [--firewall ] [--user-data ] +# provision-hetzner.sh status +# provision-hetzner.sh destroy [--force] +# provision-hetzner.sh list +# +# ENV (RULE 0.8 — secrets single source) +# HCLOUD_TOKEN — Hetzner API token (REQUIRED). Source: +# $(grep ^HCLOUD_TOKEN ~/.claude/secrets/.env | cut -d= -f2) +# +# EXIT +# 0 ok +# 1 usage / missing args / missing deps / unknown command +# 2 hcloud API error (non-idempotent path — inspect stderr) +# +# IDEMPOTENCY +# `create ` on an existing server prints its IP + exits 0. +# `destroy ` on a missing server exits 0 (nothing to do). + +set -euo pipefail + +log() { printf '[%s] [provision-hetzner] %s\n' "$(date '+%H:%M:%S')" "$*" >&2; } +die() { log "ERROR: $*"; exit "${2:-2}"; } + +check_deps() { + command -v hcloud >/dev/null 2>&1 || \ + die "hcloud CLI missing. Install: brew install hcloud (macOS) | https://github.com/hetznercloud/cli/releases" 1 + command -v jq >/dev/null 2>&1 || die "jq missing. brew install jq" 1 + [ -n "${HCLOUD_TOKEN:-}" ] || die "HCLOUD_TOKEN not set. Source ~/.claude/secrets/.env first." 1 +} + +# Print server JSON if it exists, empty string otherwise. Never fails. +server_json() { + local name="$1" + hcloud server describe "$name" -o json 2>/dev/null || true +} + +cmd_list() { + check_deps + hcloud server list -o 'columns=id,name,status,ipv4,location,server_type,created' +} + +cmd_status() { + check_deps + local name="${1:-}"; [ -n "$name" ] || die "status: required" 1 + local json; json=$(server_json "$name") + if [ -z "$json" ]; then + echo "absent" + return 0 + fi + printf 'name=%s\nstatus=%s\nipv4=%s\nlocation=%s\ntype=%s\n' \ + "$(jq -r .name <<<"$json")" \ + "$(jq -r .status <<<"$json")" \ + "$(jq -r '.public_net.ipv4.ip // "-"' <<<"$json")" \ + "$(jq -r .datacenter.location.name <<<"$json")" \ + "$(jq -r .server_type.name <<<"$json")" +} + +cmd_create() { + check_deps + local name="${1:-}"; shift || true + [ -n "$name" ] || die "create: required" 1 + + local type="cx22" location="fsn1" image="debian-12" + local ssh_key="" firewall="" user_data="" + while [ $# -gt 0 ]; do + case "$1" in + --type) type="$2"; shift 2 ;; + --location) location="$2"; shift 2 ;; + --image) image="$2"; shift 2 ;; + --ssh-key) ssh_key="$2"; shift 2 ;; + --firewall) firewall="$2"; shift 2 ;; + --user-data) user_data="$2"; shift 2 ;; + *) die "create: unknown flag '$1'" 1 ;; + esac + done + + # Idempotent fast-path: if the server already exists, just print its IP. + local existing; existing=$(server_json "$name") + if [ -n "$existing" ]; then + local ip; ip=$(jq -r '.public_net.ipv4.ip // "-"' <<<"$existing") + log "server '$name' already exists → $ip (no-op)" + echo "$ip" + return 0 + fi + + local args=(server create + --name "$name" + --type "$type" + --image "$image" + --location "$location" + --label "project=kei" + ) + [ -n "$ssh_key" ] && args+=(--ssh-key "$ssh_key") + [ -n "$firewall" ] && args+=(--firewall "$firewall") + [ -n "$user_data" ] && { [ -r "$user_data" ] || die "user-data not readable: $user_data" 1; args+=(--user-data-from-file "$user_data"); } + + log "creating '$name' ($type @ $location, image=$image)…" + hcloud "${args[@]}" -o json >/tmp/provision-hetzner-$$.json + local ip; ip=$(jq -r '.server.public_net.ipv4.ip' /tmp/provision-hetzner-$$.json) + rm -f /tmp/provision-hetzner-$$.json + [ "$ip" != "null" ] && [ -n "$ip" ] || die "create returned no IPv4 (check stderr)" + log "created '$name' → $ip" + echo "$ip" +} + +cmd_destroy() { + check_deps + local name="${1:-}"; shift || true + [ -n "$name" ] || die "destroy: required" 1 + local force="" + [ "${1:-}" = "--force" ] && force=1 + + local existing; existing=$(server_json "$name") + if [ -z "$existing" ]; then + log "server '$name' absent (no-op)" + return 0 + fi + + if [ -z "$force" ]; then + printf 'Destroy server "%s"? [y/N] ' "$name" >&2 + read -r ans + [ "$ans" = "y" ] || [ "$ans" = "Y" ] || { log "aborted"; return 1; } + fi + + log "deleting '$name'…" + hcloud server delete "$name" >&2 + log "deleted '$name'" +} + +main() { + local cmd="${1:-}"; shift || true + case "$cmd" in + create) cmd_create "$@" ;; + destroy) cmd_destroy "$@" ;; + status) cmd_status "$@" ;; + list) cmd_list "$@" ;; + -h|--help|"") cat <&2 +provision-hetzner.sh — idempotent Hetzner Cloud server provisioning. +USAGE + provision-hetzner.sh create [--type cx22|cax11] [--location fsn1] \\ + [--image debian-12] [--ssh-key ] \\ + [--firewall ] [--user-data ] + provision-hetzner.sh status + provision-hetzner.sh destroy [--force] + provision-hetzner.sh list + +ENV + HCLOUD_TOKEN (required) — load via: source ~/.claude/secrets/.env +EOF + [ "$cmd" = "-h" ] || [ "$cmd" = "--help" ] && exit 0 || exit 1 + ;; + *) die "unknown command '$cmd'. Run --help." 1 ;; + esac +} + +main "$@" diff --git a/_primitives/provision-vultr.sh b/_primitives/provision-vultr.sh new file mode 100755 index 0000000..5aec773 --- /dev/null +++ b/_primitives/provision-vultr.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# provision-vultr.sh — idempotent Vultr VPS provisioning. +# Wraps the `vultr-cli` v3. Install path: +# $HOME/.claude/agents/_primitives/provision-vultr.sh +# +# USAGE +# provision-vultr.sh create