#!/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: " SHA256: ()". # $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