feat(ansipa): store LUKS backup keys on SMB share with KeyAdmin access control

ansipa-smb-setup.sh:
- Adds KeyAdmin Linux group and luks-upload service account (member of
  KeyAdmin) on the IPA container, both persisted across restarts.
- LUKS base dir /data/luks-keys owned root:KeyAdmin, mode 2750 (setgid
  so new files inherit the group).
- New [ansipa-luks-keys] SMB share: valid users = @KeyAdmin, read only,
  write list = luks-upload. Human admins gain read access by being added
  to KeyAdmin: useradd -r -G KeyAdmin <user> && smbpasswd -a <user>.
- LUKS_KEY_UPLOAD_PASSWORD sourced from env / /data/samba/ansipa-smb.env
  alongside the existing SMB_SCAN_PASSWORD.

collect-luks-keys.yml:
- After fetching /_LUKS_BACKUP_KEY from each client, uploads it to the
  ansipa-luks-keys share via smbclient using a temp credentials file
  (no_log, deleted in post_tasks).
- Local staging copy is removed after a successful upload.
- SMB credentials file uses an epoch-stamped path to avoid collisions.

.env.example: documents LUKS_KEY_UPLOAD_PASSWORD.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
Amir Alexander Abdelbaki 2026-05-20 15:33:17 +02:00
parent aced2c754e
commit 5d56984e38
3 changed files with 160 additions and 42 deletions

View File

@ -1,56 +1,80 @@
---
# collect-luks-keys.yml — fetch LUKS backup keys from enrolled clients.
# collect-luks-keys.yml — fetch LUKS backup keys from enrolled clients and store them
# on the ansipa-luks-keys SMB share (accessible only to KeyAdmin group members).
#
# When a client was installed with disk encryption via the M-Archy installer,
# a backup LUKS key is stored at /_LUKS_BACKUP_KEY inside the encrypted root.
# This playbook fetches those keys to the controller and names each copy
# <HOSTNAME>_LUKS_BACKUP_KEY so they can be archived securely.
# Flow per host:
# 1. Fetch /_LUKS_BACKUP_KEY from the client to a local staging directory.
# 2. Upload the staged file to //IPA_SERVER/ansipa-luks-keys/ via smbclient.
# 3. Delete the local staging copy.
#
# Keys are stored in luks-keys/ relative to the playbook directory.
# Protect that directory carefully — keys can unlock client root partitions.
# The ansipa-luks-keys SMB share is write-only for 'luks-upload' and read-only
# for members of the 'KeyAdmin' group. Add a Samba user to KeyAdmin on the IPA
# container to grant read access:
# useradd -r -G KeyAdmin <user> && smbpasswd -a <user>
#
# Usage:
# ansible-playbook -i inventory collect-luks-keys.yml
# ansible-playbook -i inventory collect-luks-keys.yml -e luks_keys_store=/secure/path
# ansible-playbook -i inventory collect-luks-keys.yml \
# -e luks_smb_server=ipa.corp.example.com \
# -e luks_upload_password=<LUKS_KEY_UPLOAD_PASSWORD>
#
# To run automatically, add a cron job on the Ansible controller:
# 0 3 * * * cd /path/to/playbooks && ansible-playbook -i inventory collect-luks-keys.yml
# Or set defaults in group_vars / ansible-vault. The smb_server can also be
# auto-detected from /etc/ipa/default.conf on the clients.
- name: Collect LUKS backup keys from enrolled clients
- name: Collect and archive LUKS backup keys
hosts: all
become: yes
vars:
luks_key_path: /_LUKS_BACKUP_KEY
luks_keys_store: "{{ playbook_dir }}/luks-keys"
# Local staging dir — files are deleted after a successful SMB upload.
luks_keys_stage: "{{ playbook_dir }}/luks-keys-stage"
luks_smb_server: "{{ luks_smb_server | mandatory('luks_smb_server is required — use -e luks_smb_server=<IPA host>') }}"
luks_smb_share: ansipa-luks-keys
luks_upload_user: luks-upload
luks_upload_password: "{{ luks_upload_password | mandatory('luks_upload_password is required — use -e luks_upload_password=... or ansible-vault') }}"
# Temp credentials file on the controller — removed at the end of the play.
_smb_creds_file: "/tmp/.ansipa-luks-upload-{{ ansible_date_time.epoch }}.creds"
tasks:
- name: Ensure local key store directory exists
- name: Ensure local staging directory exists
file:
path: "{{ luks_keys_store }}"
path: "{{ luks_keys_stage }}"
state: directory
mode: '0700'
delegate_to: localhost
run_once: true
become: false
- name: Write temporary SMB credentials file on controller
copy:
dest: "{{ _smb_creds_file }}"
mode: '0600'
content: |
username = {{ luks_upload_user }}
password = {{ luks_upload_password }}
domain = WORKGROUP
delegate_to: localhost
run_once: true
become: false
no_log: true
- name: Check for LUKS backup key on client
stat:
path: "{{ luks_key_path }}"
register: luks_key_stat
- name: Fetch LUKS backup key to controller
- name: Fetch LUKS backup key to local staging area
fetch:
src: "{{ luks_key_path }}"
dest: "{{ luks_keys_store }}/{{ inventory_hostname }}_LUKS_BACKUP_KEY"
dest: "{{ luks_keys_stage }}/{{ inventory_hostname }}_LUKS_BACKUP_KEY"
flat: yes
when: luks_key_stat.stat.exists
register: luks_key_fetch
- name: Secure fetched key permissions
- name: Secure staged key permissions
file:
path: "{{ luks_keys_store }}/{{ inventory_hostname }}_LUKS_BACKUP_KEY"
path: "{{ luks_keys_stage }}/{{ inventory_hostname }}_LUKS_BACKUP_KEY"
mode: '0400'
delegate_to: localhost
become: false
@ -58,12 +82,49 @@
- luks_key_stat.stat.exists
- luks_key_fetch is changed
- name: Upload key to ansipa-luks-keys SMB share
shell: >
smbclient "//{{ luks_smb_server }}/{{ luks_smb_share }}"
-A "{{ _smb_creds_file }}"
-c "put {{ luks_keys_stage }}/{{ inventory_hostname }}_LUKS_BACKUP_KEY {{ inventory_hostname }}_LUKS_BACKUP_KEY"
delegate_to: localhost
become: false
when:
- luks_key_stat.stat.exists
- luks_key_fetch is changed
register: smb_upload
no_log: true
- name: Remove local staging copy after successful upload
file:
path: "{{ luks_keys_stage }}/{{ inventory_hostname }}_LUKS_BACKUP_KEY"
state: absent
delegate_to: localhost
become: false
when:
- luks_key_stat.stat.exists
- luks_key_fetch is changed
- smb_upload is succeeded
- name: Report key status
debug:
msg: >-
{{ inventory_hostname }}:
{% if luks_key_stat.stat.exists %}
key found and fetched to {{ luks_keys_store }}/{{ inventory_hostname }}_LUKS_BACKUP_KEY
{% if not luks_key_stat.stat.exists %}
no /_LUKS_BACKUP_KEY present (unencrypted install or key already removed)
{% elif luks_key_fetch is changed and smb_upload is succeeded %}
key uploaded to //{{ luks_smb_server }}/{{ luks_smb_share }}/{{ inventory_hostname }}_LUKS_BACKUP_KEY
{% elif luks_key_fetch is not changed %}
key unchanged since last collection — skipped upload
{% else %}
no /_LUKS_BACKUP_KEY present (unencrypted or already collected)
WARNING: key fetched but SMB upload failed — check smbclient output
{% endif %}
post_tasks:
- name: Remove temporary SMB credentials file
file:
path: "{{ _smb_creds_file }}"
state: absent
delegate_to: localhost
run_once: true
become: false

