#!/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 <&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