#!/bin/bash ################################################################################ # Script Name: password-expiry-exporter.sh # Version: 1.0 # Description: Prometheus textfile collector exporter for password expiry # Monitors password expiry for local and AD (domain) accounts # # Author: Phil Connor # Contact: contact@mylinux.work # Website: https://mylinux.work # License: MIT # Date: 2026-03-10 # # Prerequisites: # - node_exporter with textfile collector enabled # - /var/lib/node_exporter directory exists # - Run as root (to read /etc/shadow and query all users) # - For AD accounts: SSSD/realm joined or ldapsearch + Kerberos ticket # # Usage: # # Export metrics for all non-system local users # sudo ./password-expiry-exporter.sh # # # Export for specific users # USER_LIST="jsmith,admin,svc-backup" sudo ./password-expiry-exporter.sh # # # Include domain (AD) users # INCLUDE_DOMAIN=1 sudo ./password-expiry-exporter.sh # # # Dry run (output to stdout) # sudo ./password-expiry-exporter.sh --dry-run # # # Debug mode # DEBUG=1 sudo ./password-expiry-exporter.sh # # Metrics Exported: # - linux_password_expiry_days{user,type} - Days until password expires (-1 = expired, -2 = never) # - linux_password_expired{user,type} - Whether the password has expired (1/0) # - linux_password_never_expires{user,type} - Whether the password is set to never expire (1/0) # - linux_password_last_change_days{user,type} - Days since the password was last changed # - linux_password_expiry_warning_days{user,type} - Warning period in days before expiry # # Configuration: # Environment: USER_LIST (comma-separated), INCLUDE_DOMAIN, MIN_UID # Config file: /etc/password-expiry-exporter.conf (one user per line) # Textfile directory: /var/lib/node_exporter # ################################################################################ set -o pipefail # ============================================================================ # CONFIGURATION # ============================================================================ readonly VERSION="1.0" readonly SCRIPT_NAME="${0##*/}" readonly TEXTFILE_DIR="${TEXTFILE_DIR:-/var/lib/node_exporter}" readonly OUTPUT_FILE="${TEXTFILE_DIR}/password_expiry.prom" readonly CONFIG_FILE="${CONFIG_FILE:-/etc/password-expiry-exporter.conf}" readonly TMP_FILE="${OUTPUT_FILE}.$$" readonly MIN_UID="${MIN_UID:-1000}" readonly INCLUDE_DOMAIN="${INCLUDE_DOMAIN:-0}" # Runtime flags DRY_RUN=false DEBUG=${DEBUG:-} # ============================================================================ # HELPER FUNCTIONS # ============================================================================ debug_echo() { if [[ -n "$DEBUG" ]]; then echo "[DEBUG] $*" >&2 fi } log_error() { echo "[ERROR] $*" >&2 } cleanup() { rm -f "$TMP_FILE" } trap cleanup EXIT show_help() { cat <= $MIN_UID ENVIRONMENT VARIABLES: USER_LIST Comma-separated list of users to monitor CONFIG_FILE Path to config file (default: /etc/password-expiry-exporter.conf) TEXTFILE_DIR Textfile collector directory (default: /var/lib/node_exporter) MIN_UID Minimum UID for auto-discovery (default: 1000) INCLUDE_DOMAIN Set to 1 to include domain/AD users in auto-discovery DEBUG Enable debug output when set to any value EXAMPLES: sudo $SCRIPT_NAME sudo $SCRIPT_NAME --dry-run USER_LIST="admin,deploy" sudo $SCRIPT_NAME INCLUDE_DOMAIN=1 sudo $SCRIPT_NAME DEBUG=1 sudo $SCRIPT_NAME --dry-run EOF exit 0 } show_version() { echo "$SCRIPT_NAME version $VERSION" exit 0 } # ============================================================================ # ACCOUNT DETECTION # ============================================================================ is_domain_joined() { if command -v realm &>/dev/null && realm list 2>/dev/null | grep -q "configured:"; then return 0 elif [[ -f /etc/sssd/sssd.conf ]] && systemctl is-active sssd &>/dev/null; then return 0 elif grep -qE "^passwd:.*sss|winbind" /etc/nsswitch.conf 2>/dev/null; then return 0 fi return 1 } strip_domain() { local user="$1" user="${user##*\\}" user="${user%%@*}" echo "$user" } get_account_type() { local user="$1" local uid uid=$(id -u "$user" 2>/dev/null) || return 1 if grep -q "^${user}:" /etc/passwd 2>/dev/null; then echo "local" elif (( uid >= 100000 )); then echo "domain" else echo "local" fi } # ============================================================================ # USER DISCOVERY # ============================================================================ load_users() { local users=() if [[ -n "${USER_LIST:-}" ]]; then debug_echo "Loading users from USER_LIST environment variable" local raw_users=() IFS=',' read -ra raw_users <<< "$USER_LIST" for u in "${raw_users[@]}"; do users+=("$(strip_domain "$u")") done elif [[ -f "$CONFIG_FILE" ]]; then debug_echo "Loading users from config file: $CONFIG_FILE" while IFS= read -r line; do line="${line%%#*}" line="${line// /}" if [[ -n "$line" ]]; then users+=("$(strip_domain "$line")") fi done < "$CONFIG_FILE" else debug_echo "Auto-discovering local users with UID >= $MIN_UID" while IFS=: read -r username _ uid _; do if (( uid >= MIN_UID )) && (( uid < 65534 )); then users+=("$username") fi done < /etc/passwd if [[ "$INCLUDE_DOMAIN" == "1" ]] && is_domain_joined; then debug_echo "Including domain users from getent" while IFS=: read -r username _ uid _; do if (( uid >= 100000 )); then users+=("$username") fi done < <(getent passwd 2>/dev/null) fi # Fall back to root if no regular users found (e.g. containers, minimal installs) if [[ ${#users[@]} -eq 0 ]]; then debug_echo "No users with UID >= $MIN_UID found, falling back to root" users+=("root") fi fi if [[ ${#users[@]} -eq 0 ]]; then log_error "No users found to monitor" exit 1 fi debug_echo "Monitoring ${#users[@]} users: ${users[*]}" printf '%s\n' "${users[@]}" } # ============================================================================ # PASSWORD EXPIRY QUERIES # ============================================================================ get_local_expiry_info() { local user="$1" if ! chage_output=$(chage -l "$user" 2>/dev/null); then debug_echo "chage failed for $user" return 1 fi local expiry_str last_change_str warn_str expiry_str=$(echo "$chage_output" | grep "Password expires" | cut -d: -f2- | xargs) last_change_str=$(echo "$chage_output" | grep "Last password change" | cut -d: -f2- | xargs) warn_str=$(echo "$chage_output" | grep "Number of days of warning" | cut -d: -f2- | xargs) local now_epoch now_epoch=$(date +%s) # Password expiry local expiry_days=-2 # default: never local expired=0 local never_expires=1 if [[ -n "$expiry_str" && "$expiry_str" != "never" ]]; then local expiry_epoch expiry_epoch=$(date -d "$expiry_str" +%s 2>/dev/null) || true if [[ -n "$expiry_epoch" ]]; then expiry_days=$(( (expiry_epoch - now_epoch) / 86400 )) never_expires=0 if (( expiry_days < 0 )); then expired=1 expiry_days=-1 fi fi fi # Last password change local last_change_days=-1 if [[ -n "$last_change_str" && "$last_change_str" != "never" ]]; then local last_change_epoch last_change_epoch=$(date -d "$last_change_str" +%s 2>/dev/null) || true if [[ -n "$last_change_epoch" ]]; then last_change_days=$(( (now_epoch - last_change_epoch) / 86400 )) fi fi # Warning days local warning_days=0 if [[ -n "$warn_str" && "$warn_str" =~ ^[0-9]+$ ]]; then warning_days="$warn_str" fi echo "${expiry_days}|${expired}|${never_expires}|${last_change_days}|${warning_days}" } get_domain_expiry_info() { local user="$1" # Try chage first (works via SSSD PAM integration) if chage_output=$(chage -l "$user" 2>/dev/null); then local expiry_str expiry_str=$(echo "$chage_output" | grep "Password expires" | cut -d: -f2- | xargs) if [[ -n "$expiry_str" && "$expiry_str" != "never" ]]; then get_local_expiry_info "$user" return fi fi # Fall back to ldapsearch if ! command -v ldapsearch &>/dev/null; then debug_echo "ldapsearch not available for domain user $user" return 1 fi local domain dc_host base_dn domain=$(hostname -d 2>/dev/null || dnsdomainname 2>/dev/null || echo "") if [[ -z "$domain" ]]; then debug_echo "Cannot determine domain for ldapsearch" return 1 fi dc_host=$(host -t SRV "_ldap._tcp.${domain}" 2>/dev/null | head -1 | awk '{print $NF}' | sed 's/\.$//') base_dn=$(echo "$domain" | sed 's/\./,DC=/g; s/^/DC=/') if [[ -z "$dc_host" ]]; then debug_echo "Cannot find domain controller for $domain" return 1 fi local result result=$(ldapsearch -LLL -H "ldap://${dc_host}" -Y GSSAPI -Q \ -b "$base_dn" \ "(sAMAccountName=${user})" \ msDS-UserPasswordExpiryTimeComputed pwdLastSet 2>/dev/null) local expiry_ticks last_set_ticks expiry_ticks=$(echo "$result" | grep "msDS-UserPasswordExpiryTimeComputed:" | awk '{print $2}') last_set_ticks=$(echo "$result" | grep "pwdLastSet:" | awk '{print $2}') local now_epoch now_epoch=$(date +%s) # Password expiry local expiry_days=-2 local expired=0 local never_expires=1 if [[ -n "$expiry_ticks" && "$expiry_ticks" != "0" && "$expiry_ticks" != "9223372036854775807" ]]; then local expiry_epoch expiry_epoch=$(echo "($expiry_ticks - 116444736000000000) / 10000000" | bc 2>/dev/null) if [[ -n "$expiry_epoch" ]]; then expiry_days=$(( (expiry_epoch - now_epoch) / 86400 )) never_expires=0 if (( expiry_days < 0 )); then expired=1 expiry_days=-1 fi fi fi # Last password change local last_change_days=-1 if [[ -n "$last_set_ticks" && "$last_set_ticks" != "0" ]]; then local last_set_epoch last_set_epoch=$(echo "($last_set_ticks - 116444736000000000) / 10000000" | bc 2>/dev/null) if [[ -n "$last_set_epoch" ]]; then last_change_days=$(( (now_epoch - last_set_epoch) / 86400 )) fi fi echo "${expiry_days}|${expired}|${never_expires}|${last_change_days}|0" } # ============================================================================ # METRICS COLLECTION # ============================================================================ collect_metrics() { local users=() while IFS= read -r u; do users+=("$u") done < <(load_users) local output="" local has_data=false # Collect data for all users first declare -A user_data declare -A user_types for user in "${users[@]}"; do local account_type account_type=$(get_account_type "$user") || continue user_types["$user"]="$account_type" local info if [[ "$account_type" == "domain" ]]; then info=$(get_domain_expiry_info "$user") || continue else info=$(get_local_expiry_info "$user") || continue fi user_data["$user"]="$info" has_data=true debug_echo "User $user ($account_type): $info" done if [[ "$has_data" == "false" ]]; then log_error "Could not collect expiry data for any users" exit 1 fi # --- Expiry days --- output+="# HELP linux_password_expiry_days Days until password expires (-1=expired, -2=never)\n" output+="# TYPE linux_password_expiry_days gauge\n" for user in "${users[@]}"; do [[ -z "${user_data[$user]:-}" ]] && continue local type="${user_types[$user]}" local days days=$(echo "${user_data[$user]}" | cut -d'|' -f1) output+="linux_password_expiry_days{user=\"${user}\",type=\"${type}\"} ${days}\n" done # --- Expired flag --- output+="# HELP linux_password_expired Whether the password has expired (1=yes, 0=no)\n" output+="# TYPE linux_password_expired gauge\n" for user in "${users[@]}"; do [[ -z "${user_data[$user]:-}" ]] && continue local type="${user_types[$user]}" local expired expired=$(echo "${user_data[$user]}" | cut -d'|' -f2) output+="linux_password_expired{user=\"${user}\",type=\"${type}\"} ${expired}\n" done # --- Never expires flag --- output+="# HELP linux_password_never_expires Whether the password is set to never expire (1=yes, 0=no)\n" output+="# TYPE linux_password_never_expires gauge\n" for user in "${users[@]}"; do [[ -z "${user_data[$user]:-}" ]] && continue local type="${user_types[$user]}" local never never=$(echo "${user_data[$user]}" | cut -d'|' -f3) output+="linux_password_never_expires{user=\"${user}\",type=\"${type}\"} ${never}\n" done # --- Last change days --- output+="# HELP linux_password_last_change_days Days since the password was last changed\n" output+="# TYPE linux_password_last_change_days gauge\n" for user in "${users[@]}"; do [[ -z "${user_data[$user]:-}" ]] && continue local type="${user_types[$user]}" local last_change last_change=$(echo "${user_data[$user]}" | cut -d'|' -f4) output+="linux_password_last_change_days{user=\"${user}\",type=\"${type}\"} ${last_change}\n" done # --- Warning days --- output+="# HELP linux_password_expiry_warning_days Warning period in days before password expiry\n" output+="# TYPE linux_password_expiry_warning_days gauge\n" for user in "${users[@]}"; do [[ -z "${user_data[$user]:-}" ]] && continue local type="${user_types[$user]}" local warn warn=$(echo "${user_data[$user]}" | cut -d'|' -f5) output+="linux_password_expiry_warning_days{user=\"${user}\",type=\"${type}\"} ${warn}\n" done printf '%b' "$output" } # ============================================================================ # OUTPUT # ============================================================================ write_metrics() { local metrics metrics=$(collect_metrics) if [[ "$DRY_RUN" == "true" ]]; then echo "$metrics" return fi if [[ ! -d "$TEXTFILE_DIR" ]]; then log_error "Textfile collector directory does not exist: $TEXTFILE_DIR" exit 1 fi echo "$metrics" > "$TMP_FILE" mv "$TMP_FILE" "$OUTPUT_FILE" debug_echo "Metrics written to $OUTPUT_FILE" } # ============================================================================ # MAIN # ============================================================================ main() { while [[ $# -gt 0 ]]; do case "$1" in --dry-run) DRY_RUN=true shift ;; --debug) DEBUG=1 shift ;; --help|-h) show_help ;; --version|-v) show_version ;; *) log_error "Unknown option: $1" echo "Use --help for usage information" >&2 exit 1 ;; esac done if [[ $EUID -ne 0 ]]; then log_error "This script must be run as root to read password expiry for all users" exit 1 fi write_metrics } main "$@"