#!/usr/bin/env bash

#########################################################################################
#### password-expiry-check.sh — Check password expiry for AD and local accounts      ####
#### Auto-detects domain vs local. Desktop notifications when expiry is near.        ####
#### Requires: bash, chage. Optional: ldapsearch, notify-send, zenity                ####
####                                                                                 ####
#### Author: Phil Connor                                                             ####
#### Contact: contact@mylinux.work                                                   ####
#### License: MIT                                                                    ####
#### Version 1.02                                                                    ####
####                                                                                 ####
#### Usage:                                                                          ####
####   ./password-expiry-check.sh                                                    ####
####   ./password-expiry-check.sh -w 30                                              ####
####   ./password-expiry-check.sh -u jdoe --test                                     ####
####                                                                                 ####
#### See --help for all options.                                                     ####
#########################################################################################

set -u

DEBUG="${DEBUG:-}"
TEST_MODE=false
QUIET=false
WARNING_DAYS=14
USERNAME="${USER}"
ACCOUNT_TYPE=""

usage() {
    cat <<EOF
Usage: $(basename "$0") [OPTIONS]

Check password expiration for domain (AD/SSSD) and local Linux accounts.
Auto-detects account type. Sends desktop notifications when expiry is near.

Options:
  -w, --warning-days N  Warning threshold in days (default: 14)
  -u, --user USER       Check a specific user (default: current user)
  -q, --quiet           Suppress output unless password is within warning window
  --debug               Show debug output
  --test                Force notification even if expiry is not near
  -h, --help            Show this help

Environment:
  DEBUG                 Set non-empty to enable debug output

Examples:
  $(basename "$0")                    # check current user, 14-day threshold
  $(basename "$0") -w 30             # warn if expiring within 30 days
  $(basename "$0") -u jdoe           # check a specific user
  $(basename "$0") --test            # force notification for testing
  $(basename "$0") --debug           # show detection and query details
  $(basename "$0") -q               # silent unless expiry is near (for /etc/bashrc)

Schedule:
  @daily /path/to/$(basename "$0")
EOF
}

# Parse flags
while [[ $# -gt 0 ]]; do
    case "$1" in
        -h|--help)         usage; exit 0 ;;
        --debug)           DEBUG=1 ;;
        --test)            TEST_MODE=true ;;
        -q|--quiet)        QUIET=true ;;
        -w|--warning-days) WARNING_DAYS="$2"; shift ;;
        -u|--user)         USERNAME="$2"; shift ;;
        [0-9]*)            WARNING_DAYS="$1" ;;  # legacy positional arg
        *)                 echo "Unknown option: $1" >&2; usage; exit 1 ;;
    esac
    shift
done

# Strip domain prefix (DOMAIN\user) or suffix (user@domain) for queries
SAM_ACCOUNT_NAME="${USERNAME##*\\}"
SAM_ACCOUNT_NAME="${SAM_ACCOUNT_NAME%%@*}"

debug_echo() {
    if [[ -n "$DEBUG" ]]; then
        echo "[DEBUG] $*" >&2
    fi
}

debug_echo "USERNAME=$USERNAME  SAM_ACCOUNT_NAME=$SAM_ACCOUNT_NAME"

# --- Helper functions ---

die() { echo "ERROR: $*" >&2; exit 1; }

show_notification() {
    local title="$1"
    local message="$2"
    local urgency="${3:-normal}"  # low, normal, critical

    # Terminal banner (always shown)
    # Wrap message to fit within the box (max ~42 chars per line)
    local wrapped
    wrapped=$(echo "$message" | fold -s -w 42)

    # Strip emoji for terminal (variable-width chars break alignment)
    local term_title="${title#⚠  }"

    echo ""
    echo "╔══════════════════════════════════════════════╗"
    echo "║                                              ║"
    printf "║  %-42s  ║\n" "$term_title"
    echo "║──────────────────────────────────────────────║"
    while IFS= read -r line; do
        printf "║  %-42s  ║\n" "$line"
    done <<< "$wrapped"
    echo "║                                              ║"
    echo "╚══════════════════════════════════════════════╝"
    echo ""

    # Desktop dialog — requires user to click OK to dismiss
    if command -v zenity &>/dev/null && [[ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]]; then
        zenity --warning --title="$title" --text="$message" --width=400 2>/dev/null &
    elif command -v notify-send &>/dev/null && [[ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]]; then
        # Fallback: notify-send with critical urgency (persistent on most DEs)
        notify-send --urgency=critical --icon=dialog-warning "$title" "$message"
    fi
}

