feat(ansipa): add usr_smb_* and dev_ssh_* policies

usr_smb_r_<name> / usr_smb_rw_<name> (user groups):
- Server creates /data/smb-shares/<name>/ with smb-<name> system user,
  setgid directory, and Samba share stanzas [usr-<name>-r/rw] on startup
- Auto-generates credentials and stores them in IPA group descriptions
  (cifs://<host>:<samba-user>:<password>) so no out-of-band secret distribution
- Client mounts //ipa/<share> at ~/‹name› for logged-in members;
  rw membership wins over r when user is in both groups
- Revert: unmounts and rmdir the mount point on group leave

dev_ssh_<userid> (host group):
- Reads IPA user's SSH public keys (ipasshpubkey / --all output)
- Writes them into /home/<userid>/.ssh/authorized_keys in an
  ansipa-managed section (preserves manually-added keys)
- Home dir is created if it doesn't exist (user may not have logged in yet)
- Revert: removes only the ansipa-managed section on group leave
- Enables same-key SSH access from any registered device to enrolled hosts

Also: add cifs-utils + openssh-server to Ansible package list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DHops6PU4c2Mv5UyhUj8Ms
feat/astal-menu
Amir Alexander Abdelbaki 2026-07-01 13:52:26 +02:00
parent 00ce4f5260
commit 76c3c2c1c5
3 changed files with 465 additions and 4 deletions

View File

@ -81,6 +81,7 @@ RAW_GROUPS=$(ipa host-show "$HOST_FQDN" --all 2>/dev/null \
ACTIVE_DAEMON_ENABLE=()
ACTIVE_DAEMON_DISABLE=()
ACTIVE_LOCAL_SUDO_USERS=()
ACTIVE_SSH_USERS=()
WANT_TIMESHIFT_BACKUP=false
WANT_SECURITY_SCAN=false
WANT_SCAN_NOTIFY=false
@ -100,6 +101,7 @@ if [[ -n "$RAW_GROUPS" ]]; then
dev_security-scan) WANT_SECURITY_SCAN=true ;;
dev_no-local-users) WANT_NO_LOCAL_USERS=true ;;
dev_local-sudo-*) ACTIVE_LOCAL_SUDO_USERS+=("${g#dev_local-sudo-}") ;;
dev_ssh_*) ACTIVE_SSH_USERS+=("${g#dev_ssh_}") ;;
esac
done
done <<< "$RAW_GROUPS"
@ -154,7 +156,8 @@ fi
log "Device policies — daemon-enable: ${ACTIVE_DAEMON_ENABLE[*]:-none}" \
"| daemon-disable: ${ACTIVE_DAEMON_DISABLE[*]:-none}" \
"| timeshift-backup: $WANT_TIMESHIFT_BACKUP | security-scan: $WANT_SECURITY_SCAN" \
"| no-local-users: $WANT_NO_LOCAL_USERS | local-sudo: ${ACTIVE_LOCAL_SUDO_USERS[*]:-none}"
"| no-local-users: $WANT_NO_LOCAL_USERS | local-sudo: ${ACTIVE_LOCAL_SUDO_USERS[*]:-none}" \
"| ssh-keys: ${ACTIVE_SSH_USERS[*]:-none}"
log "User policies — admin: $WANT_USR_ADMIN" \
"| block-binary: ${ACTIVE_BLOCK_BINARIES[*]:-none}" \
"| printers: ${ACTIVE_PRT_PRINTERS[*]:-none}" \
@ -771,4 +774,291 @@ else
fi
unset _ap
# ── usr_smb_<r|rw>_<name>: auto-mount network shares in user home dirs ────────
# For each usr_smb_r_<name> or usr_smb_rw_<name> group in FreeIPA:
# - The FreeIPA container hosts a Samba share [usr-<name>-r] or [usr-<name>-rw]
# under /data/smb-shares/<name>/ with UNIX group permissions.
# - When a logged-in user is a member of the group the share is mounted at
# ~/name (a new folder named after the share, no r/rw suffix).
# - rw membership takes precedence over r if the user is in both groups for
# the same share name.
# - The mount is tracked in the state file; leaving the group unmounts it.
#
# Credential string (server, Samba user, password) is stored in the IPA group
# description by ansipa-smb-setup.sh: cifs://<ipa-host>:<samba-user>:<password>
#
# Requires: cifs-utils installed on the client.
SMB_MOUNT_STATE="$STATE_DIR/smb-mounts"
[[ -f "$SMB_MOUNT_STATE" ]] || touch "$SMB_MOUNT_STATE"
SMB_CREDS_DIR="$STATE_DIR/smb-creds"
mkdir -p "$SMB_CREDS_DIR"
chmod 700 "$SMB_CREDS_DIR"
if command -v mount.cifs &>/dev/null; then
# ── Discover usr_smb_* groups from IPA ───────────────────────────────────
declare -A _SMB_R_GRP _SMB_RW_GRP # share-name → IPA group name
while IFS= read -r _g; do
[[ -z "$_g" ]] && continue
if [[ "$_g" =~ ^usr_smb_r_(.+)$ ]]; then _SMB_R_GRP["${BASH_REMATCH[1]}"]="$_g"
elif [[ "$_g" =~ ^usr_smb_rw_(.+)$ ]]; then _SMB_RW_GRP["${BASH_REMATCH[1]}"]="$_g"
fi
done < <(ipa group-find --pkey-only 2>/dev/null \
| awk '/Group name:/ {print $NF}' \
| grep "^usr_smb_" | sort -u || true)
unset _g
# Union of r + rw share names.
declare -A _SMB_NAMES
for _n in "${!_SMB_R_GRP[@]}" "${!_SMB_RW_GRP[@]}"; do _SMB_NAMES["$_n"]=1; done
unset _n
# ── Helpers ───────────────────────────────────────────────────────────────
# Outputs "host samba_user password" when description contains a cifs:// credential.
_smb_parse_cred() {
local _desc
_desc=$(ipa group-show "$1" --all 2>/dev/null \
| awk -F': ' '/Description:/{print $2; exit}' || true)
[[ "$_desc" =~ ^cifs://([^:]+):([^:]+):(.+)$ ]] && \
echo "${BASH_REMATCH[1]} ${BASH_REMATCH[2]} ${BASH_REMATCH[3]}"
}
_smb_member() { id -nG "$1" 2>/dev/null | tr ' ' '\n' | grep -qxF "$2"; }
# ── Collect active logged-in users (loginctl → who fallback) ─────────────
declare -A _SMB_SESS # user → home
_smb_add_sess() {
local _u
[[ "$2" == "loginctl" ]] && _u=$(awk '{print $3}' <<< "$1") \
|| _u=$(awk '{print $1}' <<< "$1")
[[ -z "$_u" || "$_u" == "root" || "$_u" == "USER" ]] && return 0
local _h; _h=$(getent passwd "$_u" | cut -d: -f6 2>/dev/null) || return 0
[[ -d "$_h" ]] && _SMB_SESS["$_u"]="$_h"
}
if loginctl list-sessions --no-legend &>/dev/null; then
while IFS= read -r _L; do _smb_add_sess "$_L" "loginctl"
done < <(loginctl list-sessions --no-legend 2>/dev/null)
else
while IFS= read -r _L; do _smb_add_sess "$_L" "who"
done < <(who 2>/dev/null)
fi
unset _L
# ── Apply: mount shares for logged-in members ─────────────────────────────
for _SMB_NAME in "${!_SMB_NAMES[@]}"; do
_R_GRP="${_SMB_R_GRP[$_SMB_NAME]:-}"
_RW_GRP="${_SMB_RW_GRP[$_SMB_NAME]:-}"
# Both access levels use the same credential; read from whichever has it.
_CRED_LINE=""
[[ -n "$_RW_GRP" ]] && _CRED_LINE=$(_smb_parse_cred "$_RW_GRP")
[[ -z "$_CRED_LINE" && -n "$_R_GRP" ]] && _CRED_LINE=$(_smb_parse_cred "$_R_GRP")
if [[ -z "$_CRED_LINE" ]]; then
warn "No credential in IPA description for share $_SMB_NAME — skipped (run ansipa-smb.service on FreeIPA container)"
continue
fi
read -r _SMB_HOST _SMB_SUSER _SMB_SPASS <<< "$_CRED_LINE"
_CREDS_FILE="$SMB_CREDS_DIR/${_SMB_NAME}.creds"
printf 'username=%s\npassword=%s\ndomain=WORKGROUP\n' "$_SMB_SUSER" "$_SMB_SPASS" \
> "$_CREDS_FILE"
chmod 600 "$_CREDS_FILE"
for _SMB_U in "${!_SMB_SESS[@]}"; do
# Determine per-user access level (rw beats r).
_SMB_LVL=""
[[ -n "$_RW_GRP" ]] && _smb_member "$_SMB_U" "$_RW_GRP" && _SMB_LVL="rw"
[[ -z "$_SMB_LVL" && -n "$_R_GRP" ]] && _smb_member "$_SMB_U" "$_R_GRP" && _SMB_LVL="r"
[[ -z "$_SMB_LVL" ]] && continue
_SMB_H="${_SMB_SESS[$_SMB_U]}"
_SMB_MP="$_SMB_H/$_SMB_NAME"
_SMB_SHARE="usr-${_SMB_NAME}-${_SMB_LVL}"
if mountpoint -q "$_SMB_MP" 2>/dev/null; then
grep -qxF "${_SMB_U}:${_SMB_NAME}" "$SMB_MOUNT_STATE" 2>/dev/null || \
echo "${_SMB_U}:${_SMB_NAME}" >> "$SMB_MOUNT_STATE"
continue
fi
mkdir -p "$_SMB_MP"
chown "$_SMB_U" "$_SMB_MP" 2>/dev/null || true
if mount -t cifs "//${_SMB_HOST}/${_SMB_SHARE}" "$_SMB_MP" \
-o "credentials=${_CREDS_FILE},uid=${_SMB_U},gid=${_SMB_U},file_mode=0644,dir_mode=0755,nounix,noserverino" \
2>/dev/null; then
log "Mounted //${_SMB_HOST}/${_SMB_SHARE}$_SMB_MP for $_SMB_U"
grep -qxF "${_SMB_U}:${_SMB_NAME}" "$SMB_MOUNT_STATE" 2>/dev/null || \
echo "${_SMB_U}:${_SMB_NAME}" >> "$SMB_MOUNT_STATE"
else
warn "Failed to mount //${_SMB_HOST}/${_SMB_SHARE} for $_SMB_U"
rmdir "$_SMB_MP" 2>/dev/null || true
fi
done
done
unset _SMB_NAME _R_GRP _RW_GRP _CRED_LINE _SMB_HOST _SMB_SUSER _SMB_SPASS \
_CREDS_FILE _SMB_U _SMB_H _SMB_MP _SMB_SHARE _SMB_LVL
# ── Revert: unmount shares whose group was removed or user left ───────────
_NEW_SMB_MOUNTS=()
while IFS=: read -r _OLD_USER _OLD_NAME; do
[[ -z "$_OLD_USER" || -z "$_OLD_NAME" ]] && continue
_OLD_HOME=$(getent passwd "$_OLD_USER" | cut -d: -f6 2>/dev/null) || continue
_OLD_MPT="$_OLD_HOME/$_OLD_NAME"
_STILL_MEMBER=false
if [[ -n "${_SMB_R_GRP[$_OLD_NAME]+x}" ]] && \
_smb_member "$_OLD_USER" "${_SMB_R_GRP[$_OLD_NAME]}"; then
_STILL_MEMBER=true
elif [[ -n "${_SMB_RW_GRP[$_OLD_NAME]+x}" ]] && \
_smb_member "$_OLD_USER" "${_SMB_RW_GRP[$_OLD_NAME]}"; then
_STILL_MEMBER=true
fi
if [[ "$_STILL_MEMBER" == true ]]; then
_NEW_SMB_MOUNTS+=("${_OLD_USER}:${_OLD_NAME}")
else
umount -l "$_OLD_MPT" 2>/dev/null || true
rmdir "$_OLD_MPT" 2>/dev/null || true
log "Unmounted ${_OLD_MPT} for ${_OLD_USER} (left usr_smb_*_${_OLD_NAME})"
fi
done < "$SMB_MOUNT_STATE"
unset _OLD_USER _OLD_NAME _OLD_HOME _OLD_MPT _STILL_MEMBER
if [[ ${#_NEW_SMB_MOUNTS[@]} -gt 0 ]]; then
printf '%s\n' "${_NEW_SMB_MOUNTS[@]}" | sort -u > "$SMB_MOUNT_STATE"
else
> "$SMB_MOUNT_STATE"
fi
# Credential files are written fresh on each run; remove stale ones.
rm -f "$SMB_CREDS_DIR"/*.creds 2>/dev/null || true
unset _SMB_R_GRP _SMB_RW_GRP _SMB_NAMES _SMB_SESS _NEW_SMB_MOUNTS
else
warn "mount.cifs not found — install cifs-utils to enable usr_smb_* policies"
[[ -s "$SMB_MOUNT_STATE" ]] && \
warn "smb-mounts state is non-empty — cannot revert existing mounts without cifs-utils" || true
fi
# ── dev_ssh_<userid>: distribute SSH public keys to user home dirs ────────────
# When this host is in dev_ssh_<userid>, the IPA user <userid>'s public keys are
# written to /home/<userid>/.ssh/authorized_keys in an ansipa-managed section.
# This enables Amir (or any named IPA user) to SSH into this device using the key
# registered in their IPA profile — the same key reaches every enrolled device.
#
# Keys are stored in the IPA user object (not on each device); add via:
# ipa user-mod <userid> --sshpubkey="ssh-ed25519 AAAA..."
# Multiple keys (e.g. workstation + laptop) are each added with --sshpubkey.
#
# The ansipa-managed section in authorized_keys is marked with:
# # BEGIN ansipa-ssh-managed … # END ansipa-ssh-managed
# This preserves any manually-added keys and is cleanly removed on revert.
#
# Leaving the host group removes only the ansipa-managed section on the next run.
SSH_KEY_STATE="$STATE_DIR/ssh-keys"
[[ -f "$SSH_KEY_STATE" ]] || touch "$SSH_KEY_STATE"
# Write or clear the ansipa-managed section in an authorized_keys file.
# Usage: _ssh_write_keys <user> <homedir> [key1 key2 ...]
# Passing no keys removes the section (revert).
_ssh_write_keys() {
local _u="$1" _h="$2"; shift 2
local _auth="$_h/.ssh/authorized_keys"
mkdir -p "$_h/.ssh"
chmod 700 "$_h/.ssh"
[[ -f "$_auth" ]] || touch "$_auth"
# Strip any existing ansipa section.
local _rest
_rest=$(awk '
/^# BEGIN ansipa-ssh-managed$/ { skip=1; next }
skip && /^# END ansipa-ssh-managed$/ { skip=0; next }
!skip
' "$_auth" 2>/dev/null || true)
if [[ $# -gt 0 ]]; then
{ [[ -n "$_rest" ]] && printf '%s\n' "$_rest"; \
echo "# BEGIN ansipa-ssh-managed"; \
printf '%s\n' "$@"; \
echo "# END ansipa-ssh-managed"; } > "$_auth"
else
[[ -n "$_rest" ]] && printf '%s\n' "$_rest" > "$_auth" || > "$_auth"
fi
chmod 600 "$_auth"
chown "$_u:$_u" "$_h/.ssh" "$_auth" 2>/dev/null || true
}
declare -A _SSH_APPLIED # userid → 1 (applied this run)
for _SSH_UID in "${ACTIVE_SSH_USERS[@]+"${ACTIVE_SSH_USERS[@]}"}"; do
# Verify the IPA user exists.
ipa user-show "$_SSH_UID" &>/dev/null || {
warn "dev_ssh_${_SSH_UID}: IPA user '$_SSH_UID' not found — skipping"
continue
}
# Fetch SSH public keys from IPA user profile.
# `ipa user-show --all` shows: " SSH public key: <key>"
# " SSH public key fingerprint: ..." is a separate field — exclude it.
_SSH_KEYS=()
mapfile -t _SSH_KEYS < <(
ipa user-show "$_SSH_UID" --all 2>/dev/null \
| grep "^ SSH public key:" | grep -v "fingerprint" \
| sed 's/^ SSH public key: //' || true
)
if [[ ${#_SSH_KEYS[@]} -eq 0 ]]; then
warn "dev_ssh_${_SSH_UID}: '$_SSH_UID' has no SSH keys in IPA — add via:"
warn " ipa user-mod $_SSH_UID --sshpubkey='ssh-ed25519 AAAA...'"
continue
fi
# Resolve home directory via SSSD (works for IPA users once SSSD is running).
_SSH_HOME=$(getent passwd "$_SSH_UID" 2>/dev/null | cut -d: -f6 || true)
if [[ -z "$_SSH_HOME" ]]; then
warn "dev_ssh_${_SSH_UID}: '$_SSH_UID' not resolvable via getent — is SSSD running?"
continue
fi
# Create home dir now if it doesn't yet exist (IPA user may not have logged in).
if [[ ! -d "$_SSH_HOME" ]]; then
log "Creating home dir for $_SSH_UID: $_SSH_HOME"
mkdir -p "$_SSH_HOME"
chown "$_SSH_UID:$_SSH_UID" "$_SSH_HOME" 2>/dev/null || true
chmod 700 "$_SSH_HOME"
fi
_ssh_write_keys "$_SSH_UID" "$_SSH_HOME" "${_SSH_KEYS[@]}"
log "SSH keys for $_SSH_UID: ${#_SSH_KEYS[@]} key(s) written to $_SSH_HOME/.ssh/authorized_keys"
_SSH_APPLIED["$_SSH_UID"]=1
grep -qxF "$_SSH_UID" "$SSH_KEY_STATE" 2>/dev/null || echo "$_SSH_UID" >> "$SSH_KEY_STATE"
done
unset _SSH_UID _SSH_KEYS _SSH_HOME
# Revert: remove ansipa keys for users whose dev_ssh_* group was removed from this host.
_NEW_SSH_STATE=()
while IFS= read -r _OLD_SSH_UID; do
[[ -z "$_OLD_SSH_UID" ]] && continue
if [[ -n "${_SSH_APPLIED[$_OLD_SSH_UID]+x}" ]]; then
_NEW_SSH_STATE+=("$_OLD_SSH_UID")
else
_OLD_SSH_HOME=$(getent passwd "$_OLD_SSH_UID" 2>/dev/null | cut -d: -f6 || true)
if [[ -n "$_OLD_SSH_HOME" ]]; then
_ssh_write_keys "$_OLD_SSH_UID" "$_OLD_SSH_HOME" # no keys = remove section
log "Removed ansipa SSH keys for $_OLD_SSH_UID (host left dev_ssh_${_OLD_SSH_UID})"
fi
fi
done < "$SSH_KEY_STATE"
unset _OLD_SSH_UID _OLD_SSH_HOME
if [[ ${#_NEW_SSH_STATE[@]} -gt 0 ]]; then
printf '%s\n' "${_NEW_SSH_STATE[@]}" | sort -u > "$SSH_KEY_STATE"
else
> "$SSH_KEY_STATE"
fi
unset _SSH_APPLIED _NEW_SSH_STATE
log "Policy enforcement complete."

View File

@ -9,12 +9,18 @@
# dev_security-scan Enforce daily ClamAV + rkhunter + chkrootkit scans + SMB upload (02:00)
# dev_no-local-users Lock local account passwords; only FreeIPA accounts can auth
# dev_local-sudo-<username> Grant <username> local sudo on this device; reverted when host leaves
# dev_ssh_<userid> Distribute <userid>'s SSH public keys from IPA to authorized_keys
# on this device; allows that IPA user to SSH in from any of their
# registered keys without a password. Keys stored in IPA via:
# ipa user-mod <userid> --sshpubkey='ssh-ed25519 AAAA...'
#
# User policies (FreeIPA user groups — follow the user across all enrolled devices):
# usr_admin Grant full sudo on every enrolled host (sudoers drop-in, SSSD-resolved)
# usr_block-binary-<name> Block execution of <name> via a PATH-priority wrapper
# usr_prt_<printer> Auto-add printer at login (per-user); URI in IPA group description
# 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)
#
# Prerequisites:
# - Host enrolled in FreeIPA (sssd + ipa CLI available)
@ -43,6 +49,8 @@
loop:
- samba-client
- cups
- cifs-utils # needed for usr_smb_* CIFS mounts
- openssh-server # needed for dev_ssh_* (sshd must be running to accept keys)
ignore_errors: yes
- name: Deploy SMB credentials file

View File

@ -21,6 +21,7 @@ set -euo pipefail
LOG_TAG="ansipa-smb-setup"
SCAN_BASE="/data/scan-results"
LUKS_BASE="/data/luks-keys"
SHARES_BASE="/data/smb-shares"
SMB_CONF="/etc/samba/smb.conf"
SMB_USER="scanupload"
LUKS_UPLOAD_USER="luks-upload"
@ -28,17 +29,20 @@ KEYADMIN_GROUP="KeyAdmin"
ENV_FILE="/data/samba/ansipa-smb.env"
log() { echo "[$LOG_TAG] $*"; }
warn() { echo "[$LOG_TAG][WARN] $*" >&2; }
die() { echo "[$LOG_TAG][ERROR] $*" >&2; exit 1; }
# ── Resolve passwords ─────────────────────────────────────────────────────────
SMB_PASS="${SMB_SCAN_PASSWORD:-}"
LUKS_PASS="${LUKS_KEY_UPLOAD_PASSWORD:-}"
IPA_ADMIN_PASS="${IPA_ADMIN_PASSWORD:-}"
if [[ -f "$ENV_FILE" ]]; then
# shellcheck source=/dev/null
source "$ENV_FILE"
SMB_PASS="${SMB_SCAN_PASSWORD:-$SMB_PASS}"
LUKS_PASS="${LUKS_KEY_UPLOAD_PASSWORD:-$LUKS_PASS}"
IPA_ADMIN_PASS="${IPA_ADMIN_PASSWORD:-$IPA_ADMIN_PASS}"
fi
[[ -z "$SMB_PASS" ]] && die "SMB_SCAN_PASSWORD not set and $ENV_FILE not present. Set it in .env."
@ -49,12 +53,14 @@ mkdir -p "$(dirname "$ENV_FILE")"
{
printf 'SMB_SCAN_PASSWORD=%q\n' "$SMB_PASS"
printf 'LUKS_KEY_UPLOAD_PASSWORD=%q\n' "$LUKS_PASS"
[[ -n "$IPA_ADMIN_PASS" ]] && printf 'IPA_ADMIN_PASSWORD=%q\n' "$IPA_ADMIN_PASS" || true
} > "$ENV_FILE"
chmod 600 "$ENV_FILE"
# ── Directory structure (idempotent) ──────────────────────────────────────────
mkdir -p "$SCAN_BASE/archive" "$SCAN_BASE/alerts"
mkdir -p "$LUKS_BASE"
mkdir -p "$SHARES_BASE"
# ── KeyAdmin group ────────────────────────────────────────────────────────────
if ! getent group "$KEYADMIN_GROUP" &>/dev/null; then
@ -80,9 +86,10 @@ 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"
cat > "$SMB_CONF" <<CONF
# ── smb.conf (base only; _setup_user_shares appends stanzas afterwards) ───────
_write_smb_conf() {
log "Writing $SMB_CONF"
cat > "$SMB_CONF" <<CONF
[global]
workgroup = WORKGROUP
server string = Ansipa Security Server
@ -118,6 +125,7 @@ cat > "$SMB_CONF" <<CONF
create mask = 0640
directory mask = 0750
CONF
}
# ── Samba passwords (idempotent — smbpasswd -a adds or updates) ───────────────
_smb_set_pass() {
@ -128,9 +136,164 @@ _smb_set_pass() {
log "WARN: smbpasswd returned non-zero for $user (may already be set correctly)"
}
# ── User-share management (usr_smb_r_<name> / usr_smb_rw_<name> IPA groups) ──
# Each group maps to a Samba share under $SHARES_BASE/<name>/:
# usr_smb_r_<name> → read-only share [usr-<name>-r]
# usr_smb_rw_<name> → read-write share [usr-<name>-rw]
#
# Both access levels for the same share use one Linux user (smb-<name>) and one
# password; the difference is read-only vs read-write enforced by Samba.
# Credentials are auto-generated on first setup and stored in the IPA group
# description so clients can retrieve them:
# cifs://<ipa-host>:<samba-user>:<password>
#
# The server kinits as admin to query/update IPA. If IPA is not yet configured
# (first-boot race), this function exits gracefully; the next smb.service restart
# will pick up the groups once IPA is ready.
# Appends user share stanzas directly to SMB_CONF; logs go to stdout→journal.
# Split from _write_smb_conf to avoid stdout capture swallowing log lines.
_setup_user_shares() {
# Require IPA to be configured on this host already.
if [[ ! -f /etc/ipa/default.conf ]]; then
warn "IPA not yet configured — skipping user share setup (restart after ipa-first-boot)"
return 0
fi
if [[ -z "$IPA_ADMIN_PASS" ]]; then
warn "IPA_ADMIN_PASSWORD not set — skipping user share setup"
return 0
fi
local realm
realm=$(awk -F'[[:space:]]*=[[:space:]]*' '/^realm[[:space:]]*=/{print $2; exit}' \
/etc/ipa/default.conf 2>/dev/null || echo "")
# On the IPA server itself the 'server' field points to itself; prefer hostname -f.
local ipa_host
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 user share setup"
return 0
}
# Discover usr_smb_* groups.
local -a smb_groups
mapfile -t smb_groups < <(
ipa group-find --pkey-only 2>/dev/null \
| awk '/Group name:/ {print $NF}' \
| grep "^usr_smb_" | sort -u || true
)
if [[ ${#smb_groups[@]} -eq 0 ]]; then
log "No usr_smb_* groups found — no user shares to configure"
kdestroy &>/dev/null || true
return 0
fi
# Collect unique share names and track which access levels exist.
declare -A _HAS_R _HAS_RW _DONE
for _g in "${smb_groups[@]}"; do
if [[ "$_g" =~ ^usr_smb_r_(.+)$ ]]; then _HAS_R["${BASH_REMATCH[1]}"]=1
elif [[ "$_g" =~ ^usr_smb_rw_(.+)$ ]]; then _HAS_RW["${BASH_REMATCH[1]}"]=1
fi
done
for NAME in "${!_HAS_R[@]}" "${!_HAS_RW[@]}"; do
[[ -n "${_DONE[$NAME]+x}" ]] && continue
_DONE["$NAME"]=1
local SUSER="smb-${NAME}"
local SDIR="$SHARES_BASE/$NAME"
log "User share: $NAME path=$SDIR user=$SUSER"
# Create directory and Linux user.
mkdir -p "$SDIR"
if ! id "$SUSER" &>/dev/null; then
useradd -r -s /sbin/nologin -d "$SDIR" -M "$SUSER"
log " Created system user: $SUSER"
fi
chown "$SUSER:$SUSER" "$SDIR"
chmod 2770 "$SDIR" # setgid — new files inherit group; no world access
# Retrieve existing password from IPA group description or generate one.
local PASS=""
local _desc _grp
for _grp in "usr_smb_r_${NAME}" "usr_smb_rw_${NAME}"; do
ipa group-show "$_grp" &>/dev/null || continue
_desc=$(ipa group-show "$_grp" --all 2>/dev/null \
| awk -F': ' '/Description:/{print $2; exit}' || true)
# Description format: cifs://<host>:<samba-user>:<password>
if [[ "$_desc" =~ ^cifs://[^:]*:smb-[^:]+:(.+)$ ]]; then
PASS="${BASH_REMATCH[1]}"
break
fi
done
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 $SUSER"
fi
# Set Samba password.
_smb_set_pass "$SUSER" "$PASS"
# Store credential in both group descriptions so clients can read it.
local CRED="cifs://${ipa_host}:${SUSER}:${PASS}"
if [[ -n "${_HAS_R[$NAME]+x}" ]]; then
ipa group-mod "usr_smb_r_${NAME}" --desc="$CRED" &>/dev/null && \
log " Stored credential in usr_smb_r_${NAME}" || true
fi
if [[ -n "${_HAS_RW[$NAME]+x}" ]]; then
ipa group-mod "usr_smb_rw_${NAME}" --desc="$CRED" &>/dev/null && \
log " Stored credential in usr_smb_rw_${NAME}" || true
fi
# Append share stanzas directly to smb.conf.
# Clients mount [usr-<name>-r] or [usr-<name>-rw]; folder in ~ is just <name>.
if [[ -n "${_HAS_R[$NAME]+x}" ]]; then
cat >> "$SMB_CONF" <<STANZA
[usr-${NAME}-r]
comment = Ansipa user share: ${NAME} (read-only)
path = ${SDIR}
valid users = ${SUSER}
read only = yes
browseable = no
force user = ${SUSER}
force group = ${SUSER}
STANZA
fi
if [[ -n "${_HAS_RW[$NAME]+x}" ]]; then
cat >> "$SMB_CONF" <<STANZA
[usr-${NAME}-rw]
comment = Ansipa user share: ${NAME} (read-write)
path = ${SDIR}
valid users = ${SUSER}
read only = no
write list = ${SUSER}
browseable = no
force user = ${SUSER}
force group = ${SUSER}
create mask = 0664
directory mask = 02775
STANZA
fi
done
unset _HAS_R _HAS_RW _DONE _g NAME SUSER SDIR PASS _desc _grp CRED
kdestroy &>/dev/null || true
}
# ── Write smb.conf, set passwords, configure user 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_smb_conf
_setup_user_shares
# Reload smbd if it is already running (picks up new user shares without restart).
smbcontrol smbd reload-config 2>/dev/null || true
# ── 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
# lost on container recreation, so we must restore it on every start.