Files
automations/scripts/oslib.sh
T
57_WolveandClaude Opus 5 a3843d3d85 fix(ssh): build KexAlgorithms from what OpenSSH supports, add classic opt-in
Hardened hosts rejected clients that implement the very same key exchange. The
KEX list was assembled from version arithmetic and emitted only the
standardised spellings:

    KexAlgorithms mlkem768x25519-sha256,sntrup761x25519-sha512

OpenSSH called that hybrid sntrup761x25519-sha512@openssh.com before the method
was standardised (8.5, in the default proposal from 8.9) and
sntrup761x25519-sha512 after (9.9), and KEXINIT matches names byte-exactly with
no alias resolution -- so every client older than the rename was refused with
"no matching key exchange method found" despite implementing the algorithm. The
same arithmetic was a latent server-side bug: on OpenSSH 9.0-9.8 it wrote the
post-standardisation name into sshd_config, which those builds do not know, and
sshd fatals on an unknown KexAlgorithms token rather than starting.

Ask the binary instead of guessing. oslib gains kex_supported(),
ssh_kex_pq_list(), ssh_kex_classic_list(), ssh_kex_list() and ssh_kex_has_pq(),
which filter candidates through `ssh -Q kex` and offer every spelling the host
actually has. Version thresholds are gone, and with them both failure modes --
including on distros whose backports make the version string meaningless.

SSH_ALLOW_CLASSIC_KEX=1 (off by default) additionally offers curve25519-sha256
and its @libssh.org spelling. Some clients have no PQ method at all: notably
Windows' in-box ssh.exe, which is not merely old -- Microsoft's fork compiles
sntrup761 out because it needs C99 VLAs that MSVC lacks, so even a fully patched
9.5p2 reports zero PQ methods. The knob is a real trade and says so in the
warning, the generated sshd_config comment, and the README: such a session is
safe against a classical attacker but has no store-now-decrypt-later protection.
Modern clients still negotiate PQ, since the client's preference order decides.

Three defects found reviewing the above, fixed here:

- the printed pre-reload verification command pinned the server's full list via
  `-o KexAlgorithms=`, which ssh rejects at option-parse time when the client
  lacks any one name. That made the one safety gate before a wholesale
  sshd_config swap a false negative for exactly the clients this commit admits.
  Dropped, matching harden-jumphost.sh.
- the no-PQ branch was unreachable: without the opt-in the classical names are
  never collected, so a host with no PQ hybrid died reporting "no usable key
  exchange method" instead of the actionable message written for it. The branch
  now keys off a separate PQ probe, and the empty-list die is narrowed to a
  genuinely empty `ssh -Q kex`.
- SSH_VER is cosmetic but its grep could abort the whole run under pipefail on
  any banner that does not match (vendor forks, OpenSSH_for_Windows_9.5p2) --
  silently, with no message. Guarded.

Wired through cloud-init/base.yml and jumphost.yml, since harden-ssh.sh rewrites
sshd_config wholesale on every run and a hand edit there does not survive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 22:03:07 -05:00

813 lines
34 KiB
Bash

