Files
linux-scripts/password-expiry-check.sh
T
chiefgeek a1a17e81a1 Sync all scripts from website downloads — 352 scripts total
Includes updated JS challenge scripts with Claude-User whitelist,
same-site referer bypass, Blackbox-Exporter allowed bot, and all
new exporters, cheat sheets, and automation scripts.
2026-05-25 03:31:08 +02:00

349 lines
13 KiB
Bash
Executable File

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