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>
813 lines
34 KiB
Bash
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
|
|
}
|