60433e4c8d
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>
143 lines
6.1 KiB
Bash
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
|