Compare commits

...

5 Commits

Author SHA1 Message Date
Amir Alexander Abdelbaki d0bf9b2f12 chore(gitignore): ignore inventory-test to avoid committing test credentials
FreeipaAnsible test inventory contains a plaintext ansible_ssh_pass.
2026-07-02 07:03:12 +02:00
Amir Alexander Abdelbaki 03db4f00a7 fix(ansipa): Arch package/hostname fixes, safer seed_policystore delegation
- Use samba/openssh package names on Arch instead of Debian-style samba-client/openssh-server
- Skip AUR-only freeipa install when ipa-client-install already present; skip oddjob if not in official repos
- Guard seed_policystore delegate_to when ipa_server_host is undefined
- Make hostname detection and OS version parsing more robust
- Fix inverted DNS_UPDATE/CONFIGURE_SUDO enrollment flags
2026-07-02 07:01:46 +02:00
Amir Alexander Abdelbaki 815c0e57a8 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>
2026-07-01 19:27:35 +02:00
Amir Alexander Abdelbaki b4ae7ae4ce feat(tools): add proxmox-lxc-gen.sh — TUI wizard for LXC containers
Interactive dialog-based wizard that walks through every relevant
Proxmox LXC parameter and emits three files:

  pct-create-<vmid>.sh    — runnable pct create command
  lxc-<vmid>.conf         — drop-in /etc/pve/lxc/ config
  deploy-guide-<vmid>.txt — step-by-step deployment notes

TUI screens (dialog, cyan theme):
  1. Identity      — VMID, hostname, description, tags
  2. OS/template   — distro menu (debian/ubuntu/alpine/arch/fedora/
                     rocky/centos/void/custom); pveam pattern resolved
                     at deploy time so the latest version is always used
  3. Resources     — memory, swap, cores, disk size, rootfs storage pool
  4. Network       — DHCP or static (IP/CIDR + gateway + DNS + search
                     domain); bridge, VLAN tag, firewall flag
  5. Security      — unprivileged/privileged; feature checklist
                     (nesting, fuse, keyctl, mounts, apparmor unconfined,
                     cgroup:rw, seccomp disable)
  6. Options       — root password (passwordbox, hidden); SSH pubkey
                     file; start-on-boot; auto-start; TTY count
  7. Proxmox host  — optional SSH target for direct provisioning;
                     template storage; output directory; node name
  8. Summary       — scrollable review before generating

If a Proxmox host is provided, the script SCPs the create script and
runs it over SSH. Matches DIALOGRC theme style of freeipa-image.sh
(cyan accent instead of magenta to visually distinguish).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 18:55:59 +02:00
Amir Alexander Abdelbaki 0dd7ce3b51 docs(ansipa): document all policies; fix package check for pacman/dpkg
- ansipa-enforce-policies.sh header: add missing dev_ssh_*, usr_smb_r_*,
  usr_smb_rw_*, dev_mon_* (base/malware/timeshift/power), usr_mon_logins
  with concise descriptions matching actual behaviour.  Notes section
  updated with CheckMK setup prerequisite.
- ansipa_packages local check: replace broken rpm||dpkg fallback (wc -l
  always exits 0 so dpkg/pacman branches were never reached) with an
  if/elif/elif that probes for rpm, dpkg, or pacman in order.  Includes
  pacman.log-based recent-packages list for Arch hosts.
- deploy-ansipa-policies.yml prerequisites: add CheckMK and SMB mount notes.
- .env.example: add CheckMK section (CMK_ADMIN_PASSWORD, CMK_SITE_ID).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 18:46:27 +02:00
21 changed files with 2291 additions and 1448 deletions

1
.gitignore vendored
View File

@ -3,6 +3,7 @@
*.pem
*.p12
*.pfx
inventory-test
# Micro editor temporary files
/micro/buffers/**

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.
@ -41,10 +47,19 @@
# - For dev_security-scan / usr_scan-notify: smb_scan_password set (use ansible-vault in production)
# - For security-scan tools: also add host to dev_mod_anti-malware group
# - 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_*/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
@ -55,21 +70,37 @@
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:
name: "{{ item }}"
state: present
loop:
- samba-client
- "{{ 'samba' if ansible_os_family == 'Archlinux' else 'samba-client' }}"
- cups
- cifs-utils # needed for usr_smb_* CIFS mounts
- openssh-server # needed for dev_ssh_* (sshd must be running to accept keys)
- "{{ 'openssh' if ansible_os_family == 'Archlinux' else 'openssh-server' }}"
- lm_sensors # needed for dev_mon_power fallback (Intel RAPL preferred)
ignore_errors: yes
@ -147,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 | default(omit) }}"
run_once: true
when: (seed_policystore | default(false) | bool) and (ipa_server_host is defined)

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

