Files
57_Wolve 60433e4c8d fix(harden): keep hardening and the ntfy notifier alive when sshguard can't install
On a fresh AlmaLinux 9.8 box, install_bruteforce_protection ran unguarded under
'set -euo pipefail'. When sshguard (from EPEL) wasn't installable at that moment,
the single failed dnf aborted the ENTIRE harden run before it wrote sshd_config
or installed the pam_exec login notifier -- leaving a stock, unhardened box and a
silently-missing ntfy hook.

- oslib: install the iptables backend best-effort first, then sshguard, and
  return sshguard's status so callers can treat it as non-fatal.
- harden-ssh/harden-jumphost: install_openssh now dies with a clear message on
  failure; sshguard is '|| warn' so sshd hardening and the notifier still apply.
- install_login_notifier verifies the script + pam hook landed and logs
  'Login notifier ACTIVE' (or a loud warning) instead of failing silently.
- ntfy-ssh-login.sh: NTFY_DEBUG=1 logs delivery attempts + curl errors to
  /var/log/ssh-notify.log so the next silent failure leaves a trace.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:53:39 -05:00

143 lines
6.1 KiB
Bash

#!/bin/sh
#
# ntfy-ssh-login.sh -- pam_exec session hook that posts an SSH login event to
# an ntfy topic. POSIX sh (runs under busybox ash on Alpine too).
#
# Installed at /opt/scripts/ntfy-ssh-login.sh and wired into /etc/pam.d/sshd:
# session optional pam_exec.so /opt/scripts/ntfy-ssh-login.sh
#
# Reads /etc/ssh-notify.conf (see ssh-notify.conf.example). It reports:
# - the user and the source IP (PAM_USER / PAM_RHOST)
# - the SSH public key the user authenticated with (fingerprint)
# - the next hop in a ProxyJump path, when discoverable (best-effort)
# - the bastion's region tag, so you know which location it is
# and only fires for users in NOTIFY_GROUPS (if set).
#
# Notes:
# - Key capture needs `ExposeAuthInfo yes` in sshd_config (the harden
# scripts set it); it falls back to parsing the auth log.
# - Jump-target capture is best-effort: a ProxyJump opens a direct-tcpip
# channel (no session), so the target only appears in sshd logs at
# LogLevel VERBOSE/DEBUG. Absent that, it is omitted.
set -eu
CONF="${SSH_NOTIFY_CONF:-/etc/ssh-notify.conf}"
[ -r "$CONF" ] || exit 0
# shellcheck disable=SC1090
. "$CONF"
# Only act on session open, and only if a destination URL is configured.
[ "${PAM_TYPE:-}" = "open_session" ] || exit 0
[ -n "${NTFY_URL:-}" ] || exit 0
user="${PAM_USER:-unknown}"
rhost="${PAM_RHOST:-unknown}"
# ---------------------------------------------------------------------------
# Read the most recent auth-log lines, wherever this distro keeps them.
# ---------------------------------------------------------------------------
read_authlog() {
if command -v journalctl >/dev/null 2>&1; then
journalctl -n 300 --no-pager 2>/dev/null
elif [ -r /var/log/auth.log ]; then tail -n 300 /var/log/auth.log
elif [ -r /var/log/secure ]; then tail -n 300 /var/log/secure
elif [ -r /var/log/messages ]; then tail -n 300 /var/log/messages
fi
}
# ---------------------------------------------------------------------------
# Group / security-level filter. NOTIFY_GROUPS empty => notify for everyone.
# ---------------------------------------------------------------------------
ugroups="$(id -nG "$user" 2>/dev/null || echo '')"
if [ -n "${NOTIFY_GROUPS:-}" ]; then
match=0
for g in $NOTIFY_GROUPS; do
for ug in $ugroups; do [ "$g" = "$ug" ] && match=1 && break; done
[ "$match" = 1 ] && break
done
[ "$match" = 1 ] || exit 0
fi
# Per-group priority override: NOTIFY_PRIORITY_MAP="ssh-admins:high ssh-jumpers:min"
prio="${NTFY_PRIORITY:-min}"
if [ -n "${NOTIFY_PRIORITY_MAP:-}" ]; then
for entry in $NOTIFY_PRIORITY_MAP; do
g="${entry%%:*}"; p="${entry#*:}"
for ug in $ugroups; do [ "$g" = "$ug" ] && prio="$p"; done
done
fi
# ---------------------------------------------------------------------------
# Which SSH key did the user authenticate with?
# ---------------------------------------------------------------------------
keyinfo=""
if [ -n "${SSH_USER_AUTH:-}" ] && [ -r "${SSH_USER_AUTH:-}" ]; then
# Lines look like: publickey ssh-ed25519 AAAA... [comment]
pk="$(awk '$1=="publickey"{print $2" "$3; exit}' "$SSH_USER_AUTH" 2>/dev/null || true)"
if [ -n "$pk" ]; then
# ssh-keygen -l prints: "<bits> SHA256:<fp> <comment...> (<ALGO>)".
# $2 is the fingerprint; $NF is the "(ALGO)" field regardless of comment.
keyinfo="$(printf '%s\n' "$pk" | ssh-keygen -lf - 2>/dev/null | awk '{print $NF" "$2}')"
[ -n "$keyinfo" ] || keyinfo="$(printf '%s' "$pk" | awk '{print $1}')"
fi
fi
if [ -z "$keyinfo" ]; then
# Fallback: the "Accepted publickey for USER ..." auth-log line carries
# the algorithm + SHA256 fingerprint.
line="$(read_authlog | grep "Accepted publickey for $user " | tail -n1 || true)"
keyinfo="$(printf '%s' "$line" | sed -n 's/.*: \([A-Za-z0-9-]*\) \(SHA256:[A-Za-z0-9+/=]*\).*/\1 \2/p')"
fi
[ -n "$keyinfo" ] || keyinfo="(key unknown)"
# ---------------------------------------------------------------------------
# Best-effort: the next hop in a ProxyJump path (direct-tcpip target).
# ---------------------------------------------------------------------------
jump=""
jline="$(read_authlog | grep -i 'direct-tcpip' | grep -F "$rhost" | tail -n1 || true)"
[ -z "$jline" ] && jline="$(read_authlog | grep -i 'direct-tcpip' | tail -n1 || true)"
# Match "... to HOST port PORT" or "... HOST:PORT ...".
jump="$(printf '%s' "$jline" | sed -n 's/.* to \([^ ]*\) port \([0-9]*\).*/\1:\2/p')"
[ -n "$jump" ] || jump="$(printf '%s' "$jline" | grep -oE '[A-Za-z0-9._-]+:[0-9]+' | tail -n1 || true)"
# ---------------------------------------------------------------------------
# Compose and send.
# ---------------------------------------------------------------------------
ts="$(date --utc +%FT%T.%3N%Z 2>/dev/null || date -u +%FT%TZ)"
selfhost="$(hostname -f 2>/dev/null || hostname 2>/dev/null || echo unknown)"
body="SSH login: ${user} from ${rhost}
key: ${keyinfo}"
[ -n "$jump" ] && body="${body}
jump-target: ${jump}"
body="${body}
host: ${selfhost} at ${ts}"
# Build curl args.
set -- -fsS -m 5 \
-H "X-Title: ${NTFY_TITLE:-Bastion Notification}" \
-H "X-Priority: ${prio}"
[ -n "${NTFY_TOKEN:-}" ] && set -- "$@" -H "Authorization: Bearer ${NTFY_TOKEN}"
[ -n "${NTFY_EMAIL:-}" ] && set -- "$@" -H "X-Email: ${NTFY_EMAIL}"
tags="warning"
[ -n "${NTFY_REGION:-}" ] && tags="${tags},${NTFY_REGION}"
set -- "$@" -H "X-Tags: ${tags}"
# Deliver. Failures are non-fatal -- a login must never be blocked by a notifier
# hiccup. Set NTFY_DEBUG=1 in the conf to log attempts + curl errors to
# /var/log/ssh-notify.log, so a silent failure (SELinux, egress, bad token, ...)
# leaves a trace instead of vanishing.
if [ "${NTFY_DEBUG:-0}" = "1" ]; then
log=/var/log/ssh-notify.log
printf '%s login user=%s rhost=%s -> %s\n' \
"$(date -u +%FT%TZ 2>/dev/null || echo)" "$user" "$rhost" "$NTFY_URL" >> "$log" 2>/dev/null || true
if curl "$@" -d "$body" "$NTFY_URL" >>"$log" 2>&1; then
echo " -> delivered" >> "$log" 2>/dev/null || true
else
echo " -> curl FAILED (exit $?)" >> "$log" 2>/dev/null || true
fi
else
curl "$@" -d "$body" "$NTFY_URL" >/dev/null 2>&1 || true
fi
exit 0