#!/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 <<EOF
Usage: $SCRIPT_NAME [OPTIONS]

Prometheus textfile collector exporter for password expiry.
Monitors password expiry for local and AD (domain) accounts.

OPTIONS:
    --dry-run       Output metrics to stdout instead of writing to file
    --debug         Enable debug output
    --help          Show this help message
    --version       Show version

CONFIGURATION:
    Users can be configured in three ways (in priority order):

    1. Environment variable (comma-separated):
       USER_LIST="jsmith,admin" $SCRIPT_NAME

    2. Config file (one user per line):
       /etc/password-expiry-exporter.conf

    3. Auto-discovery (default):
       All local users with UID >= $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 "$@"
