a1a17e81a1
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.
524 lines
16 KiB
Bash
Executable File
524 lines
16 KiB
Bash
Executable File
#!/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 "$@"
|