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

1065 lines
47 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters!

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

#!/usr/bin/env bash
# ansipa-enforce-policies.sh — enforce FreeIPA host-group-driven policies on this client.
#
# Policies are idempotent and reversible: joining a group applies the policy;
# leaving the group removes it on the next run (every 30 min via systemd timer).
#
# Host-group naming conventions (device policies — applied to the whole machine):
# dev_daemon-enable-<unit> Ensure <unit> is enabled and running (systemctl enable --now).
# Leaving the group reverts: service is disabled and stopped.
# dev_daemon-disable-<unit> Ensure <unit> is disabled and stopped (systemctl disable --now).
# Leaving the group reverts: service is re-enabled and started.
# <unit> may omit the .service suffix; all systemd unit types work.
# If a unit appears in both enable and disable groups it is skipped.
# dev_timeshift-backup Enforce a daily Timeshift snapshot (requires timeshift installed)
# dev_security-scan Enforce daily ClamAV + rkhunter + chkrootkit scans
# dev_no-local-users Lock passwords for root and all local users (UID >= 1000) so
# that only FreeIPA domain accounts with centrally-managed sudo
# rules can authenticate and gain elevated privileges.
# Leaving the group reverts: every account locked by this policy
# is unlocked on the next run.
# dev_local-sudo-<username> Grant <username> full sudo on this specific device by adding
# them to the local sudoers drop-in. Leaving the group removes
# the drop-in on the next run.
#
# User-group naming conventions (per-user policies — follow the user across devices):
# usr_admin Members get full sudo on every enrolled host via a sudoers
# drop-in (/etc/sudoers.d/ansipa-usr-admin). Applied while the
# FreeIPA user group exists; drop-in is removed when the group is
# deleted. Uses SSSD so group membership resolves without local
# accounts.
# usr_block-binary-<name> Prevent members of this FreeIPA user group from running <name>
# on any enrolled host. Use __ in place of . to support Flatpak
# app IDs (e.g. usr_block-binary-org__gimp__Gimp blocks the
# Flatpak org.gimp.Gimp). Enforced via a PATH-priority wrapper
# that checks group membership at runtime via SSSD/id(1).
# Removing the user group from FreeIPA reverts the wrapper.
# usr_prt_<printer> Auto-add <printer> to CUPS on any enrolled host where a member
# is logged in. The printer URI is stored in the IPA group
# description (e.g. ipps://host/printers/name). Access is
# per-user only (allow:<username>); each new member who logs in
# gets added to the allow list. When the IPA group is deleted
# or all members leave, the printer is removed from CUPS on that
# host. A profile.d snippet + NOPASSWD sudo rule trigger the
# enforcer at login for immediate setup.
# usr_scan-notify Members receive scan alert notifications on every enrolled device
# they log into. The fetch-alerts timer is installed fleet-wide
# when the group exists; the notification daemon starts on login
# only for group members (checked via id(1) / SSSD).
# Deleting the IPA user group removes the timer and profile.d
# snippet on the next enforcer run.
#
# Notes:
# - Install scan tools first: add the host to dev_mod_anti-malware.
# - Configure Timeshift (type + target device) before enabling dev_timeshift-backup.
set -euo pipefail
LOG_TAG="ansipa-policies"
STATE_DIR="/var/lib/ansipa-policies"
BLOCK_DIR="/usr/local/bin"
CRON_DIR="/etc/cron.d"
log() { echo "[$LOG_TAG] $*"; logger -t "$LOG_TAG" "$*" 2>/dev/null || true; }
warn() { echo "[$LOG_TAG][WARN] $*" >&2; logger -t "$LOG_TAG" "WARN: $*" 2>/dev/null || true; }
HOST_FQDN=$(hostname -f 2>/dev/null || hostname)
if ! command -v ipa &>/dev/null; then
warn "ipa command not found — host not enrolled in FreeIPA. Exiting."
exit 0
fi
kinit -k "host/$HOST_FQDN" &>/dev/null || true
mkdir -p "$STATE_DIR"
# ── Fetch host group membership ───────────────────────────────────────────────
RAW_GROUPS=$(ipa host-show "$HOST_FQDN" --all 2>/dev/null \
| grep -i "Member of host-groups:" | sed 's/.*: //' || true)
# ── Parse active host-group (device) policies ─────────────────────────────────
ACTIVE_DAEMON_ENABLE=()
ACTIVE_DAEMON_DISABLE=()
ACTIVE_LOCAL_SUDO_USERS=()
ACTIVE_SSH_USERS=()
WANT_TIMESHIFT_BACKUP=false
WANT_SECURITY_SCAN=false
WANT_SCAN_NOTIFY=false
WANT_NO_LOCAL_USERS=false
WANT_USR_ADMIN=false
ACTIVE_PRT_GROUPS=()
ACTIVE_PRT_PRINTERS=()
if [[ -n "$RAW_GROUPS" ]]; then
while IFS=',' read -ra GRP_ARRAY; do
for g in "${GRP_ARRAY[@]}"; do
g="${g// /}"
case "$g" in
dev_daemon-enable-*) ACTIVE_DAEMON_ENABLE+=("${g#dev_daemon-enable-}") ;;
dev_daemon-disable-*) ACTIVE_DAEMON_DISABLE+=("${g#dev_daemon-disable-}") ;;
dev_timeshift-backup) WANT_TIMESHIFT_BACKUP=true ;;
dev_security-scan) WANT_SECURITY_SCAN=true ;;
dev_no-local-users) WANT_NO_LOCAL_USERS=true ;;
dev_local-sudo-*) ACTIVE_LOCAL_SUDO_USERS+=("${g#dev_local-sudo-}") ;;
dev_ssh_*) ACTIVE_SSH_USERS+=("${g#dev_ssh_}") ;;
esac
done
done <<< "$RAW_GROUPS"
fi
# ── Fetch user-group-based binary block policies from FreeIPA ─────────────────
# usr_block-binary-<name> groups are FreeIPA *user* groups — membership follows
# the user to every enrolled host rather than being tied to a device.
ACTIVE_BLOCK_BINARIES=()
ACTIVE_BLOCK_IPA_GROUPS=()
_BLOCK_LIST=$(ipa group-find --pkey-only 2>/dev/null \
| awk '/Group name:/ {print $NF}' \
| grep "^usr_block-binary-" | sort -u || true)
while IFS= read -r _grp; do
[[ -z "$_grp" ]] && continue
_raw="${_grp#usr_block-binary-}"
ACTIVE_BLOCK_BINARIES+=("${_raw//__/.}")
ACTIVE_BLOCK_IPA_GROUPS+=("$_grp")
done <<< "$_BLOCK_LIST"
unset _BLOCK_LIST _grp _raw
# ── Fetch user-group-based admin policy from FreeIPA ─────────────────────────
# usr_admin is a FreeIPA *user* group. When it exists, a sudoers drop-in grants
# full sudo to the group on every enrolled host (resolved via SSSD/NSS).
if ipa group-show usr_admin >/dev/null 2>&1; then
WANT_USR_ADMIN=true
fi
# ── Fetch user-group-based printer policies from FreeIPA ─────────────────────
# usr_prt_<printer> groups grant CUPS access to <printer> for members.
_PRT_LIST=$(ipa group-find --pkey-only 2>/dev/null \
| awk '/Group name:/ {print $NF}' \
| grep "^usr_prt_" | sort -u || true)
while IFS= read -r _pgrp; do
[[ -z "$_pgrp" ]] && continue
ACTIVE_PRT_GROUPS+=("$_pgrp")
ACTIVE_PRT_PRINTERS+=("${_pgrp#usr_prt_}")
done <<< "$_PRT_LIST"
unset _PRT_LIST _pgrp
# ── Fetch user-group-based scan-notify policy from FreeIPA ────────────────────
# usr_scan-notify is a FreeIPA *user* group — membership follows the user to
# every enrolled host. Install the fetch-alerts timer on any device where the
# group exists; the profile.d snippet gates daemon start on runtime membership.
if ipa group-show usr_scan-notify >/dev/null 2>&1; then
WANT_SCAN_NOTIFY=true
fi
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}"
log "User policies — admin: $WANT_USR_ADMIN" \
"| block-binary: ${ACTIVE_BLOCK_BINARIES[*]:-none}" \
"| printers: ${ACTIVE_PRT_PRINTERS[*]:-none}" \
"| scan-notify: $WANT_SCAN_NOTIFY"
# ── Helpers ───────────────────────────────────────────────────────────────────
_in_block_list() {
local needle="$1"
for b in "${ACTIVE_BLOCK_BINARIES[@]}"; do
[[ "$b" == "$needle" ]] && return 0
done
return 1
}
# ── Binary blocking (user-based) ──────────────────────────────────────────────
# A PATH-priority wrapper is installed in /usr/local/bin/ for every binary named
# by a usr_block-binary-* FreeIPA *user* group. The wrapper checks the
# caller's group membership at runtime (via id + SSSD) and only blocks members;
# non-members are transparently passed through to the real binary.
# __ in the group suffix decodes to . so Flatpak app IDs are fully supported.
# Removing the IPA user group causes the wrapper to be cleaned up on the next run.
BLOCK_STATE="$STATE_DIR/blocked-binaries"
[[ -f "$BLOCK_STATE" ]] || touch "$BLOCK_STATE"
for _idx in "${!ACTIVE_BLOCK_BINARIES[@]}"; do
BIN="${ACTIVE_BLOCK_BINARIES[$_idx]}"
IPA_GRP="${ACTIVE_BLOCK_IPA_GROUPS[$_idx]}"
WRAPPER="$BLOCK_DIR/$BIN"
# Write (or refresh) the wrapper when it is absent, not ours, or the group name changed.
if [[ ! -f "$WRAPPER" ]] \
|| ! grep -q "blocked by ansipa policy" "$WRAPPER" 2>/dev/null \
|| ! grep -qF "$IPA_GRP" "$WRAPPER" 2>/dev/null; then
log "Installing user-aware block wrapper: $BIN (group: $IPA_GRP)"
cat > "$WRAPPER" <<WRAPPER
#!/bin/bash
# blocked by ansipa policy (user-based)
if id -Gn 2>/dev/null | tr ' ' '\n' | grep -qxF "${IPA_GRP}"; then
echo "[ansipa-policies] '${BIN}' is blocked by system policy for your account." >&2
exit 1
fi
_real=\$(PATH="/usr/bin:/usr/sbin:/bin:/sbin:/usr/local/sbin:/opt/bin:/var/lib/flatpak/exports/bin:/usr/share/flatpak/exports/bin" command -v "${BIN}" 2>/dev/null)
[[ -n "\$_real" ]] && exec "\$_real" "\$@"
command -v flatpak &>/dev/null && exec flatpak run "${BIN}" "\$@" 2>/dev/null
echo "${BIN}: command not found" >&2
exit 127
WRAPPER
chmod 755 "$WRAPPER"
fi
done
unset _idx
# Remove wrappers whose IPA user group no longer exists.
while IFS= read -r OLD_BIN; do
[[ -z "$OLD_BIN" ]] && continue
if ! _in_block_list "$OLD_BIN"; then
WRAPPER="$BLOCK_DIR/$OLD_BIN"
if [[ -f "$WRAPPER" ]] && grep -q "blocked by ansipa policy" "$WRAPPER" 2>/dev/null; then
rm -f "$WRAPPER"
log "Removed binary block wrapper: $OLD_BIN"
fi
fi
done < "$BLOCK_STATE"
# Persist current blocked list.
if [[ ${#ACTIVE_BLOCK_BINARIES[@]} -gt 0 ]]; then
printf '%s\n' "${ACTIVE_BLOCK_BINARIES[@]}" | sort -u > "$BLOCK_STATE"
else
> "$BLOCK_STATE"
fi
# ── Timeshift daily backup ─────────────────────────────────────────────────────
TIMESHIFT_CRON="$CRON_DIR/ansipa-timeshift-backup"
if [[ "$WANT_TIMESHIFT_BACKUP" == true ]]; then
if [[ ! -f "$TIMESHIFT_CRON" ]]; then
if ! command -v timeshift &>/dev/null; then
warn "timeshift not found — add host to dev_mod_timeshift first. Cron will be installed anyway."
fi
log "Enabling daily Timeshift backups"
cat > "$TIMESHIFT_CRON" <<'CRON'
# ansipa-dev_timeshift-backup: managed by ansipa-enforce-policies — do not edit manually.
# Timeshift must be configured on this host (type + target device) before snapshots work.
0 3 * * * root /usr/bin/timeshift --create --comments "ansipa-daily" --tags D 2>&1 | logger -t timeshift-backup
CRON
chmod 644 "$TIMESHIFT_CRON"
fi
else
if [[ -f "$TIMESHIFT_CRON" ]]; then
rm -f "$TIMESHIFT_CRON"
log "Removed Timeshift backup cron (host left dev_timeshift-backup group)"
fi
fi
# ── Security scan ─────────────────────────────────────────────────────────────
SCAN_CRON="$CRON_DIR/ansipa-security-scan"
SCAN_SCRIPT="/usr/local/bin/ansipa-security-scan.sh"
if [[ "$WANT_SECURITY_SCAN" == true ]]; then
# (Re-)write the scan script so it stays current with this version of the enforcer.
cat > "$SCAN_SCRIPT" <<'SCAN'
#!/bin/bash
# ansipa-security-scan — daily ClamAV / rkhunter / chkrootkit run + SMB upload.
# Managed by ansipa-enforce-policies — do not edit manually.
LOG=/var/log/ansipa-security-scan.log
HOSTNAME=$(hostname -f 2>/dev/null || hostname)
DATE=$(date +%Y-%m-%d)
{
echo "=== ansipa-security-scan: $DATE $HOSTNAME ==="
if command -v freshclam &>/dev/null; then
freshclam --quiet 2>/dev/null || true
fi
if command -v clamscan &>/dev/null; then
clamscan -r --infected --quiet /home /etc /tmp /var/tmp 2>/dev/null || true
fi
if command -v rkhunter &>/dev/null; then
rkhunter --update --quiet 2>/dev/null || true
rkhunter --check --skip-keypress --quiet 2>/dev/null || true
fi
if command -v chkrootkit &>/dev/null; then
chkrootkit 2>/dev/null || true
fi
echo "=== scan complete ==="
} >> "$LOG" 2>&1
# ── Upload to server SMB share ────────────────────────────────────────────────
IPA_SERVER=$(awk '/^server[[:space:]]*=/{print $3}' /etc/ipa/default.conf 2>/dev/null || echo "")
if [[ -n "$IPA_SERVER" ]] && [[ -f /etc/ansipa-smb.creds ]] && command -v smbclient &>/dev/null; then
# Create host archive dir (mkdir is idempotent; errors suppressed).
smbclient "//$IPA_SERVER/ansipa-scans" -A /etc/ansipa-smb.creds \
-c "mkdir archive; mkdir archive\\$HOSTNAME; put $LOG archive\\$HOSTNAME\\$DATE.log" \
>> "$LOG" 2>&1 \
&& echo "[ansipa] Scan results uploaded to $IPA_SERVER/ansipa-scans/archive/$HOSTNAME/$DATE.log" >> "$LOG" \
|| echo "[ansipa][WARN] SMB upload failed — results remain local at $LOG" >> "$LOG"
else
echo "[ansipa] SMB upload skipped (no credentials or smbclient not found)." >> "$LOG"
fi
SCAN
chmod 755 "$SCAN_SCRIPT"
if [[ ! -f "$SCAN_CRON" ]]; then
log "Enabling daily security scans (ClamAV / rkhunter / chkrootkit)"
cat > "$SCAN_CRON" <<'CRON'
# ansipa-dev_security-scan: managed by ansipa-enforce-policies — do not edit manually.
# Install scan tools by adding the host to the dev_mod_anti-malware group.
0 2 * * * root /usr/local/bin/ansipa-security-scan.sh
CRON
chmod 644 "$SCAN_CRON"
fi
else
if [[ -f "$SCAN_CRON" ]]; then
rm -f "$SCAN_CRON"
rm -f "$SCAN_SCRIPT"
log "Removed security scan policy (host left dev_security-scan group)"
fi
fi
# ── Scan notification daemon ──────────────────────────────────────────────────
# usr_scan-notify is a FreeIPA *user* group (not a host group). The fetch-
# alerts timer runs fleet-wide on any host where the group exists; the profile.d
# snippet starts the notification daemon on login only for group members
# (checked via id(1) / SSSD so no IPA query is needed at login time).
# - Root timer (every 10 min): ansipa-fetch-alerts.sh downloads alerts from the
# server SMB share and places them in ~/administration/<hostname>/ per active user.
# - profile.d snippet: starts ansipa-scan-notify.sh as a user daemon on login;
# the daemon sends notify-send every 10 min while *.alert files remain.
# Deleting a file from ~/administration/ counts as acknowledgment.
#
# Requires: ansipa-fetch-alerts.sh and ansipa-scan-notify.sh deployed by
# deploy-ansipa-policies.yml (static scripts — not written inline here).
FETCH_SVC="/etc/systemd/system/ansipa-fetch-alerts.service"
FETCH_TIMER="/etc/systemd/system/ansipa-fetch-alerts.timer"
NOTIFY_PROFILED="/etc/profile.d/ansipa-notify.sh"
if [[ "$WANT_SCAN_NOTIFY" == true ]]; then
if [[ ! -x /usr/local/bin/ansipa-fetch-alerts.sh ]]; then
warn "ansipa-fetch-alerts.sh not found — run deploy-ansipa-policies.yml first."
fi
if [[ ! -f "$FETCH_SVC" ]]; then
log "Installing ansipa-fetch-alerts systemd service + timer"
cat > "$FETCH_SVC" <<'UNIT'
[Unit]
Description=Fetch Ansipa security alerts from the server SMB share
After=network-online.target sssd.service
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/ansipa-fetch-alerts.sh
StandardOutput=journal
StandardError=journal
UNIT
cat > "$FETCH_TIMER" <<'UNIT'
[Unit]
Description=Periodic ansipa security alert fetch
[Timer]
OnBootSec=2min
OnUnitActiveSec=10min
[Install]
WantedBy=timers.target
UNIT
systemctl daemon-reload
systemctl enable --now ansipa-fetch-alerts.timer
log "ansipa-fetch-alerts.timer enabled"
fi
if [[ ! -f "$NOTIFY_PROFILED" ]]; then
log "Installing /etc/profile.d/ansipa-notify.sh"
cat > "$NOTIFY_PROFILED" <<'PROFILED'
# ansipa-notify: launch the scan alert notification daemon on login for
# members of the usr_scan-notify FreeIPA user group.
# Managed by ansipa-enforce-policies — do not edit manually.
_NOTIFY_DAEMON=/usr/local/bin/ansipa-scan-notify.sh
if [[ -x "$_NOTIFY_DAEMON" ]] && \
id -nG 2>/dev/null | grep -qw "usr_scan-notify" && \
! pgrep -u "$(id -u)" -f "ansipa-scan-notify" >/dev/null 2>&1; then
"$_NOTIFY_DAEMON" &
disown
fi
unset _NOTIFY_DAEMON
PROFILED
chmod 644 "$NOTIFY_PROFILED"
fi
else
if [[ -f "$FETCH_TIMER" ]]; then
systemctl disable --now ansipa-fetch-alerts.timer 2>/dev/null || true
rm -f "$FETCH_SVC" "$FETCH_TIMER"
systemctl daemon-reload
log "Removed ansipa-fetch-alerts timer (usr_scan-notify user group no longer exists)"
fi
if [[ -f "$NOTIFY_PROFILED" ]]; then
rm -f "$NOTIFY_PROFILED"
log "Removed /etc/profile.d/ansipa-notify.sh"
fi
fi
# ── Daemon enable / disable ───────────────────────────────────────────────────
# dev_daemon-enable-<unit>: ensure the unit is enabled and running.
# Leaving the group reverts: unit is disabled and stopped.
# dev_daemon-disable-<unit>: ensure the unit is disabled and stopped.
# Leaving the group reverts: unit is re-enabled and started.
# <unit> may omit the .service suffix; systemd accepts both forms.
# Conflicts (unit in both lists): logged as a warning, unit is left untouched.
DAEMON_ENABLE_STATE="$STATE_DIR/daemon-enabled"
DAEMON_DISABLE_STATE="$STATE_DIR/daemon-disabled"
[[ -f "$DAEMON_ENABLE_STATE" ]] || touch "$DAEMON_ENABLE_STATE"
[[ -f "$DAEMON_DISABLE_STATE" ]] || touch "$DAEMON_DISABLE_STATE"
# Append .service only when the name has no unit-type suffix already.
_svc_unit() { [[ "$1" == *.* ]] && echo "$1" || echo "${1}.service"; }
_in_enable_list() { local n="$1"; for s in "${ACTIVE_DAEMON_ENABLE[@]}"; do [[ "$s" == "$n" ]] && return 0; done; return 1; }
_in_disable_list() { local n="$1"; for s in "${ACTIVE_DAEMON_DISABLE[@]}"; do [[ "$s" == "$n" ]] && return 0; done; return 1; }
# Apply enable policies
for _SVC in "${ACTIVE_DAEMON_ENABLE[@]}"; do
if _in_disable_list "$_SVC"; then
warn "Conflict: '$_SVC' is in both daemon-enable and daemon-disable groups — skipped"
continue
fi
_UNIT=$(_svc_unit "$_SVC")
_EN=$(systemctl is-enabled "$_UNIT" 2>/dev/null || echo "not-found")
_AC=$(systemctl is-active "$_UNIT" 2>/dev/null || echo "inactive")
if [[ "$_EN" != "enabled" || "$_AC" != "active" ]]; then
log "Enabling service: $_UNIT (enabled=$_EN active=$_AC)"
systemctl enable --now "$_UNIT" 2>/dev/null \
&& log "Service enabled: $_UNIT" \
|| warn "Failed to enable $_UNIT — unit may not exist on this host"
fi
done
# Apply disable policies
for _SVC in "${ACTIVE_DAEMON_DISABLE[@]}"; do
if _in_enable_list "$_SVC"; then
continue # conflict already warned above
fi
_UNIT=$(_svc_unit "$_SVC")
_EN=$(systemctl is-enabled "$_UNIT" 2>/dev/null || echo "not-found")
_AC=$(systemctl is-active "$_UNIT" 2>/dev/null || echo "inactive")
if [[ "$_EN" == "enabled" || "$_AC" == "active" ]]; then
log "Disabling service: $_UNIT (enabled=$_EN active=$_AC)"
systemctl disable --now "$_UNIT" 2>/dev/null \
&& log "Service disabled: $_UNIT" \
|| warn "Failed to disable $_UNIT — unit may not exist on this host"
fi
done
# Revert: host left a daemon-enable group → disable and stop the service
while IFS= read -r _OLD; do
[[ -z "$_OLD" ]] && continue
if ! _in_enable_list "$_OLD"; then
_UNIT=$(_svc_unit "$_OLD")
log "Reverting enable policy: disabling $_UNIT (host left daemon-enable group)"
systemctl disable --now "$_UNIT" 2>/dev/null \
|| warn "Failed to disable (revert) $_UNIT"
fi
done < "$DAEMON_ENABLE_STATE"
# Revert: host left a daemon-disable group → re-enable and start the service
while IFS= read -r _OLD; do
[[ -z "$_OLD" ]] && continue
if ! _in_disable_list "$_OLD"; then
_UNIT=$(_svc_unit "$_OLD")
log "Reverting disable policy: enabling $_UNIT (host left daemon-disable group)"
systemctl enable --now "$_UNIT" 2>/dev/null \
|| warn "Failed to enable (revert) $_UNIT"
fi
done < "$DAEMON_DISABLE_STATE"
# Persist current state
if [[ ${#ACTIVE_DAEMON_ENABLE[@]} -gt 0 ]]; then
printf '%s\n' "${ACTIVE_DAEMON_ENABLE[@]}" | sort -u > "$DAEMON_ENABLE_STATE"
else
> "$DAEMON_ENABLE_STATE"
fi
if [[ ${#ACTIVE_DAEMON_DISABLE[@]} -gt 0 ]]; then
printf '%s\n' "${ACTIVE_DAEMON_DISABLE[@]}" | sort -u > "$DAEMON_DISABLE_STATE"
else
> "$DAEMON_DISABLE_STATE"
fi
# ── Per-device local sudo grants ──────────────────────────────────────────────
# dev_local-sudo-<username>: write a sudoers drop-in granting <username> full sudo on
# this specific device. The drop-in is removed when the host leaves the group.
LOCAL_SUDO_DIR="/etc/sudoers.d"
LOCAL_SUDO_STATE="$STATE_DIR/local-sudo-users"
[[ -f "$LOCAL_SUDO_STATE" ]] || touch "$LOCAL_SUDO_STATE"
for _USER in "${ACTIVE_LOCAL_SUDO_USERS[@]}"; do
_DROPIN="$LOCAL_SUDO_DIR/ansipa-local-sudo-${_USER}"
if [[ ! -f "$_DROPIN" ]]; then
log "Granting local sudo to $_USER on this device"
echo "$_USER ALL=(ALL) ALL" > "$_DROPIN"
chmod 440 "$_DROPIN"
fi
grep -qxF "$_USER" "$LOCAL_SUDO_STATE" 2>/dev/null || echo "$_USER" >> "$LOCAL_SUDO_STATE"
done
# Revoke sudo for users no longer in any active dev_local-sudo-* group.
while IFS= read -r _OLD_USER; do
[[ -z "$_OLD_USER" ]] && continue
_still_active=false
for _U in "${ACTIVE_LOCAL_SUDO_USERS[@]}"; do
[[ "$_U" == "$_OLD_USER" ]] && _still_active=true && break
done
if [[ "$_still_active" == false ]]; then
_DROPIN="$LOCAL_SUDO_DIR/ansipa-local-sudo-${_OLD_USER}"
if [[ -f "$_DROPIN" ]]; then
rm -f "$_DROPIN"
log "Revoked local sudo for $_OLD_USER (host left dev_local-sudo-$_OLD_USER group)"
fi
fi
done < "$LOCAL_SUDO_STATE"
# Persist current local sudo users.
if [[ ${#ACTIVE_LOCAL_SUDO_USERS[@]} -gt 0 ]]; then
printf '%s\n' "${ACTIVE_LOCAL_SUDO_USERS[@]}" | sort -u > "$LOCAL_SUDO_STATE"
else
> "$LOCAL_SUDO_STATE"
fi
unset _USER _DROPIN _OLD_USER _still_active _U
# ── No-local-users policy ──────────────────────────────────────────────────────
# dev_no-local-users: lock the passwords of root and all local users (UID >= 1000)
# so that only FreeIPA domain accounts with centrally-managed sudo rules can
# authenticate and gain elevated privileges.
# Leaving the group reverts: every account locked by this policy is unlocked.
NO_LOCAL_USERS_STATE="$STATE_DIR/no-local-users"
_apply_no_local_users() {
log "Applying no_local_users policy — locking local account passwords"
[[ -f "$NO_LOCAL_USERS_STATE" ]] || touch "$NO_LOCAL_USERS_STATE"
while IFS=: read -r uname _ uid _; do
[[ "$uid" =~ ^[0-9]+$ ]] || continue
{ [[ "$uid" == "0" ]] || [[ "$uid" -ge 1000 ]]; } || continue
# Skip accounts already tracked (locked on a previous run)
grep -qxF "$uname" "$NO_LOCAL_USERS_STATE" 2>/dev/null && continue
# Lock only accounts that currently have a real (unlocked) password hash
local hash
hash=$(getent shadow "$uname" 2>/dev/null | cut -d: -f2 || true)
[[ -z "$hash" || "$hash" == '!'* || "$hash" == '*'* ]] && continue
if passwd -l "$uname" &>/dev/null; then
echo "$uname" >> "$NO_LOCAL_USERS_STATE"
log "Locked local account: $uname"
else
warn "Failed to lock local account: $uname"
fi
done < /etc/passwd
}
_revert_no_local_users() {
[[ -f "$NO_LOCAL_USERS_STATE" ]] || return 0
log "Reverting no_local_users policy — unlocking previously locked accounts"
while IFS= read -r uname; do
[[ -z "$uname" ]] && continue
if passwd -u "$uname" &>/dev/null; then
log "Unlocked local account: $uname"
else
warn "Failed to unlock local account: $uname (may have been removed)"
fi
done < "$NO_LOCAL_USERS_STATE"
> "$NO_LOCAL_USERS_STATE"
}
if [[ "$WANT_NO_LOCAL_USERS" == true ]]; then
_apply_no_local_users
else
if [[ -f "$NO_LOCAL_USERS_STATE" ]] && [[ -s "$NO_LOCAL_USERS_STATE" ]]; then
_revert_no_local_users
fi
fi
# ── usr_admin: global sudo for FreeIPA admin group ───────────────────────────
# When the FreeIPA user group usr_admin exists, install a sudoers drop-in that
# grants full sudo to that group (resolved via SSSD/NSS) on this host.
# The drop-in is removed when the group is deleted from FreeIPA.
USR_ADMIN_DROPIN="/etc/sudoers.d/ansipa-usr-admin"
USR_ADMIN_STATE="$STATE_DIR/usr-admin"
if [[ "$WANT_USR_ADMIN" == true ]]; then
if [[ ! -f "$USR_ADMIN_DROPIN" ]]; then
log "Installing usr_admin sudoers drop-in"
printf '%%usr_admin ALL=(ALL:ALL) ALL\n' > "$USR_ADMIN_DROPIN"
chmod 440 "$USR_ADMIN_DROPIN"
fi
touch "$USR_ADMIN_STATE"
else
if [[ -f "$USR_ADMIN_STATE" ]] || [[ -f "$USR_ADMIN_DROPIN" ]]; then
rm -f "$USR_ADMIN_DROPIN" "$USR_ADMIN_STATE"
log "Removed usr_admin sudoers drop-in (IPA group no longer exists)"
fi
fi
# ── usr_prt_<printer>: per-user printer auto-add via FreeIPA groups ───────────
# For each usr_prt_<printer> group in FreeIPA:
# - The group description must contain the printer URI
# (e.g. "ipps://printserver.corp.example.com/printers/hp-office")
# - When a logged-in user is a member of the group, the printer is added to
# CUPS on this host and restricted to that user (allow:<user>).
# - Multiple users in the group can each gain access on the same host.
# - When a user leaves the group their allow entry is revoked; when no users
# remain the printer is removed from CUPS on this host entirely.
# The profile.d snippet /etc/profile.d/ansipa-printers.sh triggers this enforcer
# at login so printers appear immediately rather than waiting for the 30-min tick.
#
# To register a printer in FreeIPA:
# ipa group-mod usr_prt_hp-office \
# --desc='ipps://printserver.corp.example.com/printers/hp-office'
PRT_STATE_DIR="$STATE_DIR/printer-users"
PRT_DEFS_DIR="$STATE_DIR/printer-defs"
PRT_PROFILED="/etc/profile.d/ansipa-printers.sh"
PRT_SUDO_DROPIN="/etc/sudoers.d/ansipa-printers-trigger"
mkdir -p "$PRT_STATE_DIR" "$PRT_DEFS_DIR"
# Read/write per-printer authorized-user state files (<printer>.users)
_prt_read_users() {
local sf="$PRT_STATE_DIR/${1}.users"
[[ -f "$sf" ]] && grep -v '^$' "$sf" | sort -u || true
}
_prt_write_users() {
local printer="$1"; shift
if [[ $# -gt 0 ]]; then
printf '%s\n' "$@" | sort -u > "$PRT_STATE_DIR/${printer}.users"
else
> "$PRT_STATE_DIR/${printer}.users"
fi
}
# Apply the full allow list from state (or remove printer if list is empty)
_prt_apply_acl() {
local printer="$1"
local -a users
mapfile -t users < <(_prt_read_users "$printer")
if [[ ${#users[@]} -gt 0 ]]; then
local allow_list
allow_list=$(IFS=','; echo "${users[*]}")
lpadmin -p "$printer" -u "allow:${allow_list}" 2>/dev/null \
|| warn "lpadmin ACL update failed for printer $printer"
else
lpadmin -x "$printer" 2>/dev/null \
&& log "Removed printer $printer (no authorized users remaining)" \
|| warn "lpadmin -x failed for printer $printer"
rm -f "$PRT_STATE_DIR/${printer}.users"
fi
}
_prt_is_active() {
local needle="$1"
for _ap in "${ACTIVE_PRT_PRINTERS[@]+"${ACTIVE_PRT_PRINTERS[@]}"}"; do
[[ "$_ap" == "$needle" ]] && return 0
done
return 1
}
if command -v lpadmin &>/dev/null; then
# ── Process each active usr_prt_* group ──────────────────────────────────
for _i in "${!ACTIVE_PRT_PRINTERS[@]}"; do
_PRT="${ACTIVE_PRT_PRINTERS[$_i]}"
_GRP="${ACTIVE_PRT_GROUPS[$_i]}"
# Fetch URI from IPA group description (must start with a scheme://)
_URI=$(ipa group-show "$_GRP" --all 2>/dev/null \
| awk -F': ' '/Description:/{print $2; exit}' \
| grep -E '^[a-z][a-z+.-]+://' || true)
if [[ -z "$_URI" ]]; then
warn "Group $_GRP has no printer URI in its description — set it with:"
warn " ipa group-mod $_GRP --desc='ipps://printserver/printers/$_PRT'"
continue
fi
echo "$_URI" > "$PRT_DEFS_DIR/${_GRP}.uri"
mapfile -t _CUR_USERS < <(_prt_read_users "$_PRT")
_UPDATED=false
# Check logged-in users for new members to grant access to
while IFS= read -r _LUSER; do
[[ -z "$_LUSER" ]] && continue
id -Gn "$_LUSER" 2>/dev/null | tr ' ' '\n' | grep -qxF "$_GRP" || continue
printf '%s\n' "${_CUR_USERS[@]+"${_CUR_USERS[@]}"}" | grep -qxF "$_LUSER" && continue
# Add the printer to CUPS if not present yet
if ! lpstat -p "$_PRT" &>/dev/null; then
log "Adding printer $_PRT (URI: $_URI)"
lpadmin -p "$_PRT" -v "$_URI" -m everywhere -E 2>/dev/null \
|| lpadmin -p "$_PRT" -v "$_URI" -E 2>/dev/null \
|| { warn "lpadmin failed to add printer $_PRT"; continue; }
fi
log "Granting printer $_PRT access to $_LUSER"
_CUR_USERS+=("$_LUSER")
_UPDATED=true
done < <(who -u 2>/dev/null | awk '{print $1}' | sort -u)
# Revoke users who left the group
_VALID_USERS=()
for _U in "${_CUR_USERS[@]+"${_CUR_USERS[@]}"}"; do
if id -Gn "$_U" 2>/dev/null | tr ' ' '\n' | grep -qxF "$_GRP"; then
_VALID_USERS+=("$_U")
else
log "Revoking printer $_PRT access for $_U (no longer in $_GRP)"
_UPDATED=true
fi
done
if [[ "$_UPDATED" == true ]]; then
_prt_write_users "$_PRT" "${_VALID_USERS[@]+"${_VALID_USERS[@]}"}"
_prt_apply_acl "$_PRT"
fi
done
unset _i _PRT _GRP _URI _LUSER _U _CUR_USERS _VALID_USERS _UPDATED _ap
# ── Remove printers whose IPA group was deleted ───────────────────────────
shopt -s nullglob
for _sf in "$PRT_STATE_DIR"/*.users; do
[[ -f "$_sf" ]] || continue
_PRT=$(basename "$_sf" .users)
_prt_is_active "$_PRT" && continue
log "Removing printer $_PRT (IPA group usr_prt_$_PRT deleted)"
lpadmin -x "$_PRT" 2>/dev/null || true
rm -f "$_sf"
done
shopt -u nullglob
unset _sf _PRT
# ── profile.d login trigger ───────────────────────────────────────────────
# Installs a profile.d snippet + NOPASSWD sudo rule so that when a user
# logs in, the enforcer runs immediately and adds their printers without
# waiting for the next 30-minute timer tick.
if [[ ${#ACTIVE_PRT_PRINTERS[@]} -gt 0 ]]; then
if [[ ! -f "$PRT_SUDO_DROPIN" ]]; then
log "Installing ansipa-printers sudo trigger rule"
printf 'ALL ALL=(root) NOPASSWD: /usr/bin/systemctl start ansipa-enforce-policies.service\n' \
> "$PRT_SUDO_DROPIN"
chmod 440 "$PRT_SUDO_DROPIN"
fi
if [[ ! -f "$PRT_PROFILED" ]]; then
log "Installing /etc/profile.d/ansipa-printers.sh"
cat > "$PRT_PROFILED" <<'PROFILED'
# ansipa-printers: trigger policy enforcer at login to auto-add printers.
# Managed by ansipa-enforce-policies — do not edit manually.
sudo -n /usr/bin/systemctl start ansipa-enforce-policies.service 2>/dev/null &
disown 2>/dev/null || true
PROFILED
chmod 644 "$PRT_PROFILED"
fi
else
if [[ -f "$PRT_SUDO_DROPIN" ]]; then
rm -f "$PRT_SUDO_DROPIN"
log "Removed ansipa-printers sudo trigger (no active printer groups)"
fi
if [[ -f "$PRT_PROFILED" ]]; then
rm -f "$PRT_PROFILED"
log "Removed /etc/profile.d/ansipa-printers.sh (no active printer groups)"
fi
fi
else
[[ ${#ACTIVE_PRT_PRINTERS[@]} -gt 0 ]] && \
warn "lpadmin not found — install cups to enforce printer policies"
fi
unset _ap
# ── usr_smb_<r|rw>_<name>: auto-mount network shares in user home dirs ────────
# For each usr_smb_r_<name> or usr_smb_rw_<name> group in FreeIPA:
# - The FreeIPA container hosts a Samba share [usr-<name>-r] or [usr-<name>-rw]
# under /data/smb-shares/<name>/ with UNIX group permissions.
# - When a logged-in user is a member of the group the share is mounted at
# ~/name (a new folder named after the share, no r/rw suffix).
# - rw membership takes precedence over r if the user is in both groups for
# the same share name.
# - The mount is tracked in the state file; leaving the group unmounts it.
#
# Credential string (server, Samba user, password) is stored in the IPA group
# description by ansipa-smb-setup.sh: cifs://<ipa-host>:<samba-user>:<password>
#
# Requires: cifs-utils installed on the client.
SMB_MOUNT_STATE="$STATE_DIR/smb-mounts"
[[ -f "$SMB_MOUNT_STATE" ]] || touch "$SMB_MOUNT_STATE"
SMB_CREDS_DIR="$STATE_DIR/smb-creds"
mkdir -p "$SMB_CREDS_DIR"
chmod 700 "$SMB_CREDS_DIR"
if command -v mount.cifs &>/dev/null; then
# ── Discover usr_smb_* groups from IPA ───────────────────────────────────
declare -A _SMB_R_GRP _SMB_RW_GRP # share-name → IPA group name
while IFS= read -r _g; do
[[ -z "$_g" ]] && continue
if [[ "$_g" =~ ^usr_smb_r_(.+)$ ]]; then _SMB_R_GRP["${BASH_REMATCH[1]}"]="$_g"
elif [[ "$_g" =~ ^usr_smb_rw_(.+)$ ]]; then _SMB_RW_GRP["${BASH_REMATCH[1]}"]="$_g"
fi
done < <(ipa group-find --pkey-only 2>/dev/null \
| awk '/Group name:/ {print $NF}' \
| grep "^usr_smb_" | sort -u || true)
unset _g
# Union of r + rw share names.
declare -A _SMB_NAMES
for _n in "${!_SMB_R_GRP[@]}" "${!_SMB_RW_GRP[@]}"; do _SMB_NAMES["$_n"]=1; done
unset _n
# ── Helpers ───────────────────────────────────────────────────────────────
# Outputs "host samba_user password" when description contains a cifs:// credential.
_smb_parse_cred() {
local _desc
_desc=$(ipa group-show "$1" --all 2>/dev/null \
| awk -F': ' '/Description:/{print $2; exit}' || true)
[[ "$_desc" =~ ^cifs://([^:]+):([^:]+):(.+)$ ]] && \
echo "${BASH_REMATCH[1]} ${BASH_REMATCH[2]} ${BASH_REMATCH[3]}"
}
_smb_member() { id -nG "$1" 2>/dev/null | tr ' ' '\n' | grep -qxF "$2"; }
# ── Collect active logged-in users (loginctl → who fallback) ─────────────
declare -A _SMB_SESS # user → home
_smb_add_sess() {
local _u
[[ "$2" == "loginctl" ]] && _u=$(awk '{print $3}' <<< "$1") \
|| _u=$(awk '{print $1}' <<< "$1")
[[ -z "$_u" || "$_u" == "root" || "$_u" == "USER" ]] && return 0
local _h; _h=$(getent passwd "$_u" | cut -d: -f6 2>/dev/null) || return 0
[[ -d "$_h" ]] && _SMB_SESS["$_u"]="$_h"
}
if loginctl list-sessions --no-legend &>/dev/null; then
while IFS= read -r _L; do _smb_add_sess "$_L" "loginctl"
done < <(loginctl list-sessions --no-legend 2>/dev/null)
else
while IFS= read -r _L; do _smb_add_sess "$_L" "who"
done < <(who 2>/dev/null)
fi
unset _L
# ── Apply: mount shares for logged-in members ─────────────────────────────
for _SMB_NAME in "${!_SMB_NAMES[@]}"; do
_R_GRP="${_SMB_R_GRP[$_SMB_NAME]:-}"
_RW_GRP="${_SMB_RW_GRP[$_SMB_NAME]:-}"
# Both access levels use the same credential; read from whichever has it.
_CRED_LINE=""
[[ -n "$_RW_GRP" ]] && _CRED_LINE=$(_smb_parse_cred "$_RW_GRP")
[[ -z "$_CRED_LINE" && -n "$_R_GRP" ]] && _CRED_LINE=$(_smb_parse_cred "$_R_GRP")
if [[ -z "$_CRED_LINE" ]]; then
warn "No credential in IPA description for share $_SMB_NAME — skipped (run ansipa-smb.service on FreeIPA container)"
continue
fi
read -r _SMB_HOST _SMB_SUSER _SMB_SPASS <<< "$_CRED_LINE"
_CREDS_FILE="$SMB_CREDS_DIR/${_SMB_NAME}.creds"
printf 'username=%s\npassword=%s\ndomain=WORKGROUP\n' "$_SMB_SUSER" "$_SMB_SPASS" \
> "$_CREDS_FILE"
chmod 600 "$_CREDS_FILE"
for _SMB_U in "${!_SMB_SESS[@]}"; do
# Determine per-user access level (rw beats r).
_SMB_LVL=""
[[ -n "$_RW_GRP" ]] && _smb_member "$_SMB_U" "$_RW_GRP" && _SMB_LVL="rw"
[[ -z "$_SMB_LVL" && -n "$_R_GRP" ]] && _smb_member "$_SMB_U" "$_R_GRP" && _SMB_LVL="r"
[[ -z "$_SMB_LVL" ]] && continue
_SMB_H="${_SMB_SESS[$_SMB_U]}"
_SMB_MP="$_SMB_H/$_SMB_NAME"
_SMB_SHARE="usr-${_SMB_NAME}-${_SMB_LVL}"
if mountpoint -q "$_SMB_MP" 2>/dev/null; then
grep -qxF "${_SMB_U}:${_SMB_NAME}" "$SMB_MOUNT_STATE" 2>/dev/null || \
echo "${_SMB_U}:${_SMB_NAME}" >> "$SMB_MOUNT_STATE"
continue
fi
mkdir -p "$_SMB_MP"
chown "$_SMB_U" "$_SMB_MP" 2>/dev/null || true
if mount -t cifs "//${_SMB_HOST}/${_SMB_SHARE}" "$_SMB_MP" \
-o "credentials=${_CREDS_FILE},uid=${_SMB_U},gid=${_SMB_U},file_mode=0644,dir_mode=0755,nounix,noserverino" \
2>/dev/null; then
log "Mounted //${_SMB_HOST}/${_SMB_SHARE}$_SMB_MP for $_SMB_U"
grep -qxF "${_SMB_U}:${_SMB_NAME}" "$SMB_MOUNT_STATE" 2>/dev/null || \
echo "${_SMB_U}:${_SMB_NAME}" >> "$SMB_MOUNT_STATE"
else
warn "Failed to mount //${_SMB_HOST}/${_SMB_SHARE} for $_SMB_U"
rmdir "$_SMB_MP" 2>/dev/null || true
fi
done
done
unset _SMB_NAME _R_GRP _RW_GRP _CRED_LINE _SMB_HOST _SMB_SUSER _SMB_SPASS \
_CREDS_FILE _SMB_U _SMB_H _SMB_MP _SMB_SHARE _SMB_LVL
# ── Revert: unmount shares whose group was removed or user left ───────────
_NEW_SMB_MOUNTS=()
while IFS=: read -r _OLD_USER _OLD_NAME; do
[[ -z "$_OLD_USER" || -z "$_OLD_NAME" ]] && continue
_OLD_HOME=$(getent passwd "$_OLD_USER" | cut -d: -f6 2>/dev/null) || continue
_OLD_MPT="$_OLD_HOME/$_OLD_NAME"
_STILL_MEMBER=false
if [[ -n "${_SMB_R_GRP[$_OLD_NAME]+x}" ]] && \
_smb_member "$_OLD_USER" "${_SMB_R_GRP[$_OLD_NAME]}"; then
_STILL_MEMBER=true
elif [[ -n "${_SMB_RW_GRP[$_OLD_NAME]+x}" ]] && \
_smb_member "$_OLD_USER" "${_SMB_RW_GRP[$_OLD_NAME]}"; then
_STILL_MEMBER=true
fi
if [[ "$_STILL_MEMBER" == true ]]; then
_NEW_SMB_MOUNTS+=("${_OLD_USER}:${_OLD_NAME}")
else
umount -l "$_OLD_MPT" 2>/dev/null || true
rmdir "$_OLD_MPT" 2>/dev/null || true
log "Unmounted ${_OLD_MPT} for ${_OLD_USER} (left usr_smb_*_${_OLD_NAME})"
fi
done < "$SMB_MOUNT_STATE"
unset _OLD_USER _OLD_NAME _OLD_HOME _OLD_MPT _STILL_MEMBER
if [[ ${#_NEW_SMB_MOUNTS[@]} -gt 0 ]]; then
printf '%s\n' "${_NEW_SMB_MOUNTS[@]}" | sort -u > "$SMB_MOUNT_STATE"
else
> "$SMB_MOUNT_STATE"
fi
# Credential files are written fresh on each run; remove stale ones.
rm -f "$SMB_CREDS_DIR"/*.creds 2>/dev/null || true
unset _SMB_R_GRP _SMB_RW_GRP _SMB_NAMES _SMB_SESS _NEW_SMB_MOUNTS
else
warn "mount.cifs not found — install cifs-utils to enable usr_smb_* policies"
[[ -s "$SMB_MOUNT_STATE" ]] && \
warn "smb-mounts state is non-empty — cannot revert existing mounts without cifs-utils" || true
fi
# ── dev_ssh_<userid>: distribute SSH public keys to user home dirs ────────────
# When this host is in dev_ssh_<userid>, the IPA user <userid>'s public keys are
# written to /home/<userid>/.ssh/authorized_keys in an ansipa-managed section.
# This enables Amir (or any named IPA user) to SSH into this device using the key
# registered in their IPA profile — the same key reaches every enrolled device.
#
# Keys are stored in the IPA user object (not on each device); add via:
# ipa user-mod <userid> --sshpubkey="ssh-ed25519 AAAA..."
# Multiple keys (e.g. workstation + laptop) are each added with --sshpubkey.
#
# The ansipa-managed section in authorized_keys is marked with:
# # BEGIN ansipa-ssh-managed … # END ansipa-ssh-managed
# This preserves any manually-added keys and is cleanly removed on revert.
#
# Leaving the host group removes only the ansipa-managed section on the next run.
SSH_KEY_STATE="$STATE_DIR/ssh-keys"
[[ -f "$SSH_KEY_STATE" ]] || touch "$SSH_KEY_STATE"
# Write or clear the ansipa-managed section in an authorized_keys file.
# Usage: _ssh_write_keys <user> <homedir> [key1 key2 ...]
# Passing no keys removes the section (revert).
_ssh_write_keys() {
local _u="$1" _h="$2"; shift 2
local _auth="$_h/.ssh/authorized_keys"
mkdir -p "$_h/.ssh"
chmod 700 "$_h/.ssh"
[[ -f "$_auth" ]] || touch "$_auth"
# Strip any existing ansipa section.
local _rest
_rest=$(awk '
/^# BEGIN ansipa-ssh-managed$/ { skip=1; next }
skip && /^# END ansipa-ssh-managed$/ { skip=0; next }
!skip
' "$_auth" 2>/dev/null || true)
if [[ $# -gt 0 ]]; then
{ [[ -n "$_rest" ]] && printf '%s\n' "$_rest"; \
echo "# BEGIN ansipa-ssh-managed"; \
printf '%s\n' "$@"; \
echo "# END ansipa-ssh-managed"; } > "$_auth"
else
[[ -n "$_rest" ]] && printf '%s\n' "$_rest" > "$_auth" || > "$_auth"
fi
chmod 600 "$_auth"
chown "$_u:$_u" "$_h/.ssh" "$_auth" 2>/dev/null || true
}
declare -A _SSH_APPLIED # userid → 1 (applied this run)
for _SSH_UID in "${ACTIVE_SSH_USERS[@]+"${ACTIVE_SSH_USERS[@]}"}"; do
# Verify the IPA user exists.
ipa user-show "$_SSH_UID" &>/dev/null || {
warn "dev_ssh_${_SSH_UID}: IPA user '$_SSH_UID' not found — skipping"
continue
}
# Fetch SSH public keys from IPA user profile.
# `ipa user-show --all` shows: " SSH public key: <key>"
# " SSH public key fingerprint: ..." is a separate field — exclude it.
_SSH_KEYS=()
mapfile -t _SSH_KEYS < <(
ipa user-show "$_SSH_UID" --all 2>/dev/null \
| grep "^ SSH public key:" | grep -v "fingerprint" \
| sed 's/^ SSH public key: //' || true
)
if [[ ${#_SSH_KEYS[@]} -eq 0 ]]; then
warn "dev_ssh_${_SSH_UID}: '$_SSH_UID' has no SSH keys in IPA — add via:"
warn " ipa user-mod $_SSH_UID --sshpubkey='ssh-ed25519 AAAA...'"
continue
fi
# Resolve home directory via SSSD (works for IPA users once SSSD is running).
_SSH_HOME=$(getent passwd "$_SSH_UID" 2>/dev/null | cut -d: -f6 || true)
if [[ -z "$_SSH_HOME" ]]; then
warn "dev_ssh_${_SSH_UID}: '$_SSH_UID' not resolvable via getent — is SSSD running?"
continue
fi
# Create home dir now if it doesn't yet exist (IPA user may not have logged in).
if [[ ! -d "$_SSH_HOME" ]]; then
log "Creating home dir for $_SSH_UID: $_SSH_HOME"
mkdir -p "$_SSH_HOME"
chown "$_SSH_UID:$_SSH_UID" "$_SSH_HOME" 2>/dev/null || true
chmod 700 "$_SSH_HOME"
fi
_ssh_write_keys "$_SSH_UID" "$_SSH_HOME" "${_SSH_KEYS[@]}"
log "SSH keys for $_SSH_UID: ${#_SSH_KEYS[@]} key(s) written to $_SSH_HOME/.ssh/authorized_keys"
_SSH_APPLIED["$_SSH_UID"]=1
grep -qxF "$_SSH_UID" "$SSH_KEY_STATE" 2>/dev/null || echo "$_SSH_UID" >> "$SSH_KEY_STATE"
done
unset _SSH_UID _SSH_KEYS _SSH_HOME
# Revert: remove ansipa keys for users whose dev_ssh_* group was removed from this host.
_NEW_SSH_STATE=()
while IFS= read -r _OLD_SSH_UID; do
[[ -z "$_OLD_SSH_UID" ]] && continue
if [[ -n "${_SSH_APPLIED[$_OLD_SSH_UID]+x}" ]]; then
_NEW_SSH_STATE+=("$_OLD_SSH_UID")
else
_OLD_SSH_HOME=$(getent passwd "$_OLD_SSH_UID" 2>/dev/null | cut -d: -f6 || true)
if [[ -n "$_OLD_SSH_HOME" ]]; then
_ssh_write_keys "$_OLD_SSH_UID" "$_OLD_SSH_HOME" # no keys = remove section
log "Removed ansipa SSH keys for $_OLD_SSH_UID (host left dev_ssh_${_OLD_SSH_UID})"
fi
fi
done < "$SSH_KEY_STATE"
unset _OLD_SSH_UID _OLD_SSH_HOME
if [[ ${#_NEW_SSH_STATE[@]} -gt 0 ]]; then
printf '%s\n' "${_NEW_SSH_STATE[@]}" | sort -u > "$SSH_KEY_STATE"
else
> "$SSH_KEY_STATE"
fi
unset _SSH_APPLIED _NEW_SSH_STATE
log "Policy enforcement complete."