View File

@ -8,10 +8,17 @@ IPA_SETUP_DNS=false
IPA_DNS_FORWARDER=
IPA_SETUP_KRA=false
# ── Ansipa SMB scan-results share ─────────────────────────────────────────────
# Password for the 'scanupload' Samba user. Deploy to clients via Ansible with
# smb_scan_password=<this value> (use ansible-vault for production).
# ── Ansipa SMB shares ─────────────────────────────────────────────────────────
# SMB_SCAN_PASSWORD — password for 'scanupload'; deploy to clients via Ansible
# with smb_scan_password=<this value> (use ansible-vault).
# LUKS_KEY_UPLOAD_PASSWORD — password for the 'luks-upload' service account used
# by the Ansible controller to write LUKS backup keys to
# the ansipa-luks-keys share. Pass to collect-luks-keys.yml
# with -e luks_upload_password=<this value>.
# To grant read access, add a Samba user to KeyAdmin on the
# container: useradd -r -G KeyAdmin <user> && smbpasswd -a <user>
SMB_SCAN_PASSWORD=ChangeMe_ScanPass!
LUKS_KEY_UPLOAD_PASSWORD=ChangeMe_LuksUpload!
# ── Keycloak ──────────────────────────────────────────────────────────────────
KC_HOSTNAME=keycloak.corp.example.com

View File

