refactor(ansipa): split enforce-policies monolith into modular policies.d/ system

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 <noreply@anthropic.com>
feat/astal-menu
Amir Alexander Abdelbaki 2026-07-01 19:27:35 +02:00
parent b4ae7ae4ce
commit 815c0e57a8
17 changed files with 1639 additions and 1453 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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-<unit> Ensure <unit> is enabled and running; reverted when host leaves group
# dev_daemon-disable-<unit> Ensure <unit> 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_<name> Mount read-only Samba share ~/name for members; credentials in IPA group description
# usr_smb_rw_<name> 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=<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=<password> -e seed_policystore=true -e ipa_server_host=<ipa-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=<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

View File

@ -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"
}

View File

@ -0,0 +1,82 @@
#!/usr/bin/env bash
# policy: dev_daemon — enable or disable systemd units via FreeIPA host-groups.
# dev_daemon-enable-<unit> ensure the unit is enabled and running
# dev_daemon-disable-<unit> 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

View File

@ -0,0 +1,40 @@
#!/usr/bin/env bash
# policy: dev_local-sudo — per-device sudo grant for named local users.
# dev_local-sudo-<username>: 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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,95 @@
#!/usr/bin/env bash
# policy: dev_ssh — distribute IPA user SSH public keys to authorized_keys.
# dev_ssh_<userid>: 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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,61 @@
#!/usr/bin/env bash
# policy: usr_block-binary — prevent IPA user group members from running named binaries.
# usr_block-binary-<name>: 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" <<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"
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

View File

@ -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://<host>:smb-policystore:<pass> 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

View File

@ -0,0 +1,145 @@
#!/usr/bin/env bash
# policy: usr_prt — per-user printer auto-add via FreeIPA usr_prt_<printer> 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

View File

@ -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

View File

@ -0,0 +1,145 @@
#!/usr/bin/env bash
# policy: usr_smb — auto-mount network shares in user home directories.
# usr_smb_r_<name> mount read-only share ~/name for logged-in members
# usr_smb_rw_<name> 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://<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_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

View File

@ -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).
@ -15,6 +15,11 @@
# Add a Samba user to KeyAdmin to grant key-read access:
# useradd -r -G KeyAdmin <user>
# smbpasswd -a <user>
# 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://<host>:smb-policystore:<password>
# 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://<host>:smb-policystore:<password>
# 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)"