Files
automations/scripts/ntfy-ssh-login.sh
T
57_Wolve 7faa9098de feat: unified launcher, multi-OS hardening, login alerts & auto-updates
Restructure around a single entry point (automations.sh) with a Gum wizard and
a self-extracting bundle for repo-less installs. Add scripts/oslib.sh so the
provisioning scripts (setup-host, harden-ssh, harden-jumphost, sshuser) run on
Alpine/Debian/Alma; seed root keys from globals/.

- ntfy SSH-login alerts (user, source IP, key, region, jump target) via pam_exec
- daily auto-updates with AUTO_REBOOT=idle (reboots only when no SSH active) and
  opt-in Alpine stable-branch upgrades
- generic + per-deployment cloud-init; Gitea release workflow on tag
- README/LICENSE/.gitignore/.gitattributes (force LF); repo URLs -> Gitea
2026-06-12 14:56:02 -05:00

128 lines
5.4 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}"
curl "$@" -d "$body" "$NTFY_URL" >/dev/null 2>&1 || true
exit 0