Dotfiles/setup/modules/FreeipaAnsible/ansible/ansipa-enforce-policies.sh

1499 lines
67 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

#!/usr/bin/env bash
# ansipa-enforce-policies.sh — enforce FreeIPA host-group-driven policies on this client.
#
# Policies are idempotent and reversible: joining a group applies the policy;
# leaving the group removes it on the next run (every 30 min via systemd timer).
#
# Host-group naming conventions (device policies — applied to the whole machine):
# dev_daemon-enable-<unit> Ensure <unit> is enabled and running (systemctl enable --now).
# Leaving the group reverts: service is disabled and stopped.
# dev_daemon-disable-<unit> Ensure <unit> is disabled and stopped (systemctl disable --now).
# Leaving the group reverts: service is re-enabled and started.
# <unit> may omit the .service suffix; all systemd unit types work.
# If a unit appears in both enable and disable groups it is skipped.
# dev_timeshift-backup Enforce a daily Timeshift snapshot (requires timeshift installed)
# dev_security-scan Enforce daily ClamAV + rkhunter + chkrootkit scans
# dev_no-local-users Lock passwords for root and all local users (UID >= 1000) so
# that only FreeIPA domain accounts with centrally-managed sudo
# rules can authenticate and gain elevated privileges.
# Leaving the group reverts: every account locked by this policy
# is unlocked on the next run.
# dev_local-sudo-<username> Grant <username> full sudo on this specific device by adding
# them to the local sudoers drop-in. Leaving the group removes
# the drop-in on the next run.
#
# User-group naming conventions (per-user policies — follow the user across devices):
# usr_admin Members get full sudo on every enrolled host via a sudoers
# drop-in (/etc/sudoers.d/ansipa-usr-admin). Applied while the
# FreeIPA user group exists; drop-in is removed when the group is
# deleted. Uses SSSD so group membership resolves without local
# accounts.
# usr_block-binary-<name> Prevent members of this FreeIPA user group from running <name>
# on any enrolled host. Use __ in place of . to support Flatpak
# app IDs (e.g. usr_block-binary-org__gimp__Gimp blocks the
# Flatpak org.gimp.Gimp). Enforced via a PATH-priority wrapper
# that checks group membership at runtime via SSSD/id(1).
# Removing the user group from FreeIPA reverts the wrapper.
# usr_prt_<printer> Auto-add <printer> to CUPS on any enrolled host where a member
# is logged in. The printer URI is stored in the IPA group
# description (e.g. ipps://host/printers/name). Access is
# per-user only (allow:<username>); each new member who logs in
# gets added to the allow list. When the IPA group is deleted
# or all members leave, the printer is removed from CUPS on that
# host. A profile.d snippet + NOPASSWD sudo rule trigger the
# enforcer at login for immediate setup.
# usr_scan-notify Members receive scan alert notifications on every enrolled device
# they log into. The fetch-alerts timer is installed fleet-wide
# when the group exists; the notification daemon starts on login
# only for group members (checked via id(1) / SSSD).
# Deleting the IPA user group removes the timer and profile.d
# snippet on the next enforcer run.
#
# Notes:
# - Install scan tools first: add the host to dev_mod_anti-malware.
# - Configure Timeshift (type + target device) before enabling dev_timeshift-backup.
set -euo pipefail
LOG_TAG="ansipa-policies"
STATE_DIR="/var/lib/ansipa-policies"
BLOCK_DIR="/usr/local/bin"
CRON_DIR="/etc/cron.d"
log() { echo "[$LOG_TAG] $*"; logger -t "$LOG_TAG" "$*" 2>/dev/null || true; }
warn() { echo "[$LOG_TAG][WARN] $*" >&2; logger -t "$LOG_TAG" "WARN: $*" 2>/dev/null || true; }
HOST_FQDN=$(hostname -f 2>/dev/null || hostname)
if ! command -v ipa &>/dev/null; then
warn "ipa command not found — host not enrolled in FreeIPA. Exiting."
exit 0
fi
kinit -k "host/$HOST_FQDN" &>/dev/null || true
mkdir -p "$STATE_DIR"
# ── Fetch host group membership ───────────────────────────────────────────────
RAW_GROUPS=$(ipa host-show "$HOST_FQDN" --all 2>/dev/null \
| grep -i "Member of host-groups:" | sed 's/.*: //' || true)
# ── Parse active host-group (device) policies ─────────────────────────────────
ACTIVE_DAEMON_ENABLE=()
ACTIVE_DAEMON_DISABLE=()
ACTIVE_LOCAL_SUDO_USERS=()
ACTIVE_SSH_USERS=()
ACTIVE_MON_DEV=() # dev_mon_* suffixes from host-group membership
WANT_TIMESHIFT_BACKUP=false
WANT_SECURITY_SCAN=false
WANT_SCAN_NOTIFY=false
WANT_NO_LOCAL_USERS=false
WANT_USR_ADMIN=false
ACTIVE_PRT_GROUPS=()
ACTIVE_PRT_PRINTERS=()
if [[ -n "$RAW_GROUPS" ]]; then
while IFS=',' read -ra GRP_ARRAY; do
for g in "${GRP_ARRAY[@]}"; do
g="${g// /}"
case "$g" in
dev_daemon-enable-*) ACTIVE_DAEMON_ENABLE+=("${g#dev_daemon-enable-}") ;;
dev_daemon-disable-*) ACTIVE_DAEMON_DISABLE+=("${g#dev_daemon-disable-}") ;;
dev_timeshift-backup) WANT_TIMESHIFT_BACKUP=true ;;
dev_security-scan) WANT_SECURITY_SCAN=true ;;
dev_no-local-users) WANT_NO_LOCAL_USERS=true ;;
dev_local-sudo-*) ACTIVE_LOCAL_SUDO_USERS+=("${g#dev_local-sudo-}") ;;
dev_ssh_*) ACTIVE_SSH_USERS+=("${g#dev_ssh_}") ;;
dev_mon_*) ACTIVE_MON_DEV+=("${g#dev_mon_}") ;;
esac
done
done <<< "$RAW_GROUPS"
fi
# ── Fetch user-group-based binary block policies from FreeIPA ─────────────────
# usr_block-binary-<name> groups are FreeIPA *user* groups — membership follows
# the user to every enrolled host rather than being tied to a device.
ACTIVE_BLOCK_BINARIES=()
ACTIVE_BLOCK_IPA_GROUPS=()
_BLOCK_LIST=$(ipa group-find --pkey-only 2>/dev/null \
| awk '/Group name:/ {print $NF}' \
| grep "^usr_block-binary-" | sort -u || true)
while IFS= read -r _grp; do
[[ -z "$_grp" ]] && continue
_raw="${_grp#usr_block-binary-}"
ACTIVE_BLOCK_BINARIES+=("${_raw//__/.}")
ACTIVE_BLOCK_IPA_GROUPS+=("$_grp")
done <<< "$_BLOCK_LIST"
unset _BLOCK_LIST _grp _raw
# ── Fetch user-group-based admin policy from FreeIPA ─────────────────────────
# usr_admin is a FreeIPA *user* group. When it exists, a sudoers drop-in grants
# full sudo to the group on every enrolled host (resolved via SSSD/NSS).
if ipa group-show usr_admin >/dev/null 2>&1; then
WANT_USR_ADMIN=true
fi
# ── Fetch user-group-based printer policies from FreeIPA ─────────────────────
# usr_prt_<printer> groups grant CUPS access to <printer> for members.
_PRT_LIST=$(ipa group-find --pkey-only 2>/dev/null \
| awk '/Group name:/ {print $NF}' \
| grep "^usr_prt_" | sort -u || true)
while IFS= read -r _pgrp; do
[[ -z "$_pgrp" ]] && continue
ACTIVE_PRT_GROUPS+=("$_pgrp")
ACTIVE_PRT_PRINTERS+=("${_pgrp#usr_prt_}")
done <<< "$_PRT_LIST"
unset _PRT_LIST _pgrp
# ── Fetch user-group-based scan-notify policy from FreeIPA ────────────────────
# usr_scan-notify is a FreeIPA *user* group — membership follows the user to
# every enrolled host. Install the fetch-alerts timer on any device where the
# group exists; the profile.d snippet gates daemon start on runtime membership.
if ipa group-show usr_scan-notify >/dev/null 2>&1; then
WANT_SCAN_NOTIFY=true
fi
# ── Fetch usr_mon_* user-group monitoring policies from FreeIPA ───────────────
# usr_mon_<type> are FreeIPA *user* groups; when the group exists the monitoring
# check is installed fleet-wide (same logic as usr_scan-notify).
ACTIVE_MON_USR=()
_MON_USR_LIST=$(ipa group-find --pkey-only 2>/dev/null \
| awk '/Group name:/ {print $NF}' \
| grep "^usr_mon_" | sort -u || true)
while IFS= read -r _mgrp; do
[[ -z "$_mgrp" ]] && continue
ACTIVE_MON_USR+=("${_mgrp#usr_mon_}")
done <<< "$_MON_USR_LIST"
unset _MON_USR_LIST _mgrp
log "Device policies — daemon-enable: ${ACTIVE_DAEMON_ENABLE[*]:-none}" \
"| daemon-disable: ${ACTIVE_DAEMON_DISABLE[*]:-none}" \
"| timeshift-backup: $WANT_TIMESHIFT_BACKUP | security-scan: $WANT_SECURITY_SCAN" \
"| no-local-users: $WANT_NO_LOCAL_USERS | local-sudo: ${ACTIVE_LOCAL_SUDO_USERS[*]:-none}" \
"| ssh-keys: ${ACTIVE_SSH_USERS[*]:-none} | mon-dev: ${ACTIVE_MON_DEV[*]:-none}"
log "User policies — admin: $WANT_USR_ADMIN" \
"| block-binary: ${ACTIVE_BLOCK_BINARIES[*]:-none}" \
"| printers: ${ACTIVE_PRT_PRINTERS[*]:-none}" \
"| scan-notify: $WANT_SCAN_NOTIFY | mon-usr: ${ACTIVE_MON_USR[*]:-none}"
# ── Helpers ───────────────────────────────────────────────────────────────────
_in_block_list() {
local needle="$1"
for b in "${ACTIVE_BLOCK_BINARIES[@]}"; do
[[ "$b" == "$needle" ]] && return 0
done
return 1
}
# ── Binary blocking (user-based) ──────────────────────────────────────────────
# A PATH-priority wrapper is installed in /usr/local/bin/ for every binary named
# by a usr_block-binary-* FreeIPA *user* group. The wrapper checks the
# caller's group membership at runtime (via id + SSSD) and only blocks members;
# non-members are transparently passed through to the real binary.
# __ in the group suffix decodes to . so Flatpak app IDs are fully supported.
# Removing the IPA user group causes the wrapper to be cleaned up on the next run.
BLOCK_STATE="$STATE_DIR/blocked-binaries"
[[ -f "$BLOCK_STATE" ]] || touch "$BLOCK_STATE"
for _idx in "${!ACTIVE_BLOCK_BINARIES[@]}"; do
BIN="${ACTIVE_BLOCK_BINARIES[$_idx]}"
IPA_GRP="${ACTIVE_BLOCK_IPA_GROUPS[$_idx]}"
WRAPPER="$BLOCK_DIR/$BIN"
# Write (or refresh) the wrapper when it is absent, not ours, or the group name changed.
if [[ ! -f "$WRAPPER" ]] \
|| ! grep -q "blocked by ansipa policy" "$WRAPPER" 2>/dev/null \
|| ! grep -qF "$IPA_GRP" "$WRAPPER" 2>/dev/null; then
log "Installing user-aware block wrapper: $BIN (group: $IPA_GRP)"
cat > "$WRAPPER" <<WRAPPER
#!/bin/bash
# blocked by ansipa policy (user-based)
if id -Gn 2>/dev/null | tr ' ' '\n' | grep -qxF "${IPA_GRP}"; then
echo "[ansipa-policies] '${BIN}' is blocked by system policy for your account." >&2
exit 1
fi
_real=\$(PATH="/usr/bin:/usr/sbin:/bin:/sbin:/usr/local/sbin:/opt/bin:/var/lib/flatpak/exports/bin:/usr/share/flatpak/exports/bin" command -v "${BIN}" 2>/dev/null)
[[ -n "\$_real" ]] && exec "\$_real" "\$@"
command -v flatpak &>/dev/null && exec flatpak run "${BIN}" "\$@" 2>/dev/null
echo "${BIN}: command not found" >&2
exit 127
WRAPPER
chmod 755 "$WRAPPER"
fi
done
unset _idx
# Remove wrappers whose IPA user group no longer exists.
while IFS= read -r OLD_BIN; do
[[ -z "$OLD_BIN" ]] && continue
if ! _in_block_list "$OLD_BIN"; then
WRAPPER="$BLOCK_DIR/$OLD_BIN"
if [[ -f "$WRAPPER" ]] && grep -q "blocked by ansipa policy" "$WRAPPER" 2>/dev/null; then
rm -f "$WRAPPER"
log "Removed binary block wrapper: $OLD_BIN"
fi
fi
done < "$BLOCK_STATE"
# Persist current blocked list.
if [[ ${#ACTIVE_BLOCK_BINARIES[@]} -gt 0 ]]; then
printf '%s\n' "${ACTIVE_BLOCK_BINARIES[@]}" | sort -u > "$BLOCK_STATE"
else
> "$BLOCK_STATE"
fi
# ── Timeshift daily backup ─────────────────────────────────────────────────────
TIMESHIFT_CRON="$CRON_DIR/ansipa-timeshift-backup"
if [[ "$WANT_TIMESHIFT_BACKUP" == true ]]; then
if [[ ! -f "$TIMESHIFT_CRON" ]]; then
if ! command -v timeshift &>/dev/null; then
warn "timeshift not found — add host to dev_mod_timeshift first. Cron will be installed anyway."
fi
log "Enabling daily Timeshift backups"
cat > "$TIMESHIFT_CRON" <<'CRON'
# ansipa-dev_timeshift-backup: managed by ansipa-enforce-policies — do not edit manually.
# Timeshift must be configured on this host (type + target device) before snapshots work.
0 3 * * * root /usr/bin/timeshift --create --comments "ansipa-daily" --tags D 2>&1 | logger -t timeshift-backup
CRON
chmod 644 "$TIMESHIFT_CRON"
fi
else
if [[ -f "$TIMESHIFT_CRON" ]]; then
rm -f "$TIMESHIFT_CRON"
log "Removed Timeshift backup cron (host left dev_timeshift-backup group)"
fi
fi
# ── Security scan ─────────────────────────────────────────────────────────────
SCAN_CRON="$CRON_DIR/ansipa-security-scan"
SCAN_SCRIPT="/usr/local/bin/ansipa-security-scan.sh"
if [[ "$WANT_SECURITY_SCAN" == true ]]; then
# (Re-)write the scan script so it stays current with this version of the enforcer.
cat > "$SCAN_SCRIPT" <<'SCAN'
#!/bin/bash
# ansipa-security-scan — daily ClamAV / rkhunter / chkrootkit run + SMB upload.
# Managed by ansipa-enforce-policies — do not edit manually.
LOG=/var/log/ansipa-security-scan.log
HOSTNAME=$(hostname -f 2>/dev/null || hostname)
DATE=$(date +%Y-%m-%d)
{
echo "=== ansipa-security-scan: $DATE $HOSTNAME ==="
if command -v freshclam &>/dev/null; then
freshclam --quiet 2>/dev/null || true
fi
if command -v clamscan &>/dev/null; then
clamscan -r --infected --quiet /home /etc /tmp /var/tmp 2>/dev/null || true
fi
if command -v rkhunter &>/dev/null; then
rkhunter --update --quiet 2>/dev/null || true
rkhunter --check --skip-keypress --quiet 2>/dev/null || true
fi
if command -v chkrootkit &>/dev/null; then
chkrootkit 2>/dev/null || true
fi
echo "=== scan complete ==="
} >> "$LOG" 2>&1
# ── Upload to server SMB share ────────────────────────────────────────────────
IPA_SERVER=$(awk '/^server[[:space:]]*=/{print $3}' /etc/ipa/default.conf 2>/dev/null || echo "")
if [[ -n "$IPA_SERVER" ]] && [[ -f /etc/ansipa-smb.creds ]] && command -v smbclient &>/dev/null; then
# Create host archive dir (mkdir is idempotent; errors suppressed).
smbclient "//$IPA_SERVER/ansipa-scans" -A /etc/ansipa-smb.creds \
-c "mkdir archive; mkdir archive\\$HOSTNAME; put $LOG archive\\$HOSTNAME\\$DATE.log" \
>> "$LOG" 2>&1 \
&& echo "[ansipa] Scan results uploaded to $IPA_SERVER/ansipa-scans/archive/$HOSTNAME/$DATE.log" >> "$LOG" \
|| echo "[ansipa][WARN] SMB upload failed — results remain local at $LOG" >> "$LOG"
else
echo "[ansipa] SMB upload skipped (no credentials or smbclient not found)." >> "$LOG"
fi
SCAN
chmod 755 "$SCAN_SCRIPT"
if [[ ! -f "$SCAN_CRON" ]]; then
log "Enabling daily security scans (ClamAV / rkhunter / chkrootkit)"
cat > "$SCAN_CRON" <<'CRON'
# ansipa-dev_security-scan: managed by ansipa-enforce-policies — do not edit manually.
# Install scan tools by adding the host to the dev_mod_anti-malware group.
0 2 * * * root /usr/local/bin/ansipa-security-scan.sh
CRON
chmod 644 "$SCAN_CRON"
fi
else
if [[ -f "$SCAN_CRON" ]]; then
rm -f "$SCAN_CRON"
rm -f "$SCAN_SCRIPT"
log "Removed security scan policy (host left dev_security-scan group)"
fi
fi
# ── Scan notification daemon ──────────────────────────────────────────────────
# usr_scan-notify is a FreeIPA *user* group (not a host group). The fetch-
# alerts timer runs fleet-wide on any host where the group exists; the profile.d
# snippet starts the notification daemon on login only for group members
# (checked via id(1) / SSSD so no IPA query is needed at login time).
# - Root timer (every 10 min): ansipa-fetch-alerts.sh downloads alerts from the
# server SMB share and places them in ~/administration/<hostname>/ per active user.
# - profile.d snippet: starts ansipa-scan-notify.sh as a user daemon on login;
# the daemon sends notify-send every 10 min while *.alert files remain.
# Deleting a file from ~/administration/ counts as acknowledgment.
#
# Requires: ansipa-fetch-alerts.sh and ansipa-scan-notify.sh deployed by
# deploy-ansipa-policies.yml (static scripts — not written inline here).
FETCH_SVC="/etc/systemd/system/ansipa-fetch-alerts.service"
FETCH_TIMER="/etc/systemd/system/ansipa-fetch-alerts.timer"
NOTIFY_PROFILED="/etc/profile.d/ansipa-notify.sh"
if [[ "$WANT_SCAN_NOTIFY" == true ]]; then
if [[ ! -x /usr/local/bin/ansipa-fetch-alerts.sh ]]; then
warn "ansipa-fetch-alerts.sh not found — run deploy-ansipa-policies.yml first."
fi
if [[ ! -f "$FETCH_SVC" ]]; then
log "Installing ansipa-fetch-alerts systemd service + timer"
cat > "$FETCH_SVC" <<'UNIT'
[Unit]
Description=Fetch Ansipa security alerts from the server SMB share
After=network-online.target sssd.service
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/ansipa-fetch-alerts.sh
StandardOutput=journal
StandardError=journal
UNIT
cat > "$FETCH_TIMER" <<'UNIT'
[Unit]
Description=Periodic ansipa security alert fetch
[Timer]
OnBootSec=2min
OnUnitActiveSec=10min
[Install]
WantedBy=timers.target
UNIT
systemctl daemon-reload
systemctl enable --now ansipa-fetch-alerts.timer
log "ansipa-fetch-alerts.timer enabled"
fi
if [[ ! -f "$NOTIFY_PROFILED" ]]; then
log "Installing /etc/profile.d/ansipa-notify.sh"
cat > "$NOTIFY_PROFILED" <<'PROFILED'
# ansipa-notify: launch the scan alert notification daemon on login for
# members of the usr_scan-notify FreeIPA user group.
# Managed by ansipa-enforce-policies — do not edit manually.
_NOTIFY_DAEMON=/usr/local/bin/ansipa-scan-notify.sh
if [[ -x "$_NOTIFY_DAEMON" ]] && \
id -nG 2>/dev/null | grep -qw "usr_scan-notify" && \
! pgrep -u "$(id -u)" -f "ansipa-scan-notify" >/dev/null 2>&1; then
"$_NOTIFY_DAEMON" &
disown
fi
unset _NOTIFY_DAEMON
PROFILED
chmod 644 "$NOTIFY_PROFILED"
fi
else
if [[ -f "$FETCH_TIMER" ]]; then
systemctl disable --now ansipa-fetch-alerts.timer 2>/dev/null || true
rm -f "$FETCH_SVC" "$FETCH_TIMER"
systemctl daemon-reload
log "Removed ansipa-fetch-alerts timer (usr_scan-notify user group no longer exists)"
fi
if [[ -f "$NOTIFY_PROFILED" ]]; then
rm -f "$NOTIFY_PROFILED"
log "Removed /etc/profile.d/ansipa-notify.sh"
fi
fi
# ── Daemon enable / disable ───────────────────────────────────────────────────
# dev_daemon-enable-<unit>: ensure the unit is enabled and running.
# Leaving the group reverts: unit is disabled and stopped.
# dev_daemon-disable-<unit>: ensure the unit is disabled and stopped.
# Leaving the group reverts: unit is re-enabled and started.
# <unit> may omit the .service suffix; systemd accepts both forms.
# Conflicts (unit in both lists): logged as a warning, unit is left untouched.
DAEMON_ENABLE_STATE="$STATE_DIR/daemon-enabled"
DAEMON_DISABLE_STATE="$STATE_DIR/daemon-disabled"
[[ -f "$DAEMON_ENABLE_STATE" ]] || touch "$DAEMON_ENABLE_STATE"
[[ -f "$DAEMON_DISABLE_STATE" ]] || touch "$DAEMON_DISABLE_STATE"
# Append .service only when the name has no unit-type suffix already.
_svc_unit() { [[ "$1" == *.* ]] && echo "$1" || echo "${1}.service"; }
_in_enable_list() { local n="$1"; for s in "${ACTIVE_DAEMON_ENABLE[@]}"; do [[ "$s" == "$n" ]] && return 0; done; return 1; }
_in_disable_list() { local n="$1"; for s in "${ACTIVE_DAEMON_DISABLE[@]}"; do [[ "$s" == "$n" ]] && return 0; done; return 1; }
# Apply enable policies
for _SVC in "${ACTIVE_DAEMON_ENABLE[@]}"; do
if _in_disable_list "$_SVC"; then
warn "Conflict: '$_SVC' is in both daemon-enable and daemon-disable groups — skipped"
continue
fi
_UNIT=$(_svc_unit "$_SVC")
_EN=$(systemctl is-enabled "$_UNIT" 2>/dev/null || echo "not-found")
_AC=$(systemctl is-active "$_UNIT" 2>/dev/null || echo "inactive")
if [[ "$_EN" != "enabled" || "$_AC" != "active" ]]; then
log "Enabling service: $_UNIT (enabled=$_EN active=$_AC)"
systemctl enable --now "$_UNIT" 2>/dev/null \
&& log "Service enabled: $_UNIT" \
|| warn "Failed to enable $_UNIT — unit may not exist on this host"
fi
done
# Apply disable policies
for _SVC in "${ACTIVE_DAEMON_DISABLE[@]}"; do
if _in_enable_list "$_SVC"; then
continue # conflict already warned above
fi
_UNIT=$(_svc_unit "$_SVC")
_EN=$(systemctl is-enabled "$_UNIT" 2>/dev/null || echo "not-found")
_AC=$(systemctl is-active "$_UNIT" 2>/dev/null || echo "inactive")
if [[ "$_EN" == "enabled" || "$_AC" == "active" ]]; then
log "Disabling service: $_UNIT (enabled=$_EN active=$_AC)"
systemctl disable --now "$_UNIT" 2>/dev/null \
&& log "Service disabled: $_UNIT" \
|| warn "Failed to disable $_UNIT — unit may not exist on this host"
fi
done
# Revert: host left a daemon-enable group → disable and stop the service
while IFS= read -r _OLD; do
[[ -z "$_OLD" ]] && continue
if ! _in_enable_list "$_OLD"; then
_UNIT=$(_svc_unit "$_OLD")
log "Reverting enable policy: disabling $_UNIT (host left daemon-enable group)"
systemctl disable --now "$_UNIT" 2>/dev/null \
|| warn "Failed to disable (revert) $_UNIT"
fi
done < "$DAEMON_ENABLE_STATE"
# Revert: host left a daemon-disable group → re-enable and start the service
while IFS= read -r _OLD; do
[[ -z "$_OLD" ]] && continue
if ! _in_disable_list "$_OLD"; then
_UNIT=$(_svc_unit "$_OLD")
log "Reverting disable policy: enabling $_UNIT (host left daemon-disable group)"
systemctl enable --now "$_UNIT" 2>/dev/null \
|| warn "Failed to enable (revert) $_UNIT"
fi
done < "$DAEMON_DISABLE_STATE"
# Persist current state
if [[ ${#ACTIVE_DAEMON_ENABLE[@]} -gt 0 ]]; then
printf '%s\n' "${ACTIVE_DAEMON_ENABLE[@]}" | sort -u > "$DAEMON_ENABLE_STATE"
else
> "$DAEMON_ENABLE_STATE"
fi
if [[ ${#ACTIVE_DAEMON_DISABLE[@]} -gt 0 ]]; then
printf '%s\n' "${ACTIVE_DAEMON_DISABLE[@]}" | sort -u > "$DAEMON_DISABLE_STATE"
else
> "$DAEMON_DISABLE_STATE"
fi
# ── Per-device local sudo grants ──────────────────────────────────────────────
# dev_local-sudo-<username>: write a sudoers drop-in granting <username> full sudo on
# this specific device. The drop-in is removed when the host leaves the group.
LOCAL_SUDO_DIR="/etc/sudoers.d"
LOCAL_SUDO_STATE="$STATE_DIR/local-sudo-users"
[[ -f "$LOCAL_SUDO_STATE" ]] || touch "$LOCAL_SUDO_STATE"
for _USER in "${ACTIVE_LOCAL_SUDO_USERS[@]}"; do
_DROPIN="$LOCAL_SUDO_DIR/ansipa-local-sudo-${_USER}"
if [[ ! -f "$_DROPIN" ]]; then
log "Granting local sudo to $_USER on this device"
echo "$_USER ALL=(ALL) ALL" > "$_DROPIN"
chmod 440 "$_DROPIN"
fi
grep -qxF "$_USER" "$LOCAL_SUDO_STATE" 2>/dev/null || echo "$_USER" >> "$LOCAL_SUDO_STATE"
done
# Revoke sudo for users no longer in any active dev_local-sudo-* group.
while IFS= read -r _OLD_USER; do
[[ -z "$_OLD_USER" ]] && continue
_still_active=false
for _U in "${ACTIVE_LOCAL_SUDO_USERS[@]}"; do
[[ "$_U" == "$_OLD_USER" ]] && _still_active=true && break
done
if [[ "$_still_active" == false ]]; then
_DROPIN="$LOCAL_SUDO_DIR/ansipa-local-sudo-${_OLD_USER}"
if [[ -f "$_DROPIN" ]]; then
rm -f "$_DROPIN"
log "Revoked local sudo for $_OLD_USER (host left dev_local-sudo-$_OLD_USER group)"
fi
fi
done < "$LOCAL_SUDO_STATE"
# Persist current local sudo users.
if [[ ${#ACTIVE_LOCAL_SUDO_USERS[@]} -gt 0 ]]; then
printf '%s\n' "${ACTIVE_LOCAL_SUDO_USERS[@]}" | sort -u > "$LOCAL_SUDO_STATE"
else
> "$LOCAL_SUDO_STATE"
fi
unset _USER _DROPIN _OLD_USER _still_active _U
# ── No-local-users policy ──────────────────────────────────────────────────────
# dev_no-local-users: lock the passwords of root and all local users (UID >= 1000)
# so that only FreeIPA domain accounts with centrally-managed sudo rules can
# authenticate and gain elevated privileges.
# Leaving the group reverts: every account locked by this policy is unlocked.
NO_LOCAL_USERS_STATE="$STATE_DIR/no-local-users"
_apply_no_local_users() {
log "Applying no_local_users policy — locking local account passwords"
[[ -f "$NO_LOCAL_USERS_STATE" ]] || touch "$NO_LOCAL_USERS_STATE"
while IFS=: read -r uname _ uid _; do
[[ "$uid" =~ ^[0-9]+$ ]] || continue
{ [[ "$uid" == "0" ]] || [[ "$uid" -ge 1000 ]]; } || continue
# Skip accounts already tracked (locked on a previous run)
grep -qxF "$uname" "$NO_LOCAL_USERS_STATE" 2>/dev/null && continue
# Lock only accounts that currently have a real (unlocked) password hash
local hash
hash=$(getent shadow "$uname" 2>/dev/null | cut -d: -f2 || true)
[[ -z "$hash" || "$hash" == '!'* || "$hash" == '*'* ]] && continue
if passwd -l "$uname" &>/dev/null; then
echo "$uname" >> "$NO_LOCAL_USERS_STATE"
log "Locked local account: $uname"
else
warn "Failed to lock local account: $uname"
fi
done < /etc/passwd
}
_revert_no_local_users() {
[[ -f "$NO_LOCAL_USERS_STATE" ]] || return 0
log "Reverting no_local_users policy — unlocking previously locked accounts"
while IFS= read -r uname; do
[[ -z "$uname" ]] && continue
if passwd -u "$uname" &>/dev/null; then
log "Unlocked local account: $uname"
else
warn "Failed to unlock local account: $uname (may have been removed)"
fi
done < "$NO_LOCAL_USERS_STATE"
> "$NO_LOCAL_USERS_STATE"
}
if [[ "$WANT_NO_LOCAL_USERS" == true ]]; then
_apply_no_local_users
else
if [[ -f "$NO_LOCAL_USERS_STATE" ]] && [[ -s "$NO_LOCAL_USERS_STATE" ]]; then
_revert_no_local_users
fi
fi
# ── usr_admin: global sudo for FreeIPA admin group ───────────────────────────
# When the FreeIPA user group usr_admin exists, install a sudoers drop-in that
# grants full sudo to that group (resolved via SSSD/NSS) on this host.
# The drop-in is removed when the group is deleted from FreeIPA.
USR_ADMIN_DROPIN="/etc/sudoers.d/ansipa-usr-admin"
USR_ADMIN_STATE="$STATE_DIR/usr-admin"
if [[ "$WANT_USR_ADMIN" == true ]]; then
if [[ ! -f "$USR_ADMIN_DROPIN" ]]; then
log "Installing usr_admin sudoers drop-in"
printf '%%usr_admin ALL=(ALL:ALL) ALL\n' > "$USR_ADMIN_DROPIN"
chmod 440 "$USR_ADMIN_DROPIN"
fi
touch "$USR_ADMIN_STATE"
else
if [[ -f "$USR_ADMIN_STATE" ]] || [[ -f "$USR_ADMIN_DROPIN" ]]; then
rm -f "$USR_ADMIN_DROPIN" "$USR_ADMIN_STATE"
log "Removed usr_admin sudoers drop-in (IPA group no longer exists)"
fi
fi
# ── usr_prt_<printer>: per-user printer auto-add via FreeIPA groups ───────────
# For each usr_prt_<printer> group in FreeIPA:
# - The group description must contain the printer URI
# (e.g. "ipps://printserver.corp.example.com/printers/hp-office")
# - When a logged-in user is a member of the group, the printer is added to
# CUPS on this host and restricted to that user (allow:<user>).
# - Multiple users in the group can each gain access on the same host.
# - When a user leaves the group their allow entry is revoked; when no users
# remain the printer is removed from CUPS on this host entirely.
# The profile.d snippet /etc/profile.d/ansipa-printers.sh triggers this enforcer
# at login so printers appear immediately rather than waiting for the 30-min tick.
#
# To register a printer in FreeIPA:
# ipa group-mod usr_prt_hp-office \
# --desc='ipps://printserver.corp.example.com/printers/hp-office'
PRT_STATE_DIR="$STATE_DIR/printer-users"
PRT_DEFS_DIR="$STATE_DIR/printer-defs"
PRT_PROFILED="/etc/profile.d/ansipa-printers.sh"
PRT_SUDO_DROPIN="/etc/sudoers.d/ansipa-printers-trigger"
mkdir -p "$PRT_STATE_DIR" "$PRT_DEFS_DIR"
# Read/write per-printer authorized-user state files (<printer>.users)
_prt_read_users() {
local sf="$PRT_STATE_DIR/${1}.users"
[[ -f "$sf" ]] && grep -v '^$' "$sf" | sort -u || true
}
_prt_write_users() {
local printer="$1"; shift
if [[ $# -gt 0 ]]; then
printf '%s\n' "$@" | sort -u > "$PRT_STATE_DIR/${printer}.users"
else
> "$PRT_STATE_DIR/${printer}.users"
fi
}
# Apply the full allow list from state (or remove printer if list is empty)
_prt_apply_acl() {
local printer="$1"
local -a users
mapfile -t users < <(_prt_read_users "$printer")
if [[ ${#users[@]} -gt 0 ]]; then
local allow_list
allow_list=$(IFS=','; echo "${users[*]}")
lpadmin -p "$printer" -u "allow:${allow_list}" 2>/dev/null \
|| warn "lpadmin ACL update failed for printer $printer"
else
lpadmin -x "$printer" 2>/dev/null \
&& log "Removed printer $printer (no authorized users remaining)" \
|| warn "lpadmin -x failed for printer $printer"
rm -f "$PRT_STATE_DIR/${printer}.users"
fi
}
_prt_is_active() {
local needle="$1"
for _ap in "${ACTIVE_PRT_PRINTERS[@]+"${ACTIVE_PRT_PRINTERS[@]}"}"; do
[[ "$_ap" == "$needle" ]] && return 0
done
return 1
}
if command -v lpadmin &>/dev/null; then
# ── Process each active usr_prt_* group ──────────────────────────────────
for _i in "${!ACTIVE_PRT_PRINTERS[@]}"; do
_PRT="${ACTIVE_PRT_PRINTERS[$_i]}"
_GRP="${ACTIVE_PRT_GROUPS[$_i]}"
# Fetch URI from IPA group description (must start with a scheme://)
_URI=$(ipa group-show "$_GRP" --all 2>/dev/null \
| awk -F': ' '/Description:/{print $2; exit}' \
| grep -E '^[a-z][a-z+.-]+://' || true)
if [[ -z "$_URI" ]]; then
warn "Group $_GRP has no printer URI in its description — set it with:"
warn " ipa group-mod $_GRP --desc='ipps://printserver/printers/$_PRT'"
continue
fi
echo "$_URI" > "$PRT_DEFS_DIR/${_GRP}.uri"
mapfile -t _CUR_USERS < <(_prt_read_users "$_PRT")
_UPDATED=false
# Check logged-in users for new members to grant access to
while IFS= read -r _LUSER; do
[[ -z "$_LUSER" ]] && continue
id -Gn "$_LUSER" 2>/dev/null | tr ' ' '\n' | grep -qxF "$_GRP" || continue
printf '%s\n' "${_CUR_USERS[@]+"${_CUR_USERS[@]}"}" | grep -qxF "$_LUSER" && continue
# Add the printer to CUPS if not present yet
if ! lpstat -p "$_PRT" &>/dev/null; then
log "Adding printer $_PRT (URI: $_URI)"
lpadmin -p "$_PRT" -v "$_URI" -m everywhere -E 2>/dev/null \
|| lpadmin -p "$_PRT" -v "$_URI" -E 2>/dev/null \
|| { warn "lpadmin failed to add printer $_PRT"; continue; }
fi
log "Granting printer $_PRT access to $_LUSER"
_CUR_USERS+=("$_LUSER")
_UPDATED=true
done < <(who -u 2>/dev/null | awk '{print $1}' | sort -u)
# Revoke users who left the group
_VALID_USERS=()
for _U in "${_CUR_USERS[@]+"${_CUR_USERS[@]}"}"; do
if id -Gn "$_U" 2>/dev/null | tr ' ' '\n' | grep -qxF "$_GRP"; then
_VALID_USERS+=("$_U")
else
log "Revoking printer $_PRT access for $_U (no longer in $_GRP)"
_UPDATED=true
fi
done
if [[ "$_UPDATED" == true ]]; then
_prt_write_users "$_PRT" "${_VALID_USERS[@]+"${_VALID_USERS[@]}"}"
_prt_apply_acl "$_PRT"
fi
done
unset _i _PRT _GRP _URI _LUSER _U _CUR_USERS _VALID_USERS _UPDATED _ap
# ── Remove printers whose IPA group was deleted ───────────────────────────
shopt -s nullglob
for _sf in "$PRT_STATE_DIR"/*.users; do
[[ -f "$_sf" ]] || continue
_PRT=$(basename "$_sf" .users)
_prt_is_active "$_PRT" && continue
log "Removing printer $_PRT (IPA group usr_prt_$_PRT deleted)"
lpadmin -x "$_PRT" 2>/dev/null || true
rm -f "$_sf"
done
shopt -u nullglob
unset _sf _PRT
# ── profile.d login trigger ───────────────────────────────────────────────
# Installs a profile.d snippet + NOPASSWD sudo rule so that when a user
# logs in, the enforcer runs immediately and adds their printers without
# waiting for the next 30-minute timer tick.
if [[ ${#ACTIVE_PRT_PRINTERS[@]} -gt 0 ]]; then
if [[ ! -f "$PRT_SUDO_DROPIN" ]]; then
log "Installing ansipa-printers sudo trigger rule"
printf 'ALL ALL=(root) NOPASSWD: /usr/bin/systemctl start ansipa-enforce-policies.service\n' \
> "$PRT_SUDO_DROPIN"
chmod 440 "$PRT_SUDO_DROPIN"
fi
if [[ ! -f "$PRT_PROFILED" ]]; then
log "Installing /etc/profile.d/ansipa-printers.sh"
cat > "$PRT_PROFILED" <<'PROFILED'
# ansipa-printers: trigger policy enforcer at login to auto-add printers.
# Managed by ansipa-enforce-policies — do not edit manually.
sudo -n /usr/bin/systemctl start ansipa-enforce-policies.service 2>/dev/null &
disown 2>/dev/null || true
PROFILED
chmod 644 "$PRT_PROFILED"
fi
else
if [[ -f "$PRT_SUDO_DROPIN" ]]; then
rm -f "$PRT_SUDO_DROPIN"
log "Removed ansipa-printers sudo trigger (no active printer groups)"
fi
if [[ -f "$PRT_PROFILED" ]]; then
rm -f "$PRT_PROFILED"
log "Removed /etc/profile.d/ansipa-printers.sh (no active printer groups)"
fi
fi
else
[[ ${#ACTIVE_PRT_PRINTERS[@]} -gt 0 ]] && \
warn "lpadmin not found — install cups to enforce printer policies"
fi
unset _ap
# ── usr_smb_<r|rw>_<name>: auto-mount network shares in user home dirs ────────
# For each usr_smb_r_<name> or usr_smb_rw_<name> group in FreeIPA:
# - The FreeIPA container hosts a Samba share [usr-<name>-r] or [usr-<name>-rw]
# under /data/smb-shares/<name>/ with UNIX group permissions.
# - When a logged-in user is a member of the group the share is mounted at
# ~/name (a new folder named after the share, no r/rw suffix).
# - rw membership takes precedence over r if the user is in both groups for
# the same share name.
# - The mount is tracked in the state file; leaving the group unmounts it.
#
# Credential string (server, Samba user, password) is stored in the IPA group
# description by ansipa-smb-setup.sh: cifs://<ipa-host>:<samba-user>:<password>
#
# Requires: cifs-utils installed on the client.
SMB_MOUNT_STATE="$STATE_DIR/smb-mounts"
[[ -f "$SMB_MOUNT_STATE" ]] || touch "$SMB_MOUNT_STATE"
SMB_CREDS_DIR="$STATE_DIR/smb-creds"
mkdir -p "$SMB_CREDS_DIR"
chmod 700 "$SMB_CREDS_DIR"
if command -v mount.cifs &>/dev/null; then
# ── Discover usr_smb_* groups from IPA ───────────────────────────────────
declare -A _SMB_R_GRP _SMB_RW_GRP # share-name → IPA group name
while IFS= read -r _g; do
[[ -z "$_g" ]] && continue
if [[ "$_g" =~ ^usr_smb_r_(.+)$ ]]; then _SMB_R_GRP["${BASH_REMATCH[1]}"]="$_g"
elif [[ "$_g" =~ ^usr_smb_rw_(.+)$ ]]; then _SMB_RW_GRP["${BASH_REMATCH[1]}"]="$_g"
fi
done < <(ipa group-find --pkey-only 2>/dev/null \
| awk '/Group name:/ {print $NF}' \
| grep "^usr_smb_" | sort -u || true)
unset _g
# Union of r + rw share names.
declare -A _SMB_NAMES
for _n in "${!_SMB_R_GRP[@]}" "${!_SMB_RW_GRP[@]}"; do _SMB_NAMES["$_n"]=1; done
unset _n
# ── Helpers ───────────────────────────────────────────────────────────────
# Outputs "host samba_user password" when description contains a cifs:// credential.
_smb_parse_cred() {
local _desc
_desc=$(ipa group-show "$1" --all 2>/dev/null \
| awk -F': ' '/Description:/{print $2; exit}' || true)
[[ "$_desc" =~ ^cifs://([^:]+):([^:]+):(.+)$ ]] && \
echo "${BASH_REMATCH[1]} ${BASH_REMATCH[2]} ${BASH_REMATCH[3]}"
}
_smb_member() { id -nG "$1" 2>/dev/null | tr ' ' '\n' | grep -qxF "$2"; }
# ── Collect active logged-in users (loginctl → who fallback) ─────────────
declare -A _SMB_SESS # user → home
_smb_add_sess() {
local _u
[[ "$2" == "loginctl" ]] && _u=$(awk '{print $3}' <<< "$1") \
|| _u=$(awk '{print $1}' <<< "$1")
[[ -z "$_u" || "$_u" == "root" || "$_u" == "USER" ]] && return 0
local _h; _h=$(getent passwd "$_u" | cut -d: -f6 2>/dev/null) || return 0
[[ -d "$_h" ]] && _SMB_SESS["$_u"]="$_h"
}
if loginctl list-sessions --no-legend &>/dev/null; then
while IFS= read -r _L; do _smb_add_sess "$_L" "loginctl"
done < <(loginctl list-sessions --no-legend 2>/dev/null)
else
while IFS= read -r _L; do _smb_add_sess "$_L" "who"
done < <(who 2>/dev/null)
fi
unset _L
# ── Apply: mount shares for logged-in members ─────────────────────────────
for _SMB_NAME in "${!_SMB_NAMES[@]}"; do
_R_GRP="${_SMB_R_GRP[$_SMB_NAME]:-}"
_RW_GRP="${_SMB_RW_GRP[$_SMB_NAME]:-}"
# Both access levels use the same credential; read from whichever has it.
_CRED_LINE=""
[[ -n "$_RW_GRP" ]] && _CRED_LINE=$(_smb_parse_cred "$_RW_GRP")
[[ -z "$_CRED_LINE" && -n "$_R_GRP" ]] && _CRED_LINE=$(_smb_parse_cred "$_R_GRP")
if [[ -z "$_CRED_LINE" ]]; then
warn "No credential in IPA description for share $_SMB_NAME — skipped (run ansipa-smb.service on FreeIPA container)"
continue
fi
read -r _SMB_HOST _SMB_SUSER _SMB_SPASS <<< "$_CRED_LINE"
_CREDS_FILE="$SMB_CREDS_DIR/${_SMB_NAME}.creds"
printf 'username=%s\npassword=%s\ndomain=WORKGROUP\n' "$_SMB_SUSER" "$_SMB_SPASS" \
> "$_CREDS_FILE"
chmod 600 "$_CREDS_FILE"
for _SMB_U in "${!_SMB_SESS[@]}"; do
# Determine per-user access level (rw beats r).
_SMB_LVL=""
[[ -n "$_RW_GRP" ]] && _smb_member "$_SMB_U" "$_RW_GRP" && _SMB_LVL="rw"
[[ -z "$_SMB_LVL" && -n "$_R_GRP" ]] && _smb_member "$_SMB_U" "$_R_GRP" && _SMB_LVL="r"
[[ -z "$_SMB_LVL" ]] && continue
_SMB_H="${_SMB_SESS[$_SMB_U]}"
_SMB_MP="$_SMB_H/$_SMB_NAME"
_SMB_SHARE="usr-${_SMB_NAME}-${_SMB_LVL}"
if mountpoint -q "$_SMB_MP" 2>/dev/null; then
grep -qxF "${_SMB_U}:${_SMB_NAME}" "$SMB_MOUNT_STATE" 2>/dev/null || \
echo "${_SMB_U}:${_SMB_NAME}" >> "$SMB_MOUNT_STATE"
continue
fi
mkdir -p "$_SMB_MP"
chown "$_SMB_U" "$_SMB_MP" 2>/dev/null || true
if mount -t cifs "//${_SMB_HOST}/${_SMB_SHARE}" "$_SMB_MP" \
-o "credentials=${_CREDS_FILE},uid=${_SMB_U},gid=${_SMB_U},file_mode=0644,dir_mode=0755,nounix,noserverino" \
2>/dev/null; then
log "Mounted //${_SMB_HOST}/${_SMB_SHARE}$_SMB_MP for $_SMB_U"
grep -qxF "${_SMB_U}:${_SMB_NAME}" "$SMB_MOUNT_STATE" 2>/dev/null || \
echo "${_SMB_U}:${_SMB_NAME}" >> "$SMB_MOUNT_STATE"
else
warn "Failed to mount //${_SMB_HOST}/${_SMB_SHARE} for $_SMB_U"
rmdir "$_SMB_MP" 2>/dev/null || true
fi
done
done
unset _SMB_NAME _R_GRP _RW_GRP _CRED_LINE _SMB_HOST _SMB_SUSER _SMB_SPASS \
_CREDS_FILE _SMB_U _SMB_H _SMB_MP _SMB_SHARE _SMB_LVL
# ── Revert: unmount shares whose group was removed or user left ───────────
_NEW_SMB_MOUNTS=()
while IFS=: read -r _OLD_USER _OLD_NAME; do
[[ -z "$_OLD_USER" || -z "$_OLD_NAME" ]] && continue
_OLD_HOME=$(getent passwd "$_OLD_USER" | cut -d: -f6 2>/dev/null) || continue
_OLD_MPT="$_OLD_HOME/$_OLD_NAME"
_STILL_MEMBER=false
if [[ -n "${_SMB_R_GRP[$_OLD_NAME]+x}" ]] && \
_smb_member "$_OLD_USER" "${_SMB_R_GRP[$_OLD_NAME]}"; then
_STILL_MEMBER=true
elif [[ -n "${_SMB_RW_GRP[$_OLD_NAME]+x}" ]] && \
_smb_member "$_OLD_USER" "${_SMB_RW_GRP[$_OLD_NAME]}"; then
_STILL_MEMBER=true
fi
if [[ "$_STILL_MEMBER" == true ]]; then
_NEW_SMB_MOUNTS+=("${_OLD_USER}:${_OLD_NAME}")
else
umount -l "$_OLD_MPT" 2>/dev/null || true
rmdir "$_OLD_MPT" 2>/dev/null || true
log "Unmounted ${_OLD_MPT} for ${_OLD_USER} (left usr_smb_*_${_OLD_NAME})"
fi
done < "$SMB_MOUNT_STATE"
unset _OLD_USER _OLD_NAME _OLD_HOME _OLD_MPT _STILL_MEMBER
if [[ ${#_NEW_SMB_MOUNTS[@]} -gt 0 ]]; then
printf '%s\n' "${_NEW_SMB_MOUNTS[@]}" | sort -u > "$SMB_MOUNT_STATE"
else
> "$SMB_MOUNT_STATE"
fi
# Credential files are written fresh on each run; remove stale ones.
rm -f "$SMB_CREDS_DIR"/*.creds 2>/dev/null || true
unset _SMB_R_GRP _SMB_RW_GRP _SMB_NAMES _SMB_SESS _NEW_SMB_MOUNTS
else
warn "mount.cifs not found — install cifs-utils to enable usr_smb_* policies"
[[ -s "$SMB_MOUNT_STATE" ]] && \
warn "smb-mounts state is non-empty — cannot revert existing mounts without cifs-utils" || true
fi
# ── dev_ssh_<userid>: distribute SSH public keys to user home dirs ────────────
# When this host is in dev_ssh_<userid>, the IPA user <userid>'s public keys are
# written to /home/<userid>/.ssh/authorized_keys in an ansipa-managed section.
# This enables Amir (or any named IPA user) to SSH into this device using the key
# registered in their IPA profile — the same key reaches every enrolled device.
#
# Keys are stored in the IPA user object (not on each device); add via:
# ipa user-mod <userid> --sshpubkey="ssh-ed25519 AAAA..."
# Multiple keys (e.g. workstation + laptop) are each added with --sshpubkey.
#
# The ansipa-managed section in authorized_keys is marked with:
# # BEGIN ansipa-ssh-managed … # END ansipa-ssh-managed
# This preserves any manually-added keys and is cleanly removed on revert.
#
# Leaving the host group removes only the ansipa-managed section on the next run.
SSH_KEY_STATE="$STATE_DIR/ssh-keys"
[[ -f "$SSH_KEY_STATE" ]] || touch "$SSH_KEY_STATE"
# Write or clear the ansipa-managed section in an authorized_keys file.
# Usage: _ssh_write_keys <user> <homedir> [key1 key2 ...]
# Passing no keys removes the section (revert).
_ssh_write_keys() {
local _u="$1" _h="$2"; shift 2
local _auth="$_h/.ssh/authorized_keys"
mkdir -p "$_h/.ssh"
chmod 700 "$_h/.ssh"
[[ -f "$_auth" ]] || touch "$_auth"
# Strip any existing ansipa section.
local _rest
_rest=$(awk '
/^# BEGIN ansipa-ssh-managed$/ { skip=1; next }
skip && /^# END ansipa-ssh-managed$/ { skip=0; next }
!skip
' "$_auth" 2>/dev/null || true)
if [[ $# -gt 0 ]]; then
{ [[ -n "$_rest" ]] && printf '%s\n' "$_rest"; \
echo "# BEGIN ansipa-ssh-managed"; \
printf '%s\n' "$@"; \
echo "# END ansipa-ssh-managed"; } > "$_auth"
else
[[ -n "$_rest" ]] && printf '%s\n' "$_rest" > "$_auth" || > "$_auth"
fi
chmod 600 "$_auth"
chown "$_u:$_u" "$_h/.ssh" "$_auth" 2>/dev/null || true
}
declare -A _SSH_APPLIED # userid → 1 (applied this run)
for _SSH_UID in "${ACTIVE_SSH_USERS[@]+"${ACTIVE_SSH_USERS[@]}"}"; do
# Verify the IPA user exists.
ipa user-show "$_SSH_UID" &>/dev/null || {
warn "dev_ssh_${_SSH_UID}: IPA user '$_SSH_UID' not found — skipping"
continue
}
# Fetch SSH public keys from IPA user profile.
# `ipa user-show --all` shows: " SSH public key: <key>"
# " SSH public key fingerprint: ..." is a separate field — exclude it.
_SSH_KEYS=()
mapfile -t _SSH_KEYS < <(
ipa user-show "$_SSH_UID" --all 2>/dev/null \
| grep "^ SSH public key:" | grep -v "fingerprint" \
| sed 's/^ SSH public key: //' || true
)
if [[ ${#_SSH_KEYS[@]} -eq 0 ]]; then
warn "dev_ssh_${_SSH_UID}: '$_SSH_UID' has no SSH keys in IPA — add via:"
warn " ipa user-mod $_SSH_UID --sshpubkey='ssh-ed25519 AAAA...'"
continue
fi
# Resolve home directory via SSSD (works for IPA users once SSSD is running).
_SSH_HOME=$(getent passwd "$_SSH_UID" 2>/dev/null | cut -d: -f6 || true)
if [[ -z "$_SSH_HOME" ]]; then
warn "dev_ssh_${_SSH_UID}: '$_SSH_UID' not resolvable via getent — is SSSD running?"
continue
fi
# Create home dir now if it doesn't yet exist (IPA user may not have logged in).
if [[ ! -d "$_SSH_HOME" ]]; then
log "Creating home dir for $_SSH_UID: $_SSH_HOME"
mkdir -p "$_SSH_HOME"
chown "$_SSH_UID:$_SSH_UID" "$_SSH_HOME" 2>/dev/null || true
chmod 700 "$_SSH_HOME"
fi
_ssh_write_keys "$_SSH_UID" "$_SSH_HOME" "${_SSH_KEYS[@]}"
log "SSH keys for $_SSH_UID: ${#_SSH_KEYS[@]} key(s) written to $_SSH_HOME/.ssh/authorized_keys"
_SSH_APPLIED["$_SSH_UID"]=1
grep -qxF "$_SSH_UID" "$SSH_KEY_STATE" 2>/dev/null || echo "$_SSH_UID" >> "$SSH_KEY_STATE"
done
unset _SSH_UID _SSH_KEYS _SSH_HOME
# Revert: remove ansipa keys for users whose dev_ssh_* group was removed from this host.
_NEW_SSH_STATE=()
while IFS= read -r _OLD_SSH_UID; do
[[ -z "$_OLD_SSH_UID" ]] && continue
if [[ -n "${_SSH_APPLIED[$_OLD_SSH_UID]+x}" ]]; then
_NEW_SSH_STATE+=("$_OLD_SSH_UID")
else
_OLD_SSH_HOME=$(getent passwd "$_OLD_SSH_UID" 2>/dev/null | cut -d: -f6 || true)
if [[ -n "$_OLD_SSH_HOME" ]]; then
_ssh_write_keys "$_OLD_SSH_UID" "$_OLD_SSH_HOME" # no keys = remove section
log "Removed ansipa SSH keys for $_OLD_SSH_UID (host left dev_ssh_${_OLD_SSH_UID})"
fi
fi
done < "$SSH_KEY_STATE"
unset _OLD_SSH_UID _OLD_SSH_HOME
if [[ ${#_NEW_SSH_STATE[@]} -gt 0 ]]; then
printf '%s\n' "${_NEW_SSH_STATE[@]}" | sort -u > "$SSH_KEY_STATE"
else
> "$SSH_KEY_STATE"
fi
unset _SSH_APPLIED _NEW_SSH_STATE
# ── CheckMK monitoring policies ───────────────────────────────────────────────
# dev_mon_base: Install CheckMK agent + register host; built-in agent checks
# handle CPU/RAM/disk/uptime; custom check reports package count.
# dev_mon_malware: ClamAV scan result local check (reads dev_security-scan log).
# dev_mon_timeshift: Timeshift snapshot age check (warn >5d, crit >10d).
# dev_mon_power: CPU package power via Intel RAPL / lm-sensors.
# usr_mon_logins: SSH login attempts (success + failure) in last 24h.
#
# CheckMK agent runs on port 6556 (pull mode — requires CMK server network access
# to clients). The host registers itself via the CMK REST API on policy apply.
# Local check scripts are in /usr/lib/check_mk_agent/local/ — executed by the
# agent on every poll and also visible via `check_mk_agent` on the client.
#
# Credentials are stored in the IPA dev_mon_base hostgroup description by
# ansipa-checkmk-setup.sh on the IPA container:
# cmk://<host>:<port>/<site>:automation:<secret>
MON_LOCAL_DIR="/usr/lib/check_mk_agent/local"
MON_STATE="$STATE_DIR/mon-registered" # present when host is registered in CMK
MON_CHECKS_STATE="$STATE_DIR/mon-checks" # list of installed local check names
MON_CMK_CREDS="$STATE_DIR/mon-cmk-creds" # CMK connection vars for push scripts
[[ -f "$MON_CHECKS_STATE" ]] || touch "$MON_CHECKS_STATE"
# Helpers: check active group lists
_mon_dev_active() { local n="$1"; for _m in "${ACTIVE_MON_DEV[@]+"${ACTIVE_MON_DEV[@]}"}"; do [[ "$_m" == "$n" ]] && return 0; done; return 1; }
_mon_usr_active() { local n="$1"; for _m in "${ACTIVE_MON_USR[@]+"${ACTIVE_MON_USR[@]}"}"; do [[ "$_m" == "$n" ]] && return 0; done; return 1; }
# Parse CheckMK credentials from IPA dev_mon_base hostgroup description.
# Sets CMK_HOST_PORT, CMK_SITE, CMK_USER, CMK_SECRET, CMK_URL, CMK_API.
CMK_URL="" CMK_SITE="" CMK_USER="" CMK_SECRET="" CMK_API="" CMK_HOST_PORT=""
_cmk_parse_creds() {
local _desc
_desc=$(ipa hostgroup-show dev_mon_base --all 2>/dev/null \
| awk -F': ' '/Description:/{print $2; exit}' || true)
# cmk://172.30.0.12:5000/cmk:automation:SECRET
if [[ "$_desc" =~ ^cmk://([^/]+)(/[^:]+):([^:]+):(.+)$ ]]; then
CMK_HOST_PORT="${BASH_REMATCH[1]}"
CMK_SITE="${BASH_REMATCH[2]#/}"
CMK_USER="${BASH_REMATCH[3]}"
CMK_SECRET="${BASH_REMATCH[4]}"
CMK_URL="http://${CMK_HOST_PORT}"
CMK_API="${CMK_URL}/${CMK_SITE}/check_mk/api/1.0"
fi
}
# Install or refresh a local check script.
# _cmk_write_check <name> <content-heredoc>
# Marks the check as wanted in the current run (for cleanup comparison).
_WANT_CHECKS=()
_cmk_write_check() {
local _name="$1" _body="$2"
mkdir -p "$MON_LOCAL_DIR"
printf '%s\n' "$_body" > "$MON_LOCAL_DIR/$_name"
chmod 755 "$MON_LOCAL_DIR/$_name"
_WANT_CHECKS+=("$_name")
}
if _mon_dev_active "base"; then
_cmk_parse_creds
if [[ -z "$CMK_SECRET" ]]; then
warn "dev_mon_base: no CheckMK credentials in IPA dev_mon_base description"
warn " — run ansipa-checkmk-setup on the FreeIPA container first"
else
# ── Install CheckMK agent (pull mode; server connects to port 6556) ────
_AGENT_INSTALLED=false
if rpm -q check-mk-agent &>/dev/null || command -v check_mk_agent &>/dev/null; then
_AGENT_INSTALLED=true
else
log "dev_mon_base: downloading CheckMK agent from ${CMK_URL}"
# Try RPM first (proper package with socket unit)
_RPM=$(curl -sf -u "${CMK_USER}:${CMK_SECRET}" \
"${CMK_URL}/${CMK_SITE}/check_mk/agents/" 2>/dev/null \
| grep -oE 'check-mk-agent-[0-9][^"]*\.noarch\.rpm' | head -1 || true)
if [[ -n "$_RPM" ]]; then
curl -sf -u "${CMK_USER}:${CMK_SECRET}" \
"${CMK_URL}/${CMK_SITE}/check_mk/agents/${_RPM}" \
-o /tmp/cmk-agent.rpm 2>/dev/null \
&& (rpm -ivh --nodeps /tmp/cmk-agent.rpm 2>/dev/null \
|| dnf install -y /tmp/cmk-agent.rpm 2>/dev/null) \
&& _AGENT_INSTALLED=true \
|| warn "dev_mon_base: CheckMK RPM install failed"
rm -f /tmp/cmk-agent.rpm
fi
# Fall back to shell agent
if [[ "$_AGENT_INSTALLED" == false ]]; then
curl -sf -u "${CMK_USER}:${CMK_SECRET}" \
"${CMK_URL}/${CMK_SITE}/check_mk/agents/check_mk_agent.linux" \
-o /usr/local/bin/check_mk_agent 2>/dev/null \
&& chmod 755 /usr/local/bin/check_mk_agent \
&& _AGENT_INSTALLED=true \
|| warn "dev_mon_base: shell agent download failed"
fi
fi
# Ensure the systemd socket is in place when using the shell agent without RPM
if [[ "$_AGENT_INSTALLED" == true ]] && [[ ! -f /etc/systemd/system/check_mk.socket ]]; then
cat > /etc/systemd/system/check_mk.socket <<'UNIT'
[Unit]
Description=Check_MK agent socket (ansipa-managed)
[Socket]
ListenStream=6556
Accept=yes
[Install]
WantedBy=sockets.target
UNIT
cat > /etc/systemd/system/check_mk@.service <<'UNIT'
[Unit]
Description=Check_MK per-connection agent (ansipa-managed)
[Service]
ExecStart=/usr/local/bin/check_mk_agent
StandardInput=socket
StandardOutput=socket
StandardError=null
UNIT
systemctl daemon-reload
fi
if [[ "$_AGENT_INSTALLED" == true ]]; then
systemctl enable --now check_mk.socket 2>/dev/null \
&& log "dev_mon_base: check_mk.socket enabled (port 6556)" \
|| warn "dev_mon_base: could not enable check_mk.socket"
fi
# ── Register host in CheckMK ──────────────────────────────────────────
_HOST_IP=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "")
_CMK_REG_URL="${CMK_API}/objects/host_config/${HOST_FQDN}"
_ALREADY_REG=false
curl -sf -o /dev/null \
-H "Authorization: Bearer ${CMK_USER} ${CMK_SECRET}" \
"$_CMK_REG_URL" 2>/dev/null && _ALREADY_REG=true || true
if [[ "$_ALREADY_REG" == false ]]; then
_reg_body="{\"host_name\":\"${HOST_FQDN}\",\"folder\":\"/ansipa\""
[[ -n "$_HOST_IP" ]] && _reg_body+=",\"attributes\":{\"ipaddress\":\"${_HOST_IP}\"}"
_reg_body+="}"
_reg_http=$(curl -sf -o /dev/null -w '%{http_code}' \
-X POST "${CMK_API}/domain-types/host_config/collections/all" \
-H "Authorization: Bearer ${CMK_USER} ${CMK_SECRET}" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d "$_reg_body" 2>/dev/null || echo "000")
if [[ "$_reg_http" =~ ^2 ]]; then
log "dev_mon_base: host ${HOST_FQDN} registered in CheckMK"
# Trigger service discovery
curl -sf \
-X POST "${CMK_API}/domain-types/service_discovery_run/actions/start/invoke" \
-H "Authorization: Bearer ${CMK_USER} ${CMK_SECRET}" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d "{\"host_name\":\"${HOST_FQDN}\",\"mode\":\"refresh\"}" \
>/dev/null 2>&1 || true
# Activate changes
curl -sf \
-X POST "${CMK_API}/domain-types/activation_run/actions/activate-changes/invoke" \
-H "Authorization: Bearer ${CMK_USER} ${CMK_SECRET}" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"redirect":false,"sites":[],"force_foreign_changes":true}' \
>/dev/null 2>&1 || true
printf '%s\n' "${CMK_API}" > "$MON_STATE"
else
warn "dev_mon_base: host registration failed (HTTP ${_reg_http})"
fi
else
log "dev_mon_base: ${HOST_FQDN} already registered in CheckMK"
printf '%s\n' "${CMK_API}" > "$MON_STATE"
fi
unset _HOST_IP _CMK_REG_URL _ALREADY_REG _reg_body _reg_http
# ── cmk-agent-ctl: register for TLS/push agent transport ─────────────
# cmk-agent-ctl is part of the check-mk-agent RPM (2.2+).
# It registers with the CheckMK server for encrypted agent communication.
# The server decides pull (TLS-encrypted) vs push mode based on host config.
# Push mode is needed when CheckMK can't reach this host on port 6556 (NAT).
if command -v cmk-agent-ctl &>/dev/null; then
_CMK_RECV_HOST=$(echo "$CMK_URL" | sed 's|http://||;s|:.*||')
_CMK_CTL_STATUS_FILE="$STATE_DIR/mon-cmk-ctl-mode"
# Register if not already registered for this server
_ALREADY_CTL=$(cmk-agent-ctl status 2>/dev/null | grep -c "${_CMK_RECV_HOST}/${CMK_SITE}" || true)
if [[ "${_ALREADY_CTL:-0}" -eq 0 ]]; then
_CTL_OUT=$(cmk-agent-ctl register \
--server "${_CMK_RECV_HOST}:8000" \
--site "${CMK_SITE}" \
--user "${CMK_USER}" \
--password "${CMK_SECRET}" \
--hostname "${HOST_FQDN}" \
--trust-cert 2>&1) && \
log "dev_mon_base: cmk-agent-ctl registered with ${_CMK_RECV_HOST}:8000" || \
warn "dev_mon_base: cmk-agent-ctl registration failed (non-fatal)"
fi
# Detect push mode and set up periodic push timer
_CTL_MODE=$(cmk-agent-ctl status 2>/dev/null | awk '/Connection mode:/{print $NF; exit}')
echo "$_CTL_MODE" > "$_CMK_CTL_STATUS_FILE" 2>/dev/null || true
if [[ "$_CTL_MODE" == "push-agent" ]]; then
log "dev_mon_base: push mode active — setting up push timer"
cmk-agent-ctl push 2>/dev/null || true
# Create systemd timer for periodic push (every 60s)
if [[ ! -f /etc/systemd/system/cmk-agent-push.timer ]]; then
cat > /etc/systemd/system/cmk-agent-push.service <<'UNIT'
[Unit]
Description=CheckMK agent push (ansipa-managed)
[Service]
Type=oneshot
ExecStart=/usr/bin/cmk-agent-ctl push
UNIT
cat > /etc/systemd/system/cmk-agent-push.timer <<'UNIT'
[Unit]
Description=CheckMK agent push timer (ansipa-managed)
[Timer]
OnBootSec=30s
OnUnitActiveSec=60s
[Install]
WantedBy=timers.target
UNIT
systemctl daemon-reload
systemctl enable --now cmk-agent-push.timer 2>/dev/null \
&& log "dev_mon_base: cmk-agent-push.timer enabled (push every 60s)" \
|| warn "dev_mon_base: could not enable push timer"
fi
fi
unset _CMK_RECV_HOST _ALREADY_CTL _CTL_OUT _CTL_MODE _CMK_CTL_STATUS_FILE
fi
# ── Persist CMK credentials for local scripts / push cron ─────────────
printf 'CMK_API=%q\nCMK_USER=%q\nCMK_SECRET=%q\n' \
"$CMK_API" "$CMK_USER" "$CMK_SECRET" > "$MON_CMK_CREDS"
chmod 600 "$MON_CMK_CREDS"
# ── dev_mon_base: installed-packages local check ───────────────────────
# CPU, RAM, disk, uptime are reported by the built-in check_mk_agent sections.
_cmk_write_check "ansipa_packages" '#!/bin/bash
PKG_COUNT=$(rpm -qa --qf "%{NAME}\n" 2>/dev/null | wc -l || dpkg-query -f "${Package}\n" -W 2>/dev/null | wc -l || echo 0)
RECENT=$(rpm -qa --qf "%{INSTALLTIME} %{NAME}\n" 2>/dev/null | sort -rn | head -5 | awk "{print \$2}" | tr "\n" "," | sed "s/,\$//" || echo "n/a")
echo "0 Ansipa_Packages packages=${PKG_COUNT} ${PKG_COUNT} packages installed. Recent: ${RECENT}"'
# ── dev_mon_malware: ClamAV scan result check ──────────────────────────
if _mon_dev_active "malware"; then
_cmk_write_check "ansipa_clamav" '#!/bin/bash
LOG=/var/log/ansipa-security-scan.log
CLAMDB=/var/lib/clamav/main.cvd
[[ -f /var/lib/clamav/main.cld ]] && CLAMDB=/var/lib/clamav/main.cld
if [[ ! -f "$LOG" ]]; then
echo "3 Ansipa_ClamAV - No scan log found. Add host to dev_security-scan first."
exit 0
fi
LAST_DATE=$(grep "^=== ansipa-security-scan:" "$LOG" 2>/dev/null | tail -1 \
| grep -oE "[0-9]{4}-[0-9]{2}-[0-9]{2}" || echo "")
INFECTED=$(grep -c "FOUND$" "$LOG" 2>/dev/null) || INFECTED=0
DB_DAYS=999
if [[ -f "$CLAMDB" ]]; then
DB_DAYS=$(( ( $(date +%s) - $(stat -c %Y "$CLAMDB" 2>/dev/null || echo 0) ) / 86400 ))
fi
PERF="infected=${INFECTED};0;0 db_age_days=${DB_DAYS};7;30"
if [[ -z "$LAST_DATE" ]]; then
echo "1 Ansipa_ClamAV ${PERF} No scan found yet"
elif [[ "$INFECTED" -gt 0 ]]; then
echo "2 Ansipa_ClamAV ${PERF} INFECTED: ${INFECTED} threat(s) found — last scan: ${LAST_DATE}"
elif [[ "$DB_DAYS" -gt 7 ]]; then
echo "1 Ansipa_ClamAV ${PERF} DB stale (${DB_DAYS}d). Last scan: ${LAST_DATE}"
else
echo "0 Ansipa_ClamAV ${PERF} Clean — last scan: ${LAST_DATE}, DB age: ${DB_DAYS}d"
fi'
fi
# ── dev_mon_timeshift: snapshot age check ──────────────────────────────
if _mon_dev_active "timeshift"; then
_cmk_write_check "ansipa_timeshift" '#!/bin/bash
if ! command -v timeshift &>/dev/null; then
echo "3 Ansipa_Timeshift - Timeshift not installed on this host"
exit 0
fi
LAST=$(timeshift --list 2>/dev/null | grep -E "^\s*[0-9]+" \
| awk "{print \$3, \$4}" | tail -1 || echo "")
if [[ -z "$LAST" ]]; then
echo "2 Ansipa_Timeshift last_backup_days=9999;5;10 No Timeshift snapshots found"
exit 0
fi
LAST_TS=$(date -d "$LAST" +%s 2>/dev/null || echo 0)
DAYS=$(( ( $(date +%s) - LAST_TS ) / 86400 ))
PERF="last_backup_days=${DAYS};5;10"
if [[ "$DAYS" -ge 10 ]]; then
echo "2 Ansipa_Timeshift ${PERF} Last backup: ${DAYS}d ago (${LAST}) — CRIT"
elif [[ "$DAYS" -ge 5 ]]; then
echo "1 Ansipa_Timeshift ${PERF} Last backup: ${DAYS}d ago (${LAST}) — WARN"
else
echo "0 Ansipa_Timeshift ${PERF} Last backup: ${DAYS}d ago (${LAST})"
fi'
fi
# ── dev_mon_power: CPU package power via RAPL / lm-sensors ───────────
if _mon_dev_active "power"; then
_cmk_write_check "ansipa_power" '#!/bin/bash
RAPL=/sys/class/powercap/intel-rapl/intel-rapl:0/energy_uj
POWER_W=0; SOURCE=""
if [[ -r "$RAPL" ]]; then
E1=$(cat "$RAPL" 2>/dev/null || echo 0)
sleep 1
E2=$(cat "$RAPL" 2>/dev/null || echo 0)
if [[ "$E2" -gt "$E1" ]]; then
POWER_W=$(( (E2 - E1) / 1000000 ))
SOURCE="RAPL"
fi
fi
if [[ -z "$SOURCE" ]] && command -v sensors &>/dev/null; then
W=$(sensors 2>/dev/null | grep -iE "package.*power|cpu.*power" \
| grep -oP "[0-9]+\.[0-9]+" | head -1 | cut -d. -f1 || echo 0)
[[ "${W:-0}" -gt 0 ]] && POWER_W="$W" && SOURCE="lm-sensors"
fi
if [[ -z "$SOURCE" ]]; then
echo "3 Ansipa_Power power_w=0 Power monitoring unavailable (needs Intel RAPL or lm-sensors)"
exit 0
fi
PERF="power_w=${POWER_W};65;95"
if [[ "$POWER_W" -ge 95 ]]; then
echo "2 Ansipa_Power ${PERF} CPU power: ${POWER_W}W via ${SOURCE} — CRIT"
elif [[ "$POWER_W" -ge 65 ]]; then
echo "1 Ansipa_Power ${PERF} CPU power: ${POWER_W}W via ${SOURCE} — WARN"
else
echo "0 Ansipa_Power ${PERF} CPU power: ${POWER_W}W via ${SOURCE}"
fi'
fi
# ── usr_mon_logins: SSH login audit (last 24h) ─────────────────────────
if _mon_usr_active "logins"; then
_cmk_write_check "ansipa_logins" '#!/bin/bash
count_journal() {
local pat="$1"
journalctl -u sshd --since "24 hours ago" --no-pager -q 2>/dev/null \
| grep -c "$pat" 2>/dev/null || true
}
count_secure() {
local pat="$1"
{ grep "$(date "+%b %e")" /var/log/secure 2>/dev/null
grep "$(date -d yesterday "+%b %e" 2>/dev/null || true)" /var/log/secure 2>/dev/null; } \
| grep -c "$pat" 2>/dev/null || true
}
if command -v journalctl &>/dev/null && journalctl -u sshd --since "24 hours ago" -q &>/dev/null; then
ACCEPTED=$(count_journal "Accepted ")
FAILED=$(count_journal "Failed ")
INVALID=$(count_journal "Invalid user ")
else
ACCEPTED=$(count_secure "sshd.*Accepted")
FAILED=$(count_secure "sshd.*Failed")
INVALID=$(count_secure "sshd.*Invalid user")
fi
PERF="successful=${ACCEPTED};; failed=${FAILED};10;50 invalid=${INVALID};5;20"
MSG="Logins 24h: ${ACCEPTED} ok / ${FAILED} failed / ${INVALID} invalid user"
if [[ "$FAILED" -ge 50 ]] || [[ "$INVALID" -ge 20 ]]; then
echo "2 Ansipa_Logins ${PERF} HIGH ACTIVITY — ${MSG}"
elif [[ "$FAILED" -ge 10 ]] || [[ "$INVALID" -ge 5 ]]; then
echo "1 Ansipa_Logins ${PERF} ELEVATED — ${MSG}"
else
echo "0 Ansipa_Logins ${PERF} ${MSG}"
fi'
fi
fi # CMK_SECRET non-empty
else
# ── Revert: host left dev_mon_base → deregister and clean up ─────────────
if [[ -f "$MON_STATE" ]]; then
_OLD_CMK_API=$(cat "$MON_STATE" 2>/dev/null || true)
if [[ -n "$_OLD_CMK_API" ]] && [[ -f "$MON_CMK_CREDS" ]]; then
source "$MON_CMK_CREDS" 2>/dev/null || true
_del_http=$(curl -sf -o /dev/null -w '%{http_code}' \
-X DELETE "${_OLD_CMK_API}/objects/host_config/${HOST_FQDN}" \
-H "Authorization: Bearer ${CMK_USER:-} ${CMK_SECRET:-}" \
-H "Accept: application/json" 2>/dev/null || echo "000")
if [[ "$_del_http" =~ ^(2|404) ]]; then
log "Deregistered ${HOST_FQDN} from CheckMK (HTTP ${_del_http})"
curl -sf \
-X POST "${_OLD_CMK_API}/domain-types/activation_run/actions/activate-changes/invoke" \
-H "Authorization: Bearer ${CMK_USER:-} ${CMK_SECRET:-}" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"redirect":false,"sites":[],"force_foreign_changes":true}' \
>/dev/null 2>&1 || true
else
warn "CheckMK deregistration returned HTTP ${_del_http} — may need manual cleanup"
fi
fi
rm -f "$MON_STATE" "$MON_CMK_CREDS"
systemctl disable --now check_mk.socket 2>/dev/null || true
systemctl disable --now cmk-agent-push.timer 2>/dev/null || true
command -v cmk-agent-ctl &>/dev/null && cmk-agent-ctl delete-all 2>/dev/null || true
log "dev_mon_base reverted: check_mk.socket disabled, host deregistered"
unset _OLD_CMK_API _del_http
fi
fi
# ── Cleanup: remove local check scripts for groups no longer active ───────────
# _WANT_CHECKS is built by _cmk_write_check during the apply phase above, so it
# already contains exactly the checks that were installed this run. Anything in
# the state file that is not in _WANT_CHECKS needs to be removed.
while IFS= read -r _old_check; do
[[ -z "$_old_check" ]] && continue
_still_wanted=false
for _wc in "${_WANT_CHECKS[@]+"${_WANT_CHECKS[@]}"}"; do
[[ "$_wc" == "$_old_check" ]] && _still_wanted=true && break
done
if [[ "$_still_wanted" == false ]] && [[ -f "$MON_LOCAL_DIR/$_old_check" ]]; then
rm -f "$MON_LOCAL_DIR/$_old_check"
log "Removed CheckMK local check: $_old_check (group left)"
fi
done < "$MON_CHECKS_STATE"
unset _old_check _still_wanted _wc
# Persist current wanted check list.
if [[ ${#_WANT_CHECKS[@]} -gt 0 ]]; then
printf '%s\n' "${_WANT_CHECKS[@]+"${_WANT_CHECKS[@]}"}" | sort -u > "$MON_CHECKS_STATE"
else
> "$MON_CHECKS_STATE"
fi
unset _WANT_CHECKS _AGENT_INSTALLED
log "Policy enforcement complete."