#!/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 ... _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 ... _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 -> 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 _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 _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 _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 _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 _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() { # 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 _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" </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 [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" </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" < "/etc/systemd/system/$name.timer" </dev/null 2>&1 || true ;; esac } remove_daily_job() { # remove_daily_job _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 } # 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() { _require_detected case "$OS_FAMILY" in alpine) svc_enable iptables; svc_enable ip6tables ;; debian) svc_enable netfilter-persistent ;; rhel) svc_enable iptables; svc_enable ip6tables ;; esac } # 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 # # 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 <> "$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). 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:-}" 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 }