From 815c0e57a8b51d9fc48837aedeb2fa842a9d7587 Mon Sep 17 00:00:00 2001 From: The_miro Date: Wed, 1 Jul 2026 19:27:35 +0200 Subject: [PATCH] refactor(ansipa): split enforce-policies monolith into modular policies.d/ system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 1550-line ansipa-enforce-policies.sh is replaced by a slim orchestrator that sources policy files from policies.d/ — one file per policy type — via a shared lib/ansipa-policy.sh that consolidates all IPA group discovery into two queries (one host-show, one group-find) instead of five or more. New files: - ansible/lib/ansipa-policy.sh shared constants, log/warn, _ansipa_discover(), _smb_parse_cred(), _ansipa_sync_policystore() - ansible/policies.d/dev_daemon.sh - ansible/policies.d/dev_timeshift-backup.sh - ansible/policies.d/dev_security-scan.sh - ansible/policies.d/dev_no-local-users.sh - ansible/policies.d/dev_local-sudo.sh - ansible/policies.d/dev_ssh.sh - ansible/policies.d/dev_mon.sh includes usr_mon_logins (same CMK dep) - ansible/policies.d/usr_admin.sh - ansible/policies.d/usr_block-binary.sh - ansible/policies.d/usr_prt.sh - ansible/policies.d/usr_scan-notify.sh - ansible/policies.d/usr_smb.sh - ansible/policies.d/usr_policystore.sh NEW: auto-mount ~/policystore for usr_smb_adm_policystore members The policies.d/ folder on the FreeIPA container is exposed as an SMB share (ansipa-policystore) so admins can live-edit policy files without Ansible. The enforcer syncs from the share before each run; cached local files are used as fallback when SMB is unreachable. - ansipa-smb-setup.sh: adds ansipa-policystore share + smb-policystore system user + usr_smb_adm_policystore IPA group with auto-generated credential - deploy-ansipa-policies.yml: deploys lib/ and policies.d/ to enrolled clients; adds optional seed_policystore task for bootstrap Co-Authored-By: Claude Sonnet 4.6 --- .../ansible/ansipa-enforce-policies.sh | 1460 +---------------- .../ansible/deploy-ansipa-policies.yml | 48 +- .../ansible/lib/ansipa-policy.sh | 160 ++ .../ansible/policies.d/dev_daemon.sh | 82 + .../ansible/policies.d/dev_local-sudo.sh | 40 + .../ansible/policies.d/dev_mon.sh | 396 +++++ .../ansible/policies.d/dev_no-local-users.sh | 51 + .../ansible/policies.d/dev_security-scan.sh | 67 + .../ansible/policies.d/dev_ssh.sh | 95 ++ .../policies.d/dev_timeshift-backup.sh | 25 + .../ansible/policies.d/usr_admin.sh | 21 + .../ansible/policies.d/usr_block-binary.sh | 61 + .../ansible/policies.d/usr_policystore.sh | 119 ++ .../ansible/policies.d/usr_prt.sh | 145 ++ .../ansible/policies.d/usr_scan-notify.sh | 75 + .../ansible/policies.d/usr_smb.sh | 145 ++ .../FreeipaAnsible/image/ansipa-smb-setup.sh | 102 +- 17 files changed, 1639 insertions(+), 1453 deletions(-) create mode 100644 setup/modules/FreeipaAnsible/ansible/lib/ansipa-policy.sh create mode 100644 setup/modules/FreeipaAnsible/ansible/policies.d/dev_daemon.sh create mode 100644 setup/modules/FreeipaAnsible/ansible/policies.d/dev_local-sudo.sh create mode 100644 setup/modules/FreeipaAnsible/ansible/policies.d/dev_mon.sh create mode 100644 setup/modules/FreeipaAnsible/ansible/policies.d/dev_no-local-users.sh create mode 100644 setup/modules/FreeipaAnsible/ansible/policies.d/dev_security-scan.sh create mode 100644 setup/modules/FreeipaAnsible/ansible/policies.d/dev_ssh.sh create mode 100644 setup/modules/FreeipaAnsible/ansible/policies.d/dev_timeshift-backup.sh create mode 100644 setup/modules/FreeipaAnsible/ansible/policies.d/usr_admin.sh create mode 100644 setup/modules/FreeipaAnsible/ansible/policies.d/usr_block-binary.sh create mode 100644 setup/modules/FreeipaAnsible/ansible/policies.d/usr_policystore.sh create mode 100644 setup/modules/FreeipaAnsible/ansible/policies.d/usr_prt.sh create mode 100644 setup/modules/FreeipaAnsible/ansible/policies.d/usr_scan-notify.sh create mode 100644 setup/modules/FreeipaAnsible/ansible/policies.d/usr_smb.sh diff --git a/setup/modules/FreeipaAnsible/ansible/ansipa-enforce-policies.sh b/setup/modules/FreeipaAnsible/ansible/ansipa-enforce-policies.sh index 275f9c0..41e515a 100755 --- a/setup/modules/FreeipaAnsible/ansible/ansipa-enforce-policies.sh +++ b/setup/modules/FreeipaAnsible/ansible/ansipa-enforce-policies.sh @@ -78,6 +78,11 @@ # ansipa-smb-setup.sh (cifs://::). # Leaving the group unmounts the share on the next run. # Requires: cifs-utils installed on the client. +# usr_smb_adm_policystore Mount the ansipa policy store at ~/policystore (rw) for members. +# Credential in IPA group description (set by ansipa-smb-setup.sh). +# Also used by the enforcer itself to sync policy files from the +# server before applying them — edits on the SMB share take effect +# on the next 30-min enforcer tick fleet-wide. # usr_mon_logins (CheckMK) Local check for SSH login attempts in the last 24 h: # successful + failed + invalid-user counts with thresholds. # WARN: ≥10 failed or ≥5 invalid; CRIT: ≥50 failed or ≥20 invalid. @@ -88,1457 +93,34 @@ # - Configure Timeshift (type + target device) before enabling dev_timeshift-backup. # - CheckMK server must be running and accessible before adding hosts to dev_mon_base. # Run ansipa-checkmk-setup.sh on the FreeIPA container to seed credentials and groups. +# - Policy files live in /usr/local/lib/ansipa/policies.d/ on enrolled clients. +# The enforcer syncs them from the ansipa-policystore SMB share before each run; +# edit files on the share directly (~/policystore if you are in usr_smb_adm_policystore) +# to update policies without re-running Ansible. 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; } +# shellcheck source=lib/ansipa-policy.sh +source /usr/local/lib/ansipa/policy.sh 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 +command -v ipa &>/dev/null || { warn "ipa not found — host not enrolled in FreeIPA. Exiting."; exit 0; } 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) +log "Discovering groups..." +_ansipa_discover -# ── 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=() +log "Syncing policy store..." +_ansipa_sync_policystore -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 +log "Applying policies..." +for _p in "$POLICY_DIR"/*.sh; do + [[ -f "$_p" ]] || continue + # shellcheck disable=SC1090 + source "$_p" 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=0 -RECENT="n/a" -if command -v rpm &>/dev/null; then - PKG_COUNT=$(rpm -qa --qf "%{NAME}\n" 2>/dev/null | wc -l) - RECENT=$(rpm -qa --qf "%{INSTALLTIME} %{NAME}\n" 2>/dev/null | sort -rn | head -5 | awk "{print \$2}" | tr "\n" "," | sed "s/,\$//") -elif command -v dpkg &>/dev/null; then - PKG_COUNT=$(dpkg --list 2>/dev/null | grep -c "^ii") - RECENT=$(grep " install " /var/log/dpkg.log 2>/dev/null | tail -5 | awk "{print \$4}" | tr "\n" "," | sed "s/,\$//") -elif command -v pacman &>/dev/null; then - PKG_COUNT=$(pacman -Qq 2>/dev/null | wc -l) - RECENT=$(grep "\[ALPM\] installed" /var/log/pacman.log 2>/dev/null | tail -5 | awk "{print \$4}" | tr "\n" "," | sed "s/,\$//") -fi -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." diff --git a/setup/modules/FreeipaAnsible/ansible/deploy-ansipa-policies.yml b/setup/modules/FreeipaAnsible/ansible/deploy-ansipa-policies.yml index 5fd22e4..227331d 100644 --- a/setup/modules/FreeipaAnsible/ansible/deploy-ansipa-policies.yml +++ b/setup/modules/FreeipaAnsible/ansible/deploy-ansipa-policies.yml @@ -2,6 +2,10 @@ # deploy-ansipa-policies.yml — deploy the policy enforcement daemon to enrolled clients. # # Installs ansipa-enforce-policies.sh and a systemd timer that runs it every 30 minutes. +# Policy logic lives in /usr/local/lib/ansipa/policies.d/ (one .sh file per policy type). +# At runtime the enforcer also syncs those files from the ansipa-policystore SMB share +# on the FreeIPA container, so live edits via the share take effect without re-running Ansible. +# # Device policies (FreeIPA host groups — applied to the whole machine): # dev_daemon-enable- Ensure is enabled and running; reverted when host leaves group # dev_daemon-disable- Ensure is disabled and stopped; reverted when host leaves group @@ -21,6 +25,8 @@ # usr_scan-notify Fetch alerts from server, notify user every 10 min until acknowledged # usr_smb_r_ Mount read-only Samba share ~/‹name› for members; credentials in IPA group description # usr_smb_rw_ Mount read-write Samba share ~/‹name› for members (rw beats r if both) +# usr_smb_adm_policystore Mount the policy store at ~/policystore (rw) for admin members; +# credential in IPA group description (set by ansipa-smb-setup.sh) # # CheckMK monitoring policies (device host-groups, unless noted): # dev_mon_base Install CheckMK agent; register host in CMK; check: installed packages. @@ -43,12 +49,17 @@ # - For timeshift-backup: also add host to dev_mod_timeshift group # - For dev_mon_*: CheckMK CE must be up; run ansipa-checkmk-setup.sh on the FreeIPA # container first (seeds credentials + groups in IPA) -# - For usr_smb_r_*/usr_smb_rw_*: cifs-utils installed (handled below); Samba service -# running on the IPA container (ansipa-smb.service auto-starts) +# - For usr_smb_r_*/usr_smb_rw_*/usr_smb_adm_policystore: cifs-utils installed (handled below); +# Samba service running on the IPA container (ansipa-smb.service auto-starts) # # Usage: # ansible-playbook -i inventory deploy-ansipa-policies.yml \ # -e smb_scan_password= # or use --vault-password-file +# +# To seed the policystore on the FreeIPA container for the first time (bootstrap only; +# thereafter edit files directly via the SMB share at ~/policystore): +# ansible-playbook -i inventory deploy-ansipa-policies.yml \ +# -e smb_scan_password= -e seed_policystore=true -e ipa_server_host= - name: Deploy FreeIPA policy enforcer hosts: all @@ -59,11 +70,27 @@ tasks: - - name: Create local check scripts directory (CheckMK agent) + - name: Create ansipa library and policy directories file: - path: /usr/lib/check_mk_agent/local + path: "{{ item }}" state: directory mode: '0755' + loop: + - /usr/local/lib/ansipa + - /usr/local/lib/ansipa/policies.d + - /usr/lib/check_mk_agent/local + + - name: Deploy shared policy library + copy: + src: lib/ansipa-policy.sh + dest: /usr/local/lib/ansipa/policy.sh + mode: '0644' + + - name: Deploy policy modules + copy: + src: policies.d/ + dest: /usr/local/lib/ansipa/policies.d/ + mode: '0644' - name: Install required packages package: @@ -151,3 +178,16 @@ name: ansipa-enforce-policies.timer enabled: yes state: started + + # ── Bootstrap: seed policy files onto the policystore SMB share ────────── + # Run once with -e seed_policystore=true -e ipa_server_host=. + # After seeding, edit policies directly via ~/policystore (usr_smb_adm_policystore). + - name: Seed policystore on IPA server (bootstrap — run once) + copy: + src: "policies.d/" + dest: /data/policy-store/policies.d/ + mode: '0664' + directory_mode: '02775' + delegate_to: "{{ ipa_server_host }}" + run_once: true + when: seed_policystore | default(false) | bool diff --git a/setup/modules/FreeipaAnsible/ansible/lib/ansipa-policy.sh b/setup/modules/FreeipaAnsible/ansible/lib/ansipa-policy.sh new file mode 100644 index 0000000..fa19d20 --- /dev/null +++ b/setup/modules/FreeipaAnsible/ansible/lib/ansipa-policy.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# ansipa-policy.sh — shared library for ansipa policy enforcement. +# +# Sourced by ansipa-enforce-policies.sh before any policy file is loaded. +# Provides: constants, logging, group discovery, policystore sync, +# and the _smb_parse_cred() helper (shared across usr_smb and usr_policystore). +# +# After _ansipa_discover() the following variables are exported for policy files: +# RAW_GROUPS (comma-sep host-group string from ipa host-show) +# _ALL_USER_GROUPS (newline-sep full list from one ipa group-find call) +# ACTIVE_DAEMON_ENABLE ACTIVE_DAEMON_DISABLE (arrays, unit name suffixes) +# WANT_TIMESHIFT_BACKUP WANT_SECURITY_SCAN WANT_NO_LOCAL_USERS (booleans) +# ACTIVE_LOCAL_SUDO_USERS ACTIVE_SSH_USERS ACTIVE_MON_DEV (arrays) +# WANT_USR_ADMIN WANT_SCAN_NOTIFY (booleans) +# ACTIVE_BLOCK_BINARIES ACTIVE_BLOCK_IPA_GROUPS (parallel arrays) +# ACTIVE_PRT_GROUPS ACTIVE_PRT_PRINTERS (parallel arrays) +# ACTIVE_MON_USR (array, usr_mon_* suffixes) + +# ── Constants ───────────────────────────────────────────────────────────────── +STATE_DIR="/var/lib/ansipa-policies" +BLOCK_DIR="/usr/local/bin" +CRON_DIR="/etc/cron.d" +LOG_TAG="ansipa-policies" +POLICY_DIR="/usr/local/lib/ansipa/policies.d" + +# ── Logging ─────────────────────────────────────────────────────────────────── +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; } + +# ── Group discovery ─────────────────────────────────────────────────────────── +# Called once by the orchestrator. Sets all policy-state variables needed by +# every policy file, using exactly two IPA queries (one host-show, one group-find). +_ansipa_discover() { + # ── Host groups (device policies) ───────────────────────────────────────── + RAW_GROUPS=$(ipa host-show "$HOST_FQDN" --all 2>/dev/null \ + | grep -i "Member of host-groups:" | sed 's/.*: //' || true) + + ACTIVE_DAEMON_ENABLE=() + ACTIVE_DAEMON_DISABLE=() + ACTIVE_LOCAL_SUDO_USERS=() + ACTIVE_SSH_USERS=() + ACTIVE_MON_DEV=() + WANT_TIMESHIFT_BACKUP=false + WANT_SECURITY_SCAN=false + WANT_NO_LOCAL_USERS=false + + 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 + + # ── User groups (one call, all prefixes) ────────────────────────────────── + _ALL_USER_GROUPS=$(ipa group-find --pkey-only 2>/dev/null \ + | awk '/Group name:/ {print $NF}' | sort -u || true) + + WANT_USR_ADMIN=false + WANT_SCAN_NOTIFY=false + ACTIVE_BLOCK_BINARIES=() + ACTIVE_BLOCK_IPA_GROUPS=() + ACTIVE_PRT_GROUPS=() + ACTIVE_PRT_PRINTERS=() + ACTIVE_MON_USR=() + + while IFS= read -r _grp; do + [[ -z "$_grp" ]] && continue + case "$_grp" in + usr_admin) WANT_USR_ADMIN=true ;; + usr_scan-notify) WANT_SCAN_NOTIFY=true ;; + usr_block-binary-*) + local _raw="${_grp#usr_block-binary-}" + ACTIVE_BLOCK_BINARIES+=("${_raw//__/.}") + ACTIVE_BLOCK_IPA_GROUPS+=("$_grp") ;; + usr_prt_*) + ACTIVE_PRT_GROUPS+=("$_grp") + ACTIVE_PRT_PRINTERS+=("${_grp#usr_prt_}") ;; + usr_mon_*) + ACTIVE_MON_USR+=("${_grp#usr_mon_}") ;; + esac + done <<< "$_ALL_USER_GROUPS" + unset _grp _raw + + 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}" +} + +# ── SMB credential parser ───────────────────────────────────────────────────── +# Used by both usr_smb.sh and usr_policystore.sh. +# Outputs "host samba_user password" when the IPA group description contains +# a cifs:// credential (written by ansipa-smb-setup.sh). +_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]}" +} + +# ── Policystore sync ────────────────────────────────────────────────────────── +# Called by the orchestrator after discovery. Reads the SMB credential from the +# usr_smb_adm_policystore IPA group description, then syncs *.sh files from the +# ansipa-policystore SMB share into $POLICY_DIR. +# +# On SMB failure: warns and returns 0; the orchestrator continues with whatever +# files are already in $POLICY_DIR (Ansible-deployed or previously synced). +_ansipa_sync_policystore() { + command -v smbclient &>/dev/null || return 0 + + local _cred_line _host _suser _spass + _cred_line=$(_smb_parse_cred "usr_smb_adm_policystore" 2>/dev/null || true) + if [[ -z "$_cred_line" ]]; then + return 0 # group absent or no credential yet — use local files + fi + read -r _host _suser _spass <<< "$_cred_line" + + local _tmp + _tmp=$(mktemp -d) + + # Build a temporary smbclient credentials file (avoids shell-quoting issues) + local _creds_tmp + _creds_tmp=$(mktemp) + printf 'username=%s\npassword=%s\ndomain=WORKGROUP\n' "$_suser" "$_spass" \ + > "$_creds_tmp" + chmod 600 "$_creds_tmp" + + if smbclient "//${_host}/ansipa-policystore" -A "$_creds_tmp" \ + -c "cd policies.d; mask *.sh; recurse ON; prompt OFF; lcd ${_tmp}; mget *" \ + &>/dev/null + then + # Atomic swap: remove stale files and replace with synced set. + rm -f "$POLICY_DIR"/*.sh 2>/dev/null || true + cp "$_tmp"/*.sh "$POLICY_DIR"/ 2>/dev/null \ + && log "Synced policy store from //${_host}/ansipa-policystore/policies.d" \ + || warn "Policystore sync: no .sh files on share — using local files" + else + warn "Policystore sync failed (SMB unreachable?) — using cached local files" + fi + + rm -rf "$_tmp" + rm -f "$_creds_tmp" +} diff --git a/setup/modules/FreeipaAnsible/ansible/policies.d/dev_daemon.sh b/setup/modules/FreeipaAnsible/ansible/policies.d/dev_daemon.sh new file mode 100644 index 0000000..7199ebb --- /dev/null +++ b/setup/modules/FreeipaAnsible/ansible/policies.d/dev_daemon.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# policy: dev_daemon — enable or disable systemd units via FreeIPA host-groups. +# dev_daemon-enable- ensure the unit is enabled and running +# dev_daemon-disable- ensure the unit is disabled and stopped +# Conflicts (same unit in both lists) are skipped with a warning. + +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" + +_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[@]+"${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[@]+"${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 +unset _SVC _UNIT _EN _AC _OLD diff --git a/setup/modules/FreeipaAnsible/ansible/policies.d/dev_local-sudo.sh b/setup/modules/FreeipaAnsible/ansible/policies.d/dev_local-sudo.sh new file mode 100644 index 0000000..ad703ad --- /dev/null +++ b/setup/modules/FreeipaAnsible/ansible/policies.d/dev_local-sudo.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# policy: dev_local-sudo — per-device sudo grant for named local users. +# dev_local-sudo-: adds a sudoers drop-in; removed when host leaves 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[@]+"${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[@]+"${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" + +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 diff --git a/setup/modules/FreeipaAnsible/ansible/policies.d/dev_mon.sh b/setup/modules/FreeipaAnsible/ansible/policies.d/dev_mon.sh new file mode 100644 index 0000000..d9ec71f --- /dev/null +++ b/setup/modules/FreeipaAnsible/ansible/policies.d/dev_mon.sh @@ -0,0 +1,396 @@ +#!/usr/bin/env bash +# policy: dev_mon — CheckMK monitoring agent + local checks. +# dev_mon_base install agent, register host, installed-packages check +# dev_mon_malware ClamAV scan result check +# dev_mon_timeshift Timeshift snapshot age check +# dev_mon_power CPU package power via RAPL / lm-sensors +# usr_mon_logins SSH login audit check (user group, but requires dev_mon_base) +# Revert: deregisters the host and removes the agent socket when dev_mon_base leaves. + +MON_LOCAL_DIR="/usr/lib/check_mk_agent/local" +MON_STATE="$STATE_DIR/mon-registered" +MON_CHECKS_STATE="$STATE_DIR/mon-checks" +MON_CMK_CREDS="$STATE_DIR/mon-cmk-creds" +[[ -f "$MON_CHECKS_STATE" ]] || touch "$MON_CHECKS_STATE" + +_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; } + +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) + 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 +} + +_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 ───────────────────────────────────────────── + _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}" + _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 + 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 + + 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" + 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 + 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 transport ─────────────────── + 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" + _ALREADY_CTL=$(cmk-agent-ctl status 2>/dev/null | grep -c "${_CMK_RECV_HOST}/${CMK_SITE}" || true) + if [[ "${_ALREADY_CTL:-0}" -eq 0 ]]; then + cmk-agent-ctl register \ + --server "${_CMK_RECV_HOST}:8000" \ + --site "${CMK_SITE}" \ + --user "${CMK_USER}" \ + --password "${CMK_SECRET}" \ + --hostname "${HOST_FQDN}" \ + --trust-cert &>/dev/null \ + && 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 + _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 + 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_MODE _CMK_CTL_STATUS_FILE + fi + + # ── Persist CMK credentials for push scripts ────────────────────────── + 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 ────────────────────── + _cmk_write_check "ansipa_packages" '#!/bin/bash +PKG_COUNT=0 +RECENT="n/a" +if command -v rpm &>/dev/null; then + PKG_COUNT=$(rpm -qa --qf "%{NAME}\n" 2>/dev/null | wc -l) + RECENT=$(rpm -qa --qf "%{INSTALLTIME} %{NAME}\n" 2>/dev/null | sort -rn | head -5 | awk "{print \$2}" | tr "\n" "," | sed "s/,\$//") +elif command -v dpkg &>/dev/null; then + PKG_COUNT=$(dpkg --list 2>/dev/null | grep -c "^ii") + RECENT=$(grep " install " /var/log/dpkg.log 2>/dev/null | tail -5 | awk "{print \$4}" | tr "\n" "," | sed "s/,\$//") +elif command -v pacman &>/dev/null; then + PKG_COUNT=$(pacman -Qq 2>/dev/null | wc -l) + RECENT=$(grep "\[ALPM\] installed" /var/log/pacman.log 2>/dev/null | tail -5 | awk "{print \$4}" | tr "\n" "," | sed "s/,\$//") +fi +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 (requires dev_mon_base agent) ───── + 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 + # shellcheck source=/dev/null + 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 ─────────── +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 + +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 diff --git a/setup/modules/FreeipaAnsible/ansible/policies.d/dev_no-local-users.sh b/setup/modules/FreeipaAnsible/ansible/policies.d/dev_no-local-users.sh new file mode 100644 index 0000000..a9b4f17 --- /dev/null +++ b/setup/modules/FreeipaAnsible/ansible/policies.d/dev_no-local-users.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# policy: dev_no-local-users — lock local account passwords so only FreeIPA accounts can auth. +# Applied when host is member of dev_no-local-users host-group. +# Revert: unlocks every account that was locked by this policy. + +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 + + grep -qxF "$uname" "$NO_LOCAL_USERS_STATE" 2>/dev/null && continue + + 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 diff --git a/setup/modules/FreeipaAnsible/ansible/policies.d/dev_security-scan.sh b/setup/modules/FreeipaAnsible/ansible/policies.d/dev_security-scan.sh new file mode 100644 index 0000000..cd1934d --- /dev/null +++ b/setup/modules/FreeipaAnsible/ansible/policies.d/dev_security-scan.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# policy: dev_security-scan — daily ClamAV + rkhunter + chkrootkit scans + SMB upload at 02:00. +# Applied when host is member of dev_security-scan host-group. + +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 diff --git a/setup/modules/FreeipaAnsible/ansible/policies.d/dev_ssh.sh b/setup/modules/FreeipaAnsible/ansible/policies.d/dev_ssh.sh new file mode 100644 index 0000000..4be1f45 --- /dev/null +++ b/setup/modules/FreeipaAnsible/ansible/policies.d/dev_ssh.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# policy: dev_ssh — distribute IPA user SSH public keys to authorized_keys. +# dev_ssh_: writes the IPA user's keys in an ansipa-managed section. +# Leaving the host-group removes only the ansipa section; manual keys are preserved. + +SSH_KEY_STATE="$STATE_DIR/ssh-keys" +[[ -f "$SSH_KEY_STATE" ]] || touch "$SSH_KEY_STATE" + +_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" + 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 + +for _SSH_UID in "${ACTIVE_SSH_USERS[@]+"${ACTIVE_SSH_USERS[@]}"}"; do + ipa user-show "$_SSH_UID" &>/dev/null || { + warn "dev_ssh_${_SSH_UID}: IPA user '$_SSH_UID' not found — skipping" + continue + } + + _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 + + _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 + + 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" + 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 diff --git a/setup/modules/FreeipaAnsible/ansible/policies.d/dev_timeshift-backup.sh b/setup/modules/FreeipaAnsible/ansible/policies.d/dev_timeshift-backup.sh new file mode 100644 index 0000000..5de630a --- /dev/null +++ b/setup/modules/FreeipaAnsible/ansible/policies.d/dev_timeshift-backup.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# policy: dev_timeshift-backup — daily Timeshift snapshot at 03:00. +# Applied when host is member of dev_timeshift-backup host-group. + +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 diff --git a/setup/modules/FreeipaAnsible/ansible/policies.d/usr_admin.sh b/setup/modules/FreeipaAnsible/ansible/policies.d/usr_admin.sh new file mode 100644 index 0000000..2559e11 --- /dev/null +++ b/setup/modules/FreeipaAnsible/ansible/policies.d/usr_admin.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# policy: usr_admin — global sudo grant for FreeIPA usr_admin user group. +# When the IPA group exists, a sudoers drop-in grants full sudo (SSSD-resolved). +# 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 diff --git a/setup/modules/FreeipaAnsible/ansible/policies.d/usr_block-binary.sh b/setup/modules/FreeipaAnsible/ansible/policies.d/usr_block-binary.sh new file mode 100644 index 0000000..d5aa8d9 --- /dev/null +++ b/setup/modules/FreeipaAnsible/ansible/policies.d/usr_block-binary.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# policy: usr_block-binary — prevent IPA user group members from running named binaries. +# usr_block-binary-: installs a PATH-priority wrapper that checks group membership +# at runtime via SSSD/id. __ in the suffix decodes to . (Flatpak app ID support). +# Removing the IPA group causes the wrapper to be cleaned up on the next run. + +BLOCK_STATE="$STATE_DIR/blocked-binaries" +[[ -f "$BLOCK_STATE" ]] || touch "$BLOCK_STATE" + +_in_block_list() { + local needle="$1" + for b in "${ACTIVE_BLOCK_BINARIES[@]+"${ACTIVE_BLOCK_BINARIES[@]}"}"; do + [[ "$b" == "$needle" ]] && return 0 + done + return 1 +} + +for _idx in "${!ACTIVE_BLOCK_BINARIES[@]}"; do + BIN="${ACTIVE_BLOCK_BINARIES[$_idx]}" + IPA_GRP="${ACTIVE_BLOCK_IPA_GROUPS[$_idx]}" + WRAPPER="$BLOCK_DIR/$BIN" + 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" + +if [[ ${#ACTIVE_BLOCK_BINARIES[@]} -gt 0 ]]; then + printf '%s\n' "${ACTIVE_BLOCK_BINARIES[@]}" | sort -u > "$BLOCK_STATE" +else + > "$BLOCK_STATE" +fi +unset BIN IPA_GRP WRAPPER OLD_BIN diff --git a/setup/modules/FreeipaAnsible/ansible/policies.d/usr_policystore.sh b/setup/modules/FreeipaAnsible/ansible/policies.d/usr_policystore.sh new file mode 100644 index 0000000..c17a60b --- /dev/null +++ b/setup/modules/FreeipaAnsible/ansible/policies.d/usr_policystore.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# policy: usr_policystore — auto-mount the ansipa policy store for admins. +# When the IPA user group usr_smb_adm_policystore exists, members who are +# logged in get the ansipa-policystore SMB share mounted at ~/policystore (rw). +# The mount is tracked in state and removed when the group is deleted or the +# user is no longer a member. +# Credential: cifs://:smb-policystore: in the IPA group description, +# written by ansipa-smb-setup.sh. + +PS_MOUNT_STATE="$STATE_DIR/policystore-mounts" +PS_CREDS_DIR="$STATE_DIR/smb-creds" +PS_CREDS_FILE="$PS_CREDS_DIR/policystore.creds" +[[ -f "$PS_MOUNT_STATE" ]] || touch "$PS_MOUNT_STATE" +mkdir -p "$PS_CREDS_DIR" +chmod 700 "$PS_CREDS_DIR" + +_ps_grp_exists=false +grep -qxF "usr_smb_adm_policystore" <<< "$_ALL_USER_GROUPS" && _ps_grp_exists=true + +_ps_member() { id -nG "$1" 2>/dev/null | tr ' ' '\n' | grep -qxF "usr_smb_adm_policystore"; } + +if [[ "$_ps_grp_exists" == true ]] && command -v mount.cifs &>/dev/null; then + _PS_CRED=$(_smb_parse_cred "usr_smb_adm_policystore") + if [[ -z "$_PS_CRED" ]]; then + warn "usr_smb_adm_policystore: no cifs:// credential in IPA group description" + warn " — run ansipa-smb.service on the FreeIPA container first" + else + read -r _PS_HOST _PS_SUSER _PS_SPASS <<< "$_PS_CRED" + printf 'username=%s\npassword=%s\ndomain=WORKGROUP\n' "$_PS_SUSER" "$_PS_SPASS" \ + > "$PS_CREDS_FILE" + chmod 600 "$PS_CREDS_FILE" + + # Collect logged-in users. + declare -A _PS_SESS + _ps_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" ]] && _PS_SESS["$_u"]="$_h" + } + if loginctl list-sessions --no-legend &>/dev/null; then + while IFS= read -r _L; do _ps_add_sess "$_L" "loginctl" + done < <(loginctl list-sessions --no-legend 2>/dev/null) + else + while IFS= read -r _L; do _ps_add_sess "$_L" "who" + done < <(who 2>/dev/null) + fi + unset _L + + # Mount for logged-in members. + for _PS_U in "${!_PS_SESS[@]}"; do + _ps_member "$_PS_U" || continue + _PS_H="${_PS_SESS[$_PS_U]}" + _PS_MP="$_PS_H/policystore" + + if mountpoint -q "$_PS_MP" 2>/dev/null; then + grep -qxF "$_PS_U" "$PS_MOUNT_STATE" 2>/dev/null || \ + echo "$_PS_U" >> "$PS_MOUNT_STATE" + continue + fi + + mkdir -p "$_PS_MP" + chown "$_PS_U" "$_PS_MP" 2>/dev/null || true + + if mount -t cifs "//${_PS_HOST}/ansipa-policystore" "$_PS_MP" \ + -o "credentials=${PS_CREDS_FILE},uid=${_PS_U},gid=${_PS_U},file_mode=0664,dir_mode=02775,nounix,noserverino" \ + 2>/dev/null; then + log "Mounted //${_PS_HOST}/ansipa-policystore → $_PS_MP for $_PS_U" + grep -qxF "$_PS_U" "$PS_MOUNT_STATE" 2>/dev/null || \ + echo "$_PS_U" >> "$PS_MOUNT_STATE" + else + warn "Failed to mount policystore for $_PS_U" + rmdir "$_PS_MP" 2>/dev/null || true + fi + done + unset _PS_U _PS_H _PS_MP + + # Revert: unmount for users no longer in the group or group deleted. + _NEW_PS_MOUNTS=() + while IFS= read -r _OLD_PS_U; do + [[ -z "$_OLD_PS_U" ]] && continue + _OLD_PS_HOME=$(getent passwd "$_OLD_PS_U" | cut -d: -f6 2>/dev/null) || continue + _OLD_PS_MP="$_OLD_PS_HOME/policystore" + if _ps_member "$_OLD_PS_U"; then + _NEW_PS_MOUNTS+=("$_OLD_PS_U") + else + umount -l "$_OLD_PS_MP" 2>/dev/null || true + rmdir "$_OLD_PS_MP" 2>/dev/null || true + log "Unmounted policystore for $_OLD_PS_U (no longer in usr_smb_adm_policystore)" + fi + done < "$PS_MOUNT_STATE" + unset _OLD_PS_U _OLD_PS_HOME _OLD_PS_MP + + if [[ ${#_NEW_PS_MOUNTS[@]} -gt 0 ]]; then + printf '%s\n' "${_NEW_PS_MOUNTS[@]}" | sort -u > "$PS_MOUNT_STATE" + else + > "$PS_MOUNT_STATE" + fi + unset _NEW_PS_MOUNTS _PS_SESS _PS_HOST _PS_SUSER _PS_SPASS _PS_CRED + + rm -f "$PS_CREDS_FILE" 2>/dev/null || true + fi +else + # Group deleted — unmount for any previously-tracked users. + if [[ -s "$PS_MOUNT_STATE" ]]; then + while IFS= read -r _OLD_PS_U; do + [[ -z "$_OLD_PS_U" ]] && continue + _OLD_PS_HOME=$(getent passwd "$_OLD_PS_U" | cut -d: -f6 2>/dev/null) || continue + umount -l "$_OLD_PS_HOME/policystore" 2>/dev/null || true + rmdir "$_OLD_PS_HOME/policystore" 2>/dev/null || true + log "Unmounted policystore for $_OLD_PS_U (usr_smb_adm_policystore group deleted)" + done < "$PS_MOUNT_STATE" + > "$PS_MOUNT_STATE" + unset _OLD_PS_U _OLD_PS_HOME + fi +fi +unset _ps_grp_exists diff --git a/setup/modules/FreeipaAnsible/ansible/policies.d/usr_prt.sh b/setup/modules/FreeipaAnsible/ansible/policies.d/usr_prt.sh new file mode 100644 index 0000000..c688011 --- /dev/null +++ b/setup/modules/FreeipaAnsible/ansible/policies.d/usr_prt.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# policy: usr_prt — per-user printer auto-add via FreeIPA usr_prt_ groups. +# Printer URI stored in IPA group description (ipps://... or similar). +# Logged-in members get the printer added to CUPS; leaving the group revokes access. + +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" + +_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 +} +_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 + for _i in "${!ACTIVE_PRT_PRINTERS[@]}"; do + _PRT="${ACTIVE_PRT_PRINTERS[$_i]}" + _GRP="${ACTIVE_PRT_GROUPS[$_i]}" + + _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 + + 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 + + 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) + + _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 + + 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 diff --git a/setup/modules/FreeipaAnsible/ansible/policies.d/usr_scan-notify.sh b/setup/modules/FreeipaAnsible/ansible/policies.d/usr_scan-notify.sh new file mode 100644 index 0000000..40bd6ba --- /dev/null +++ b/setup/modules/FreeipaAnsible/ansible/policies.d/usr_scan-notify.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# policy: usr_scan-notify — scan alert notification daemon for IPA group members. +# When the usr_scan-notify user group exists: installs a systemd fetch-alerts timer +# (fleet-wide) and a profile.d snippet that starts the notification daemon on login +# for group members (checked at login time via id/SSSD). + +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 diff --git a/setup/modules/FreeipaAnsible/ansible/policies.d/usr_smb.sh b/setup/modules/FreeipaAnsible/ansible/policies.d/usr_smb.sh new file mode 100644 index 0000000..c2d18fc --- /dev/null +++ b/setup/modules/FreeipaAnsible/ansible/policies.d/usr_smb.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# policy: usr_smb — auto-mount network shares in user home directories. +# usr_smb_r_ mount read-only share ~/‹name› for logged-in members +# usr_smb_rw_ mount read-write share ~/‹name› for logged-in members +# rw membership takes precedence over r for the same share name. +# Credential (server, Samba user, password) stored in 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_r_* / usr_smb_rw_* from the already-fetched group list + declare -A _SMB_R_GRP _SMB_RW_GRP + + 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 <<< "$_ALL_USER_GROUPS" + unset _g + + declare -A _SMB_NAMES + for _n in "${!_SMB_R_GRP[@]}" "${!_SMB_RW_GRP[@]}"; do _SMB_NAMES["$_n"]=1; done + unset _n + + _smb_member() { id -nG "$1" 2>/dev/null | tr ' ' '\n' | grep -qxF "$2"; } + + declare -A _SMB_SESS + _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 + + for _SMB_NAME in "${!_SMB_NAMES[@]}"; do + _R_GRP="${_SMB_R_GRP[$_SMB_NAME]:-}" + _RW_GRP="${_SMB_RW_GRP[$_SMB_NAME]:-}" + + _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 + _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 + + _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 + + 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 diff --git a/setup/modules/FreeipaAnsible/image/ansipa-smb-setup.sh b/setup/modules/FreeipaAnsible/image/ansipa-smb-setup.sh index 9c1a51b..7043257 100644 --- a/setup/modules/FreeipaAnsible/image/ansipa-smb-setup.sh +++ b/setup/modules/FreeipaAnsible/image/ansipa-smb-setup.sh @@ -1,5 +1,5 @@ #!/bin/bash -# ansipa-smb-setup.sh — configure the Samba scan-results and LUKS-key shares on the IPA container. +# ansipa-smb-setup.sh — configure Samba shares on the IPA container. # # Runs on every container start via ansipa-smb.service so that smb.conf and # Samba users are always in place after container restarts (ephemeral rootfs). @@ -9,12 +9,17 @@ # 2. /data/samba/ansipa-smb.env (persisted from first boot) # # Shares: -# ansipa-scans — write-only for 'scanupload'; clients push scan results here. -# ansipa-luks-keys — write-only for 'luks-upload' (Ansible controller); -# read for members of the 'KeyAdmin' Linux group. -# Add a Samba user to KeyAdmin to grant key-read access: -# useradd -r -G KeyAdmin -# smbpasswd -a +# ansipa-scans — write-only for 'scanupload'; clients push scan results here. +# ansipa-luks-keys — write-only for 'luks-upload' (Ansible controller); +# read for members of the 'KeyAdmin' Linux group. +# Add a Samba user to KeyAdmin to grant key-read access: +# useradd -r -G KeyAdmin +# smbpasswd -a +# ansipa-policystore — read-write for 'smb-policystore'; admins in the IPA group +# usr_smb_adm_policystore mount this to edit policy files live. +# Password auto-generated and stored in the IPA group description: +# cifs://:smb-policystore: +# The enforcer on clients reads this credential to sync policy files. set -euo pipefail @@ -22,9 +27,11 @@ LOG_TAG="ansipa-smb-setup" SCAN_BASE="/data/scan-results" LUKS_BASE="/data/luks-keys" SHARES_BASE="/data/smb-shares" +POLICY_STORE_BASE="/data/policy-store" SMB_CONF="/etc/samba/smb.conf" SMB_USER="scanupload" LUKS_UPLOAD_USER="luks-upload" +POLICY_STORE_USER="smb-policystore" KEYADMIN_GROUP="KeyAdmin" ENV_FILE="/data/samba/ansipa-smb.env" @@ -61,6 +68,7 @@ chmod 600 "$ENV_FILE" mkdir -p "$SCAN_BASE/archive" "$SCAN_BASE/alerts" mkdir -p "$LUKS_BASE" mkdir -p "$SHARES_BASE" +mkdir -p "$POLICY_STORE_BASE/policies.d" # ── KeyAdmin group ──────────────────────────────────────────────────────────── if ! getent group "$KEYADMIN_GROUP" &>/dev/null; then @@ -74,6 +82,11 @@ if ! id "$SMB_USER" &>/dev/null; then log "Created system user: $SMB_USER" fi +if ! id "$POLICY_STORE_USER" &>/dev/null; then + useradd -r -s /sbin/nologin -d "$POLICY_STORE_BASE" -M "$POLICY_STORE_USER" + log "Created system user: $POLICY_STORE_USER" +fi + if ! id "$LUKS_UPLOAD_USER" &>/dev/null; then useradd -r -s /sbin/nologin -d "$LUKS_BASE" -M -G "$KEYADMIN_GROUP" "$LUKS_UPLOAD_USER" log "Created system user: $LUKS_UPLOAD_USER (member of $KEYADMIN_GROUP)" @@ -85,6 +98,8 @@ fi chown -R "$SMB_USER:$SMB_USER" "$SCAN_BASE" chown -R "root:$KEYADMIN_GROUP" "$LUKS_BASE" chmod 2750 "$LUKS_BASE" # setgid so new files inherit KeyAdmin group +chown -R "$POLICY_STORE_USER:$POLICY_STORE_USER" "$POLICY_STORE_BASE" +chmod 2770 "$POLICY_STORE_BASE" # ── smb.conf (base only; _setup_user_shares appends stanzas afterwards) ─────── _write_smb_conf() { @@ -124,6 +139,17 @@ _write_smb_conf() { browseable = no create mask = 0640 directory mask = 0750 + +[ansipa-policystore] + comment = Ansipa policy store — admin rw; enforcer syncs policy files from here + path = $POLICY_STORE_BASE + valid users = $POLICY_STORE_USER + read only = no + browseable = no + force user = $POLICY_STORE_USER + force group = $POLICY_STORE_USER + create mask = 0664 + directory mask = 02775 CONF } @@ -283,13 +309,69 @@ STANZA kdestroy &>/dev/null || true } -# ── Write smb.conf, set passwords, configure user shares ───────────────────── +# ── Policystore share management (usr_smb_adm_policystore IPA user group) ──── +# Creates the ansipa-policystore Samba share with auto-generated credentials. +# Credential is stored in the IPA group description so clients can discover it: +# cifs://:smb-policystore: +# Uses the same kinit/kdestroy pattern as _setup_user_shares. +_setup_policystore() { + if [[ ! -f /etc/ipa/default.conf ]]; then + warn "IPA not yet configured — skipping policystore setup (restart after ipa-first-boot)" + return 0 + fi + if [[ -z "$IPA_ADMIN_PASS" ]]; then + warn "IPA_ADMIN_PASSWORD not set — skipping policystore setup" + return 0 + fi + + local realm ipa_host + realm=$(awk -F'[[:space:]]*=[[:space:]]*' '/^realm[[:space:]]*=/{print $2; exit}' \ + /etc/ipa/default.conf 2>/dev/null || echo "") + ipa_host=$(hostname -f 2>/dev/null || echo "ipa.test.local") + + echo "$IPA_ADMIN_PASS" | kinit "admin@${realm}" &>/dev/null || { + warn "kinit admin@${realm} failed — skipping policystore setup" + return 0 + } + + # Retrieve existing password from the IPA group description or generate one. + local PASS="" + local _desc + if ipa group-show usr_smb_adm_policystore &>/dev/null; then + _desc=$(ipa group-show usr_smb_adm_policystore --all 2>/dev/null \ + | awk -F': ' '/Description:/{print $2; exit}' || true) + if [[ "$_desc" =~ ^cifs://[^:]*:smb-policystore:(.+)$ ]]; then + PASS="${BASH_REMATCH[1]}" + fi + fi + if [[ -z "$PASS" ]]; then + PASS=$(tr -dc 'A-Za-z0-9' < /dev/urandom 2>/dev/null | head -c 32 \ + || openssl rand -base64 24 | tr -d '/+=\n') + log "Generated new password for $POLICY_STORE_USER" + fi + + _smb_set_pass "$POLICY_STORE_USER" "$PASS" + + # Create the IPA group if absent, then store/update the credential in its description. + ipa group-add usr_smb_adm_policystore \ + --desc="cifs://${ipa_host}:${POLICY_STORE_USER}:${PASS}" 2>/dev/null \ + || ipa group-mod usr_smb_adm_policystore \ + --desc="cifs://${ipa_host}:${POLICY_STORE_USER}:${PASS}" 2>/dev/null \ + || true + log "Stored policystore credential in IPA group usr_smb_adm_policystore" + + unset PASS _desc + kdestroy &>/dev/null || true +} + +# ── Write smb.conf, set passwords, configure shares ────────────────────────── _smb_set_pass "$SMB_USER" "$SMB_PASS" _smb_set_pass "$LUKS_UPLOAD_USER" "$LUKS_PASS" -# Write base smb.conf first, then have _setup_user_shares append to it. +# Write base smb.conf first, then append dynamic share stanzas. _write_smb_conf _setup_user_shares +_setup_policystore # Reload smbd if it is already running (picks up new user shares without restart). smbcontrol smbd reload-config 2>/dev/null || true @@ -304,4 +386,4 @@ CRON chmod 644 /etc/cron.d/ansipa-check-scans log "Installed hourly scan-checker cron" -log "Samba setup complete. Share: //localhost/ansipa-scans user: $SMB_USER" +log "Samba setup complete. Shares: ansipa-scans ($SMB_USER), ansipa-policystore ($POLICY_STORE_USER)"