@ -1,50 +1,84 @@
#!/bin/bash
# ansipa-smb-setup.sh — configure the Samba scan-results share on the IPA container.
# ansipa-smb-setup.sh — configure the Samba scan-results and LUKS-key shares on the IPA container.
#
# Runs on every container start via ansipa-smb.service so that smb.conf and
# the Samba user are always in place after container restarts (ephemeral rootfs).
# Samba users are always in place after container restarts (ephemeral rootfs).
#
# Password source (first match wins):
# 1. SMB_SCAN_PASSWORD environment variable (first boot / explicit override)
# 2. /data/samba/ansipa-smb.env (persisted from first boot)
# Password sources (first match wins per variable):
# 1. Environment variable (first boot / explicit override)
# 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>
set -euo pipefail
LOG_TAG="ansipa-smb-setup"
SCAN_BASE="/data/scan-results"
LUKS_BASE="/data/luks-keys"
SMB_CONF="/etc/samba/smb.conf"
SMB_USER="scanupload"
LUKS_UPLOAD_USER="luks-upload"
KEYADMIN_GROUP="KeyAdmin"
ENV_FILE="/data/samba/ansipa-smb.env"
log() { echo "[$LOG_TAG] $*"; }
die() { echo "[$LOG_TAG][ERROR] $*" >&2; exit 1; }
# ── Resolve password ─────────────────────────────────────────────────────────
# ── Resolve passwords ─────────────────────────────────────────────────────────
SMB_PASS="${SMB_SCAN_PASSWORD:-}"
LUKS_PASS="${LUKS_KEY_UPLOAD_PASSWORD:-}"
if [[ -z "$SMB_PASS" ]] && [[ -f "$ENV_FILE" ]]; then
if [[ -f "$ENV_FILE" ]]; then
# shellcheck source=/dev/null
source "$ENV_FILE"
SMB_PASS="${SMB_SCAN_PASSWORD:-}"
SMB_PASS="${SMB_SCAN_PASSWORD:-$SMB_PASS}"
LUKS_PASS="${LUKS_KEY_UPLOAD_PASSWORD:-$LUKS_PASS}"
fi
[[ -z "$SMB_PASS" ]] && die "SMB_SCAN_PASSWORD not set and $ENV_FILE not present. Set it in .env."
[[ -z "$SMB_PASS" ]] && die "SMB_SCAN_PASSWORD not set and $ENV_FILE not present. Set it in .env."
[[ -z "$LUKS_PASS" ]] && die "LUKS_KEY_UPLOAD_PASSWORD not set and $ENV_FILE not present. Set it in .env."
# ── Persist for subsequent restarts ──────────────────────────────────────────
# %q shell-quotes the value so passwords with spaces or special chars are safe.
mkdir -p "$(dirname "$ENV_FILE")"
printf 'SMB_SCAN_PASSWORD=%q\n' "$SMB_PASS" > "$ENV_FILE"
{
printf 'SMB_SCAN_PASSWORD=%q\n' "$SMB_PASS"
printf 'LUKS_KEY_UPLOAD_PASSWORD=%q\n' "$LUKS_PASS"
} > "$ENV_FILE"
chmod 600 "$ENV_FILE"
# ── Directory structure (idempotent) ──────────────────────────────────────────
mkdir -p "$SCAN_BASE/archive" "$SCAN_BASE/alerts"
mkdir -p "$LUKS_BASE"
# ── System user ───────────────────────────────────────────────────────────────
# ── KeyAdmin group ────────────────────────────────────────────────────────────
if ! getent group "$KEYADMIN_GROUP" &>/dev/null; then
groupadd -r "$KEYADMIN_GROUP"
log "Created group: $KEYADMIN_GROUP"
fi
# ── System users ──────────────────────────────────────────────────────────────
if ! id "$SMB_USER" &>/dev/null; then
useradd -r -s /sbin/nologin -d "$SCAN_BASE" -M "$SMB_USER"
log "Created system user: $SMB_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)"
else
# Ensure group membership is correct after container recreations
usermod -aG "$KEYADMIN_GROUP" "$LUKS_UPLOAD_USER"
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
# ── smb.conf ──────────────────────────────────────────────────────────────────
log "Writing $SMB_CONF"
@ -73,13 +107,29 @@ cat > "$SMB_CONF" <<CONF
create mask = 0644
directory mask = 0755
force user = $SMB_USER
[ansipa-luks-keys]
comment = Ansipa LUKS backup keys — KeyAdmin read, luks-upload write only
path = $LUKS_BASE
valid users = @$KEYADMIN_GROUP
read only = yes
write list = $LUKS_UPLOAD_USER
browseable = no
create mask = 0640
directory mask = 0750
CONF
# ── Samba password (idempotent — smbpasswd -a adds or updates) ────────────────
log "Setting Samba password for $SMB_USER"
printf '%s\n%s\n' "$SMB_PASS" "$SMB_PASS" | smbpasswd -a -s "$SMB_USER" 2>/dev/null || \
printf '%s\n%s\n' "$SMB_PASS" "$SMB_PASS" | smbpasswd -s "$SMB_USER" 2>/dev/null || \
log "WARN: smbpasswd returned non-zero (user may already exist with correct password)"
# ── Samba passwords (idempotent — smbpasswd -a adds or updates) ───────────────
_smb_set_pass() {
local user="$1" pass="$2"
log "Setting Samba password for $user"
printf '%s\n%s\n' "$pass" "$pass" | smbpasswd -a -s "$user" 2>/dev/null || \
printf '%s\n%s\n' "$pass" "$pass" | smbpasswd -s "$user" 2>/dev/null || \
log "WARN: smbpasswd returned non-zero for $user (may already be set correctly)"
}
_smb_set_pass "$SMB_USER" "$SMB_PASS"
_smb_set_pass "$LUKS_UPLOAD_USER" "$LUKS_PASS"
# ── Server-side scan checker cron (hourly, analysed on the IPA server itself) ─
# Always (re-)write: /etc/cron.d is on the ephemeral container layer and is