#!/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- Ensure is enabled and running (systemctl enable --now). # Leaving the group reverts: service is disabled and stopped. # dev_daemon-disable- Ensure is disabled and stopped (systemctl disable --now). # Leaving the group reverts: service is re-enabled and started. # 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- Grant 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- Prevent members of this FreeIPA user group from running # 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_ Auto-add 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:); 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- 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_ groups grant CUPS access to 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_ 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" </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// 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-: ensure the unit is enabled and running. # Leaving the group reverts: unit is disabled and stopped. # dev_daemon-disable-: ensure the unit is disabled and stopped. # Leaving the group reverts: unit is re-enabled and started. # 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-: write a sudoers drop-in granting 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_: per-user printer auto-add via FreeIPA groups ─────────── # For each usr_prt_ 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:). # - 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 (.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__: auto-mount network shares in user home dirs ──────── # For each usr_smb_r_ or usr_smb_rw_ group in FreeIPA: # - The FreeIPA container hosts a Samba share [usr--r] or [usr--rw] # under /data/smb-shares// 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://:: # # 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_: distribute SSH public keys to user home dirs ──────────── # When this host is in dev_ssh_, the IPA user 's public keys are # written to /home//.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 --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 [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: " # " 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://:/:automation: 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 # 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."