#!/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; }
# True on a Proxmox host that ships pve-firewall -- Proxmox VE and Proxmox Mail
# Gateway. Both are Debian underneath, so os_detect reports debian/systemd and
# every other helper here is correct for them; the ONE thing that is not is the
# firewall. pve-firewall owns the host ruleset, and while it leaves foreign rules
# alone (it restores with --noflush and touches only its own PVEFW-* chains), a
# deny-by-default INPUT chain underneath it overrides the accepts it RETURNs on
# and cuts the web UI, corosync and migration traffic -- and our boot-time
# iptables-restore would wipe its hook outright. harden-firewall.sh therefore
# skips these hosts -- see the Proxmox backend there for the full reasoning.
#
# Proxmox Backup Server ships no firewall of its own and is deliberately NOT
# matched: it is a plain Debian host as far as we are concerned.
is_proxmox() {
[[ -x /usr/sbin/pve-firewall || -x /usr/bin/pveversion || -d /etc/pve/nodes ]]
}
# ============================================================================
# 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_disable() { # stop it being started at boot (the mirror of svc_enable)
_require_detected
case "$INIT_SYSTEM" in
openrc) rc-update del "$1" default >/dev/null 2>&1 || true ;;
systemd) systemctl disable "$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 key exchange -- post-quantum hybrid, with an opt-in classical fallback.
# ============================================================================
# Build the KexAlgorithms list from what THIS OpenSSH build actually supports,
# asked via `ssh -Q kex`, instead of inferring it from a version number. The
# same algorithm has two spellings -- OpenSSH used
# sntrup761x25519-sha512@openssh.com before the method was standardised and
# sntrup761x25519-sha512 after -- and guessing wrong breaks in both directions:
# a name the local sshd does not know is a fatal sshd_config error, while a name
# the CLIENT does not know is an "Unable to negotiate ... no matching key
# exchange method found" lockout even though both ends implement the algorithm.
# Offering every spelling this host supports costs nothing and avoids both.
#
# SSH_ALLOW_CLASSIC_KEX=1 additionally offers curve25519-sha256 (and its older
# @libssh.org spelling): ordinary X25519 ECDH, secure against a classical
# attacker but with NO post-quantum protection. It exists for clients too old
# for any PQ method -- notably the ssh.exe bundled with Windows, which has none
# -- and the callers warn when it is on.
kex_supported() { # kex_supported <algorithm-name>
ssh -Q kex 2>/dev/null | grep -qxF "$1"
}
# The post-quantum hybrids this host supports, every spelling it has. Empty when
# this OpenSSH has none -- pre-8.5, or a build with sntrup761 compiled out (the
# Microsoft fork does exactly that: it needs C99 VLAs, which MSVC lacks).
ssh_kex_pq_list() {
local list="" k
for k in mlkem768x25519-sha256 \
sntrup761x25519-sha512 \
sntrup761x25519-sha512@openssh.com; do
if kex_supported "$k"; then list="${list:+$list,}$k"; fi
done
printf '%s\n' "$list"
}
# The classical fallback. Offered only when SSH_ALLOW_CLASSIC_KEX=1.
ssh_kex_classic_list() {
local list="" k
for k in curve25519-sha256 curve25519-sha256@libssh.org; do
if kex_supported "$k"; then list="${list:+$list,}$k"; fi
done
printf '%s\n' "$list"
}
# The KexAlgorithms value itself: PQ first, classical appended only on request.
ssh_kex_list() {
local pq classic=""
pq="$(ssh_kex_pq_list)"
if [[ "${SSH_ALLOW_CLASSIC_KEX:-0}" == "1" ]]; then classic="$(ssh_kex_classic_list)"; fi
if [[ -n "$pq" && -n "$classic" ]]; then
printf '%s,%s\n' "$pq" "$classic"
else
printf '%s\n' "${pq}${classic}"
fi
}
ssh_kex_has_pq() { # ssh_kex_has_pq <list>
case "$1" in *mlkem*|*sntrup*) return 0 ;; *) return 1 ;; esac
}
# ============================================================================
# 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
}
# ----------------------------------------------------------------------------
# Alpine keeps PAM support in a SEPARATE binary.
#
# openssh-server -> /usr/sbin/sshd (built WITHOUT PAM)
# openssh-server-pam -> /usr/sbin/sshd.pam (built WITH PAM)
#
# The OpenRC init picks between them in start_pre (checkconfig -> update_command:
# "sshd.pam if it is executable and the config says UsePAM yes"), and its
# reload/stop match the running process with `start-stop-daemon --exec "$command"`.
# So a host that is ALREADY running /usr/sbin/sshd never swaps over on its own:
# reload signals a process that doesn't match (nothing happens), stop matches
# nothing, and a follow-up start hits "address already in use". The daemon keeps
# serving without PAM -- which means /etc/pam.d/sshd, and therefore the pam_exec
# login notifier, is silently never consulted. Every other distro builds PAM into
# the one sshd binary, so this is Alpine-only.
# ----------------------------------------------------------------------------
sshd_wanted_binary() { # echo the sshd binary this host's config should be running
_require_detected
if [[ "$OS_FAMILY" == alpine ]] && [[ -x /usr/sbin/sshd.pam ]] && grep -qiE '^[[:space:]]*UsePAM[[:space:]]+yes' /etc/ssh/sshd_config 2>/dev/null; then
echo /usr/sbin/sshd.pam
else
echo /usr/sbin/sshd
fi
}
sshd_running_binary() { # echo the executable behind the running sshd master ('' if unknown)
local pid="" p
if [[ -r /run/sshd.pid ]]; then pid="$(cat /run/sshd.pid 2>/dev/null || true)"; fi
if [[ -z "$pid" ]]; then
for p in sshd.pam sshd; do
pid="$(pgrep -x "$p" 2>/dev/null | head -n1 || true)"
[[ -n "$pid" ]] && break
done
fi
[[ -n "$pid" ]] || return 0
readlink -f "/proc/$pid/exe" 2>/dev/null || true
}
# Apply a freshly written sshd_config. Normally a reload (keeps connections).
# On Alpine, when the running binary is not the one the config calls for, the
# service is stopped by pidfile and started again so the PAM build takes over --
# established sessions are separate processes and survive; only the listener
# blinks.
sshd_apply_config() {
_require_detected
local svc; svc="$(sshd_service)"
if [[ "$OS_FAMILY" == alpine ]]; then
local want run pid i
want="$(sshd_wanted_binary)"
run="$(sshd_running_binary)"
if [[ -n "$run" && "$run" != "$want" ]]; then
_warn "sshd is running $run, but this config needs $want -- restarting to swap it in."
_warn "(Existing SSH sessions survive; the listener is down for about a second.)"
pid=""
if [[ -r /run/sshd.pid ]]; then pid="$(cat /run/sshd.pid 2>/dev/null || true)"; fi
rc-service "$svc" stop >/dev/null 2>&1 || true
if [[ -n "$pid" ]]; then
kill "$pid" 2>/dev/null || true
i=0
while kill -0 "$pid" 2>/dev/null && [[ "$i" -lt 10 ]]; do sleep 1; i=$((i + 1)); done
kill -9 "$pid" 2>/dev/null || true
fi
rm -f /run/sshd.pid
svc_start "$svc" || _die "sshd failed to start as $want. Check: rc-service $svc start"
_log "sshd restarted as $(sshd_running_binary)."
return 0
fi
fi
svc_reload "$svc"
}
# ============================================================================
# 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 -d -m 0755 /usr/local/sbin
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
# Alpine's baselayout ships /usr/local/{bin,lib,share} but NOT sbin.
install -d -m 0755 /usr/local/sbin
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} || return 1
# Alpine needs linux-pam present for the PAM server build. Use an if-block,
# NOT `[[ ... ]] && ...`: as the LAST statement, that trailing test makes the
# whole function exit 1 on every non-Alpine OS (a false `[[ ]]` returns 1) --
# harmless to a bare call under `set -e`, but a caller guarding with `|| die`
# reads it as an OpenSSH install failure. The `|| return 1` above still
# surfaces a real package failure.
if [[ "$OS_FAMILY" == alpine ]]; then
pkg_install linux-pam openrc
fi
}
# Install sshguard + an iptables firewall backend. On RHEL/Alma sshguard lives
# in EPEL, so enable that first. The iptables backend is installed best-effort
# FIRST (it's usually already present as iptables-nft), then sshguard, and the
# function RETURNS sshguard's status -- so a caller can treat a sshguard miss
# (e.g. EPEL momentarily unreachable) as non-fatal and still apply the rest of
# the hardening instead of aborting the whole run.
install_bruteforce_protection() {
_require_detected
case "$OS_FAMILY" in
alpine) pkg_install iptables ip6tables || true
pkg_install sshguard ;;
debian) pkg_install iptables || true
pkg_install sshguard ;;
rhel) pkg_install iptables || true # el9+: provided by iptables-nft
pkg_install epel-release || true # sshguard lives in EPEL
pkg_install sshguard ;;
esac
}
# ============================================================================
# Host firewall -- native iptables persistence (NO boot hook).
# ============================================================================
# Each family ships a package that saves the live ruleset to disk and restores
# it at boot. harden-firewall.sh builds the INPUT rules live, then persists via
# fw_save_cmd and enables boot restore via fw_enable_restore -- so the firewall
# survives reboot without any custom boot hook.
#
# family persistence package save target(s) restore svc
# alpine iptables ip6tables /etc/iptables/rules{,6}-save iptables, ip6tables
# debian iptables-persistent /etc/iptables/rules.v{4,6} netfilter-persistent
# rhel iptables-services /etc/sysconfig/{,ip6}tables iptables, ip6tables
# Install iptables + the family's native persistence package.
install_iptables() {
_require_detected
case "$OS_FAMILY" in
alpine) pkg_install iptables ip6tables ;;
debian)
# iptables-persistent's postinst asks (debconf) whether to save the
# CURRENT rules on install. Preseed "no" so it never blocks on a
# prompt; harden-firewall.sh saves explicitly once its rules are up.
echo 'iptables-persistent iptables-persistent/autosave_v4 boolean false' | debconf-set-selections 2>/dev/null || true
echo 'iptables-persistent iptables-persistent/autosave_v6 boolean false' | debconf-set-selections 2>/dev/null || true
pkg_install iptables iptables-persistent ;;
rhel) pkg_install iptables-services ;;
esac
}
# Echo the family's native "persist the live ruleset to disk" command. This is
# baked verbatim into the generated /usr/local/sbin/firewall-apply so the saved
# rules survive reboot even on a host that never sees this repo again.
fw_save_cmd() {
_require_detected
case "$OS_FAMILY" in
alpine) echo 'rc-service iptables save >/dev/null 2>&1 || true; rc-service ip6tables save >/dev/null 2>&1 || true' ;;
debian) echo 'netfilter-persistent save >/dev/null 2>&1 || true' ;;
rhel) echo 'iptables-save > /etc/sysconfig/iptables 2>/dev/null || true; ip6tables-save > /etc/sysconfig/ip6tables 2>/dev/null || true' ;;
esac
}
# The service(s) that restore the saved ruleset at boot, and the file(s) they
# restore from -- the inverse of fw_save_cmd. Undoing the firewall on a host that
# should never have had it means disabling the former and clearing the latter
# (harden-firewall.sh's Proxmox `disable`), so name them once, here.
fw_restore_services() {
_require_detected
case "$OS_FAMILY" in
alpine) echo "iptables ip6tables" ;;
debian) echo "netfilter-persistent" ;;
rhel) echo "iptables ip6tables" ;;
esac
}
fw_saved_files() {
_require_detected
case "$OS_FAMILY" in
alpine) echo "/etc/iptables/rules-save /etc/iptables/rules6-save" ;;
debian) echo "/etc/iptables/rules.v4 /etc/iptables/rules.v6" ;;
rhel) echo "/etc/sysconfig/iptables /etc/sysconfig/ip6tables" ;;
esac
}
# Enable the family's native boot-time restore service(s). Rules are already
# live when this runs, so we only need them re-applied on the NEXT boot --
# enable, don't start.
fw_enable_restore() {
local s
for s in $(fw_restore_services); do svc_enable "$s"; done
}
# Install + enable firewalld (the native RHEL/Alma firewall). harden-firewall.sh
# uses this on the rhel family instead of raw iptables; sshguard then blocks via
# the sshguard-firewalld backend (no INPUT->sshguard jump, no boot hook).
install_firewalld() {
_require_detected
command -v firewall-cmd >/dev/null 2>&1 || pkg_install firewalld
svc_enable firewalld
# Must be running before we push --permanent rules and --reload.
firewall-cmd --state >/dev/null 2>&1 || svc_start firewalld || true
}
# ============================================================================
# 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:-}"
# Set to 1 to log every delivery attempt (and curl errors) to /var/log/ssh-notify.log.
NTFY_DEBUG="${NTFY_DEBUG:-0}"
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
# Verify the hook actually landed and report loudly. A notifier that fails to
# install silently is worse than none -- you'd believe logins are watched
# when they aren't (exactly the trap that hid this on the first Alma run).
# "Wired into /etc/pam.d/sshd" is not the same as "will fire": on Alpine the
# PAM stack is only read when sshd IS the PAM build (see sshd_wanted_binary).
# Check that too, or we'd report ACTIVE for a hook that can never run.
if [[ "${OS_FAMILY:-}" == alpine ]]; then
if [[ ! -x /usr/sbin/sshd.pam ]]; then
_warn "Alpine: /usr/sbin/sshd.pam is missing, so /etc/pam.d/sshd is never read and this notifier cannot fire."
_warn " Fix: apk add openssh-server-pam && rc-service $(sshd_service) restart"
elif ! grep -qiE '^[[:space:]]*UsePAM[[:space:]]+yes' /etc/ssh/sshd_config 2>/dev/null; then
_warn "Alpine: sshd_config lacks 'UsePAM yes', so the PAM build is never selected and this notifier cannot fire."
fi
fi
if [[ -x /opt/scripts/ntfy-ssh-login.sh ]] \
&& grep -qF '/opt/scripts/ntfy-ssh-login.sh' "$pam" 2>/dev/null; then
_log "Login notifier ACTIVE -> ${NTFY_URL:-<NTFY_URL unset!>}"
return 0
fi
_warn "Login notifier did NOT fully install (script or pam hook missing)."
return 1
}
# Locate the sshguard firewall-backend binary (path varies by packaging).
# On RHEL/Alma we run firewalld, so prefer sshguard's firewalld backend
# (from the sshguard-firewalld package) -- blocks land in firewalld and there
# is no iptables INPUT->sshguard jump to maintain.
sshguard_backend() {
_require_detected
local c
if [[ "${OS_FAMILY:-}" == rhel ]]; then
for c in /usr/libexec/sshguard/sshg-fw-firewalld \
/usr/lib/sshguard/sshg-fw-firewalld; do
[[ -x "$c" ]] && { echo "$c"; return; }
done
# firewalld backend expected but not found yet; name it anyway so
# sshguard.conf points at the right binary once the package is in.
echo /usr/libexec/sshguard/sshg-fw-firewalld
return
fi
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
}