# --- Account type detection ---

detect_account_type() {
    # Check if the system is domain-joined
    local domain_joined=false

    if command -v realm &>/dev/null && realm list 2>/dev/null | grep -q "configured:"; then
        domain_joined=true
        debug_echo "Domain detected via: realm"
    elif [[ -f /etc/sssd/sssd.conf ]] && systemctl is-active sssd &>/dev/null; then
        domain_joined=true
        debug_echo "Domain detected via: sssd.conf"
    elif grep -qE "^passwd:.*sss|winbind" /etc/nsswitch.conf 2>/dev/null; then
        domain_joined=true
        debug_echo "Domain detected via: nsswitch.conf"
    fi
    debug_echo "domain_joined=$domain_joined"

    if [[ "$domain_joined" == true ]]; then
        # Check if this specific user is a domain account
        # Domain users typically have a UID >= 100000 (SSSD default range)
        # and their passwd entry comes from sss/winbind, not local files
        local user_source
        user_source=$(getent passwd "$SAM_ACCOUNT_NAME" 2>/dev/null | cut -d: -f3)

        if [[ -n "$user_source" ]] && (( user_source >= 100000 )); then
            ACCOUNT_TYPE="domain"
            return
        fi

        # Also check if user exists in /etc/passwd (local) vs only via NSS
        if grep -q "^${SAM_ACCOUNT_NAME}:" /etc/passwd 2>/dev/null; then
            ACCOUNT_TYPE="local"
            return
        fi

        # User not in /etc/passwd but system is domain-joined — likely domain account
        ACCOUNT_TYPE="domain"
        return
    fi

    ACCOUNT_TYPE="local"
}

# --- Local account expiry ---

get_expiry_local() {
    local expiry_str
    expiry_str=$(chage -l "$SAM_ACCOUNT_NAME" 2>/dev/null | grep "Password expires" | cut -d: -f2- | xargs || true)

    if [[ -z "$expiry_str" || "$expiry_str" == "never" ]]; then
        echo "never"
        return
    fi

    # Convert to epoch
    date -d "$expiry_str" +%s 2>/dev/null || echo ""
}

# --- Domain account expiry via SSSD/chage ---

get_expiry_sssd() {
    local expiry_str
    expiry_str=$(chage -l "$SAM_ACCOUNT_NAME" 2>/dev/null | grep "Password expires" | cut -d: -f2- | xargs || true)
    debug_echo "SSSD/chage result for '$SAM_ACCOUNT_NAME': '$expiry_str'"

    if [[ -z "$expiry_str" || "$expiry_str" == "never" ]]; then
        echo ""
        return
    fi

    # Convert to epoch
    date -d "$expiry_str" +%s 2>/dev/null || echo ""
}

# --- Domain account expiry via ldapsearch ---

get_expiry_ldap() {
    if ! command -v ldapsearch &>/dev/null; then
        debug_echo "ldapsearch not found, skipping LDAP method"
        echo ""
        return
    fi

    local domain realm dc_host base_dn
    domain=$(hostname -d 2>/dev/null || dnsdomainname 2>/dev/null || echo "")
    debug_echo "LDAP domain: '$domain'"
    if [[ -z "$domain" ]]; then
        debug_echo "Could not determine domain, skipping LDAP"
        echo ""
        return
    fi

    realm=$(echo "$domain" | tr '[:lower:]' '[:upper:]')
    local dc_hosts
    dc_hosts=$(host -t SRV "_ldap._tcp.${domain}" 2>/dev/null | awk '{print $NF}' | sed 's/\.$//')
    base_dn=$(echo "$domain" | sed 's/\./,DC=/g; s/^/DC=/')
    debug_echo "LDAP dc_hosts='$(echo $dc_hosts)' base_dn='$base_dn'"

    if [[ -z "$dc_hosts" ]]; then
        debug_echo "Could not find domain controller via SRV lookup"
        echo ""
        return
    fi

    local result=""
    local stderr_target="/dev/null"
    [[ -n "$DEBUG" ]] && stderr_target="/dev/stderr"

    for dc_host in $dc_hosts; do
        debug_echo "Trying DC: ldapsearch -H ldap://${dc_host} -b $base_dn (sAMAccountName=${SAM_ACCOUNT_NAME})"
        result=$(ldapsearch -LLL -H "ldap://${dc_host}" -Y GSSAPI -Q \
            -b "$base_dn" \
            "(sAMAccountName=${SAM_ACCOUNT_NAME})" \
            msDS-UserPasswordExpiryTimeComputed 2>"$stderr_target" \
            | grep "msDS-UserPasswordExpiryTimeComputed:" | awk '{print $2}' || true)

        if [[ -n "$result" ]]; then
            debug_echo "Got result from $dc_host"
            break
        fi
        debug_echo "No result from $dc_host, trying next..."
    done
    debug_echo "LDAP msDS-UserPasswordExpiryTimeComputed: '$result'"

    if [[ -z "$result" || "$result" == "0" || "$result" == "9223372036854775807" ]]; then
        debug_echo "LDAP result empty, zero, or never-expire sentinel"
        echo ""
        return
    fi

    # AD timestamp is 100-nanosecond intervals since 1601-01-01
    # Convert to Unix epoch: subtract 116444736000000000, divide by 10000000
    local unix_epoch
    unix_epoch=$(echo "($result - 116444736000000000) / 10000000" | bc 2>/dev/null)
    debug_echo "Converted epoch: $unix_epoch"
    echo "$unix_epoch"
}