@ -132,7 +132,7 @@ section "Detecting OS"
if [[ -f /etc/os-release ]]; then
source /etc/os-release
OS_ID="${ID}"
OS_VERSION="${VERSION_ID%%.*}"
OS_VERSION="${VERSION_ID:+${VERSION_ID%%.*}}"
log "Detected: $PRETTY_NAME"
else
error "Cannot detect OS — /etc/os-release missing"
@ -259,7 +259,18 @@ section "Installing packages"
if [[ "$OS_ID" == "arch" ]]; then
# Ensure pacman db is fresh
pacman -Sy --noconfirm
pkg_install $IPA_CLIENT_PKG $SSSD_PKGS $ODDJOB_PKGS
# freeipa is AUR-only; skip if ipa-client-install already present
if command -v ipa-client-install &>/dev/null; then
log "ipa-client-install already present — skipping AUR freeipa install"
else
pkg_install $IPA_CLIENT_PKG $SSSD_PKGS
fi
# oddjob (for mkhomedir) — skip if not available in official repos
if pacman -Si oddjob &>/dev/null 2>&1; then
pkg_install $ODDJOB_PKGS
else
warn "oddjob not in official repos — mkhomedir via pam_mkhomedir fallback"
fi
# Arch: pam-u2f may be in AUR — try pacman first, fall back to yay/paru
if [[ "$ENABLE_FIDO2" == true ]]; then
@ -299,7 +310,7 @@ log "Packages installed"
# ─── Set hostname ─────────────────────────────────────────────────────────────
section "Configuring hostname"
CURRENT_HOSTNAME=$(hostname -f 2>/dev/null || hostname)
CURRENT_HOSTNAME=$(hostname -f 2>/dev/null || hostnamectl hostname 2>/dev/null || cat /etc/hostname 2>/dev/null || echo "unknown")
if [[ "$CURRENT_HOSTNAME" != "$IPA_HOSTNAME" ]]; then
log "Setting hostname to $IPA_HOSTNAME"
hostnamectl set-hostname "$IPA_HOSTNAME"
@ -344,8 +355,8 @@ ENROLL_ARGS=(
)
[[ "$MKHOMEDIR" == true ]] && ENROLL_ARGS+=(--mkhomedir)
[[ "$DNS_UPDATE" == false ]] && ENROLL_ARGS+=(--no-dns-update)
[[ "$CONFIGURE_SUDO" == true ]] && ENROLL_ARGS+=(--enable-dns-updates)
[[ "$DNS_UPDATE" == true ]] && ENROLL_ARGS+=(--enable-dns-updates)
[[ "$CONFIGURE_SUDO" == true ]] && true # sudo via SSSD is configured post-enrollment
log "Running ipa-client-install..."
if ipa-client-install "${ENROLL_ARGS[@]}"; then

View File

@ -20,6 +20,14 @@ IPA_SETUP_KRA=false
SMB_SCAN_PASSWORD=ChangeMe_ScanPass!
LUKS_KEY_UPLOAD_PASSWORD=ChangeMe_LuksUpload!
# ── CheckMK CE monitoring ─────────────────────────────────────────────────────
# CMK_ADMIN_PASSWORD — web UI password for cmkadmin.
# CMK_SITE_ID — OMD site name (default: cmk). Used in all CheckMK URLs and
# agent registration. Must match whatever was used on first start.
# Web UI: http://localhost:8090/cmk/ after `docker compose up -d`
CMK_ADMIN_PASSWORD=ChangeMe_CMK!
CMK_SITE_ID=cmk
# ── Keycloak ──────────────────────────────────────────────────────────────────
KC_HOSTNAME=keycloak.corp.example.com
KC_REALM=corp

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).
@ -9,12 +9,17 @@
# 2. /data/samba/ansipa-smb.env (persisted from first boot)
#
# Shares:
# ansipa-scans — write-only for 'scanupload'; clients push scan results here.
# ansipa-luks-keys — write-only for 'luks-upload' (Ansible controller);
# read for members of the 'KeyAdmin' Linux group.
# Add a Samba user to KeyAdmin to grant key-read access:
# useradd -r -G KeyAdmin <user>
# smbpasswd -a <user>
# ansipa-scans — write-only for 'scanupload'; clients push scan results here.
# ansipa-luks-keys — write-only for 'luks-upload' (Ansible controller);
# read for members of the 'KeyAdmin' Linux group.
# Add a Samba user to KeyAdmin to grant key-read access:
# useradd -r -G KeyAdmin <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)"

587
setup/tools/proxmox-lxc-gen.sh Executable file
View File

