feat: unified launcher, multi-OS hardening, login alerts & auto-updates

Restructure around a single entry point (automations.sh) with a Gum wizard and
a self-extracting bundle for repo-less installs. Add scripts/oslib.sh so the
provisioning scripts (setup-host, harden-ssh, harden-jumphost, sshuser) run on
Alpine/Debian/Alma; seed root keys from globals/.

- ntfy SSH-login alerts (user, source IP, key, region, jump target) via pam_exec
- daily auto-updates with AUTO_REBOOT=idle (reboots only when no SSH active) and
  opt-in Alpine stable-branch upgrades
- generic + per-deployment cloud-init; Gitea release workflow on tag
- README/LICENSE/.gitignore/.gitattributes (force LF); repo URLs -> Gitea
This commit is contained in:
2026-06-12 14:56:02 -05:00
parent 85eeb79971
commit 7faa9098de
58 changed files with 6225 additions and 284 deletions
+19
View File
@@ -0,0 +1,19 @@
# auto-update.conf -- defaults for the daily auto-update job
# (scripts/auto-update.sh). Installed at /etc/auto-update.conf by
# `auto-update.sh install` (the harden scripts do this). Environment variables
# still override these at runtime.
# When a reboot is needed after an upgrade:
# 0 never reboot (just flag / notify)
# 1 always reboot
# idle reboot only when NO SSH connections are active -- safe for a bastion,
# since it won't drop a live admin session or a ProxyJump tunnel. A
# deferred reboot is retried on the next daily run.
AUTO_REBOOT="idle"
# (Alpine) also jump to a newer STABLE branch (e.g. 3.21 -> 3.22) when posted.
# Off by default; when off a new branch is only reported via ntfy.
ALLOW_RELEASE_UPGRADE="0"
# Send an ntfy summary after each run (reuses /etc/ssh-notify.conf creds).
NOTIFY="1"
+310
View File
@@ -0,0 +1,310 @@
#!/usr/bin/env bash
#
# auto-update.sh -- unattended package updates + new-OS-release check, for
# Alpine, Debian, and Alma. Designed for SSH-only bastion hosts, where the
# blast radius of a routine upgrade is tiny.
#
# Policy:
# - Apply all in-branch package upgrades automatically (apk/apt/dnf).
# - Do NOT auto-jump to a new Alpine branch (e.g. 3.21 -> 3.22): that
# rewrites the repo branch and is a human decision. Instead, NOTIFY that
# one is available.
# - Detect when a reboot is needed (kernel/libc/openssl). Reboot only if
# AUTO_REBOOT=1, otherwise just report it.
# - Send an ntfy summary, reusing /etc/ssh-notify.conf (same creds as the
# login notifier).
#
# Subcommands:
# run (default) do the update pass and notify
# install schedule this script to run daily (oslib install_daily_job)
# uninstall remove the daily schedule
#
# Env (also settable in /etc/auto-update.conf, which the daily run reads;
# environment overrides the file):
# AUTO_REBOOT=0 when a reboot is needed:
# 0 = never (just flag/notify)
# 1 = always
# idle = only when NO SSH connections are active
# (safe for a bastion -- won't cut a live
# admin session or a ProxyJump tunnel; a
# deferred reboot is retried each day)
# ALLOW_RELEASE_UPGRADE=0 (Alpine) also jump to a newer STABLE branch when
# one is posted (e.g. 3.21 -> 3.22). Off by default;
# when off, a new branch is only reported.
# NOTIFY=1 send an ntfy summary (0 to disable)
# SSH_NOTIFY_CONF=/etc/ssh-notify.conf
# DRY_RUN=0 print what would happen; touch nothing (for testing)
# LOG=/var/log/auto-update.log
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Locate oslib.sh. When this script is installed standalone (e.g. to
# /usr/local/sbin by install_daily_job), oslib is co-installed alongside it.
_oslib_loaded=0
for _cand in "$SCRIPT_DIR/oslib.sh" /usr/local/sbin/oslib.sh /opt/automations/scripts/oslib.sh; do
if [[ -f "$_cand" ]]; then . "$_cand"; _oslib_loaded=1; break; fi
done
[[ "$_oslib_loaded" == 1 ]] || { echo "[x] oslib.sh not found next to auto-update.sh" >&2; exit 1; }
SELF="$SCRIPT_DIR/$(basename "${BASH_SOURCE[0]}")" # resolved path to this file
# Load defaults from the conf for any var not already set in the environment
# (precedence: environment > conf > built-in). This is how the daily cron run
# picks up AUTO_REBOOT etc. without baking them into the schedule.
: "${AUTO_UPDATE_CONF:=/etc/auto-update.conf}"
if [[ -r "$AUTO_UPDATE_CONF" ]]; then
while IFS= read -r _line; do
[[ "$_line" =~ ^[[:space:]]*# || -z "${_line//[[:space:]]/}" ]] && continue
_k="${_line%%=*}"; _v="${_line#*=}"; _k="${_k//[[:space:]]/}"
[[ "$_k" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
[[ -n "${!_k:-}" ]] && continue # env already set -> wins
_v="${_v%\"}"; _v="${_v#\"}" # strip surrounding quotes
printf -v "$_k" '%s' "$_v"
done < "$AUTO_UPDATE_CONF"
fi
: "${AUTO_REBOOT:=0}"
: "${ALLOW_RELEASE_UPGRADE:=0}"
: "${NOTIFY:=1}"
: "${SSH_NOTIFY_CONF:=/etc/ssh-notify.conf}"
: "${DRY_RUN:=0}"
: "${LOG:=/var/log/auto-update.log}"
# Pending-reboot marker. In /run (tmpfs), so it is cleared by a real reboot;
# its presence on a later run means a deferred reboot is still pending.
: "${REBOOT_FLAG:=/run/automations-reboot}"
log() { _log "$@"; }
warn() { _warn "$@"; }
die() { _die "$@"; }
run() { if [[ "$DRY_RUN" == "1" ]]; then echo "DRY: $*"; else eval "$@"; fi; }
# ============================================================================
# Update pass
# ============================================================================
UPGRADED=0
REBOOT=0
NEW_RELEASE="" # a newer stable branch exists (e.g. 3.22)
UPGRADED_TO="" # we actually upgraded to this branch (ALLOW_RELEASE_UPGRADE)
CUR_BRANCH=""
apply_updates() {
case "$OS_FAMILY" in
alpine)
run "apk update"
local out
if [[ "$DRY_RUN" == "1" ]]; then out="$(apk version -l '<' 2>/dev/null || true)"; else out="$(apk upgrade --no-self-upgrade 2>&1 | tee -a "$LOG")"; fi
UPGRADED=$(printf '%s\n' "$out" | grep -cE '^\([0-9]+/[0-9]+\) (Upgrading|Installing)' || true)
# A kernel / libc / crypto / busybox change wants a reboot.
printf '%s\n' "$out" | grep -qiE 'linux-(lts|virt|edge|rpi)|(^|[^a-z])musl|openssl|libcrypto|busybox' && REBOOT=1
;;
debian)
run "apt-get update -qq"
local before after
before="$(dpkg -l 2>/dev/null | grep -c '^ii' || echo 0)"
run "DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold upgrade"
run "DEBIAN_FRONTEND=noninteractive apt-get -y autoremove"
after="$(dpkg -l 2>/dev/null | grep -c '^ii' || echo 0)"
UPGRADED=$(( before > after ? 0 : after - before )) # rough; reboot flag is the signal that matters
[[ -f /var/run/reboot-required ]] && REBOOT=1
;;
rhel)
run "dnf -y upgrade --refresh"
command -v needs-restarting >/dev/null 2>&1 || run "dnf -y install dnf-utils"
if command -v needs-restarting >/dev/null 2>&1; then
needs-restarting -r >/dev/null 2>&1 || REBOOT=1
fi
;;
esac
return 0 # never let a non-matching grep above fail the function (set -e)
}
# ============================================================================
# New-release check (Alpine: is a newer stable BRANCH posted?)
# ============================================================================
check_new_release() {
[[ "$OS_FAMILY" == alpine ]] || return 0
command -v curl >/dev/null 2>&1 || return 0
local cur cur_branch repo base latest
cur="$(cat /etc/alpine-release 2>/dev/null || echo 0.0.0)"
cur_branch="$(printf '%s' "$cur" | cut -d. -f1,2)"
CUR_BRANCH="$cur_branch"
repo="$(grep -m1 -E '^https?://' /etc/apk/repositories 2>/dev/null || true)"
[[ -n "$repo" ]] || return 0
base="$(printf '%s' "$repo" | sed -E 's#(/alpine/).*#\1#')"
# Newest vX.Y branch advertised on the mirror.
latest="$(curl -fsSL "$base" 2>/dev/null | grep -oE 'v[0-9]+\.[0-9]+' | tr -d v | sort -uV | tail -1 || true)"
[[ -n "$latest" ]] || return 0
# If the newest branch sorts strictly above the current branch, flag it.
if [[ "$latest" != "$cur_branch" ]] && \
[[ "$(printf '%s\n%s\n' "$cur_branch" "$latest" | sort -V | tail -1)" == "$latest" ]]; then
NEW_RELEASE="$latest"
fi
return 0
}
# Jump to the newest STABLE Alpine branch (ALLOW_RELEASE_UPGRADE=1). Only the
# vX.Y stable branches are ever considered -- `edge` is never a target. Repoints
# /etc/apk/repositories from the current vX.Y to the new one (leaving any edge
# or non-versioned lines alone) and runs `apk upgrade --available`.
do_release_upgrade() {
[[ "$OS_FAMILY" == alpine ]] || { warn "Release upgrade is Alpine-only; skipping."; return 0; }
[[ -n "$NEW_RELEASE" ]] || return 0
[[ "$NEW_RELEASE" =~ ^[0-9]+\.[0-9]+$ ]] || { warn "Refusing non-stable release target '$NEW_RELEASE'."; return 0; }
log "Upgrading Alpine ${CUR_BRANCH} -> ${NEW_RELEASE} (ALLOW_RELEASE_UPGRADE=1)..."
if [[ "$DRY_RUN" == "1" ]]; then
echo "DRY: sed repositories /v${CUR_BRANCH}/ -> /v${NEW_RELEASE}/ ; apk update ; apk upgrade --available"
UPGRADED_TO="$NEW_RELEASE"; REBOOT=1; return 0
fi
cp -a /etc/apk/repositories "/etc/apk/repositories.bak.$(date -u +%Y%m%d%H%M%S)"
# Only touch versioned (vX.Y) lines; edge / non-versioned lines are left as-is.
sed -i -E "s#/v[0-9]+\.[0-9]+/#/v${NEW_RELEASE}/#g" /etc/apk/repositories
apk update
apk upgrade --available 2>&1 | tee -a "$LOG" || true
UPGRADED_TO="$NEW_RELEASE"
REBOOT=1 # a branch jump replaces kernel/musl/openssl -- always reboot
log "Now on Alpine $(cat /etc/alpine-release 2>/dev/null || echo '?')."
return 0
}
# ============================================================================
# Notify (reuse the login-notifier's ntfy config)
# ============================================================================
send_notice() {
[[ "$NOTIFY" == "1" ]] || return 0
[[ -r "$SSH_NOTIFY_CONF" ]] || return 0
# shellcheck disable=SC1090
. "$SSH_NOTIFY_CONF"
[[ -n "${NTFY_URL:-}" ]] || return 0
command -v curl >/dev/null 2>&1 || return 0
local host prio tags body
host="$(hostname -f 2>/dev/null || hostname 2>/dev/null || echo unknown)"
prio="min"
[[ "$REBOOT" == "1" || -n "$NEW_RELEASE" ]] && prio="default"
[[ -n "$UPGRADED_TO" ]] && prio="high"
body="Auto-update on ${host} (${OS_ID})
packages upgraded: ${UPGRADED}"
[[ "$REBOOT" == "1" ]] && body="${body}
reboot needed: yes$( [[ "$AUTO_REBOOT" == "1" ]] && echo ' (rebooting)' )"
if [[ -n "$UPGRADED_TO" ]]; then
body="${body}
UPGRADED Alpine release: ${CUR_BRANCH} -> ${UPGRADED_TO}"
elif [[ -n "$NEW_RELEASE" ]]; then
body="${body}
NEW Alpine release available: ${NEW_RELEASE} (current branch ${CUR_BRANCH})"
fi
set -- -fsS -m 5 -H "X-Title: Host Update" -H "X-Priority: ${prio}"
[[ -n "${NTFY_TOKEN:-}" ]] && set -- "$@" -H "Authorization: Bearer ${NTFY_TOKEN}"
[[ -n "${NTFY_EMAIL:-}" ]] && set -- "$@" -H "X-Email: ${NTFY_EMAIL}"
local t="update"; [[ -n "${NTFY_REGION:-}" ]] && t="${t},${NTFY_REGION}"
[[ -n "$NEW_RELEASE" && -z "$UPGRADED_TO" ]] && t="${t},new_release"
[[ -n "$UPGRADED_TO" ]] && t="${t},release_upgraded"
set -- "$@" -H "X-Tags: ${t}"
if [[ "$DRY_RUN" == "1" ]]; then
echo "DRY: curl $* -d <body> $NTFY_URL"
else
curl "$@" -d "$body" "$NTFY_URL" >/dev/null 2>&1 || true
fi
return 0
}
# Are there any active inbound SSH connections? Counts established TCP
# connections on the sshd port -- this catches ProxyJump tunnels too, which
# never open a login session (so `who` would miss them).
ssh_sessions_active() {
local port n
port="$(awk '/^Port /{print $2; exit}' /etc/ssh/sshd_config 2>/dev/null || true)"
port="${port:-22}"
if command -v ss >/dev/null 2>&1; then
n="$(ss -Htn state established "sport = :$port" 2>/dev/null | grep -c . || true)"
elif command -v netstat >/dev/null 2>&1; then
n="$(netstat -tn 2>/dev/null | awk -v p=":$port" '$NF=="ESTABLISHED" && $4 ~ p"$"' | grep -c . || true)"
else
# last resort: sshd per-connection worker processes
n="$(ps ax 2>/dev/null | grep -E '[s]shd:.*@|[s]shd: ' | grep -c . || true)"
fi
[ "${n:-0}" -gt 0 ]
}
# ============================================================================
# Subcommands
# ============================================================================
do_run() {
[[ $EUID -eq 0 ]] || die "Run as root."
os_detect
# A reboot pending from a previous (deferred) run -- the flag is in /run,
# wiped on a real reboot, so its presence means "still pending".
[[ -f "$REBOOT_FLAG" ]] && REBOOT=1
[[ "$DRY_RUN" == "1" ]] || { install -d -m 0755 "$(dirname "$LOG")" 2>/dev/null || true; echo "=== auto-update $(date -u +%FT%TZ) ===" >> "$LOG"; }
log "Applying updates (${OS_ID})..."
apply_updates
check_new_release
if [[ -n "$NEW_RELEASE" && "$ALLOW_RELEASE_UPGRADE" == "1" ]]; then
do_release_upgrade
fi
# Record the pending reboot so a deferral survives to the next daily run.
if [[ "$REBOOT" == "1" && "$DRY_RUN" != "1" ]]; then : > "$REBOOT_FLAG" 2>/dev/null || true; fi
log "Upgraded: ${UPGRADED} | reboot: ${REBOOT} | new release: ${NEW_RELEASE:-none}${UPGRADED_TO:+ | upgraded to: $UPGRADED_TO}"
send_notice
if [[ "$REBOOT" == "1" ]]; then
case "$AUTO_REBOOT" in
1)
log "Rebooting (AUTO_REBOOT=1)..."; run "reboot" ;;
idle)
if ssh_sessions_active; then
warn "Reboot needed, but SSH connections are active; deferring until idle (retried daily)."
else
log "No active SSH connections; rebooting (AUTO_REBOOT=idle)..."; run "reboot"
fi ;;
*)
warn "A reboot is recommended (kernel/libc/crypto updated). Set AUTO_REBOOT=1 or idle to automate." ;;
esac
fi
}
# Write /etc/auto-update.conf so the scheduled run inherits these defaults.
write_autoupdate_conf() {
local f="$AUTO_UPDATE_CONF"
if [[ -f "$f" && "${AU_FORCE_CONF:-0}" != "1" ]]; then
log "$f exists; leaving it (set AU_FORCE_CONF=1 to overwrite)."
return 0
fi
cat > "$f" <<CONF
# Defaults for the daily auto-update job (scripts/auto-update.sh).
# Environment variables still override these at runtime.
AUTO_REBOOT="${AUTO_REBOOT}" # 0 | 1 | idle
ALLOW_RELEASE_UPGRADE="${ALLOW_RELEASE_UPGRADE}" # Alpine stable-branch jump
NOTIFY="${NOTIFY}"
CONF
chmod 644 "$f"
log "Wrote $f"
}
do_install() {
[[ $EUID -eq 0 ]] || die "Run as root."
os_detect
write_autoupdate_conf
install_daily_job auto-update "$SELF" run
log "Scheduled daily auto-update (AUTO_REBOOT=${AUTO_REBOOT})."
}
do_uninstall() {
[[ $EUID -eq 0 ]] || die "Run as root."
os_detect
remove_daily_job auto-update
log "Removed daily auto-update."
}
case "${1:-run}" in
run) do_run ;;
install) do_install ;;
uninstall) do_uninstall ;;
*) die "Usage: auto-update.sh [run|install|uninstall]" ;;
esac
+121 -107
View File
@@ -2,34 +2,24 @@
#
# harden-jumphost.sh
#
# Hardens an Alpine box for use as an SSH jump host (bastion). Run on a fresh
# box. Layered on the same PQ-hybrid posture as harden-ssh.sh, plus jump-host
# specifics.
# 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 (for maintenance only).
# No forwarding. This is for fixing the box, not for
# reaching anywhere else.
# 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.
# These users cannot get a prompt on the jump host even if
# their key works.
#
# How the restriction works:
# - Global default: DisableForwarding yes, PermitTTY no, ForceCommand
# /sbin/nologin. So a user with no group membership cannot do anything.
# - Match Group ssh-admins: re-enables PermitTTY and clears ForceCommand
# so admins get a normal shell. Forwarding stays off.
# - Match Group ssh-jumpers: enables AllowTcpForwarding + PermitOpen
# whitelist. Keeps PermitTTY no and ForceCommand /sbin/nologin so any
# attempt at an interactive session fails -- but ProxyJump (direct-tcpip)
# still works because it doesn't open a session channel.
#
# A note on ProxyJump and ForceCommand:
# `ssh -J jumphost target` opens a direct-tcpip channel on the jump host;
# it is NOT a session/exec request, so ForceCommand never fires. That's
# why this pattern works: jumpers literally cannot run anything on the
# jump host, but their tunnels go through.
# <nologin>. 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 <nologin>. ProxyJump
# (direct-tcpip) works because it never opens a session channel, so
# ForceCommand never fires.
#
# Usage:
# bash harden-jumphost.sh
@@ -40,56 +30,57 @@
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 jumpers can ProxyJump nowhere (deny-all). Set this to your
# internal targets, e.g. "10.0.0.5:22 10.0.0.6:22".
# 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() { printf '\033[1;32m[+]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[!]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31m[x]\033[0m %s\n' "$*" >&2; exit 1; }
log() { _log "$@"; }
warn() { _warn "$@"; }
die() { _die "$@"; }
[[ $EUID -eq 0 ]] || die "Run as root."
[[ -f /etc/alpine-release ]] || die "This script targets Alpine Linux."
os_detect
log "Detected OS: ${OS_ID} (family ${OS_FAMILY}, init ${INIT_SYSTEM})"
# ----------------------------------------------------------------------------
# 1. Packages
# ----------------------------------------------------------------------------
log "Installing openssh + PAM + sshguard + iptables + gum..."
if apk info -e openssh-server >/dev/null 2>&1 && \
! apk info -e openssh-server-pam >/dev/null 2>&1; then
apk del -q openssh-server || true
fi
apk add -q openssh openssh-server-pam linux-pam sshguard iptables ip6tables openrc gum shadow
log "Installing OpenSSH + sshguard + iptables..."
install_openssh
install_bruteforce_protection
ensure_gum || warn "gum not installed; sshuser will use its CLI mode."
# Install sshuser tool alongside this script if present.
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
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 -m 0755 "$SCRIPT_DIR/sshuser.sh" /usr/local/bin/sshuser
log "Installed /usr/local/bin/sshuser"
fi
# ----------------------------------------------------------------------------
# 2. PQ KEX detection (same as harden-ssh.sh)
# 2. PQ KEX detection
# ----------------------------------------------------------------------------
log "Checking OpenSSH version supports PQ KEX..."
SSH_VER=$(ssh -V 2>&1 | grep -oE 'OpenSSH_[0-9]+\.[0-9]+' | head -1 | sed 's/OpenSSH_//')
SSH_MAJOR=${SSH_VER%%.*}
SSH_MINOR=${SSH_VER##*.}
HAS_MLKEM=0
HAS_SNTRUP=0
HAS_MLKEM=0; HAS_SNTRUP=0
[[ $SSH_MAJOR -gt 9 || ( $SSH_MAJOR -eq 9 && $SSH_MINOR -ge 0 ) ]] && HAS_SNTRUP=1
[[ $SSH_MAJOR -gt 9 || ( $SSH_MAJOR -eq 9 && $SSH_MINOR -ge 9 ) ]] && HAS_MLKEM=1
[[ $HAS_SNTRUP -eq 1 || $HAS_MLKEM -eq 1 ]] \
|| die "OpenSSH ${SSH_VER} has no PQ KEX. Need >= 9.0."
[[ $HAS_SNTRUP -eq 1 || $HAS_MLKEM -eq 1 ]] || die "OpenSSH ${SSH_VER} has no PQ KEX. Need >= 9.0."
log "OpenSSH ${SSH_VER}: ML-KEM=${HAS_MLKEM} sntrup761=${HAS_SNTRUP}"
KEX_LIST=""
@@ -107,25 +98,16 @@ if [[ ! -f /etc/ssh/ssh_host_ed25519_key ]]; then
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/^/ /'
# Stop Alpine's sshd init from regenerating RSA/ECDSA keys.
if [[ -f /etc/conf.d/sshd ]]; then
if ! grep -q '^sshd_disable_keygen=' /etc/conf.d/sshd; then
echo 'sshd_disable_keygen="yes"' >> /etc/conf.d/sshd
else
sed -i 's/^sshd_disable_keygen=.*/sshd_disable_keygen="yes"/' /etc/conf.d/sshd
fi
fi
sshd_disable_keygen # Alpine-only; no-op on systemd
# ----------------------------------------------------------------------------
# 4. Groups -- create if missing
# 4. Groups
# ----------------------------------------------------------------------------
log "Ensuring groups ssh-admins and ssh-jumpers exist..."
getent group ssh-admins >/dev/null || addgroup -S ssh-admins
getent group ssh-jumpers >/dev/null || addgroup -S ssh-jumpers
group_add_system ssh-admins
group_add_system ssh-jumpers
# ----------------------------------------------------------------------------
# 5. Root keypair (for ssh-admins maintenance access)
@@ -135,27 +117,35 @@ mkdir -p /root/.ssh
chmod 700 /root/.ssh
touch /root/.ssh/authorized_keys
chmod 600 /root/.ssh/authorized_keys
# Add root to ssh-admins so the Match block applies.
adduser root ssh-admins 2>/dev/null || true
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")
if ! grep -qxF "$ROOT_PUB" /root/.ssh/authorized_keys; then
echo "$ROOT_PUB" >> /root/.ssh/authorized_keys
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. Build PermitOpen line from JUMP_TARGETS
# 6. PermitOpen line from JUMP_TARGETS (space-separated -> comma-separated)
# ----------------------------------------------------------------------------
# JUMP_TARGETS is space-separated; PermitOpen wants comma-separated.
# Empty => "none" (deny all). sshd_config doesn't accept a literal empty list.
PERMIT_OPEN_LINE="none"
if [[ -n "$JUMP_TARGETS" ]]; then
PERMIT_OPEN_LINE=$(echo "$JUMP_TARGETS" | tr -s ' ' ',' | sed 's/^,//;s/,$//')
fi
[[ -n "$JUMP_TARGETS" ]] && PERMIT_OPEN_LINE=$(echo "$JUMP_TARGETS" | tr -s ' ' ',' | sed 's/^,//;s/,$//')
# ----------------------------------------------------------------------------
# 7. sshd_config
@@ -164,13 +154,18 @@ 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 <<EOF
# Generated by harden-jumphost.sh -- $(date -u +%FT%TZ)
# Generated by harden-jumphost.sh on ${OS_ID} -- $(date -u +%FT%TZ)
# Original config preserved at /etc/ssh/sshd_config.orig
Port ${SSH_PORT}
AddressFamily any
ListenAddress 0.0.0.0
ListenAddress ::
PidFile /run/sshd.pid
# VERBOSE so the auth log records key fingerprints and direct-tcpip targets
# (used by the login notifier to report the key and best-effort jump target).
LogLevel VERBOSE
# --- Host key: Ed25519 only ---
HostKey /etc/ssh/ssh_host_ed25519_key
@@ -199,13 +194,15 @@ AuthenticationMethods publickey
MaxAuthTries 3
MaxSessions 10
LoginGraceTime 30s
# Expose the authenticated key (file at \$SSH_USER_AUTH) for the notifier.
ExposeAuthInfo yes
# --- Default posture: deny everything ---
# Anyone not in ssh-admins or ssh-jumpers gets nothing. Matches below
# Anyone not matched below gets nothing. The per-group Match blocks
# selectively re-enable what each group needs.
DisableForwarding yes
PermitTTY no
ForceCommand /sbin/nologin
ForceCommand ${NOLOGIN}
X11Forwarding no
GatewayPorts no
PermitTunnel no
@@ -228,13 +225,11 @@ Compression no
Banner none
# --- Subsystems ---
# SFTP off by default. Admins who need it can be added to a separate
# Match block; jumpers must never have it.
# Subsystem sftp internal-sftp
# SFTP off by default on a jump host; jumpers must never have it. If you
# enable it for admins, log transfers to AUTHPRIV at INFO:
# Subsystem sftp ${SFTP_PATH} -f AUTHPRIV -l INFO
# --- Allowlist ---
# Only members of these groups can authenticate at all. AllowGroups
# enforces this BEFORE the per-group Match blocks run.
AllowGroups ssh-admins ssh-jumpers
# ============================================================================
@@ -252,13 +247,10 @@ Match Group ssh-admins
X11Forwarding no
PermitOpen none
# --- Jumpers: ProxyJump only, no shell, no SFTP, whitelisted destinations ---
# direct-tcpip channels are controlled by AllowTcpForwarding + PermitOpen.
# Session channels (shell/exec/subsystem) hit ForceCommand=/sbin/nologin
# and PermitTTY=no, so any interactive attempt fails immediately.
# --- Jumpers: ProxyJump only, no shell, whitelisted destinations ---
Match Group ssh-jumpers
PermitTTY no
ForceCommand /sbin/nologin
ForceCommand ${NOLOGIN}
AllowTcpForwarding yes
PermitOpen ${PERMIT_OPEN_LINE}
AllowAgentForwarding no
@@ -287,14 +279,17 @@ log "Configuring sshguard..."
mkdir -p /etc/sshguard
WHITELIST=/etc/sshguard/whitelist
{
echo "127.0.0.1"
echo "::1"
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 <<EOF
BACKEND="/usr/libexec/sshg-fw-iptables"
LOGREADER="LANG=C journalctl -afb -p info -n1 -u sshd -o cat"
BACKEND="${SSHGUARD_BACKEND}"
${SSHGUARD_LOGREADER:+LOGREADER="${SSHGUARD_LOGREADER}"}
THRESHOLD=30
BLOCK_TIME=300
DETECTION_TIME=1800
@@ -302,53 +297,74 @@ PID_FILE=/run/sshguard.pid
WHITELIST_FILE=${WHITELIST}
EOF
cat > /etc/local.d/sshguard-iptables.start <<'EOF'
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
chmod +x /etc/local.d/sshguard-iptables.start
rc-update add local default 2>/dev/null || true
/etc/local.d/sshguard-iptables.start
install_boot_hook sshguard-iptables "$HOOK"
rm -f "$HOOK"
rc-update add sshguard default
rc-service sshguard restart || rc-service sshguard start
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
# ----------------------------------------------------------------------------
log "Enabling sshd at boot..."
rc-update add sshd default
SSHD_SVC="$(sshd_service)"
log "Enabling ${SSHD_SVC} at boot..."
svc_enable "$SSHD_SVC"
cat <<EOF
================================================================
JUMP HOST SETUP COMPLETE
JUMP HOST SETUP COMPLETE (${OS_ID})
Groups created:
ssh-admins -- full shell on the jump host (root added)
ssh-jumpers -- ProxyJump only, no shell
Add a jumper user:
adduser -D -s /sbin/nologin alice
addgroup alice ssh-jumpers
mkdir -p /home/alice/.ssh && chmod 700 /home/alice/.ssh
echo 'ssh-ed25519 AAA...' > /home/alice/.ssh/authorized_keys
chmod 600 /home/alice/.ssh/authorized_keys
chown -R alice:alice /home/alice/.ssh
Add a jumper user (using the installed tool):
sshuser add -u alice -r jumper -k "ssh-ed25519 AAA..."
Note the user's shell can be /sbin/nologin -- ProxyJump still works
because it never opens a session channel.
Or an admin:
sshuser add -u bob -r admin -k "ssh-ed25519 AAA..."
Allowed jump targets (PermitOpen):
${PERMIT_OPEN_LINE}
To change targets: edit JUMP_TARGETS and re-run, or edit the Match
To change targets: re-run with JUMP_TARGETS set, or edit the Match
block in /etc/ssh/sshd_config directly.
COPY THIS PRIVATE KEY TO YOUR CLIENT *NOW* (admin/root key):
@@ -366,10 +382,8 @@ 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@<jumphost>
# ProxyJump through to an internal target:
ssh -J root@<jumphost>:${SSH_PORT} -i ~/.ssh/id_ed25519_target user@<target>
@@ -390,11 +404,11 @@ Reload sshd now? [y/N]
EOF
read -r ans
if [[ "${ans,,}" != "y" && "${ans,,}" != "yes" ]]; then
warn "Skipping reload. Run 'rc-service sshd reload' manually when ready."
warn "Skipping reload. Reload ${SSHD_SVC} manually when ready."
exit 0
fi
fi
log "Reloading sshd..."
rc-service sshd reload || rc-service sshd restart
log "Reloading ${SSHD_SVC}..."
svc_reload "$SSHD_SVC"
log "Done."
+144 -146
View File
@@ -2,129 +2,108 @@
#
# harden-ssh.sh
#
# SSH hardening for Alpine Linux. Run BEFORE deploy-simplex.sh on a fresh box.
# SSH hardening for Alpine, Debian, and Alma Linux. Run on a fresh box (and,
# for the simplex relay, BEFORE deploy-simplex.sh).
#
# All distro-specific operations go through scripts/oslib.sh. The OS-specific
# surface for this script is: package install, the sshd service name, the
# external sftp-server path, host-key keygen suppression (Alpine only), the
# sshguard log source + firewall backend, and the boot hook that installs the
# iptables jump. Each is clearly marked.
#
# What this does:
# 1. Generates fresh Ed25519 host keys; removes RSA/ECDSA/DSA host keys
# 2. Generates an Ed25519 root keypair on the host, installs the public key
# into /root/.ssh/authorized_keys, and PRINTS the private key to stdout
# so you can copy it to your client. THIS IS YOUR ONLY CHANCE TO COPY IT.
# 3. Forces post-quantum hybrid KEX only:
# mlkem768x25519-sha256 (the future default, NIST ML-KEM hybrid)
# sntrup761x25519-sha512 (older PQ KEX, kept as fallback)
# Drops every classical-only KEX. Connections that don't speak PQ KEX
# will be rejected.
# 4. Modern ciphers and MACs only (chacha20-poly1305, aes256-gcm,
# hmac-sha2-512-etm)
# 5. Disables everything not needed for an interactive terminal:
# - password auth, root password login (key-only)
# - challenge-response, GSSAPI, PAM, host-based auth
# - X11 forwarding, agent forwarding
# - TCP forwarding, stream-local forwarding (UNIX sockets)
# - tunneling (PermitTunnel), gateway ports
# - SFTP subsystem (kept ON — needed for backup retrieval)
# - empty passwords, .ssh/rc execution, compression
# Result: a session can run a shell. That's it. No -L, no -R, no -D, no
# jump hosting, no sftp, no scp.
# 6. Optional non-default port (-p PORT)
# 7. Installs sshguard with iptables backend for brute-force protection
# 8. Validates config with `sshd -t` and prompts for confirmation before
# reloading sshd (so a config error or a typo doesn't lock you out)
# 2. Generates an Ed25519 root keypair, installs the public key into
# /root/.ssh/authorized_keys, and PRINTS the private key to stdout once.
# 3. Forces post-quantum hybrid KEX only (mlkem768x25519, sntrup761x25519).
# 4. Modern ciphers and MACs only.
# 5. Disables everything but an interactive terminal + SFTP (no forwarding,
# tunneling, X11, agent, password auth).
# 6. Optional non-default port (SSH_PORT).
# 7. Installs sshguard for brute-force protection.
# 8. Validates with `sshd -t` and prompts before reloading (so you don't
# lock yourself out).
#
# A note on "quantum-safe":
# Stock OpenSSH provides PQ KEY EXCHANGE (the session key, the thing that
# matters for "store now, decrypt later"). It does NOT yet provide PQ
# AUTHENTICATION KEYS -- there is no standardized PQ host or user key
# algorithm in mainline OpenSSH yet. So:
# - Your session is PQ-protected against SNDL: yes
# - Your auth keypair (Ed25519) is classical: yes, and that's the best
# practical choice today. PQ signature support exists only in the
# open-quantum-safe/openssh fork, which breaks compatibility with
# every standard SSH client.
# This script gives you the strongest stock-OpenSSH posture available.
# A note on "quantum-safe": stock OpenSSH gives PQ KEY EXCHANGE (protects the
# session key against store-now-decrypt-later) but classical Ed25519 AUTH
# keys -- the strongest practical posture available without breaking client
# compatibility.
#
# Usage:
# bash harden-ssh.sh # port stays 22, default
# bash harden-ssh.sh # port stays 22
# SSH_PORT=2222 bash harden-ssh.sh # change port
# ALLOWED_IP=1.2.3.4 bash harden-ssh.sh # whitelist your client IP in sshguard
# FORCE=1 bash harden-ssh.sh # skip the "are you sure" prompt
# ALLOWED_IP=1.2.3.4 bash harden-ssh.sh # whitelist your client IP
# FORCE=1 bash harden-ssh.sh # skip the confirm prompt
set -euo pipefail
# Load the OS abstraction layer (sits next to this script).
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/oslib.sh
. "$SCRIPT_DIR/oslib.sh"
# ============================================================================
# CONFIG
# ============================================================================
: "${SSH_PORT:=22}"
: "${ALLOWED_IP:=}" # optional: your client IP, will be sshguard-whitelisted
: "${ALLOWED_IP:=}" # optional: your client IP, sshguard-whitelisted
: "${KEY_COMMENT:=root@$(hostname)-$(date +%Y%m%d)}"
: "${FORCE:=0}"
log() { printf '\033[1;32m[+]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[!]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31m[x]\033[0m %s\n' "$*" >&2; exit 1; }
# log()/warn()/die() come from oslib (_log/_warn/_die); alias for readability.
log() { _log "$@"; }
warn() { _warn "$@"; }
die() { _die "$@"; }
[[ $EUID -eq 0 ]] || die "Run as root."
[[ -f /etc/alpine-release ]] || die "This script targets Alpine Linux."
os_detect
log "Detected OS: ${OS_ID} (family ${OS_FAMILY}, init ${INIT_SYSTEM})"
# ----------------------------------------------------------------------------
# 1. Pre-flight checks
# 1. Pre-flight: ensure an ssh client exists before probing its version
# ----------------------------------------------------------------------------
# Ensure ssh client is installed before probing its version. On a fresh
# Alpine box there's no `ssh` binary at all, and the version-detect
# pipeline below would die silently under `set -euo pipefail`.
if ! command -v ssh >/dev/null 2>&1; then
log "ssh not found; installing openssh + openssh-server..."
apk add -q openssh openssh-server
log "ssh not found; installing openssh..."
install_openssh
fi
log "Checking OpenSSH version supports PQ KEX..."
SSH_VER=$(ssh -V 2>&1 | grep -oE 'OpenSSH_[0-9]+\.[0-9]+' | head -1 \
| sed 's/OpenSSH_//')
SSH_VER=$(ssh -V 2>&1 | grep -oE 'OpenSSH_[0-9]+\.[0-9]+' | head -1 | sed 's/OpenSSH_//')
SSH_MAJOR=${SSH_VER%%.*}
SSH_MINOR=${SSH_VER##*.}
# OpenSSH 9.0+ has sntrup761x25519-sha512.
# OpenSSH 9.9+ also has mlkem768x25519-sha256.
# OpenSSH 9.0+ has sntrup761x25519-sha512; 9.9+ adds mlkem768x25519-sha256.
HAS_MLKEM=0
HAS_SNTRUP=0
if [[ $SSH_MAJOR -gt 9 || ( $SSH_MAJOR -eq 9 && $SSH_MINOR -ge 0 ) ]]; then
HAS_SNTRUP=1
fi
if [[ $SSH_MAJOR -gt 9 || ( $SSH_MAJOR -eq 9 && $SSH_MINOR -ge 9 ) ]]; then
HAS_MLKEM=1
fi
[[ $SSH_MAJOR -gt 9 || ( $SSH_MAJOR -eq 9 && $SSH_MINOR -ge 0 ) ]] && HAS_SNTRUP=1
[[ $SSH_MAJOR -gt 9 || ( $SSH_MAJOR -eq 9 && $SSH_MINOR -ge 9 ) ]] && HAS_MLKEM=1
[[ $HAS_SNTRUP -eq 1 || $HAS_MLKEM -eq 1 ]] \
|| die "OpenSSH ${SSH_VER} has no PQ KEX. Need >= 9.0. Upgrade Alpine first."
|| die "OpenSSH ${SSH_VER} has no PQ KEX. Need >= 9.0. Upgrade the base OS first."
log "OpenSSH ${SSH_VER}: ML-KEM=${HAS_MLKEM} sntrup761=${HAS_SNTRUP}"
# Build the KEX list from what's actually available.
KEX_LIST=""
[[ $HAS_MLKEM -eq 1 ]] && KEX_LIST="mlkem768x25519-sha256"
[[ $HAS_SNTRUP -eq 1 ]] && KEX_LIST="${KEX_LIST:+$KEX_LIST,}sntrup761x25519-sha512"
# ----------------------------------------------------------------------------
# 2. Install packages
# 2. Install packages (OS-gated inside oslib)
# ----------------------------------------------------------------------------
log "Installing openssh-server-pam, sshguard, iptables..."
# openssh-server-pam replaces openssh-server (PAM-enabled sshd). If the
# non-pam version was installed earlier, swap it out cleanly.
if apk info -e openssh-server >/dev/null 2>&1 && \
! apk info -e openssh-server-pam >/dev/null 2>&1; then
apk del -q openssh-server || true
fi
apk add -q openssh openssh-server-pam linux-pam sshguard iptables ip6tables openrc
log "Installing OpenSSH server + sshguard + iptables..."
install_openssh
install_bruteforce_protection
# The external SFTP subsystem binary path differs per distro.
SFTP_PATH="$(sftp_server_path)"
[[ -x "$SFTP_PATH" ]] || warn "sftp-server not found at expected path ($SFTP_PATH); SFTP may not work until installed."
# ----------------------------------------------------------------------------
# 3. Host keys -- regenerate with Ed25519 only
# ----------------------------------------------------------------------------
log "Regenerating host keys (Ed25519 only)..."
rm -f /etc/ssh/ssh_host_rsa_key* \
/etc/ssh/ssh_host_ecdsa_key* \
rm -f /etc/ssh/ssh_host_rsa_key* \
/etc/ssh/ssh_host_ecdsa_key* \
/etc/ssh/ssh_host_dsa_key*
# Keep existing ed25519 key if there is one (so the host fingerprint doesn't
# change unnecessarily on re-runs). Generate one if not.
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)"
@@ -135,16 +114,9 @@ chmod 644 /etc/ssh/ssh_host_ed25519_key.pub
log "Host key fingerprint (verify on first connect):"
ssh-keygen -l -f /etc/ssh/ssh_host_ed25519_key.pub | sed 's/^/ /'
# Stop Alpine's sshd init from regenerating RSA/ECDSA keys on every start.
# /etc/conf.d/sshd: pin sshd_disable_keygen=no but only generate ed25519 by
# overriding the keygen line in the init script via a drop-in.
if [[ -f /etc/conf.d/sshd ]]; then
if ! grep -q '^sshd_disable_keygen=' /etc/conf.d/sshd; then
echo 'sshd_disable_keygen="yes"' >> /etc/conf.d/sshd
else
sed -i 's/^sshd_disable_keygen=.*/sshd_disable_keygen="yes"/' /etc/conf.d/sshd
fi
fi
# Alpine's OpenRC sshd init regenerates RSA/ECDSA keys on each start; pin off.
# No-op on systemd distros.
sshd_disable_keygen
# ----------------------------------------------------------------------------
# 4. Root user keypair
@@ -155,54 +127,64 @@ chmod 700 /root/.ssh
touch /root/.ssh/authorized_keys
chmod 600 /root/.ssh/authorized_keys
# Always create a brand-new key pair in a temp location so we can show the
# private key to the user and then add the public key to authorized_keys.
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")
# Idempotency: don't add the same pubkey twice.
if ! grep -qxF "$ROOT_PUB" /root/.ssh/authorized_keys; then
echo "$ROOT_PUB" >> /root/.ssh/authorized_keys
grep -qxF "$ROOT_PUB" /root/.ssh/authorized_keys || echo "$ROOT_PUB" >> /root/.ssh/authorized_keys
# Seed root's authorized_keys with the shared admin keys from globals/ so the
# box has a known, secure default login (SSH_KEYS_URL first, else
# globals/authorized_keys). Best-effort: needs the repo's lib.sh present.
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
# ----------------------------------------------------------------------------
# 5. sshd_config
# ----------------------------------------------------------------------------
log "Writing /etc/ssh/sshd_config..."
# Back up whatever was there before, once.
[[ -f /etc/ssh/sshd_config.orig ]] || cp /etc/ssh/sshd_config /etc/ssh/sshd_config.orig
cat > /etc/ssh/sshd_config <<EOF
# Generated by harden-ssh.sh -- $(date -u +%FT%TZ)
# Generated by harden-ssh.sh on ${OS_ID} -- $(date -u +%FT%TZ)
# Original config preserved at /etc/ssh/sshd_config.orig
Port ${SSH_PORT}
AddressFamily any
ListenAddress 0.0.0.0
ListenAddress ::
PidFile /run/sshd.pid
# --- Host key: Ed25519 only ---
HostKey /etc/ssh/ssh_host_ed25519_key
# --- Post-quantum hybrid KEX only ---
# Anything not in this list (every classical-only KEX) is rejected. This
# protects against "store now, decrypt later" because the session key is
# derived from a PQ KEM hybrid.
# Anything not in this list (every classical-only KEX) is rejected, which is
# what protects the session key against "store now, decrypt later".
KexAlgorithms ${KEX_LIST}
# --- Modern ciphers and MACs ---
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,umac-128-etm@openssh.com
# --- Host key signature algorithms (for the host proving itself) ---
# --- Host key signature algorithms ---
HostKeyAlgorithms ssh-ed25519,ssh-ed25519-cert-v01@openssh.com
# --- Public key algorithms accepted from clients ---
# Ed25519 + RSA-4096 (RSA kept for older YubiKey firmware that lacks Ed25519
# in the PIV applet -- pre-5.7). Plain ssh-rsa (SHA-1) is NOT included.
# Ed25519 + RSA-4096 (RSA kept for older YubiKey PIV firmware pre-5.7).
PubkeyAcceptedAlgorithms ssh-ed25519,ssh-ed25519-cert-v01@openssh.com,sk-ssh-ed25519@openssh.com,sk-ssh-ed25519-cert-v01@openssh.com,rsa-sha2-512,rsa-sha2-512-cert-v01@openssh.com,rsa-sha2-256,rsa-sha2-256-cert-v01@openssh.com
RequiredRSASize 4096
@@ -213,21 +195,16 @@ PasswordAuthentication no
PermitEmptyPasswords no
ChallengeResponseAuthentication no
KbdInteractiveAuthentication no
# PAM is enabled so the session stack runs on every login (used by
# pam_exec.so for post-login notification hooks). Auth still requires a
# pubkey -- PAM only handles account/session, not auth, because
# PasswordAuthentication and KbdInteractiveAuthentication are off.
UsePAM yes
AuthenticationMethods publickey
MaxAuthTries 3
MaxSessions 4
LoginGraceTime 30s
# Expose the authenticated key to the session (file at \$SSH_USER_AUTH) so the
# pam_exec login notifier can report which key was used.
ExposeAuthInfo yes
# --- Session restrictions: terminal access only, no forwarding/tunneling ---
# This server runs SimpleX relays. The only legitimate SSH use is an admin
# terminal session. Every forwarding feature below is therefore disabled --
# they're just attack surface and lateral-movement aids if the host is ever
# compromised.
# --- Session restrictions: terminal + SFTP only, no forwarding/tunneling ---
X11Forwarding no
X11UseLocalhost yes
AllowAgentForwarding no
@@ -236,7 +213,7 @@ AllowStreamLocalForwarding no
DisableForwarding yes # belt-and-braces: kills *all* forwarding types
GatewayPorts no
PermitTunnel no
PermitUserRC no # don't run ~/.ssh/rc on login
PermitUserRC no
PermitListen none
PermitOpen none
PrintMotd no
@@ -251,10 +228,10 @@ HostbasedAuthentication no
Compression no
Banner none
# SFTP subsystem: enabled for backup retrieval only.
# Note: this allows sftp and scp to work. Only the forwarding/tunneling
# features above are disabled -- file transfer via SFTP is legitimate.
Subsystem sftp internal-sftp
# SFTP subsystem: external sftp-server binary (path is per-distro, resolved
# by oslib's sftp_server_path). Enabled for backup retrieval, with logging to
# AUTHPRIV at INFO so file transfers are auditable.
Subsystem sftp ${SFTP_PATH} -f AUTHPRIV -l INFO
# Restrict who can SSH in. Add other users here if you create them.
AllowUsers root
@@ -274,10 +251,9 @@ rm -f /tmp/sshd-test.err
# ----------------------------------------------------------------------------
# 7. sshguard (brute-force protection)
# ----------------------------------------------------------------------------
log "Configuring sshguard with iptables backend..."
log "Configuring sshguard (backend + log source are OS-gated in oslib)..."
mkdir -p /etc/sshguard
# Whitelist: localhost always, plus optional caller-supplied IP.
WHITELIST=/etc/sshguard/whitelist
{
echo "127.0.0.1"
@@ -285,10 +261,13 @@ WHITELIST=/etc/sshguard/whitelist
[[ -n "$ALLOWED_IP" ]] && echo "$ALLOWED_IP"
} > "$WHITELIST"
# sshguard.conf: explicitly point it at iptables backend.
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 <<EOF
BACKEND="/usr/libexec/sshg-fw-iptables"
LOGREADER="LANG=C journalctl -afb -p info -n1 -u sshd -o cat"
BACKEND="${SSHGUARD_BACKEND}"
${SSHGUARD_LOGREADER:+LOGREADER="${SSHGUARD_LOGREADER}"}
THRESHOLD=30
BLOCK_TIME=300
DETECTION_TIME=1800
@@ -296,44 +275,68 @@ PID_FILE=/run/sshguard.pid
WHITELIST_FILE=${WHITELIST}
EOF
# sshguard inserts rules into chain "sshguard"; we need a jump from INPUT.
# awall doesn't manage this chain, so we add it once and make it persist via
# a small startup hook.
cat > /etc/local.d/sshguard-iptables.start <<'EOF'
# Boot hook: ensure the sshguard chain exists and INPUT jumps to it for the
# SSH port. oslib installs this as an OpenRC local.d script or a systemd
# oneshot unit, depending on the init system.
HOOK=$(mktemp)
cat > "$HOOK" <<'EOF'
#!/bin/sh
# Ensure sshguard chain exists and INPUT jumps to it for tcp/22 (and PORT).
# Ensure sshguard chain exists and INPUT jumps to it for the SSH port.
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
chmod +x /etc/local.d/sshguard-iptables.start
rc-update add local default 2>/dev/null || true
/etc/local.d/sshguard-iptables.start
install_boot_hook sshguard-iptables "$HOOK"
rm -f "$HOOK"
rc-update add sshguard default
rc-service sshguard restart || rc-service sshguard start
svc_enable_start sshguard || warn "Could not start sshguard; check 'sshguard.conf' on this distro."
# ----------------------------------------------------------------------------
# 8. SSHD enable & reload (with safety prompt)
# 7b. Optional: SSH login notifier (pam_exec -> ntfy)
# ----------------------------------------------------------------------------
log "Enabling sshd at boot..."
rc-update add sshd default
# Enabled when NTFY_URL is provided. Reports user + source IP + the key used,
# filtered by NOTIFY_GROUPS (empty here = every login on this host).
if [[ -n "${NTFY_URL:-}" ]]; then
: "${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
# Print the private key BEFORE reloading sshd so even if reload locks the
# user out, they have what they need to come back in via console.
# ----------------------------------------------------------------------------
# 7c. Optional: daily unattended updates (set AUTO_UPDATE=1). New OS branches
# are reported, not auto-applied.
# ----------------------------------------------------------------------------
if [[ "${AUTO_UPDATE:-0}" == "1" && -f "$SCRIPT_DIR/auto-update.sh" ]]; then
log "Scheduling daily auto-update..."
AUTO_REBOOT="${AUTO_REBOOT:-0}" \
ALLOW_RELEASE_UPGRADE="${ALLOW_RELEASE_UPGRADE:-0}" \
NOTIFY="${NOTIFY:-1}" \
bash "$SCRIPT_DIR/auto-update.sh" install || warn "Could not schedule auto-update."
fi
# ----------------------------------------------------------------------------
# 8. Enable sshd & reload (with safety prompt)
# ----------------------------------------------------------------------------
SSHD_SVC="$(sshd_service)"
log "Enabling ${SSHD_SVC} at boot..."
svc_enable "$SSHD_SVC"
# Print the private key BEFORE reloading sshd so a bad reload still leaves you
# with what you need to get back in via console.
cat <<EOF
================================================================
COPY THIS PRIVATE KEY TO YOUR CLIENT *NOW*
Save it as e.g. ~/.ssh/id_ed25519_simplex on your local machine,
Save it as e.g. ~/.ssh/id_ed25519_host on your local machine,
chmod 600, and connect with:
ssh -i ~/.ssh/id_ed25519_simplex -p ${SSH_PORT} root@<host>
ssh -i ~/.ssh/id_ed25519_host -p ${SSH_PORT} root@<host>
----- BEGIN ROOT PRIVATE KEY (Ed25519) -----
${ROOT_PRIV}
@@ -348,19 +351,15 @@ $(ssh-keygen -l -f /etc/ssh/ssh_host_ed25519_key.pub)
================================================================
EOF
# Wipe the temp files holding the private key.
shred -u "$TMP_KEY" "${TMP_KEY}.pub" 2>/dev/null || rm -f "$TMP_KEY" "${TMP_KEY}.pub"
# Final guard: confirm before reloading sshd. A bad reload is recoverable from
# console; a bad reload while you assumed everything was fine is not.
if [[ "$FORCE" != "1" ]]; then
cat <<EOF
sshd config has passed validation. Ready to reload sshd.
If you are connected via SSH RIGHT NOW, opening a SECOND ssh session in
another terminal -- before answering yes -- to verify the new keys, port,
and PQ KEX work is the safest path. If your new key/port/KEX is wrong,
this reload will end your current session.
If you are connected via SSH RIGHT NOW, open a SECOND session in another
terminal -- before answering yes -- to verify the new keys, port, and PQ
KEX work. If something is wrong, this reload will end your current session.
Test in another terminal first:
ssh -i ~/.ssh/<your saved key> -p ${SSH_PORT} \\
@@ -370,13 +369,12 @@ Reload sshd now? [y/N]
EOF
read -r ans
if [[ "${ans,,}" != "y" && "${ans,,}" != "yes" ]]; then
warn "Skipping reload. Run 'rc-service sshd reload' manually when ready."
warn "Skipping reload. Run 'svc reload of ${SSHD_SVC}' manually when ready."
exit 0
fi
fi
log "Reloading sshd..."
rc-service sshd reload || rc-service sshd restart
log "Reloading ${SSHD_SVC}..."
svc_reload "$SSHD_SVC"
log "Done. Your session, if any, should remain alive (reload preserves connections)."
log "Test from another machine before closing this session."
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
#
# lib.sh -- shared helpers for the launcher and the automation scripts.
#
# Source it, don't execute it:
# . "$(dirname "$0")/lib.sh" # from within scripts/
# . "$REPO_ROOT/scripts/lib.sh"
#
# Sourcing has no side effects beyond defining functions and REPO_ROOT, and
# pulling in oslib.sh. Targets Alpine/Debian/Alma + bash. Safe to source under
# `set -euo pipefail`.
# Resolve the repository root from this file's location (scripts/lib.sh ->
# repo root is one level up). Works whether sourced by an absolute or
# relative path.
_lib_self="${BASH_SOURCE[0]}"
REPO_ROOT="$(cd "$(dirname "$_lib_self")/.." && pwd)"
export REPO_ROOT
_log() { printf '\033[1;32m[+]\033[0m %s\n' "$*"; }
_warn() { printf '\033[1;33m[!]\033[0m %s\n' "$*" >&2; }
_die() { printf '\033[1;31m[x]\033[0m %s\n' "$*" >&2; exit 1; }
# Pull in the OS abstraction layer (multi-OS ensure_gum, pkg/service helpers,
# os_detect). It reuses the _log/_warn/_die defined above.
# shellcheck source=scripts/oslib.sh
. "$(cd "$(dirname "$_lib_self")" && pwd)/oslib.sh"
# ----------------------------------------------------------------------------
# load_globals -- export shared defaults from globals/globals.env, falling
# back to globals.env.example. Existing environment values win (a var already
# set in the environment is not overwritten), so callers and cloud-init can
# override anything.
# ----------------------------------------------------------------------------
load_globals() {
local f
for f in "$REPO_ROOT/globals/globals.env" "$REPO_ROOT/globals/globals.env.example"; do
[[ -f "$f" ]] || continue
local line key val
while IFS= read -r line || [[ -n "$line" ]]; do
# skip comments and blanks
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
key="${line%%=*}"
val="${line#*=}"
key="${key//[[:space:]]/}"
[[ -n "$key" ]] || continue
# don't clobber values already provided in the environment
[[ -n "${!key:-}" ]] && continue
printf -v "$key" '%s' "$val"
export "${key?}"
done < "$f"
return 0
done
return 0
}
# ensure_gum is provided by oslib.sh (multi-OS: Alpine community repo, or the
# Charm apt/yum repos on Debian/Alma).
# ----------------------------------------------------------------------------
# resolve_ssh_keys -- print the set of admin SSH public keys, one per line.
# URL-preferred: fetch SSH_KEYS_URL when set; otherwise read
# globals/authorized_keys. Comment (#...) and blank lines are stripped from
# both sources.
# ----------------------------------------------------------------------------
resolve_ssh_keys() {
local raw
if [[ -n "${SSH_KEYS_URL:-}" ]]; then
command -v curl >/dev/null 2>&1 || _die "SSH_KEYS_URL is set but curl is unavailable."
raw="$(curl -fsSL "$SSH_KEYS_URL")" || _die "Failed to fetch SSH_KEYS_URL: $SSH_KEYS_URL"
else
local f="$REPO_ROOT/globals/authorized_keys"
[[ -f "$f" ]] || _die "No SSH_KEYS_URL set and $f is missing."
raw="$(cat "$f")"
fi
printf '%s\n' "$raw" | grep -vE '^[[:space:]]*(#|$)'
}
+127
View File
@@ -0,0 +1,127 @@
#!/bin/sh
#
# ntfy-ssh-login.sh -- pam_exec session hook that posts an SSH login event to
# an ntfy topic. POSIX sh (runs under busybox ash on Alpine too).
#
# Installed at /opt/scripts/ntfy-ssh-login.sh and wired into /etc/pam.d/sshd:
# session optional pam_exec.so /opt/scripts/ntfy-ssh-login.sh
#
# Reads /etc/ssh-notify.conf (see ssh-notify.conf.example). It reports:
# - the user and the source IP (PAM_USER / PAM_RHOST)
# - the SSH public key the user authenticated with (fingerprint)
# - the next hop in a ProxyJump path, when discoverable (best-effort)
# - the bastion's region tag, so you know which location it is
# and only fires for users in NOTIFY_GROUPS (if set).
#
# Notes:
# - Key capture needs `ExposeAuthInfo yes` in sshd_config (the harden
# scripts set it); it falls back to parsing the auth log.
# - Jump-target capture is best-effort: a ProxyJump opens a direct-tcpip
# channel (no session), so the target only appears in sshd logs at
# LogLevel VERBOSE/DEBUG. Absent that, it is omitted.
set -eu
CONF="${SSH_NOTIFY_CONF:-/etc/ssh-notify.conf}"
[ -r "$CONF" ] || exit 0
# shellcheck disable=SC1090
. "$CONF"
# Only act on session open, and only if a destination URL is configured.
[ "${PAM_TYPE:-}" = "open_session" ] || exit 0
[ -n "${NTFY_URL:-}" ] || exit 0
user="${PAM_USER:-unknown}"
rhost="${PAM_RHOST:-unknown}"
# ---------------------------------------------------------------------------
# Read the most recent auth-log lines, wherever this distro keeps them.
# ---------------------------------------------------------------------------
read_authlog() {
if command -v journalctl >/dev/null 2>&1; then
journalctl -n 300 --no-pager 2>/dev/null
elif [ -r /var/log/auth.log ]; then tail -n 300 /var/log/auth.log
elif [ -r /var/log/secure ]; then tail -n 300 /var/log/secure
elif [ -r /var/log/messages ]; then tail -n 300 /var/log/messages
fi
}
# ---------------------------------------------------------------------------
# Group / security-level filter. NOTIFY_GROUPS empty => notify for everyone.
# ---------------------------------------------------------------------------
ugroups="$(id -nG "$user" 2>/dev/null || echo '')"
if [ -n "${NOTIFY_GROUPS:-}" ]; then
match=0
for g in $NOTIFY_GROUPS; do
for ug in $ugroups; do [ "$g" = "$ug" ] && match=1 && break; done
[ "$match" = 1 ] && break
done
[ "$match" = 1 ] || exit 0
fi
# Per-group priority override: NOTIFY_PRIORITY_MAP="ssh-admins:high ssh-jumpers:min"
prio="${NTFY_PRIORITY:-min}"
if [ -n "${NOTIFY_PRIORITY_MAP:-}" ]; then
for entry in $NOTIFY_PRIORITY_MAP; do
g="${entry%%:*}"; p="${entry#*:}"
for ug in $ugroups; do [ "$g" = "$ug" ] && prio="$p"; done
done
fi
# ---------------------------------------------------------------------------
# Which SSH key did the user authenticate with?
# ---------------------------------------------------------------------------
keyinfo=""
if [ -n "${SSH_USER_AUTH:-}" ] && [ -r "${SSH_USER_AUTH:-}" ]; then
# Lines look like: publickey ssh-ed25519 AAAA... [comment]
pk="$(awk '$1=="publickey"{print $2" "$3; exit}' "$SSH_USER_AUTH" 2>/dev/null || true)"
if [ -n "$pk" ]; then
# ssh-keygen -l prints: "<bits> SHA256:<fp> <comment...> (<ALGO>)".
# $2 is the fingerprint; $NF is the "(ALGO)" field regardless of comment.
keyinfo="$(printf '%s\n' "$pk" | ssh-keygen -lf - 2>/dev/null | awk '{print $NF" "$2}')"
[ -n "$keyinfo" ] || keyinfo="$(printf '%s' "$pk" | awk '{print $1}')"
fi
fi
if [ -z "$keyinfo" ]; then
# Fallback: the "Accepted publickey for USER ..." auth-log line carries
# the algorithm + SHA256 fingerprint.
line="$(read_authlog | grep "Accepted publickey for $user " | tail -n1 || true)"
keyinfo="$(printf '%s' "$line" | sed -n 's/.*: \([A-Za-z0-9-]*\) \(SHA256:[A-Za-z0-9+/=]*\).*/\1 \2/p')"
fi
[ -n "$keyinfo" ] || keyinfo="(key unknown)"
# ---------------------------------------------------------------------------
# Best-effort: the next hop in a ProxyJump path (direct-tcpip target).
# ---------------------------------------------------------------------------
jump=""
jline="$(read_authlog | grep -i 'direct-tcpip' | grep -F "$rhost" | tail -n1 || true)"
[ -z "$jline" ] && jline="$(read_authlog | grep -i 'direct-tcpip' | tail -n1 || true)"
# Match "... to HOST port PORT" or "... HOST:PORT ...".
jump="$(printf '%s' "$jline" | sed -n 's/.* to \([^ ]*\) port \([0-9]*\).*/\1:\2/p')"
[ -n "$jump" ] || jump="$(printf '%s' "$jline" | grep -oE '[A-Za-z0-9._-]+:[0-9]+' | tail -n1 || true)"
# ---------------------------------------------------------------------------
# Compose and send.
# ---------------------------------------------------------------------------
ts="$(date --utc +%FT%T.%3N%Z 2>/dev/null || date -u +%FT%TZ)"
selfhost="$(hostname -f 2>/dev/null || hostname 2>/dev/null || echo unknown)"
body="SSH login: ${user} from ${rhost}
key: ${keyinfo}"
[ -n "$jump" ] && body="${body}
jump-target: ${jump}"
body="${body}
host: ${selfhost} at ${ts}"
# Build curl args.
set -- -fsS -m 5 \
-H "X-Title: ${NTFY_TITLE:-Bastion Notification}" \
-H "X-Priority: ${prio}"
[ -n "${NTFY_TOKEN:-}" ] && set -- "$@" -H "Authorization: Bearer ${NTFY_TOKEN}"
[ -n "${NTFY_EMAIL:-}" ] && set -- "$@" -H "X-Email: ${NTFY_EMAIL}"
tags="warning"
[ -n "${NTFY_REGION:-}" ] && tags="${tags},${NTFY_REGION}"
set -- "$@" -H "X-Tags: ${tags}"
curl "$@" -d "$body" "$NTFY_URL" >/dev/null 2>&1 || true
exit 0
+520
View File
@@ -0,0 +1,520 @@
#!/usr/bin/env bash
#
# oslib.sh -- OS abstraction layer for Alpine, Debian, and Alma Linux.
#
# Source it; it has no side effects beyond defining functions and, after you
# call os_detect, exporting OS_ID / OS_FAMILY / INIT_SYSTEM.
#
# . "$(dirname "$0")/oslib.sh"
# os_detect
# pkg_install curl jq
#
# Every distro-specific decision lives here, behind a function or a per-OS
# `case "$OS_FAMILY"`. Consumers (harden-ssh.sh, harden-jumphost.sh,
# sshuser.sh, setup-host.sh) should never call apk/apt/dnf, rc-service, or
# systemctl directly -- they call these helpers instead. That keeps the
# OS-specific surface in ONE auditable file.
#
# Supported targets:
# OS_ID OS_FAMILY INIT_SYSTEM package mgr
# ------ --------- ----------- -----------
# alpine alpine openrc apk
# debian debian systemd apt-get (also ubuntu)
# alma rhel systemd dnf (also rocky/rhel/centos)
# Reuse the log helpers if a consumer already defined them; otherwise define.
if ! declare -f _log >/dev/null 2>&1; then
_log() { printf '\033[1;32m[+]\033[0m %s\n' "$*"; }
_warn() { printf '\033[1;33m[!]\033[0m %s\n' "$*" >&2; }
_die() { printf '\033[1;31m[x]\033[0m %s\n' "$*" >&2; exit 1; }
fi
# ============================================================================
# Detection
# ============================================================================
os_detect() {
local id="" like="" osr="${OS_RELEASE_FILE:-/etc/os-release}"
if [[ -r "$osr" ]]; then
# shellcheck disable=SC1091
id="$(. "$osr" 2>/dev/null && echo "${ID:-}")"
like="$(. "$osr" 2>/dev/null && echo "${ID_LIKE:-}")"
elif [[ -f /etc/alpine-release ]]; then
id=alpine
fi
case "$id" in
alpine) OS_ID=alpine; OS_FAMILY=alpine ;;
debian|ubuntu|raspbian) OS_ID=debian; OS_FAMILY=debian ;;
almalinux|alma|rocky|rhel|centos|fedora) OS_ID=alma; OS_FAMILY=rhel ;;
*)
# Fall back to ID_LIKE for derivatives we didn't name explicitly.
case " $like " in
*" alpine "*) OS_ID=alpine; OS_FAMILY=alpine ;;
*" debian "*|*" ubuntu "*) OS_ID=debian; OS_FAMILY=debian ;;
*" rhel "*|*" fedora "*|*" centos "*) OS_ID=alma; OS_FAMILY=rhel ;;
*) _die "Unsupported OS (ID='$id', ID_LIKE='$like'). Supported: Alpine, Debian, Alma." ;;
esac ;;
esac
case "$OS_FAMILY" in
alpine) INIT_SYSTEM=openrc ;;
*) INIT_SYSTEM=systemd ;;
esac
export OS_ID OS_FAMILY INIT_SYSTEM
}
_require_detected() { [[ -n "${OS_FAMILY:-}" ]] || os_detect; }
# ============================================================================
# Packages
# ============================================================================
pkg_update() {
_require_detected
case "$OS_FAMILY" in
alpine) apk update -q ;;
debian) apt-get update -qq ;;
rhel) dnf -q makecache || true ;;
esac
}
pkg_install() { # pkg_install <name>...
_require_detected
case "$OS_FAMILY" in
alpine) apk add -q "$@" ;;
debian) DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$@" ;;
rhel) dnf install -y -q "$@" ;;
esac
}
pkg_remove() { # pkg_remove <name>...
_require_detected
case "$OS_FAMILY" in
alpine) apk del -q "$@" 2>/dev/null || true ;;
debian) DEBIAN_FRONTEND=noninteractive apt-get remove -y -qq "$@" 2>/dev/null || true ;;
rhel) dnf remove -y -q "$@" 2>/dev/null || true ;;
esac
}
pkg_installed() { # pkg_installed <name> -> 0 if installed
_require_detected
case "$OS_FAMILY" in
alpine) apk info -e "$1" >/dev/null 2>&1 ;;
debian) dpkg -s "$1" >/dev/null 2>&1 ;;
rhel) rpm -q "$1" >/dev/null 2>&1 ;;
esac
}
# Logical package-name differences across families. Echo the right name(s).
pkg_name() { # pkg_name <logical>
_require_detected
case "$1" in
openssh-server)
# Alpine: the PAM-enabled variant so the session stack runs.
case "$OS_FAMILY" in alpine) echo openssh-server-pam ;; *) echo openssh-server ;; esac ;;
openssh-client)
case "$OS_FAMILY" in
alpine) echo openssh-client ;;
debian) echo openssh-client ;; # Debian/Ubuntu: singular
rhel) echo openssh-clients ;; # RHEL/Alma: plural
esac ;;
sftp-server)
# The external SFTP subsystem binary's package. Alpine ships it
# separately; Debian/Alma bundle it inside openssh-server.
case "$OS_FAMILY" in alpine) echo openssh-sftp-server ;; *) echo "" ;; esac ;;
*) echo "$1" ;;
esac
}
# ============================================================================
# Services (OpenRC vs systemd)
# ============================================================================
svc_enable() { # enable at boot
_require_detected
case "$INIT_SYSTEM" in
openrc) rc-update add "$1" default >/dev/null 2>&1 || true ;;
systemd) systemctl enable "$1" >/dev/null 2>&1 || true ;;
esac
}
svc_start() { _require_detected; [[ "$INIT_SYSTEM" == openrc ]] && rc-service "$1" start || systemctl start "$1"; }
svc_restart() { _require_detected; [[ "$INIT_SYSTEM" == openrc ]] && { rc-service "$1" restart || rc-service "$1" start; } || systemctl restart "$1"; }
svc_reload() { _require_detected; [[ "$INIT_SYSTEM" == openrc ]] && { rc-service "$1" reload || rc-service "$1" restart; } || { systemctl reload "$1" || systemctl restart "$1"; }; }
svc_enable_start() { svc_enable "$1"; svc_start "$1"; }
# The sshd service is named differently per distro.
sshd_service() {
_require_detected
[[ "$OS_FAMILY" == debian ]] && echo ssh || echo sshd
}
# ============================================================================
# SSH-specific paths
# ============================================================================
# External sftp-server binary path. The user asked for the external subsystem
# (not internal-sftp), and the path differs by distro.
sftp_server_path() {
_require_detected
local p
case "$OS_FAMILY" in
alpine) p=/usr/lib/ssh/sftp-server ;;
debian) p=/usr/lib/openssh/sftp-server ;;
rhel) p=/usr/libexec/openssh/sftp-server ;;
esac
# Verify; fall back to a search so an unusual layout still works.
if [[ ! -x "$p" ]]; then
local found
found="$(command -v sftp-server 2>/dev/null)" || true
[[ -z "$found" ]] && for c in /usr/lib/ssh/sftp-server /usr/lib/openssh/sftp-server \
/usr/libexec/openssh/sftp-server /usr/libexec/sftp-server; do
[[ -x "$c" ]] && { found="$c"; break; }
done
[[ -n "$found" ]] && p="$found"
fi
echo "$p"
}
# After writing sshd_config, Alpine's OpenRC init can regenerate RSA/ECDSA
# host keys on every start. Pin it off. No-op on systemd distros (they only
# generate host keys at install time, via ssh-keygen -A).
sshd_disable_keygen() {
_require_detected
[[ "$OS_FAMILY" == alpine ]] || return 0
[[ -f /etc/conf.d/sshd ]] || return 0
if grep -q '^sshd_disable_keygen=' /etc/conf.d/sshd; then
sed -i 's/^sshd_disable_keygen=.*/sshd_disable_keygen="yes"/' /etc/conf.d/sshd
else
echo 'sshd_disable_keygen="yes"' >> /etc/conf.d/sshd
fi
}
# ============================================================================
# Users & groups (busybox adduser/addgroup vs shadow useradd/groupadd)
# ============================================================================
group_add_system() { # group_add_system <group>
_require_detected
getent group "$1" >/dev/null && return 0
case "$OS_FAMILY" in
alpine) addgroup -S "$1" ;;
*) groupadd -r "$1" ;;
esac
}
user_add_to_group() { # user_add_to_group <user> <group>
_require_detected
case "$OS_FAMILY" in
alpine) addgroup "$1" "$2" 2>/dev/null || adduser "$1" "$2" 2>/dev/null || true ;;
*) usermod -aG "$2" "$1" ;;
esac
}
# Create a no-login user (for ssh jumpers) with the right nologin shell.
user_add_nologin() { # user_add_nologin <name>
_require_detected
getent passwd "$1" >/dev/null && return 0
case "$OS_FAMILY" in
alpine) adduser -D -s /sbin/nologin "$1" ;;
*) useradd -m -s /usr/sbin/nologin "$1" 2>/dev/null \
|| useradd -m -s /sbin/nologin "$1" ;;
esac
}
# Default interactive shell present on the distro (for admin users).
default_shell() {
_require_detected
if [[ -x /bin/bash ]]; then echo /bin/bash
elif [[ "$OS_FAMILY" == alpine ]]; then echo /bin/ash
else echo /bin/sh; fi
}
# Path to nologin (consistent across distros, but verify).
nologin_path() {
for p in /sbin/nologin /usr/sbin/nologin; do [[ -x "$p" ]] && { echo "$p"; return; }; done
echo /sbin/nologin
}
# ============================================================================
# Hostname
# ============================================================================
set_hostname() { # set_hostname <fqdn>
_require_detected
local fqdn="$1" short="${1%%.*}"
if command -v hostnamectl >/dev/null 2>&1; then
hostnamectl set-hostname "$fqdn"
else
# Alpine / no-systemd path.
echo "$short" > /etc/hostname # Alpine stores the short name
hostname "$short" 2>/dev/null || true
command -v rc-service >/dev/null 2>&1 && rc-service hostname restart >/dev/null 2>&1 || true
fi
# Maintain /etc/hosts so `hostname -f` resolves.
_update_etc_hosts "$fqdn" "$short"
}
# Echo the region segment of this host's FQDN (e.g. "us-evi-1" from
# ssh-1.us-evi-1.srvno.de), or nothing when the name carries no region
# (e.g. sto-1.srvno.de). Used to tag login notifications by location.
host_region() {
local fqdn seg
fqdn="$(hostname -f 2>/dev/null || hostname 2>/dev/null || echo)"
seg="$(printf '%s' "$fqdn" | cut -d. -f2)"
printf '%s' "$seg" | grep -qE '^[a-z]{2}-[a-z]{2,4}-[0-9]+$' && printf '%s' "$seg" || printf ''
}
_update_etc_hosts() { # <fqdn> <short>
local fqdn="$1" short="$2"
touch /etc/hosts
# Debian convention: 127.0.1.1 for the box's own FQDN.
local line="127.0.1.1 ${fqdn} ${short}"
if grep -qE '^127\.0\.1\.1[[:space:]]' /etc/hosts; then
sed -i "s|^127\.0\.1\.1[[:space:]].*|${line}|" /etc/hosts
else
printf '%s\n' "$line" >> /etc/hosts
fi
}
# ============================================================================
# Boot hooks -- run a script once at every boot, init-system agnostic.
# Used to (re)install the iptables INPUT->sshguard jump.
# ============================================================================
install_boot_hook() { # install_boot_hook <name> <path-to-script>
_require_detected
local name="$1" src="$2"
case "$INIT_SYSTEM" in
openrc)
install -m 0755 "$src" "/etc/local.d/${name}.start"
rc-update add local default >/dev/null 2>&1 || true
"/etc/local.d/${name}.start" || true ;;
systemd)
install -m 0755 "$src" "/usr/local/sbin/${name}"
cat > "/etc/systemd/system/${name}.service" <<UNIT
[Unit]
Description=${name} (oslib boot hook)
After=network.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/${name}
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable "${name}.service" >/dev/null 2>&1 || true
systemctl start "${name}.service" || true ;;
esac
}
# ============================================================================
# Daily scheduled jobs -- busybox crond (/etc/periodic/daily) on Alpine, a
# systemd timer elsewhere.
# ============================================================================
install_daily_job() { # install_daily_job <name> <script-src> [run-args...]
_require_detected
local name="$1" src="$2"; shift 2
local args="$*"
# The job scripts use bash; ensure it's present (Alpine images often lack it).
command -v bash >/dev/null 2>&1 || pkg_install bash || true
install -m 0755 "$src" "/usr/local/sbin/$name"
# Co-install oslib.sh so a script that sources it still works standalone.
local srcdir; srcdir="$(dirname "$src")"
[[ -f "$srcdir/oslib.sh" ]] && install -m 0644 "$srcdir/oslib.sh" /usr/local/sbin/oslib.sh
case "$INIT_SYSTEM" in
openrc)
command -v crond >/dev/null 2>&1 || pkg_install busybox-suid 2>/dev/null || true
cat > "/etc/periodic/daily/$name" <<HOOK
#!/bin/sh
exec /usr/local/sbin/$name $args
HOOK
chmod +x "/etc/periodic/daily/$name"
rc-update add crond default >/dev/null 2>&1 || true
rc-service crond status >/dev/null 2>&1 || rc-service crond start >/dev/null 2>&1 || true ;;
systemd)
cat > "/etc/systemd/system/$name.service" <<UNIT
[Unit]
Description=$name (daily job)
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/$name $args
UNIT
cat > "/etc/systemd/system/$name.timer" <<UNIT
[Unit]
Description=Run $name daily
[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=1h
[Install]
WantedBy=timers.target
UNIT
systemctl daemon-reload
systemctl enable --now "$name.timer" >/dev/null 2>&1 || true ;;
esac
}
remove_daily_job() { # remove_daily_job <name>
_require_detected
case "$INIT_SYSTEM" in
openrc) rm -f "/etc/periodic/daily/$1" ;;
systemd) systemctl disable --now "$1.timer" >/dev/null 2>&1 || true
rm -f "/etc/systemd/system/$1.timer" "/etc/systemd/system/$1.service"
systemctl daemon-reload ;;
esac
rm -f "/usr/local/sbin/$1"
}
# ============================================================================
# Brute-force protection (sshguard) -- log source & firewall backend differ.
# ============================================================================
# A sshguard LOGREADER line appropriate for the distro's logging.
sshguard_logreader() {
_require_detected
local svc; svc="$(sshd_service)"
case "$OS_FAMILY" in
# Alpine uses busybox syslogd -> /var/log/messages (no journald).
alpine) echo "LANG=C tail -F -n0 /var/log/messages" ;;
# Debian & Alma run systemd-journald.
*) echo "LANG=C journalctl -afb -p info -n1 -u ${svc} -o cat" ;;
esac
}
# ============================================================================
# High-level installers (compose the primitives above; OS knowledge lives here)
# ============================================================================
# Install OpenSSH server + client + the external SFTP subsystem. On Alpine we
# swap the non-PAM server for the PAM-enabled one so the session stack runs.
install_openssh() {
_require_detected
if [[ "$OS_FAMILY" == alpine ]]; then
if pkg_installed openssh-server && ! pkg_installed openssh-server-pam; then
pkg_remove openssh-server
fi
fi
local sftp_pkg; sftp_pkg="$(pkg_name sftp-server)"
# shellcheck disable=SC2046
pkg_install $(pkg_name openssh-server) $(pkg_name openssh-client) ${sftp_pkg:+$sftp_pkg}
# Alpine needs linux-pam present for the PAM server build.
[[ "$OS_FAMILY" == alpine ]] && pkg_install linux-pam openrc
}
# Install sshguard + an iptables firewall backend. On RHEL/Alma sshguard lives
# in EPEL, so enable that first.
install_bruteforce_protection() {
_require_detected
case "$OS_FAMILY" in
alpine) pkg_install sshguard iptables ip6tables ;;
debian) pkg_install sshguard iptables ;;
rhel) pkg_install epel-release || true
pkg_install sshguard iptables ;;
esac
}
# ============================================================================
# gum (Charm TUI) -- multi-OS installer. Best-effort; callers that can fall
# back to CLI prompts should not treat failure as fatal.
# ============================================================================
ensure_gum() {
command -v gum >/dev/null 2>&1 && return 0
_require_detected
case "$OS_FAMILY" in
alpine)
apk add -q gum 2>/dev/null && return 0
# community repo may be disabled; enable it for this release, retry.
local rel mirror
rel="$(cut -d. -f1,2 < /etc/alpine-release 2>/dev/null || echo edge)"
mirror="https://dl-cdn.alpinelinux.org/alpine/v${rel}/community"
grep -qF "$mirror" /etc/apk/repositories 2>/dev/null || echo "$mirror" >> /etc/apk/repositories
apk update -q && apk add -q gum ;;
debian)
# Charm's apt repo.
pkg_install curl gnupg ca-certificates || true
mkdir -p /etc/apt/keyrings
curl -fsSL https://repo.charm.sh/apt/gpg.key | gpg --dearmor -o /etc/apt/keyrings/charm.gpg
echo "deb [signed-by=/etc/apt/keyrings/charm.gpg] https://repo.charm.sh/apt/ * *" \
> /etc/apt/sources.list.d/charm.list
apt-get update -qq && pkg_install gum ;;
rhel)
cat > /etc/yum.repos.d/charm.repo <<'REPO'
[charm]
name=Charm
baseurl=https://repo.charm.sh/yum/
enabled=1
gpgcheck=1
gpgkey=https://repo.charm.sh/yum/gpg.key
REPO
pkg_install gum ;;
esac
command -v gum >/dev/null 2>&1
}
# ============================================================================
# SSH login notifier (pam_exec -> ntfy)
# ============================================================================
# Install the ntfy login hook: drop the script in /opt/scripts, write
# /etc/ssh-notify.conf from the NTFY_* / NOTIFY_* environment, and add the
# pam_exec line to /etc/pam.d/sshd (idempotent). Requires curl.
#
# install_login_notifier <path-to-ntfy-ssh-login.sh>
#
# Honored env: NTFY_URL (required to be useful), NTFY_TOKEN, NTFY_EMAIL,
# NTFY_TITLE, NTFY_PRIORITY, NTFY_REGION, NOTIFY_GROUPS, NOTIFY_PRIORITY_MAP.
# Set NTFY_FORCE_CONF=1 to overwrite an existing /etc/ssh-notify.conf.
install_login_notifier() {
_require_detected
local src="$1"
[[ -f "$src" ]] || { _warn "notifier script not found: $src"; return 1; }
command -v curl >/dev/null 2>&1 || pkg_install curl || _warn "curl not installed; notifier needs it."
install -d -m 0755 /opt/scripts
install -m 0755 "$src" /opt/scripts/ntfy-ssh-login.sh
if [[ ! -f /etc/ssh-notify.conf || "${NTFY_FORCE_CONF:-0}" == "1" ]]; then
( umask 077
cat > /etc/ssh-notify.conf <<CONF
# Generated by oslib install_login_notifier -- $(date -u +%FT%TZ)
NTFY_URL="${NTFY_URL:-}"
NTFY_TOKEN="${NTFY_TOKEN:-}"
NTFY_EMAIL="${NTFY_EMAIL:-}"
NTFY_TITLE="${NTFY_TITLE:-Bastion Notification}"
NTFY_PRIORITY="${NTFY_PRIORITY:-min}"
NTFY_REGION="${NTFY_REGION:-}"
NOTIFY_GROUPS="${NOTIFY_GROUPS:-}"
NOTIFY_PRIORITY_MAP="${NOTIFY_PRIORITY_MAP:-}"
CONF
)
chmod 600 /etc/ssh-notify.conf
_log "Wrote /etc/ssh-notify.conf (mode 0600)."
else
_log "/etc/ssh-notify.conf exists; left untouched (set NTFY_FORCE_CONF=1 to replace)."
fi
# Wire pam_exec into the sshd PAM stack (idempotent).
local pam=/etc/pam.d/sshd
local line='session optional pam_exec.so /opt/scripts/ntfy-ssh-login.sh'
if [[ -f "$pam" ]]; then
grep -qF '/opt/scripts/ntfy-ssh-login.sh' "$pam" || echo "$line" >> "$pam"
_log "Enabled pam_exec login notifier in $pam."
else
_warn "$pam not found; add this line to your sshd PAM stack manually:"
_warn " $line"
fi
}
# Locate the sshguard iptables backend binary (path varies by packaging).
sshguard_backend() {
local c
for c in /usr/libexec/sshguard/sshg-fw-iptables \
/usr/libexec/sshg-fw-iptables \
/usr/lib/sshguard/sshg-fw-iptables \
/usr/libexec/sshguard/sshg-fw-nft \
/usr/sbin/sshg-fw-iptables; do
[[ -x "$c" ]] && { echo "$c"; return; }
done
# Sensible default if nothing matched; caller may warn.
echo /usr/libexec/sshg-fw-iptables
}
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env bash
#
# setup-host.sh -- set a host's name (per the Network Domain Name Schema) and
# install the shared SSH MOTD banner. Works on Alpine, Debian, and Alma via
# oslib.sh.
#
# Hostnames follow globals/Network Domain Name Schema.md. Our VMs skip the
# region code and use srvno.de as the base, so the form is:
# <service>-<n>.srvno.de e.g. sto-1.srvno.de
# from which we derive:
# Node ID = <SERVICE>-<N> e.g. STO-1 (short name, uppercased)
#
# The MOTD is rendered from globals/motd.txt. You edit the *content* there;
# this script draws the borders and computes every bit of padding, so the box
# stays aligned regardless of value length (the spacing problem, solved).
#
# Usage:
# bash setup-host.sh sto-1 # -> sto-1.srvno.de, Node ID STO-1
# HOST=dns-1 bash setup-host.sh
# HOST=web-2.srvno.de bash setup-host.sh # full FQDN accepted too
# BASE_DOMAIN=example.net HOST=app-1 bash setup-host.sh
# DATACENTER="Stockholm SE" HOST=sto-1 bash setup-host.sh
#
# Env:
# HOST required short name (sto-1) or FQDN (sto-1.srvno.de)
# BASE_DOMAIN srvno.de appended when HOST has no dot
# DATACENTER "Globally Everywhere" shown in the MOTD
# MOTD_TEMPLATE <repo>/globals/motd.txt
# MOTD_WIDTH 60 inner width of the MOTD box
# SET_HOSTNAME 1 set to 0 to render MOTD only (skip hostname)
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=scripts/oslib.sh
. "$REPO_ROOT/scripts/oslib.sh"
: "${HOST:=${1:-}}"
: "${BASE_DOMAIN:=srvno.de}"
: "${DATACENTER:=Globally Everywhere}"
: "${MOTD_TEMPLATE:=$REPO_ROOT/globals/motd.txt}"
: "${MOTD_WIDTH:=60}"
: "${MOTD_OUT:=/etc/motd}"
: "${SET_HOSTNAME:=1}"
[[ $EUID -eq 0 ]] || _die "Run as root."
[[ -n "$HOST" ]] || _die "Set HOST=<service>-<n> (e.g. sto-1) or a full FQDN."
os_detect
# ----------------------------------------------------------------------------
# Derive FQDN / short name / Node ID from HOST
# ----------------------------------------------------------------------------
if [[ "$HOST" == *.* ]]; then
FQDN="$HOST"; SHORT="${HOST%%.*}"
else
SHORT="$HOST"; FQDN="$HOST.$BASE_DOMAIN"
fi
NODE_ID="$(printf '%s' "$SHORT" | tr '[:lower:]' '[:upper:]')"
# Light sanity check against the schema (svc code + instance number). Warn
# only -- we don't want to block an intentional exception.
[[ "$SHORT" =~ ^[a-z]{2,4}-[0-9]+$ ]] \
|| _warn "Short name '$SHORT' doesn't match <svc>-<n> (see globals/Network Domain Name Schema.md)."
_log "Host: $FQDN"
_log "Node ID: $NODE_ID"
_log "Data Cntr: $DATACENTER"
# ----------------------------------------------------------------------------
# Set hostname (OS-gated inside oslib)
# ----------------------------------------------------------------------------
if [[ "$SET_HOSTNAME" == "1" ]]; then
_log "Setting hostname to $FQDN ..."
set_hostname "$FQDN"
fi
# ============================================================================
# MOTD renderer
# ============================================================================
# All padding is computed in ASCII (spaces, labels, values are ASCII so
# ${#str} is the column count). Box-drawing glyphs are only ever emitted by
# repetition, never measured -- so this is correct under any shell/locale.
W="$MOTD_WIDTH"
_spaces() { printf '%*s' "$1" ''; } # N ASCII spaces
_repeat() { local i; for ((i=0;i<$2;i++)); do printf '%s' "$1"; done; } # glyph xN
# Bordered content line: ┃ + <inner W cols> + ┃
_line() { printf '┃%s┃\n' "$1"; }
# Center an ASCII string within W.
_center() {
local s="$1" len=${#1} pad left
(( len > W )) && { s="${s:0:W}"; len=$W; }
pad=$(( W - len )); left=$(( pad / 2 ))
_line "$(_spaces "$left")$s$(_spaces $(( pad - left )))"
}
# Center a pre-built string of KNOWN visible width vis (may contain glyphs).
_center_known() {
local s="$1" vis="$2" pad left
pad=$(( W - vis )); left=$(( pad / 2 ))
_line "$(_spaces "$left")$s$(_spaces $(( pad - left )))"
}
# Field line: left margin, right-aligned label, ": ", value, pad to W.
LM=8 # left margin before the label column
_field() {
local label="$1" value="$2" content
content="$(printf '%*s%*s: %s' "$LM" '' "$LABELW" "$label" "$value")"
local len=${#content}
(( len > W )) && { content="${content:0:W}"; len=$W; }
_line "$content$(_spaces $(( W - len )))"
}
# Mini double-line box around centered ASCII text, itself centered in W.
_inner_box() {
local text="$1" tw=$(( ${#1} + 2 )) vis
vis=$(( tw + 2 )) # ║ + text(+2 spaces) + ║
_center_known "$(_repeat '═' "$tw")" "$vis"
_center_known "$text" "$vis"
_center_known "$(_repeat '═' "$tw")" "$vis"
}
render_motd() {
# First pass: compute LABELW (max @F@ label width) so colons align.
LABELW=0
local line tok rest label
while IFS= read -r line || [[ -n "$line" ]]; do
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ "$line" == "@F@ "* ]] || continue
rest="${line#@F@ }"; label="${rest%%|*}"
(( ${#label} > LABELW )) && LABELW=${#label}
done < "$MOTD_TEMPLATE"
# Second pass: emit. Borders come from the @TOP@/@HR@/@BOT@ tokens.
while IFS= read -r line || [[ -n "$line" ]]; do
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ -z "$line" ]] && continue
tok="${line%% *}"; rest="${line#"$tok"}"; rest="${rest# }"
# substitute placeholders
rest="${rest//\{\{HOSTNAME\}\}/$FQDN}"
rest="${rest//\{\{NODE_ID\}\}/$NODE_ID}"
rest="${rest//\{\{DATACENTER\}\}/$DATACENTER}"
case "$tok" in
@TOP@) printf '┏%s┓\n' "$(_repeat '━' "$W")" ;;
@BOT@) printf '┗%s┛\n' "$(_repeat '━' "$W")" ;;
@HR@) printf '┣%s┫\n' "$(_repeat '━' "$W")" ;;
@BLANK@) _line "$(_spaces "$W")" ;;
@C@) _center "$rest" ;;
@BOX@) _inner_box "$rest" ;;
@F@) _field "${rest%%|*}" "${rest#*|}" ;;
*) _warn "Unknown MOTD token: $tok (skipped)" ;;
esac
done < "$MOTD_TEMPLATE"
}
[[ -f "$MOTD_TEMPLATE" ]] || _die "MOTD template not found: $MOTD_TEMPLATE"
_log "Rendering MOTD -> $MOTD_OUT"
render_motd > "$MOTD_OUT"
chmod 644 "$MOTD_OUT" 2>/dev/null || true
_log "Done."
echo
cat "$MOTD_OUT"
+31
View File
@@ -0,0 +1,31 @@
# ssh-notify.conf -- config for /opt/scripts/ntfy-ssh-login.sh (the pam_exec
# SSH-login notifier). Installed at /etc/ssh-notify.conf (mode 0600 -- it may
# hold a token). The harden scripts generate it from NTFY_* env vars; this is
# the reference of every key.
# Destination ntfy topic URL. REQUIRED -- the notifier is a no-op if empty.
NTFY_URL="https://msg-1.srvno.de/canary"
# Bearer token for publishing. Optional: leave empty if the topic allows
# unauthenticated publish (e.g. read-gated). Kept here so it stays out of the
# script and the file can be 0600.
NTFY_TOKEN=""
# Optional ntfy headers.
NTFY_EMAIL="sysadmin@example.com"
NTFY_TITLE="Bastion Notification"
NTFY_PRIORITY="min"
# Region tag added to X-Tags so you can tell which bastion/location fired the
# alert (e.g. us-evi-1). The harden scripts default this to the region segment
# of the host's own FQDN when present.
NTFY_REGION="us-evi-1"
# --- Who to notify for (security-level filter) ---
# Space-separated group list: only notify when the logging-in user belongs to
# one of these groups. Empty => notify for every login.
NOTIFY_GROUPS="ssh-admins ssh-jumpers"
# Optional per-group priority overrides: "group:priority" entries. Lets you,
# e.g., page loudly for admins but whisper for routine jumpers.
NOTIFY_PRIORITY_MAP="ssh-admins:high ssh-jumpers:min"
+50 -16
View File
@@ -1,10 +1,14 @@
#!/usr/bin/env bash
#
# sshuser -- manage SSH users on a hardened Alpine box.
# sshuser -- manage SSH users on a hardened box (Alpine, Debian, or Alma).
#
# Two roles, matching harden-jumphost.sh:
# admin -> group ssh-admins, shell /bin/ash (full shell)
# jumper -> group ssh-jumpers, shell /sbin/nologin (ProxyJump only)
# admin -> group ssh-admins, interactive shell (full shell)
# jumper -> group ssh-jumpers, nologin shell (ProxyJump only)
#
# The interactive shell and nologin paths, and the user-management commands,
# differ per distro -- this script detects the OS and adapts (it's installed
# standalone, so it can't share scripts/oslib.sh).
#
# Two modes:
# - TUI (gum) : run with no command, or any command with missing args
@@ -12,7 +16,7 @@
#
# Install:
# install -m 0755 sshuser.sh /usr/local/bin/sshuser
# apk add gum # only needed for TUI mode
# gum is only needed for TUI mode (apk add gum / apt install gum / dnf install gum)
#
# Usage:
# sshuser # interactive TUI
@@ -30,8 +34,40 @@ set -euo pipefail
ADMIN_GROUP="ssh-admins"
JUMPER_GROUP="ssh-jumpers"
ADMIN_SHELL="/bin/ash"
# ---------------------------------------------------------------------------
# OS detection + per-distro user-management primitives. (Standalone install,
# so we can't source oslib.sh -- this is the minimal slice we need.)
# ---------------------------------------------------------------------------
_OS_FAMILY=alpine
if [[ -r /etc/os-release ]]; then
_osid="$(. /etc/os-release 2>/dev/null && echo "${ID:-}")"
_oslike="$(. /etc/os-release 2>/dev/null && echo "${ID_LIKE:-}")"
case " ${_osid} ${_oslike} " in
*" debian "*|*" ubuntu "*) _OS_FAMILY=debian ;;
*" rhel "*|*" fedora "*|*" centos "*|*" almalinux "*|*" rocky "*) _OS_FAMILY=rhel ;;
*" alpine "*) _OS_FAMILY=alpine ;;
esac
fi
# Admin (interactive) shell: ash on Alpine, bash elsewhere.
case "$_OS_FAMILY" in
alpine) ADMIN_SHELL="/bin/ash" ;;
*) ADMIN_SHELL="/bin/bash" ;;
esac
[[ -x "$ADMIN_SHELL" ]] || ADMIN_SHELL="/bin/sh"
# Nologin path: /usr/sbin/nologin on Debian, /sbin/nologin on Alpine/Alma.
JUMPER_SHELL="/sbin/nologin"
[[ -x "$JUMPER_SHELL" ]] || for _p in /usr/sbin/nologin /sbin/nologin; do
[[ -x "$_p" ]] && { JUMPER_SHELL="$_p"; break; }
done
user_create() { case "$_OS_FAMILY" in alpine) adduser -D -s "$2" -g "" "$1";; *) useradd -m -s "$2" "$1";; esac; }
user_join_group() { case "$_OS_FAMILY" in alpine) adduser "$1" "$2";; *) usermod -aG "$2" "$1";; esac; }
user_leave_group() { case "$_OS_FAMILY" in alpine) deluser "$1" "$2" 2>/dev/null || true;; *) gpasswd -d "$1" "$2" 2>/dev/null || true;; esac; }
user_delete() { case "$_OS_FAMILY" in alpine) deluser --remove-home "$1" 2>/dev/null || deluser "$1";; *) userdel -r "$1" 2>/dev/null || userdel "$1";; esac; }
set_user_shell() { usermod -s "$2" "$1" 2>/dev/null || chsh -s "$2" "$1" 2>/dev/null \
|| sed -i "s|^\($1:.*:\)[^:]*$|\1$2|" /etc/passwd; }
# ---------------------------------------------------------------------------
# Helpers
@@ -147,8 +183,8 @@ cmd_add() {
confirm "Create user $user as $role (group $group, shell $shell)?" || { warn "Aborted."; exit 1; }
log "Creating $user..."
adduser -D -s "$shell" -g "" "$user"
adduser "$user" "$group"
user_create "$user" "$shell"
user_join_group "$user" "$group"
if [[ -n "$key" ]]; then
local ak; ak=$(ssh_dir_setup "$user")
@@ -184,7 +220,7 @@ cmd_edit() {
"add ssh key") ADD_KEY_ARG=$(ask "Paste SSH public key") ;;
"remove ssh key") REMOVE_KEY_ARG=$(ask "Substring of key to remove (comment is fine)") ;;
"change role") ROLE_ARG=$(choose "New role" admin jumper) ;;
"change shell") SHELL_ARG=$(ask "New shell" "/bin/ash") ;;
"change shell") SHELL_ARG=$(ask "New shell" "$ADMIN_SHELL") ;;
*) warn "Cancelled."; return 0 ;;
esac
fi
@@ -195,16 +231,14 @@ cmd_edit() {
# Remove from the other ssh group, add to the target.
local other
[[ "$new_group" == "$ADMIN_GROUP" ]] && other="$JUMPER_GROUP" || other="$ADMIN_GROUP"
deluser "$user" "$other" 2>/dev/null || true
adduser "$user" "$new_group" 2>/dev/null || true
usermod -s "$new_shell" "$user" 2>/dev/null || \
sed -i "s|^\($user:.*:\)[^:]*$|\1$new_shell|" /etc/passwd
user_leave_group "$user" "$other"
user_join_group "$user" "$new_group"
set_user_shell "$user" "$new_shell"
fi
if [[ -n "${SHELL_ARG:-}" ]]; then
log "Setting $user shell to $SHELL_ARG"
usermod -s "$SHELL_ARG" "$user" 2>/dev/null || \
sed -i "s|^\($user:.*:\)[^:]*$|\1$SHELL_ARG|" /etc/passwd
set_user_shell "$user" "$SHELL_ARG"
fi
if [[ -n "${ADD_KEY_ARG:-}" ]]; then
@@ -238,7 +272,7 @@ cmd_remove() {
confirm "DELETE user $user and their home directory?" || { warn "Aborted."; exit 1; }
log "Deleting $user..."
deluser --remove-home "$user" 2>/dev/null || deluser "$user"
user_delete "$user"
log "Done."
}
@@ -294,7 +328,7 @@ cmd_show() {
}
cmd_tui() {
have_gum || err "TUI mode requires gum: 'apk add gum'. Or use CLI flags (sshuser --help)."
have_gum || err "TUI mode requires gum (apk/apt/dnf install gum). Or use CLI flags (sshuser --help)."
local action
action=$(choose "What do you want to do?" \
"add user" "edit user" "remove user" "list users" "show user" "quit")