# --- Main ---

detect_account_type

expiry_epoch=""

if [[ "$ACCOUNT_TYPE" == "domain" ]]; then
    # Try SSSD/chage first (simplest on domain-joined systems)
    expiry_epoch=$(get_expiry_sssd)

    # Fall back to ldapsearch
    if [[ -z "$expiry_epoch" ]]; then
        expiry_epoch=$(get_expiry_ldap)
    fi

    if [[ -z "$expiry_epoch" ]]; then
        if [[ "$QUIET" != "true" ]]; then
            echo "Account Type:     Domain"
            echo "User:             ${USERNAME}"
            echo "Password is set to never expire or could not determine expiry date."
            echo "Ensure this host is domain-joined (realm/SSSD) or has LDAP access."
        fi
        [[ "$TEST_MODE" == true ]] && show_notification "Password Expiry Test" "Account: ${USERNAME} (domain) — password never expires or expiry unknown. (--test mode)" "normal"
        exit 0
    fi
else
    # Local account
    expiry_epoch=$(get_expiry_local)

    if [[ "$expiry_epoch" == "never" ]]; then
        if [[ "$QUIET" != "true" ]]; then
            echo "Account Type:     Local"
            echo "User:             ${USERNAME}"
            echo "Password is set to never expire."
        fi
        [[ "$TEST_MODE" == true ]] && show_notification "Password Expiry Test" "Account: ${USERNAME} (local) — password never expires. (--test mode)" "normal"
        exit 0
    fi

    if [[ -z "$expiry_epoch" ]]; then
        if [[ "$QUIET" != "true" ]]; then
            echo "Account Type:     Local"
            echo "User:             ${USERNAME}"
            echo "Could not determine password expiry date."
        fi
        exit 1
    fi
fi

now_epoch=$(date +%s)
days_remaining=$(( (expiry_epoch - now_epoch) / 86400 ))
expiry_date=$(date -d "@${expiry_epoch}" "+%Y-%m-%d %H:%M" 2>/dev/null || date -r "${expiry_epoch}" "+%Y-%m-%d %H:%M" 2>/dev/null)

if [[ "$QUIET" == "true" ]] && (( days_remaining > WARNING_DAYS )) && [[ "$TEST_MODE" != true ]]; then
    # Quiet mode and password is not near expiry — suppress all output
    exit 0
fi

echo "Account Type:     ${ACCOUNT_TYPE^}"
echo "User:             ${USERNAME}"
echo "Password Expires: ${expiry_date}"
echo "Days Remaining:   ${days_remaining}"

if (( days_remaining <= 0 )); then
    show_notification \
        "⚠  PASSWORD EXPIRED" \
        "Your password EXPIRED (${expiry_date}). Run passwd to change it now." \
        "critical"
elif (( days_remaining <= WARNING_DAYS )); then
    urgency="normal"
    (( days_remaining <= 3 )) && urgency="critical"

    show_notification \
        "⚠  Password Expiring Soon" \
        "Your password expires in ${days_remaining} day(s) on ${expiry_date}. Change it with: passwd" \
        "$urgency"
elif [[ "$TEST_MODE" == true ]]; then
    show_notification \
        "Password Expiry Test" \
        "Your password expires in ${days_remaining} day(s) on ${expiry_date}. (--test mode)" \
        "normal"
else
    echo "Password expiry is more than ${WARNING_DAYS} days away. No notification needed."
fi
