SambaDC/image/ad-entrypoint.sh
2026-09-24 11:29:54 -05:00

460 lines
23 KiB
Bash
Executable file

#!/usr/bin/env bash
# =====================================================================================
# Samba AD DC container entrypoint.
#
# Idempotent. Safe to restart. Behaviour is driven entirely by environment variables
# (see .env.example / README.md). Decision tree:
#
# /var/lib/samba/private/sam.ldb exists ?
# yes -> start the existing domain (primary or replica, we don't care)
# no -> AD_JOIN_SERVER set ?
# yes -> join the existing forest as an additional DC
# no -> provision a brand new forest
#
# Then: apply hardening params -> validate smb.conf with testparm (fail fast) ->
# self-referencing resolv.conf -> keep DNS records fresh -> exec samba in foreground.
# =====================================================================================
set -Eeuo pipefail
ts() { date -u +%H:%M:%S; }
log() { printf '\033[1;36m[ad-dc %s]\033[0m %s\n' "$(ts)" "$*"; }
warn() { printf '\033[1;33m[ad-dc %s] WARN: %s\033[0m\n' "$(ts)" "$*" >&2; }
die() { printf '\033[1;31m[ad-dc %s] FATAL: %s\033[0m\n' "$(ts)" "$*" >&2; exit 1; }
on_err() { printf '\033[1;31m[ad-dc %s] FATAL: entrypoint failed at line %s (rc=%s)\033[0m\n' "$(ts)" "$2" "$1" >&2; exit "$1"; }
trap 'on_err "$?" "$LINENO"' ERR
SAMDB=/var/lib/samba/private/sam.ldb
SMBCONF=/etc/samba/smb.conf
SECRETS_DIR="${AD_SECRETS_DIR:-/run/ad-secrets}"
# -------------------------------------------------------------------------------------
# 1. Read + normalise configuration
# -------------------------------------------------------------------------------------
: "${AD_REALM:?AD_REALM is required, e.g. AD.EXAMPLE.COM (uppercase)}"
AD_REALM="${AD_REALM^^}"
AD_REALM="${AD_REALM//[[:space:]]/}"
AD_DNS_DOMAIN="${AD_DNS_DOMAIN:-${AD_REALM,,}}"
AD_DNS_DOMAIN="${AD_DNS_DOMAIN,,}"
AD_HOSTNAME="${AD_HOSTNAME:-dc1}"
AD_HOSTNAME="${AD_HOSTNAME,,}"
FQDN="${AD_HOSTNAME}.${AD_DNS_DOMAIN}"
NETBIOS_DOMAIN="${AD_NETBIOS_DOMAIN:-${AD_REALM%%.*}}"
NETBIOS_DOMAIN="${NETBIOS_DOMAIN^^}"
NETBIOS_HOST="${AD_HOSTNAME^^}"
NETBIOS_HOST="${NETBIOS_HOST:0:15}"
NETBIOS_DOMAIN="${NETBIOS_DOMAIN:0:15}"
AD_DNS_BACKEND="${AD_DNS_BACKEND:-SAMBA_INTERNAL}"
AD_SITE="${AD_SITE:-Default-First-Site-Name}"
AD_FUNCTION_LEVEL="${AD_FUNCTION_LEVEL:-2008_R2}"
AD_BASE_SCHEMA="${AD_BASE_SCHEMA:-2019}"
AD_USE_RFC2307="${AD_USE_RFC2307:-yes}"
AD_DNS_FORWARDER="${AD_DNS_FORWARDER:-1.1.1.1 8.8.8.8}"
AD_ADMIN_USER="${AD_ADMIN_USER:-administrator}"
AD_MANAGE_RESOLV="${AD_MANAGE_RESOLV:-yes}"
AD_APPLY_HARDENING="${AD_APPLY_HARDENING:-yes}"
AD_LOG_LEVEL="${AD_LOG_LEVEL:-1}"
AD_DNS_REFRESH_SECONDS="${AD_DNS_REFRESH_SECONDS:-300}"
# Hardening knobs -> smb.conf [global]
AD_SMB_SIGNING="${AD_SMB_SIGNING:-required}" # required | yes | no
AD_SMB_ENCRYPT="${AD_SMB_ENCRYPT:-desired}" # required | desired | off | on
AD_NTLM_AUTH="${AD_NTLM_AUTH:-ntlmv2-only}" # disabled | ntlmv2-only | ms-capable
AD_RESTRICT_ANONYMOUS="${AD_RESTRICT_ANONYMOUS:-2}" # 2 = reject anonymous (no guest fallback)
AD_SMB_MIN_PROTOCOL="${AD_SMB_MIN_PROTOCOL:-SMB2_02}" # SMB1 stays off, Win11 removed it anyway
# -------------------------------------------------------------------------------------
# 2. Validation
# -------------------------------------------------------------------------------------
[[ "$AD_REALM" =~ ^[A-Z0-9][A-Z0-9.-]*[A-Z0-9]\.[A-Z][A-Z]+$ ]] \
|| die "AD_REALM must be an uppercase DNS realm without underscores (got '$AD_REALM')"
[[ "$AD_DNS_DOMAIN" =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ ]] \
|| die "AD_DNS_DOMAIN invalid (got '$AD_DNS_DOMAIN')"
[[ "$AD_HOSTNAME" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]] \
|| die "AD_HOSTNAME must be a short hostname, a-z0-9- (got '$AD_HOSTNAME')"
[ "${#NETBIOS_DOMAIN}" -ge 1 ] || die "NETBIOS_DOMAIN derived to empty string"
case "$AD_DNS_BACKEND" in SAMBA_INTERNAL|BIND9_DLZ|NONE) ;; *) die "AD_DNS_BACKEND must be SAMBA_INTERNAL | BIND9_DLZ | NONE";; esac
# -------------------------------------------------------------------------------------
# 3. Credentials: env var, or a 0600 file under the secrets mount.
# -------------------------------------------------------------------------------------
read_secret() { # $1=env value $2=file path
if [ -n "${1:-}" ]; then printf '%s' "$1"; return 0; fi
if [ -r "$2" ]; then tr -d '\r\n' < "$2"; return 0; fi
return 1
}
pw_quality() { # $1 = password on stdin (kept out of argv)
printf '%s' "$1" | python3 -c 'import samba,sys; sys.exit(0 if samba.check_password_quality(sys.stdin.read()) else 1)' 2>/dev/null
}
# Resolved lazily: a restore run must NOT need the admin password, so only the code paths
# that actually create/join a directory call require_admin_pw.
ADMIN_PW="$(read_secret "${AD_ADMIN_PASSWORD:-}" "$SECRETS_DIR/ad_admin.password" || true)"
require_admin_pw() {
[ -n "${ADMIN_PW:-}" ] || die "no administrator password. Set AD_ADMIN_PASSWORD or mount a file at ${SECRETS_DIR}/ad_admin.password (chmod 600)"
pw_quality "$ADMIN_PW" || die "administrator password fails AD quality check (need >=12 chars and 3 of 4 classes: upper/lower/digit/symbol)"
}
# -------------------------------------------------------------------------------------
# 4. Own IP address (this is what lands in the DNS A/AAAA records -> must be reachable)
# -------------------------------------------------------------------------------------
detect_ip() {
ip -4 route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([^ ]*\).*/\1/p' | head -1
}
if [ -z "${AD_IP:-}" ]; then
AD_IP="$(detect_ip || true)"
[ -n "$AD_IP" ] || die "cannot determine my IPv4 address - set AD_IP explicitly"
n4=$(ip -4 -o addr show scope global 2>/dev/null | grep -c . || true)
[ "${n4:-1}" -gt 1 ] && warn "multiple global IPv4 addresses found; auto-picked $AD_IP. Set AD_IP to be deterministic."
fi
if [ "${AD_IP6:-}" = "auto" ]; then
AD_IP6="$(ip -6 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | grep -v '^[fd]' | head -1 || true)"
fi
log "identity: hostname=$AD_HOSTNAME fqdn=$FQDN realm=$AD_REALM netbios=$NETBIOS_DOMAIN ip=${AD_IP}${AD_IP6:+/${AD_IP6}}"
# AD_IP is what we publish in the A record and what every dynamic update is pinned to, so it
# must actually be bound here. When it isn't, the DC cannot reach its own advertised address:
# updates fail with "not reachable by the transport" and the DC comes up healthy while serving
# NO SRV records - every client then fails to find a domain controller. Observed in a DR
# restore where the container landed on .2 while AD_IP said .30. Fail loudly instead.
addr_present() { ip -o addr show 2>/dev/null | grep -qF " $1/"; } # matches 'inet <addr>/<mask>'
if ! addr_present "$AD_IP"; then
have="$(ip -4 -o addr show scope global 2>/dev/null | awk '{printf "%s ", $4}')"
if [ "${AD_IP_STRICT:-yes}" = "yes" ]; then
die "AD_IP=${AD_IP} is not assigned to any interface here (global addresses: ${have:-none}).
Clients follow AD_IP, so it has to be this machine's address.
Correct AD_IP in .env, or set AD_IP_STRICT=no if something else assigns it later."
else
warn "AD_IP=${AD_IP} is not bound here (have: ${have:-none}); continuing because AD_IP_STRICT=no"
fi
fi
if [ -n "${AD_IP6:-}" ] && ! addr_present "$AD_IP6"; then
[ "${AD_IP_STRICT:-yes}" = "yes" ] \
&& die "AD_IP6=${AD_IP6} is not assigned to any interface here (AD_IP_STRICT=no to allow)" \
|| warn "AD_IP6=${AD_IP6} is not bound here; continuing because AD_IP_STRICT=no"
fi
# /etc/hosts (append in place: the file may be a docker bind-mount, never unlink it)
if ! grep -q "[[:space:]]${FQDN}\([[:space:]]\|$\)" /etc/hosts 2>/dev/null; then
printf '%s\t%s %s\n' "$AD_IP" "$FQDN" "$AD_HOSTNAME" >> /etc/hosts
fi
if [ -n "${AD_EXTRA_HOSTS:-}" ]; then
# e.g. AD_EXTRA_HOSTS="192.0.2.5 dc1.ad.example.com dc1"
printf '%s\n' "${AD_EXTRA_HOSTS//;//}" >> /etc/hosts
fi
# -------------------------------------------------------------------------------------
# 5. /etc/krb5.conf (explicit KDCs; we do not trust DNS ordering during boot)
# -------------------------------------------------------------------------------------
KDC_HOSTS="$FQDN"
if [ -n "${AD_JOIN_SERVER:-}" ]; then
KDC_HOSTS="$AD_JOIN_SERVER $FQDN"
fi
{
echo "# rendered by ad-entrypoint at $(date -u '+%FT%TZ') - do not edit inside the container"
echo "[libdefaults]"
echo " default_realm = ${AD_REALM}"
echo " dns_lookup_realm = false"
echo " dns_lookup_kdc = false"
echo " rdns = false"
echo " udp_preference_limit = 1465" # Win11 + many group SIDs => PAC > 1465 bytes
echo " default_ccache_name = FILE:/tmp/krb5cc_%{uid}"
echo " ticket_lifetime = 10h"
echo " renew_lifetime = 7d"
echo " forwardable = true"
echo
echo "[realms]"
echo " ${AD_REALM} = {"
for h in $KDC_HOSTS; do
echo " kdc = ${h}"
echo " admin_server = ${h}"
echo " kpasswd_server = ${h}"
done
echo " default_domain = ${AD_DNS_DOMAIN}"
echo " }"
echo
echo "[domain_realm]"
echo " .${AD_DNS_DOMAIN} = ${AD_REALM}"
echo " ${AD_DNS_DOMAIN} = ${AD_REALM}"
} > /etc/krb5.conf
# -------------------------------------------------------------------------------------
# 6. smb.conf helpers
# -------------------------------------------------------------------------------------
# Upsert "key = value" inside [global] (replaces in place, otherwise inserts).
smb_set() { # $1=key $2=value
local key="$1" val="$2" tmp
tmp="$(mktemp)"
awk -v k="$key" -v v="$val" '
BEGIN { insec = 0; done = 0 }
/^[[:space:]]*#/ { print; next }
/^\[/ {
if (insec && !done) { print " " k " = " v; done = 1 }
insec = ($0 ~ /^\[global\]/)
print; next
}
{
if (insec && !done && $0 ~ "^[[:space:]]*" k "[[:space:]]*=") {
print " " k " = " v; done = 1; next
}
print
}
END { if (insec && !done) print " " k " = " v }
' "$SMBCONF" > "$tmp"
cat "$tmp" > "$SMBCONF"; rm -f "$tmp"
}
smb_get() { # $1=key -> value or empty
awk -v k="$1" '
/^[[:space:]]*#/ { next }
/^\[/ { insec = ($0 ~ /^\[global\]/); next }
insec && $0 ~ "^[[:space:]]*" k "[[:space:]]*=" {
sub(/^[[:space:]]*[^=]*=[[:space:]]*/, ""); print; exit
}' "$SMBCONF"
}
smb_unset() { # $1=key (regex-escaped-ish)
local key="$1" tmp
tmp="$(mktemp)"
awk -v k="$key" '
!($0 ~ "^[[:space:]]*" k "[[:space:]]*=") { print }' "$SMBCONF" > "$tmp"
cat "$tmp" > "$SMBCONF"; rm -f "$tmp"
}
# -------------------------------------------------------------------------------------
# 7. Optional: restore an online backup into a FRESH data volume, then stop.
#
# IMPORTANT semantics of `samba-tool domain backup restore`: it does a *join*, i.e. it
# restores the database and then ADDS this host as a new domain controller. Restoring under
# the same server name that the backup was taken from fails with "Entry CN=<NAME>,OU=Domain
# Controllers... already exists" - so this must run with a NEW AD_HOSTNAME (and a free IP).
# The archive carries its own smb.conf at ./etc/smb.conf; we promote it to /etc/samba.
# -------------------------------------------------------------------------------------
if [ -n "${AD_RESTORE_FROM:-}" ]; then
[ "${AD_RESTORE_CONFIRM:-no}" = "yes" ] || die "AD_RESTORE_FROM set but AD_RESTORE_CONFIRM!=yes (this writes directory data)"
[ -f "$AD_RESTORE_FROM" ] || die "backup file not found: $AD_RESTORE_FROM"
[ ! -f "$SAMDB" ] || die "refusing to restore over an existing database at $SAMDB - use a fresh data volume"
# Read the archive manifest once, parse with bash regex - no `head` in a pipeline, which
# under `set -o pipefail` turns a normal SIGPIPE into a fatal error.
backup_txt="$(tar -xOf "$AD_RESTORE_FROM" ./backup.txt 2>/dev/null || true)"
src_dc=""; src_netbios=""
[[ "$backup_txt" =~ using\ DC\ \'([^\']+)\' ]] && src_dc="${BASH_REMATCH[1]}"
[[ "$backup_txt" =~ Backup\ for\ domain\ ([A-Z0-9-]+)\ \(NetBIOS\) ]] && src_netbios="${BASH_REMATCH[1]}"
log "archive origin: dc=${src_dc:-unknown} netbios=${src_netbios:-unknown}"
if [ -n "$src_dc" ] && [ "${src_dc%%.*}" = "$AD_HOSTNAME" ]; then
die "restore under the same hostname as the backup source (${src_dc}) is not supported by Samba's restore tool. Set AD_HOSTNAME to a NEW name (and a free AD_IP) and retry."
fi
log "restoring ${AD_RESTORE_FROM} as NEW domain controller ${FQDN} (${AD_IP})"
samba-tool domain backup restore \
--backup-file="$AD_RESTORE_FROM" \
--targetdir=/var/lib/samba \
--newservername="$NETBIOS_HOST" \
--host-ip="$AD_IP" \
${AD_IP6:+--host-ip6="$AD_IP6"} \
--site="$AD_SITE"
if [ -f /var/lib/samba/etc/smb.conf ]; then
mkdir -p /etc/samba
cp /var/lib/samba/etc/smb.conf /etc/samba/smb.conf
rm -rf /var/lib/samba/etc
log "promoted the archive's smb.conf to /etc/samba/smb.conf"
fi
printf 'realm=%s\nfqdn=%s\nsource=%s\nrestored_from_dc=%s\nts=%s\n' \
"$AD_REALM" "$FQDN" "$AD_RESTORE_FROM" "${src_dc:-unknown}" "$(date -u '+%FT%TZ')" > /var/lib/samba/.ad-provisioned
log "restore complete. Unset AD_RESTORE_FROM and 'docker compose up -d' to serve it."
exit 0
fi
# A completed provision/join leaves a marker. A provision that dies half way (e.g. SYSVOL
# NT-ACL write refused by the underlying filesystem/capability set) otherwise looks like a
# healthy database to the next start, which then serves a broken domain. Fail loudly here.
MARKER=/var/lib/samba/.ad-provisioned
if [ -f "$SAMDB" ] && [ -f "$MARKER" ]; then
log "existing AD database found -> starting domain '${AD_REALM}'"
elif [ -f "$SAMDB" ]; then
if [ "${AD_FORCE_START:-no}" = "yes" ]; then
warn "sam.ldb present but $(basename "$MARKER") missing; AD_FORCE_START=yes -> continuing"
else
die "sam.ldb exists but $(basename "$MARKER") is missing: a previous provision/join was interrupted. Inspect with 'samba-tool dbcheck', or remove $SAMDB and restart, or set AD_FORCE_START=yes"
fi
else
if [ -n "${AD_JOIN_SERVER:-}" ]; then
log "joining existing domain ${AD_DNS_DOMAIN} via ${AD_JOIN_SERVER} as ${AD_HOSTNAME}"
JOIN_PW="$(read_secret "${AD_JOIN_PASSWORD:-}" "$SECRETS_DIR/join.password" || true)"
[ -n "$JOIN_PW" ] || die "AD_JOIN_PASSWORD (or ${SECRETS_DIR}/join.password) is required to join"
samba-tool domain join "${AD_DNS_DOMAIN}" DC \
--realm="$AD_REALM" \
--server-role=dc \
--host-name="$NETBIOS_HOST" \
--host-ip="$AD_IP" \
${AD_IP6:+--host-ip6="$AD_IP6"} \
--target-server="${AD_JOIN_SERVER}" \
-U "${AD_REALM}\\${AD_JOIN_USER:-${AD_ADMIN_USER}}%${JOIN_PW}" \
${AD_USE_RFC2307:+--use-rfc2307}
else
# IMPORTANT: never pre-create smb.conf before provisioning. `samba-tool domain
# provision` refuses to configure a DC over a config that has no [netlogon]
# share, so the realm/forwarder are passed via --option and Samba generates the
# authoritative config (incl. the NETLOGON/SYSVOL shares) itself.
if [ -f "$SMBCONF" ]; then
backup="${SMBCONF}.discarded-$(date -u +%Y%m%dT%H%M%SZ)"
warn "moving pre-existing smb.conf out of the way -> $backup"
mv "$SMBCONF" "$backup"
fi
require_admin_pw
log "provisioning NEW forest ${AD_REALM} (this takes 30-90s, heavy CPU/IO)"
samba-tool domain provision \
--option="realm=${AD_REALM}" \
--option="dns forwarder = ${AD_DNS_FORWARDER}" \
--server-role=dc \
--domain="$NETBIOS_DOMAIN" \
--host-name="$NETBIOS_HOST" \
--host-ip="$AD_IP" \
${AD_IP6:+--host-ip6="$AD_IP6"} \
--dns-backend="$AD_DNS_BACKEND" \
--function-level="$AD_FUNCTION_LEVEL" \
--base-schema="$AD_BASE_SCHEMA" \
--site="$AD_SITE" \
${AD_USE_RFC2307:+--use-rfc2307} \
--adminpass="$ADMIN_PW"
log "provisioning complete"
fi
[ -f "$SAMDB" ] || die "directory database still missing after provision/join: $SAMDB"
printf 'realm=%s\nfqdn=%s\njoined_via=%s\nts=%s\n' \
"$AD_REALM" "$FQDN" "${AD_JOIN_SERVER:-none}" "$(date -u '+%FT%TZ')" > "$MARKER"
fi
# -------------------------------------------------------------------------------------
# 8. Hardening (applied on every start, so .env changes take effect on restart)
# -------------------------------------------------------------------------------------
if [ "$AD_APPLY_HARDENING" = "yes" ]; then
smb_set "dns forwarder" "$AD_DNS_FORWARDER"
smb_set "server signing" "$AD_SMB_SIGNING"
smb_set "smb encrypt" "$AD_SMB_ENCRYPT"
smb_set "ntlm auth" "$AD_NTLM_AUTH"
smb_set "restrict anonymous" "$AD_RESTRICT_ANONYMOUS"
smb_set "map to guest" "never"
smb_set "server min protocol" "$AD_SMB_MIN_PROTOCOL"
smb_set "log level" "$AD_LOG_LEVEL"
smb_set "max log size" "10000"
log "hardening: signing=$AD_SMB_SIGNING encrypt=$AD_SMB_ENCRYPT ntlm=$AD_NTLM_AUTH anon=$AD_RESTRICT_ANONYMOUS min=$AD_SMB_MIN_PROTOCOL"
else
warn "AD_APPLY_HARDENING=no -> smb.conf is used exactly as you wrote it"
fi
# LDAPS: only if certs are present. Samba's own auto-generated cert is untrusted by
# Windows, so bring your own CA (scripts/tls.sh) for anything beyond smoke tests.
#
# Samba refuses to load a private key that is not root-owned / not group-other readable
# (CVE-2013-4476). Bind-mounted host certs are owned by the host uid, so we copy them into
# our own private dir as root and set the mode explicitly. The secrets mount stays read-only.
if [ -n "${AD_TLS_CERT:-}" ] && [ -r "${AD_TLS_CERT:-/nonexistent}" ]; then
[ -r "${AD_TLS_KEY:-/nonexistent}" ] || die "AD_TLS_CERT set but AD_TLS_KEY is unreadable"
staged="/var/lib/samba/private/tls"
mkdir -p "$staged"
cp -f "$AD_TLS_CERT" "$staged/ad_server.pem"
cp -f "$AD_TLS_KEY" "$staged/ad_server.key"
chmod 0644 "$staged/ad_server.pem"
chmod 0600 "$staged/ad_server.key"
chown root:root "$staged/ad_server.pem" "$staged/ad_server.key" 2>/dev/null || true
conf_cert="$staged/ad_server.pem"
conf_key="$staged/ad_server.key"
if [ -n "${AD_TLS_CA:-}" ] && [ -r "${AD_TLS_CA:-/nonexistent}" ]; then
cp -f "$AD_TLS_CA" "$staged/ad_ca.pem"
chmod 0644 "$staged/ad_ca.pem"
smb_set "tls cafile" "$staged/ad_ca.pem"
fi
# sanity: key must match cert, otherwise samba fails late and cryptically
c_mod="$(openssl x509 -noout -modulus -in "$conf_cert" 2>/dev/null | openssl md5 2>/dev/null)"
k_mod="$(openssl rsa -noout -modulus -in "$conf_key" 2>/dev/null | openssl md5 2>/dev/null)"
[ -n "$c_mod" ] && [ "$c_mod" = "$k_mod" ] || die "AD_TLS_CERT and AD_TLS_KEY do not match (modulus mismatch)"
san="$(openssl x509 -noout -ext subjectAltName -in "$conf_cert" 2>/dev/null | tr -d ' \n')"
printf '%s' "$san" | grep -qi "$FQDN" || warn "leaf cert SAN does not include $FQDN - Windows will reject LDAPS: ${san:-none}"
smb_set "tls enabled" "yes"
smb_set "tls certfile" "$conf_cert"
smb_set "tls keyfile" "$conf_key"
log "LDAPS enabled (staged $conf_cert / $conf_key, mode 0600, SAN: ${san:-none})"
else
log "LDAPS: no AD_TLS_CERT mounted (Samba will self-sign an untrusted cert on 636)"
fi
# -------------------------------------------------------------------------------------
# 9. Validate the rendered config. Fail loudly instead of half-working.
# -------------------------------------------------------------------------------------
tp_out="$(testparm -s "$SMBCONF" 2>&1 || true)"
if printf '%s' "$tp_out" | grep -qiE 'unknown parameter encountered|cannot parse|invalid parameter'; then
printf '%s\n' "$tp_out" | grep -iE 'unknown parameter encountered|cannot parse|invalid parameter' | sort -u >&2
die "smb.conf contains rejected parameters (see above) - refusing to start"
fi
log "smb.conf validated by testparm"
# -------------------------------------------------------------------------------------
# 10. Self-referencing resolver (classic AD DC requirement).
# In host network mode this file lives in the container only; the host is untouched.
# -------------------------------------------------------------------------------------
if [ "$AD_MANAGE_RESOLV" = "yes" ]; then
printf '# rendered by ad-entrypoint: the DC resolves its own zone from itself\nsearch %s\nnameserver 127.0.0.1\noptions timeout:2 attempts:2\n' \
"$AD_DNS_DOMAIN" > /etc/resolv.conf
log "/etc/resolv.conf -> nameserver 127.0.0.1 search ${AD_DNS_DOMAIN} (upstream via 'dns forwarder = ${AD_DNS_FORWARDER}')"
fi
# Pin the daemon's OWN updater too (not just our loop above): every `dns update command`
# invocation from the samba daemon goes through the wrapper, which reads the advertised
# addresses from /run/ad-dc-addresses.
printf '%s\n%s\n' "$AD_IP" "${AD_IP6:-}" | sed '/^$/d' > /run/ad-dc-addresses
smb_set "dns update command" "/usr/local/bin/ad-dnsupdate"
log "DNS registration pinned to: $(tr '\n' ' ' < /run/ad-dc-addresses)"
# -------------------------------------------------------------------------------------
# 11. Keep DNS records aligned with the current IP (laptop/DHCP or after failover)
#
# We pin registration to AD_IP on purpose. A Docker host is multi-homed (docker0 and every
# user-defined bridge, plus stale DHCP addresses), and Samba happily registers ALL of them
# into the DC's A record - clients then sometimes connect to 172.17.0.1 and fail in ways
# that look like Kerberos bugs. --current-ip keeps exactly one address published.
# -------------------------------------------------------------------------------------
(
trap 'exit 0' TERM INT
sleep 25
while true; do
if samba_dnsupdate --use-samba-tool \
--current-ip="$AD_IP" \
${AD_IP6:+--current-ip="$AD_IP6"} \
--verbose >/tmp/dnsupdate.out 2>&1; then
:
else
warn "samba_dnsupdate failed (see /tmp/dnsupdate.out)"
fi
# Self-heal: stale A records left behind by an earlier (unpinned) updater are not
# removed by dnsupdate, because they are no longer in its update cache. Delete any
# address on our own FQDN that we are not advertising. Uses the DC's own machine
# account (-P) so no admin password needs to exist inside the container.
escaped="$(printf '%s' "$AD_IP" | sed 's/\./\\./g')"
extra="$(dig +short @127.0.0.1 "$FQDN" A 2>/dev/null | grep -E '^[0-9.]+$' | grep -vx "$escaped" || true)"
for bad in $extra; do
log "removing stale A record ${FQDN} -> ${bad}"
samba-tool dns delete 127.0.0.1 "$AD_DNS_DOMAIN" "$FQDN" A "$bad" -P \
>/dev/null 2>&1 \
|| warn "could not delete stale A record ${FQDN} -> ${bad}; run: samba-tool dns delete 127.0.0.1 ${AD_DNS_DOMAIN} ${FQDN} A ${bad} -P"
done
sleep "$AD_DNS_REFRESH_SECONDS"
done
) &
DNS_UPDATER_PID=$!
trap 'kill "$DNS_UPDATER_PID" 2>/dev/null || true' EXIT
# -------------------------------------------------------------------------------------
# 12. Foreground, exactly like the Debian systemd unit, so SIGTERM shuts down cleanly.
# -------------------------------------------------------------------------------------
log "starting ${FQDN} for realm ${AD_REALM}"
exec samba --foreground --no-process-group --option="log level = ${AD_LOG_LEVEL}" -s "$SMBCONF"