@ -0,0 +1,587 @@
#!/usr/bin/env bash
# proxmox-lxc-gen.sh — TUI wizard for configuring and deploying Proxmox LXC containers.
#
# Walks through a step-by-step dialog interface covering every relevant LXC
# parameter, then writes three files to an output directory:
#
# pct-create-<vmid>.sh — runnable pct create command (execute on Proxmox host)
# lxc-<vmid>.conf — ready-to-drop /etc/pve/lxc/ config file
# deploy-guide-<vmid>.txt — step-by-step deployment notes
#
# Optionally SSHs to the Proxmox host to run pct create directly.
#
# Requires: dialog (pacman -S dialog / apt install dialog / dnf install dialog)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SHELL_SCRIPTS_DIR="$SCRIPT_DIR/../Setup-shell-4-containers"
# ─── Helpers ──────────────────────────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'
BLUE='\033[0;34m'; CYAN='\033[0;36m'; NC='\033[0m'
log() { printf "${GREEN}[+]${NC} %s\n" "$*"; }
warn() { printf "${YELLOW}[!]${NC} %s\n" "$*"; }
error() { printf "${RED}[✗]${NC} %s\n" "$*" >&2; }
info() { printf "${CYAN}[i]${NC} %s\n" "$*"; }
section() { printf "\n${BLUE}━━━ %s ━━━${NC}\n" "$*"; }
[[ $EUID -eq 0 ]] && { error "Run as a normal user (not root)."; exit 1; }
# ─── dialog dependency ────────────────────────────────────────────────────────
if ! command -v dialog &>/dev/null; then
warn "dialog not found — attempting install..."
if command -v pacman &>/dev/null; then sudo pacman -S --noconfirm dialog
elif command -v apt-get &>/dev/null; then sudo apt-get install -y dialog
elif command -v dnf &>/dev/null; then sudo dnf install -y dialog
else error "Install dialog manually, then re-run."; exit 1
fi
fi
# ─── dialog theme ─────────────────────────────────────────────────────────────
TMP_D="$(mktemp -d)"
trap 'rm -rf "$TMP_D"' EXIT
BACKTITLE="Proxmox LXC Generator"
export DIALOGRC="$TMP_D/dialogrc"
cat > "$DIALOGRC" <<'DLRC'
use_shadow = ON
use_colors = ON
screen_color = (BLACK,BLACK,ON)
title_color = (CYAN,BLACK,ON)
border_color = (CYAN,BLACK,ON)
button_active_color = (BLACK,CYAN,ON)
button_inactive_color = (WHITE,BLACK,OFF)
menubox_color = (WHITE,BLACK,OFF)
menubox_border_color = (CYAN,BLACK,ON)
item_color = (WHITE,BLACK,OFF)
item_selected_color = (BLACK,CYAN,ON)
tag_color = (CYAN,BLACK,ON)
tag_selected_color = (BLACK,CYAN,ON)
check_color = (WHITE,BLACK,OFF)
check_selected_color = (BLACK,CYAN,ON)
inputbox_color = (WHITE,BLACK,OFF)
inputbox_border_color = (CYAN,BLACK,ON)
uarrow_color = (CYAN,BLACK,ON)
darrow_color = (CYAN,BLACK,ON)
DLRC
# Dialog wrapper: redirect stdout↔stderr so dialog output is captured.
D() { dialog --backtitle "$BACKTITLE" "$@" 3>&1 1>&2 2>&3; }
abort() { clear; echo "Aborted."; exit 0; }
# ─── Screen 1: Identity ───────────────────────────────────────────────────────
IDENTITY=$(D --title " Container Identity " \
--form "\nSet the container ID, hostname, and optional metadata." \
14 66 4 \
"Container ID (VMID):" 1 1 "100" 1 26 8 9 \
"Hostname:" 2 1 "" 2 26 32 253 \
"Description (optional):" 3 1 "" 3 26 36 512 \
"Tags (comma-separated):" 4 1 "" 4 26 36 512 \
) || abort
VMID=$( awk 'NR==1' <<< "$IDENTITY")
CT_HOSTNAME=$(awk 'NR==2' <<< "$IDENTITY")
CT_DESC=$( awk 'NR==3' <<< "$IDENTITY")
CT_TAGS=$( awk 'NR==4' <<< "$IDENTITY")
[[ -z "$VMID" || ! "$VMID" =~ ^[0-9]{1,9}$ ]] && {
error "VMID must be a positive integer (got: '${VMID}')."; exit 1; }
[[ -z "$CT_HOSTNAME" ]] && { error "Hostname is required."; exit 1; }
# ─── Screen 2: OS / Template ──────────────────────────────────────────────────
TMPL_DISTRO=$(D --title " OS / Template " \
--menu "\nSelect the base distribution.\nThe matching CT template will be located via pveam on Proxmox." \
20 66 9 \
"debian" "Debian 12 Bookworm — stable, recommended default" \
"ubuntu" "Ubuntu 24.04 LTS — popular, snap support" \
"alpine" "Alpine Linux — minimal, musl, ~10 MB" \
"arch" "Arch Linux — rolling release" \
"fedora" "Fedora — recent packages, rpm" \
"rocky" "Rocky Linux 9 — RHEL-compatible enterprise" \
"centos" "CentOS Stream 9 — RHEL upstream" \
"void" "Void Linux — runit init, musl/glibc" \
"custom" "Custom template — enter filename manually" \
) || abort
case "$TMPL_DISTRO" in
debian) CT_OSTYPE="debian"; TMPL_PATTERN="debian-12" ;;
ubuntu) CT_OSTYPE="ubuntu"; TMPL_PATTERN="ubuntu-24.04" ;;
alpine) CT_OSTYPE="alpine"; TMPL_PATTERN="alpine-3" ;;
arch) CT_OSTYPE="archlinux"; TMPL_PATTERN="archlinux-base" ;;
fedora) CT_OSTYPE="fedora"; TMPL_PATTERN="fedora-" ;;
rocky) CT_OSTYPE="centos"; TMPL_PATTERN="rockylinux-9" ;;
centos) CT_OSTYPE="centos"; TMPL_PATTERN="centos-9-stream" ;;
void) CT_OSTYPE="unmanaged"; TMPL_PATTERN="voidlinux-" ;;
custom) CT_OSTYPE="unmanaged"; TMPL_PATTERN="" ;;
esac
if [[ "$TMPL_DISTRO" == "custom" ]]; then
CT_TEMPLATE=$(D --title " Custom Template " \
--inputbox \
"Enter the exact template filename as shown by pveam available.
Example: debian-12-standard_12.7-1_amd64.tar.zst" \
10 72 "") || abort
[[ -z "$CT_TEMPLATE" ]] && { error "Template filename is required."; exit 1; }
else
CT_TEMPLATE="$TMPL_PATTERN" # resolved to the full name on Proxmox at deploy time
fi
# ─── Screen 3: Resources ──────────────────────────────────────────────────────
RESOURCES=$(D --title " Resources " \
--form "\nAllocate compute and storage resources for the container." \
15 66 5 \
"Memory (MB):" 1 1 "512" 1 22 10 10 \
"Swap (MB):" 2 1 "512" 2 22 10 10 \
"CPU cores:" 3 1 "1" 3 22 5 4 \
"Disk size (GB):" 4 1 "8" 4 22 10 10 \
"Rootfs storage:" 5 1 "local-lvm" 5 22 24 64 \
) || abort
CT_MEMORY=$(awk 'NR==1' <<< "$RESOURCES")
CT_SWAP=$( awk 'NR==2' <<< "$RESOURCES")
CT_CORES=$( awk 'NR==3' <<< "$RESOURCES")
CT_DISK=$( awk 'NR==4' <<< "$RESOURCES")
CT_STORAGE=$(awk 'NR==5' <<< "$RESOURCES")
for _f in CT_MEMORY CT_SWAP CT_CORES CT_DISK; do
[[ "${!_f}" =~ ^[0-9]+$ ]] || { error "$_f must be a number (got: '${!_f}')."; exit 1; }
done
# ─── Screen 4: Network ────────────────────────────────────────────────────────
CT_NET_MODE=$(D --title " Network — IP Assignment " \
--radiolist "\nChoose how the container obtains its IP address:" \
10 56 2 \
"dhcp" "DHCP (automatic — recommended for most setups)" ON \
"static" "Static IP address (you specify CIDR + gateway)" OFF \
) || abort
CT_IP="" CT_GW="" CT_DNS="8.8.8.8" CT_SEARCHDOMAIN=""
if [[ "$CT_NET_MODE" == "static" ]]; then
STATIC=$(D --title " Static Network " \
--form "\nEnter static network settings for this container." \
14 68 4 \
"IP address (CIDR):" 1 1 "192.168.1.100/24" 1 24 22 18 \
"Gateway:" 2 1 "192.168.1.1" 2 24 16 15 \
"DNS server:" 3 1 "8.8.8.8" 3 24 16 15 \
"Search domain:" 4 1 "" 4 24 36 255 \
) || abort
CT_IP=$( awk 'NR==1' <<< "$STATIC")
CT_GW=$( awk 'NR==2' <<< "$STATIC")
CT_DNS=$( awk 'NR==3' <<< "$STATIC")
CT_SEARCHDOMAIN=$(awk 'NR==4' <<< "$STATIC")
fi
NET_ADV=$(D --title " Network — Interface " \
--form "\nConfigure the virtual NIC attached to this container." \
12 60 3 \
"Bridge:" 1 1 "vmbr0" 1 18 16 16 \
"VLAN tag (blank=none):" 2 1 "" 2 18 6 4 \
"Enable firewall [0/1]:" 3 1 "1" 3 18 2 1 \
) || abort
CT_BRIDGE=$( awk 'NR==1' <<< "$NET_ADV")
CT_VLAN=$( awk 'NR==2' <<< "$NET_ADV")
CT_FIREWALL=$(awk 'NR==3' <<< "$NET_ADV")
[[ "$CT_FIREWALL" != "1" ]] && CT_FIREWALL=0
# ─── Screen 5: Security ───────────────────────────────────────────────────────
UNPRIV=$(D --title " Privilege Level " \
--radiolist \
"\nUnprivileged containers map UIDs to avoid host root exposure (recommended).
Privileged containers share the host UID namespace — needed for some services
(e.g. FreeIPA, NFS server) but should only be used when necessary." \
13 72 2 \
"unprivileged" "Unprivileged — UID remapping, recommended" ON \
"privileged" "Privileged — full host UIDs, reduced isolation" OFF \
) || abort
[[ "$UNPRIV" == "unprivileged" ]] && CT_UNPRIVILEGED=1 || CT_UNPRIVILEGED=0
FEATURES_RAW=$(D --title " LXC Features " \
--separate-output \
--checklist \
"\nSelect kernel features to enable for this container.
Use Space to toggle, Enter to confirm." \
18 72 7 \
"nesting" "nesting=1 — run Docker or nested LXC inside this container" OFF \
"fuse" "fuse=1 — allow FUSE mounts (sshfs, rclone, etc.)" OFF \
"keyctl" "keyctl=1 — Linux keyring API (required by some services)" OFF \
"mounts" "mounts=fuse — permit FUSE bind-mounts" OFF \
"apparmor" "Unconfined AppArmor profile (privileged workaround)" OFF \
"cgroup" "cgroup:rw mounts (needed by systemd inside container)" OFF \
"seccomp" "seccomp=0 — disable seccomp filtering (last resort only)" OFF \
) || true # OK if nothing selected
CT_FEATURES="" CT_APPARMOR=0 CT_CGROUP=0 CT_SECCOMP=""
while IFS= read -r _feat; do
[[ -z "$_feat" ]] && continue
case "$_feat" in
nesting) CT_FEATURES+="nesting=1," ;;
fuse) CT_FEATURES+="fuse=1," ;;
keyctl) CT_FEATURES+="keyctl=1," ;;
mounts) CT_FEATURES+="mounts=fuse," ;;
apparmor) CT_APPARMOR=1 ;;
cgroup) CT_CGROUP=1 ;;
seccomp) CT_SECCOMP="lxc.seccomp.profile:" ;;
esac
done <<< "$FEATURES_RAW"
CT_FEATURES="${CT_FEATURES%,}"
# ─── Screen 6: Options ────────────────────────────────────────────────────────
# Root password via passwordbox (hidden input), remaining options via form.
CT_ROOT_PW=$(D --title " Root Password " \
--insecure \
--passwordbox \
"Enter the root password for this container.
Leave blank to disable password auth (SSH key recommended)." \
10 62 "") || abort
OPTIONS=$(D --title " Container Options " \
--form "\nFinal container settings." \
13 68 4 \
"SSH pubkey file (blank=skip):" 1 1 "" 1 34 30 256 \
"Start on boot [0/1]:" 2 1 "0" 2 34 2 1 \
"Start after creation [y/n]:" 3 1 "n" 3 34 2 1 \
"Console/TTY count:" 4 1 "2" 4 34 3 3 \
) || abort
CT_SSHKEY=$( awk 'NR==1' <<< "$OPTIONS")
CT_ONBOOT=$( awk 'NR==2' <<< "$OPTIONS")
CT_AUTOSTART=$(awk 'NR==3' <<< "$OPTIONS")
CT_TTY=$( awk 'NR==4' <<< "$OPTIONS")
[[ "$CT_ONBOOT" != "1" ]] && CT_ONBOOT=0
[[ "${CT_AUTOSTART,,}" == "y"* ]] && CT_AUTOSTART=true || CT_AUTOSTART=false
CT_SSHKEY="${CT_SSHKEY/#\~/$HOME}"
if [[ -n "$CT_SSHKEY" && ! -f "$CT_SSHKEY" ]]; then
warn "SSH key file not found: $CT_SSHKEY (will be ignored)"
CT_SSHKEY=""
fi
# ─── Screen 7: Proxmox target ─────────────────────────────────────────────────
PVE_FORM=$(D --title " Proxmox Host " \
--form \
"\nLeave 'Proxmox host' blank to generate output files only (no deployment).
SSH target format: root@192.168.1.10 or root@pve.example.com" \
15 72 4 \
"Proxmox host (blank=local only):" 1 1 "" 1 36 30 255 \
"Template storage (pveam target):" 2 1 "local" 2 36 20 64 \
"Output directory:" 3 1 "$HOME/proxmox-lxc" 3 36 30 255 \
"Proxmox node name:" 4 1 "pve" 4 36 20 64 \
) || abort
PVE_HOST=$( awk 'NR==1' <<< "$PVE_FORM")
TMPL_STORAGE=$(awk 'NR==2' <<< "$PVE_FORM")
OUTPUT_DIR=$( awk 'NR==3' <<< "$PVE_FORM")
PVE_NODE=$( awk 'NR==4' <<< "$PVE_FORM")
OUTPUT_DIR="${OUTPUT_DIR/#\~/$HOME}"
[[ -z "$TMPL_STORAGE" ]] && TMPL_STORAGE="local"
[[ -z "$PVE_NODE" ]] && PVE_NODE="pve"
# ─── Assemble net0 string ─────────────────────────────────────────────────────
_NET="name=eth0,bridge=${CT_BRIDGE}"
[[ -n "$CT_VLAN" && "$CT_VLAN" =~ ^[0-9]+$ ]] && _NET+=",tag=${CT_VLAN}"
if [[ "$CT_NET_MODE" == "dhcp" ]]; then
_NET+=",ip=dhcp,ip6=auto"
else
_NET+=",ip=${CT_IP}"
[[ -n "$CT_GW" ]] && _NET+=",gw=${CT_GW}"
fi
[[ "$CT_FIREWALL" == "1" ]] && _NET+=",firewall=1"
# ─── Confirm summary ──────────────────────────────────────────────────────────
SUMMARY=" VMID : $VMID
Hostname : $CT_HOSTNAME
Description : ${CT_DESC:-(none)}
Tags : ${CT_TAGS:-(none)}
OS / template : $TMPL_DISTRO ($CT_TEMPLATE)
OS type : $CT_OSTYPE
Memory : ${CT_MEMORY} MB
Swap : ${CT_SWAP} MB
CPU cores : $CT_CORES
Disk : ${CT_DISK} GB on $CT_STORAGE
Network : $_NET
DNS : ${CT_DNS}${CT_SEARCHDOMAIN:+ / $CT_SEARCHDOMAIN}
Unprivileged : $([[ "$CT_UNPRIVILEGED" == 1 ]] && echo yes || echo 'NO — privileged')
Features : ${CT_FEATURES:-(none)}
AppArmor : $([[ "$CT_APPARMOR" == 1 ]] && echo unconfined || echo default)
cgroup rw : $([[ "$CT_CGROUP" == 1 ]] && echo yes || echo no)
Root PW set : $([[ -n "$CT_ROOT_PW" ]] && echo yes || echo no)
SSH pubkey : ${CT_SSHKEY:-(none)}
Start on boot : $CT_ONBOOT
Auto-start : $CT_AUTOSTART
TTY count : $CT_TTY
Output dir : $OUTPUT_DIR
Proxmox host : ${PVE_HOST:-(local — files only)}"
D --title " Configuration Summary " \
--ok-label "Generate" \
--cancel-label "Abort" \
--scrolltext \
--msgbox "$SUMMARY" 38 68 || abort
clear
# ─── Generate output files ────────────────────────────────────────────────────
mkdir -p "$OUTPUT_DIR"
section "Generating output files → $OUTPUT_DIR"
# ── pct-create script ─────────────────────────────────────────────────────────
PCT_SCRIPT="$OUTPUT_DIR/pct-create-${VMID}.sh"
{
printf '#!/usr/bin/env bash\n'
printf '# pct-create-%s.sh — generated by proxmox-lxc-gen.sh %s\n' "$VMID" "$(date -Iseconds)"
printf '# Run on the Proxmox host as root.\n\n'
printf 'set -euo pipefail\n\n'
if [[ "$TMPL_DISTRO" != "custom" ]]; then
printf '# ── Resolve template ──────────────────────────────────────────────────────────\n'
printf 'TEMPLATE=$(pveam available --section system 2>/dev/null \\\n'
printf ' | awk '"'"'/%s/ {print $2; exit}'"'"')\n' "$CT_TEMPLATE"
printf '[[ -z "$TEMPLATE" ]] && TEMPLATE=$(pveam available --section turnkeylinux 2>/dev/null \\\n'
printf ' | awk '"'"'/%s/ {print $2; exit}'"'"') || true\n' "$CT_TEMPLATE"
printf 'if [[ -z "$TEMPLATE" ]]; then\n'
printf ' echo "ERROR: no template matching '"'"'%s'"'"' found." >&2\n' "$CT_TEMPLATE"
printf ' echo "Run: pveam update && pveam available --section system" >&2\n'
printf ' exit 1\n'
printf 'fi\n'
printf 'echo "Resolved template: $TEMPLATE"\n\n'
printf '# Download template if not already cached\n'
printf 'pveam download %s "$TEMPLATE" 2>/dev/null \\\n' "$TMPL_STORAGE"
printf ' && echo "Template downloaded." \\\n'
printf ' || echo "Template already present (or download failed — continuing)"\n\n'
else
printf 'TEMPLATE="%s"\n\n' "$CT_TEMPLATE"
fi
printf '# ── Create the container ──────────────────────────────────────────────────────\n'
printf 'pct create %s \\\n' "$VMID"
printf ' %s:vztmpl/"$TEMPLATE" \\\n' "$TMPL_STORAGE"
printf ' --hostname "%s" \\\n' "$CT_HOSTNAME"
printf ' --ostype "%s" \\\n' "$CT_OSTYPE"
printf ' --memory %s \\\n' "$CT_MEMORY"
printf ' --swap %s \\\n' "$CT_SWAP"
printf ' --cores %s \\\n' "$CT_CORES"
printf ' --rootfs "%s:%s" \\\n' "$CT_STORAGE" "$CT_DISK"
printf ' --net0 "%s" \\\n' "$_NET"
[[ -n "$CT_DNS" ]] && printf ' --nameserver "%s" \\\n' "$CT_DNS"
[[ -n "$CT_SEARCHDOMAIN" ]] && printf ' --searchdomain "%s" \\\n' "$CT_SEARCHDOMAIN"
printf ' --unprivileged %s \\\n' "$CT_UNPRIVILEGED"
[[ -n "$CT_FEATURES" ]] && printf ' --features "%s" \\\n' "$CT_FEATURES"
printf ' --onboot %s \\\n' "$CT_ONBOOT"
printf ' --tty %s \\\n' "$CT_TTY"
[[ -n "$CT_ROOT_PW" ]] && printf ' --password "%s" \\\n' "$CT_ROOT_PW"
[[ -n "$CT_SSHKEY" ]] && printf ' --ssh-public-keys "%s" \\\n' "$CT_SSHKEY"
[[ -n "$CT_DESC" ]] && printf ' --description "%s" \\\n' "${CT_DESC//\"/\\\"}"
[[ -n "$CT_TAGS" ]] && printf ' --tags "%s" \\\n' "$CT_TAGS"
printf ' --start 0\n\n'
if [[ "$CT_APPARMOR" == 1 || "$CT_CGROUP" == 1 || -n "$CT_SECCOMP" ]]; then
printf '# ── Apply extra lxc.* options ────────────────────────────────────────────────\n'
printf 'CONF="/etc/pve/lxc/%s.conf"\n' "$VMID"
[[ "$CT_APPARMOR" == 1 ]] && printf 'printf '"'"'lxc.apparmor.profile: unconfined\nlxc.cap.drop:\n'"'"' >> "$CONF"\n'
[[ "$CT_CGROUP" == 1 ]] && printf 'printf '"'"'lxc.mount.auto: proc:rw sys:rw cgroup:rw\nlxc.cgroup2.devices.allow: a\n'"'"' >> "$CONF"\n'
[[ -n "$CT_SECCOMP" ]] && printf 'printf '"'"'lxc.seccomp.profile:\n'"'"' >> "$CONF"\n'
printf '\n'
fi
if [[ "$CT_AUTOSTART" == true ]]; then
printf '# ── Start the container ──────────────────────────────────────────────────────\n'
printf 'pct start %s\n' "$VMID"
printf 'echo "Container %s is running."\n' "$VMID"
printf 'echo "Access: pct enter %s or ssh root@<container-ip>"\n\n' "$VMID"
fi
printf 'echo "Done. Container %s created."\n' "$VMID"
} > "$PCT_SCRIPT"
chmod 755 "$PCT_SCRIPT"
log "pct create script: $(basename "$PCT_SCRIPT")"
# ── LXC conf file ─────────────────────────────────────────────────────────────
LXC_CONF="$OUTPUT_DIR/lxc-${VMID}.conf"
{
printf '# Generated by proxmox-lxc-gen.sh — %s\n' "$(date -Iseconds)"
printf '# Install: cp %s /etc/pve/lxc/%s.conf\n\n' "$(basename "$LXC_CONF")" "$VMID"
printf 'arch: amd64\n'
printf 'cores: %s\n' "$CT_CORES"
printf 'hostname: %s\n' "$CT_HOSTNAME"
printf 'memory: %s\n' "$CT_MEMORY"
printf 'swap: %s\n' "$CT_SWAP"
printf 'net0: %s\n' "$_NET"
printf 'ostype: %s\n' "$CT_OSTYPE"
printf 'rootfs: %s:vm-%s-disk-0,size=%sG\n' "$CT_STORAGE" "$VMID" "$CT_DISK"
printf 'unprivileged: %s\n' "$CT_UNPRIVILEGED"
printf 'onboot: %s\n' "$CT_ONBOOT"
printf 'tty: %s\n' "$CT_TTY"
[[ -n "$CT_FEATURES" ]] && printf 'features: %s\n' "$CT_FEATURES"
[[ -n "$CT_DNS" ]] && printf 'nameserver: %s\n' "$CT_DNS"
[[ -n "$CT_SEARCHDOMAIN" ]] && printf 'searchdomain: %s\n' "$CT_SEARCHDOMAIN"
[[ -n "$CT_TAGS" ]] && printf 'tags: %s\n' "$CT_TAGS"
[[ -n "$CT_DESC" ]] && printf 'description: %s\n' "$CT_DESC"
if [[ "$CT_APPARMOR" == 1 ]]; then
printf '\n# AppArmor override (applied post-create)\n'
printf 'lxc.apparmor.profile: unconfined\n'
printf 'lxc.cap.drop:\n'
fi
if [[ "$CT_CGROUP" == 1 ]]; then
printf '\n# cgroup access override (required by systemd in containers)\n'
printf 'lxc.mount.auto: proc:rw sys:rw cgroup:rw\n'
printf 'lxc.cgroup2.devices.allow: a\n'
fi
[[ -n "$CT_SECCOMP" ]] && printf '\nlxc.seccomp.profile:\n'
} > "$LXC_CONF"
log "LXC conf: $(basename "$LXC_CONF")"
# ── Deploy guide ──────────────────────────────────────────────────────────────
GUIDE="$OUTPUT_DIR/deploy-guide-${VMID}.txt"
{
printf 'Proxmox LXC deploy guide — generated %s
══════════════════════════════════════════════════════════════════════════
Container : %s (VMID %s)
OS : %s (%s)
Resources : %s core(s), %s MB RAM, %s GB disk on %s
Network : %s
Privilege : %s
━━━ Step 1 — Upload files to Proxmox ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
scp %s/pct-create-%s.sh root@<proxmox>:/tmp/
' \
"$(date -Iseconds)" \
"$CT_HOSTNAME" "$VMID" \
"$TMPL_DISTRO" "$CT_OSTYPE" \
"$CT_CORES" "$CT_MEMORY" "$CT_DISK" "$CT_STORAGE" \
"$_NET" \
"$([[ "$CT_UNPRIVILEGED" == 1 ]] && echo unprivileged || echo 'PRIVILEGED')" \
"$OUTPUT_DIR" "$VMID"
printf '━━━ Step 2 — Update template catalogue and create container ━━━━━━━━━━━━━
ssh root@<proxmox>
pveam update
bash /tmp/pct-create-%s.sh
The script resolves the latest matching template for "%s",
downloads it if not cached, and calls pct create with all
parameters from this wizard.
━━━ Step 3 — Verify ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
pct list
pct status %s
pct config %s
━━━ Step 4 — Access the container ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Console (always available):
pct start %s
pct enter %s
' \
"$VMID" "$CT_TEMPLATE" \
"$VMID" "$VMID" \
"$VMID" "$VMID"
if [[ "$CT_NET_MODE" == "static" ]]; then
printf ' # SSH (static IP configured):\n ssh root@%s\n\n' "${CT_IP%%/*}"
else
printf ' # SSH (find DHCP IP via):\n'
printf ' pct exec %s -- ip -4 addr show eth0\n\n' "$VMID"
fi
printf '━━━ Step 5 — Post-install shell setup (optional) ━━━━━━━━━━━━━━━━━━━━━━
# Run inside the container to set up the shell environment:
'
case "$TMPL_DISTRO" in
debian|ubuntu)
printf ' pct exec %s -- bash /path/to/Setup-shell-4-containers/debian.sh\n' "$VMID" ;;
arch)
printf ' pct exec %s -- bash /path/to/Setup-shell-4-containers/arch.sh\n' "$VMID" ;;
alpine)
printf ' pct exec %s -- ash /path/to/Setup-shell-4-containers/alpine.sh\n' "$VMID" ;;
fedora|rocky|centos)
printf ' pct exec %s -- bash /path/to/Setup-shell-4-containers/fedora.sh\n' "$VMID" ;;
void)
printf ' pct exec %s -- bash /path/to/Setup-shell-4-containers/void.sh\n' "$VMID" ;;
*)
printf ' # See setup/Setup-shell-4-containers/ for distro-specific scripts\n' ;;
esac
printf '\n━━━ Notes ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n'
[[ "$CT_UNPRIVILEGED" == 0 ]] && printf \
' PRIVILEGED container — reduced isolation. If services fail with
permission errors, verify these are in /etc/pve/lxc/%s.conf:
lxc.apparmor.profile: unconfined
lxc.cap.drop:\n\n' "$VMID"
[[ "$CT_APPARMOR" == 1 ]] && printf \
' AppArmor profile: unconfined — container has reduced AppArmor isolation.
Only use for trusted workloads (FreeIPA, Docker-in-LXC, etc.).\n\n'
[[ "$CT_CGROUP" == 1 ]] && printf \
' cgroup:rw enabled — container has full cgroup access.
Required by systemd services that manage their own cgroups.\n\n'
[[ -n "$CT_FEATURES" ]] && printf ' LXC features: %s\n\n' "$CT_FEATURES"
printf \
' Useful pct commands:
pct set %s --memory 2048 update a setting live
pct snapshot %s before-update take a snapshot
pct rollback %s before-update revert to snapshot
pct destroy %s delete the container (irreversible)
pvesh get /nodes/%s/lxc/%s/status/current
Generated config: %s
Proxmox path: /etc/pve/lxc/%s.conf
' \
"$VMID" "$VMID" "$VMID" "$VMID" \
"$PVE_NODE" "$VMID" \
"$(basename "$LXC_CONF")" "$VMID"
} > "$GUIDE"
log "Deploy guide: $(basename "$GUIDE")"
# ─── Optional: SSH deploy ─────────────────────────────────────────────────────
if [[ -n "$PVE_HOST" ]]; then
section "Deploying to $PVE_HOST"
info "Uploading pct-create-${VMID}.sh..."
scp "$PCT_SCRIPT" "${PVE_HOST}:/tmp/pct-create-${VMID}.sh"
if [[ -n "$CT_SSHKEY" ]]; then
info "Uploading SSH public key..."
scp "$CT_SSHKEY" "${PVE_HOST}:/tmp/lxc-sshkey-${VMID}.pub"
ssh "$PVE_HOST" \
"sed -i 's|${CT_SSHKEY}|/tmp/lxc-sshkey-${VMID}.pub|' /tmp/pct-create-${VMID}.sh"
fi
info "Running pct create on $PVE_HOST (this may take a moment)..."
if ssh "$PVE_HOST" "bash /tmp/pct-create-${VMID}.sh"; then
log "Container $VMID created on $PVE_HOST"
else
warn "pct create reported an error — check the output above"
fi
ssh "$PVE_HOST" "rm -f /tmp/pct-create-${VMID}.sh /tmp/lxc-sshkey-${VMID}.pub" 2>/dev/null || true
fi
# ─── Final summary ────────────────────────────────────────────────────────────
section "Done"
printf '\n'
info "Output: $OUTPUT_DIR/"
printf ' %-36s pct create command\n' "pct-create-${VMID}.sh"
printf ' %-36s LXC config (→ /etc/pve/lxc/)\n' "lxc-${VMID}.conf"
printf ' %-36s step-by-step notes\n' "deploy-guide-${VMID}.txt"
printf '\n'
if [[ -z "$PVE_HOST" ]]; then
info "To deploy, copy to your Proxmox host and run:"
printf ' scp %s/pct-create-%s.sh root@<proxmox>:/tmp/\n' "$OUTPUT_DIR" "$VMID"
printf ' ssh root@<proxmox> "bash /tmp/pct-create-%s.sh"\n' "$VMID"
fi