#!/usr/bin/env bash set -euo pipefail log() { printf '[init] %s\n' "$*" } require_env() { local name="$1" if [[ -z "${!name:-}" ]]; then printf '[init] ERROR: missing required env var %s\n' "$name" >&2 exit 1 fi } append_winbind_to_nss() { sed -ri '/^passwd:/ { /winbind/! s/$/ winbind/ }' /etc/nsswitch.conf sed -ri '/^group:/ { /winbind/! s/$/ winbind/ }' /etc/nsswitch.conf } detect_modules_dir() { smbd -b | sed -n 's/^ *MODULESDIR: //p' | head -n1 } require_vfs_modules() { local modules_dir="" modules_dir="$(detect_modules_dir)" if [[ -z "$modules_dir" ]]; then printf '[init] ERROR: unable to detect Samba MODULESDIR via smbd -b\n' >&2 exit 1 fi local missing=0 local module for module in acl_xattr full_audit; do if [[ ! -f "$modules_dir/vfs/${module}.so" ]]; then printf '[init] ERROR: missing VFS module %s at %s/vfs/%s.so\n' "$module" "$modules_dir" "$module" >&2 missing=1 fi done if [[ "$missing" -ne 0 ]]; then printf '[init] Install package samba-vfs-modules and rebuild the image.\n' >&2 exit 1 fi } derive_netbios_name() { local raw_name="${NETBIOS_NAME:-ADSAMBAFSRV}" local upper_name="${raw_name^^}" local cleaned_name cleaned_name="$(printf '%s' "$upper_name" | tr -cd 'A-Z0-9')" if [[ -z "$cleaned_name" ]]; then cleaned_name="SAMBAFS" fi if [[ ${#cleaned_name} -gt 15 ]]; then log "NETBIOS_NAME derived from '${raw_name}' exceeds 15 chars, truncating." fi export NETBIOS_NAME="${cleaned_name:0:15}" } derive_backup_start_hour() { local raw_hour="${BACKUP_START_HOUR:-2}" if [[ "$raw_hour" =~ ^[0-9]+$ ]] && (( raw_hour >= 0 && raw_hour <= 23 )); then printf '%d\n' "$raw_hour" return fi log "Invalid BACKUP_START_HOUR '${raw_hour}', defaulting to 2." printf '2\n' } derive_ad_dns_name() { local raw_name="${AD_DNS_NAME:-}" local host_name="${SAMBA_HOSTNAME:-}" if [[ -z "$host_name" ]]; then host_name="$(hostname -s 2>/dev/null || true)" fi if [[ -z "$host_name" ]]; then host_name="${NETBIOS_NAME,,}" fi if [[ -n "$raw_name" ]]; then if [[ "$raw_name" == *.* ]]; then AD_DNS_NAME="$raw_name" else AD_DNS_NAME="${raw_name}.${DOMAIN}" fi else AD_DNS_NAME="${host_name}.${DOMAIN}" fi export AD_DNS_NAME="${AD_DNS_NAME,,}" } resolve_sid_to_group() { local sid="$1" local resolved_name="" local sid_output="" if sid_output="$(wbinfo --sid-to-fullname "$sid" 2>/dev/null)"; then resolved_name="${sid_output%%$'\t'*}" resolved_name="$(printf '%s' "$resolved_name" | sed -E 's/[[:space:]]+[0-9]+$//')" fi if [[ -z "$resolved_name" ]] && sid_output="$(wbinfo -s "$sid" 2>/dev/null)"; then resolved_name="$(printf '%s' "$sid_output" | sed -E 's/[[:space:]]+[0-9]+$//')" fi if [[ -z "$resolved_name" ]]; then printf '[init] ERROR: unable to resolve SID %s via winbind\n' "$sid" >&2 return 1 fi if [[ "$resolved_name" != *\\* ]]; then resolved_name="${WORKGROUP}\\${resolved_name}" fi printf '%s\n' "$resolved_name" } ensure_machine_keytab() { local keytab_path="/var/lib/samba/private/krb5.keytab" mkdir -p /var/lib/samba/private if [[ ! -s /etc/krb5.keytab ]]; then if ! net ads keytab create -P >/dev/null 2>&1; then if [[ -n "${JOIN_USER:-}" && -n "${JOIN_PASSWORD:-}" ]]; then printf '%s\n' "$JOIN_PASSWORD" | net ads keytab create -U "$JOIN_USER" >/dev/null 2>&1 || true fi fi fi if [[ -s /etc/krb5.keytab ]]; then cp /etc/krb5.keytab "$keytab_path" chmod 600 "$keytab_path" fi } resolve_share_groups_from_sids() { export DOMAIN_USERS_GROUP DOMAIN_USERS_GROUP="$(resolve_sid_to_group "$DOMAIN_USERS_SID")" export DOMAIN_ADMINS_GROUP DOMAIN_ADMINS_GROUP="$(resolve_sid_to_group "$DOMAIN_ADMINS_SID")" export FSLOGIX_GROUP FSLOGIX_GROUP="$(resolve_sid_to_group "$FSLOGIX_GROUP_SID")" log "Resolved DOMAIN_USERS_SID to '${DOMAIN_USERS_GROUP}'" log "Resolved DOMAIN_ADMINS_SID to '${DOMAIN_ADMINS_GROUP}'" log "Resolved FSLOGIX_GROUP_SID to '${FSLOGIX_GROUP}'" } render_krb5_conf() { cat > /etc/krb5.conf < /etc/samba/smb.conf testparm -s /etc/samba/smb.conf >/dev/null } write_runtime_env_file() { { printf 'export REALM=%q\n' "$REALM" printf 'export WORKGROUP=%q\n' "$WORKGROUP" printf 'export DOMAIN=%q\n' "$DOMAIN" printf 'export NETBIOS_NAME=%q\n' "$NETBIOS_NAME" printf 'export AD_DNS_NAME=%q\n' "$AD_DNS_NAME" if [[ -n "${AD_DNS_IP:-}" ]]; then printf 'export AD_DNS_IP=%q\n' "$AD_DNS_IP" fi printf 'export DOMAIN_USERS_SID=%q\n' "$DOMAIN_USERS_SID" printf 'export DOMAIN_ADMINS_SID=%q\n' "$DOMAIN_ADMINS_SID" printf 'export FSLOGIX_GROUP_SID=%q\n' "$FSLOGIX_GROUP_SID" printf 'export DOMAIN_USERS_GROUP=%q\n' "$DOMAIN_USERS_GROUP" printf 'export DOMAIN_ADMINS_GROUP=%q\n' "$DOMAIN_ADMINS_GROUP" printf 'export FSLOGIX_GROUP=%q\n' "$FSLOGIX_GROUP" printf 'export BACKUP_START_HOUR=%q\n' "$BACKUP_START_HOUR" if [[ -n "${BACKUP_DESTINATION:-}" ]]; then printf 'export BACKUP_DESTINATION=%q\n' "$BACKUP_DESTINATION" fi if [[ -n "${BACKUP_RETENTION_DAILY:-}" ]]; then printf 'export BACKUP_RETENTION_DAILY=%q\n' "$BACKUP_RETENTION_DAILY" fi if [[ -n "${BACKUP_RETENTION_WEEKLY:-}" ]]; then printf 'export BACKUP_RETENTION_WEEKLY=%q\n' "$BACKUP_RETENTION_WEEKLY" fi if [[ -n "${BACKUP_RETENTION_MONTHLY:-}" ]]; then printf 'export BACKUP_RETENTION_MONTHLY=%q\n' "$BACKUP_RETENTION_MONTHLY" fi if [[ -n "${BACKUP_RETENTION_YEARLY:-}" ]]; then printf 'export BACKUP_RETENTION_YEARLY=%q\n' "$BACKUP_RETENTION_YEARLY" fi if [[ -n "${BACKUP_LOG_FILE:-}" ]]; then printf 'export BACKUP_LOG_FILE=%q\n' "$BACKUP_LOG_FILE" fi if [[ -n "${BACKUP_PROGRESS:-}" ]]; then printf 'export BACKUP_PROGRESS=%q\n' "$BACKUP_PROGRESS" fi if [[ -n "${BACKUP_PROGRESS_INTERVAL_SECONDS:-}" ]]; then printf 'export BACKUP_PROGRESS_INTERVAL_SECONDS=%q\n' "$BACKUP_PROGRESS_INTERVAL_SECONDS" fi if [[ -n "${BACKUP_STATUS_FILE:-}" ]]; then printf 'export BACKUP_STATUS_FILE=%q\n' "$BACKUP_STATUS_FILE" fi if [[ -n "${JOIN_USER:-}" ]]; then printf 'export JOIN_USER=%q\n' "$JOIN_USER" fi if [[ -n "${JOIN_PASSWORD:-}" ]]; then printf 'export JOIN_PASSWORD=%q\n' "$JOIN_PASSWORD" fi if [[ -n "${LDAP_URI:-}" ]]; then printf 'export LDAP_URI=%q\n' "$LDAP_URI" fi if [[ -n "${LDAP_BASE_DN:-}" ]]; then printf 'export LDAP_BASE_DN=%q\n' "$LDAP_BASE_DN" fi } > /app/runtime.env chmod 600 /app/runtime.env } join_domain_if_needed() { if net ads testjoin >/dev/null 2>&1; then log 'Domain join already present; skipping join.' return fi require_env JOIN_USER require_env JOIN_PASSWORD local join_options=() if [[ -n "${AD_DNS_IP:-}" ]]; then join_options=(--no-dns-updates "dnshostname=${AD_DNS_NAME}") fi log "Joining AD domain ${REALM}" if ! printf '%s\n' "$JOIN_PASSWORD" | net ads join "${join_options[@]}" -U "$JOIN_USER" -S "$DOMAIN"; then log 'Join using explicit server failed, retrying automatic DC discovery.' printf '%s\n' "$JOIN_PASSWORD" | net ads join "${join_options[@]}" -U "$JOIN_USER" fi } register_ad_dns_record() { if [[ -z "${AD_DNS_IP:-}" ]]; then log 'AD_DNS_IP is unset; skipping explicit AD DNS registration.' return fi log "Registering AD DNS ${AD_DNS_NAME} -> ${AD_DNS_IP}" net ads dns unregister -P "$AD_DNS_NAME" >/dev/null 2>&1 || true if ! net ads dns register -P "$AD_DNS_NAME" "$AD_DNS_IP" >/dev/null; then printf '[init] ERROR: failed to register AD DNS %s -> %s\n' "$AD_DNS_NAME" "$AD_DNS_IP" >&2 return 1 fi } wait_for_winbind() { local tries=0 local max_tries=30 until wbinfo -t >/dev/null 2>&1; do tries=$((tries + 1)) if [[ "$tries" -ge "$max_tries" ]]; then printf '[init] ERROR: winbind trust test failed after %d attempts\n' "$max_tries" >&2 return 1 fi sleep 2 done return 0 } env_is_true() { case "${1,,}" in 1|true|yes|on) return 0 ;; *) return 1 ;; esac } web_should_start() { if [[ -n "${WEB_ENABLED:-}" ]]; then env_is_true "$WEB_ENABLED" return fi if [[ -n "${STEP_CA_URL:-}" ]] || \ [[ -s "${WEB_TLS_CERT_FILE:-/state/tls/web.crt}" ]]; then return 0 fi log 'WEB_ENABLED is unset and no TLS configuration exists; web UI disabled for upgrade compatibility.' return 1 } ensure_web_jwt_secret() { local secret_file="/state/web/jwt-secret" if [[ -n "${WEB_JWT_SECRET:-}" ]]; then export WEB_JWT_SECRET return fi mkdir -p /state/web if [[ ! -s "$secret_file" ]]; then umask 077 python3 -c 'import secrets; print(secrets.token_urlsafe(48))' > "$secret_file" fi WEB_JWT_SECRET="$(<"$secret_file")" export WEB_JWT_SECRET log 'WEB_JWT_SECRET was not provided; using a persistent generated secret.' } configure_web_tls() { local tls_mode="${WEB_TLS_MODE:-step}" local tls_dir="" local provisioner_password_file="" export WEB_HOSTNAME="${WEB_HOSTNAME:-${AD_DNS_NAME}}" export WEB_BIND_PORT="${WEB_BIND_PORT:-8443}" export WEB_TLS_CERT_FILE="${WEB_TLS_CERT_FILE:-/state/tls/web.crt}" export WEB_TLS_KEY_FILE="${WEB_TLS_KEY_FILE:-/state/tls/web.key}" tls_dir="$(dirname "$WEB_TLS_CERT_FILE")" mkdir -p "$tls_dir" "$(dirname "$WEB_TLS_KEY_FILE")" if [[ "$tls_mode" == "files" ]]; then if [[ ! -s "$WEB_TLS_CERT_FILE" || ! -s "$WEB_TLS_KEY_FILE" ]]; then printf '[init] ERROR: WEB_TLS_MODE=files requires %s and %s\n' "$WEB_TLS_CERT_FILE" "$WEB_TLS_KEY_FILE" >&2 return 1 fi return fi if [[ "$tls_mode" != "step" ]]; then printf '[init] ERROR: WEB_TLS_MODE must be step or files\n' >&2 return 1 fi export STEPPATH="${STEP_PATH:-/state/step}" if [[ ! -s "$STEPPATH/config/defaults.json" ]] || \ env_is_true "${STEP_CA_REBOOTSTRAP:-false}"; then require_env STEP_CA_URL require_env STEP_CA_FINGERPRINT log "Bootstrapping Smallstep trust for ${STEP_CA_URL}" step ca bootstrap --force --ca-url "$STEP_CA_URL" --fingerprint "$STEP_CA_FINGERPRINT" fi if [[ ! -s "$WEB_TLS_CERT_FILE" || ! -s "$WEB_TLS_KEY_FILE" ]]; then log "Requesting HTTPS certificate for ${WEB_HOSTNAME}" if [[ -n "${STEP_CA_TOKEN:-}" ]]; then step ca certificate "$WEB_HOSTNAME" "$WEB_TLS_CERT_FILE" "$WEB_TLS_KEY_FILE" --token "$STEP_CA_TOKEN" --force else require_env STEP_CA_PROVISIONER if [[ -n "${STEP_CA_PROVISIONER_PASSWORD_FILE:-}" ]]; then provisioner_password_file="$STEP_CA_PROVISIONER_PASSWORD_FILE" elif [[ -n "${STEP_CA_PROVISIONER_PASSWORD:-}" ]]; then provisioner_password_file="/run/step-provisioner-password" umask 077 printf '%s\n' "$STEP_CA_PROVISIONER_PASSWORD" > "$provisioner_password_file" else printf '[init] ERROR: STEP_CA_TOKEN, STEP_CA_PROVISIONER_PASSWORD_FILE, or STEP_CA_PROVISIONER_PASSWORD is required for initial TLS setup\n' >&2 return 1 fi step ca certificate "$WEB_HOSTNAME" "$WEB_TLS_CERT_FILE" "$WEB_TLS_KEY_FILE" \ --provisioner "$STEP_CA_PROVISIONER" \ --provisioner-password-file "$provisioner_password_file" \ --force if [[ "$provisioner_password_file" == "/run/step-provisioner-password" ]]; then rm -f "$provisioner_password_file" fi fi fi chmod 0600 "$WEB_TLS_KEY_FILE" chmod 0644 "$WEB_TLS_CERT_FILE" } start_observability_services() { log 'Starting Samba audit collector' python3 /app/audit_collector.py & if web_should_start; then ensure_web_jwt_secret configure_web_tls unset STEP_CA_PROVISIONER_PASSWORD STEP_CA_TOKEN if [[ "${WEB_TLS_MODE:-step}" == "step" ]] && env_is_true "${WEB_TLS_AUTORENEW:-true}"; then log 'Starting Smallstep certificate renewal daemon' step ca renew --daemon --force "$WEB_TLS_CERT_FILE" "$WEB_TLS_KEY_FILE" & fi log "Starting read-only web UI for https://${WEB_HOSTNAME}" python3 /app/web_ui.py & else log 'WEB_ENABLED is false; web UI disabled' fi } install_cron_job() { cat > /etc/cron.d/reconcile-shares <<'EOF' SHELL=/bin/bash PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin */5 * * * * root source /app/runtime.env && /usr/bin/python3 /app/reconcile_shares.py >> /var/log/reconcile.log 2>&1 EOF if [[ -n "${BACKUP_DESTINATION:-}" ]]; then cat >> /etc/cron.d/reconcile-shares <