#!/bin/bash # Copyright (C) 2026 Ilker Manap # SPDX-License-Identifier: AGPL-3.0-or-later # # adc-maas-init - configures a MAAS-deployed Debian node as a Samba Active # Directory domain controller on first boot. # # Stages, each run once and recorded in /var/lib/adc-maas/.done: # # hosts the FQDN must resolve to the management address; Samba insists # identity regenerate anything that must not be shared between clones # time Kerberos rejects a clock skew over 5 minutes, so chrony first # resolver a DC has to resolve its own domain # domain provision a new domain, or join an existing one # services hand SMB over to the samba-ad-dc daemon # sysvol set up SYSVOL replication, which Samba does not do by itself # selftest prove Kerberos and SMB actually answer # # Configuration comes from /etc/adc-maas/adc-maas.conf and, overriding it, # /etc/adc-maas/conf.d/*.conf — which cloud-init writes from the user-data MAAS # passes at deploy time. # set -uo pipefail CONF_DIR=/etc/adc-maas STATE_DIR=/var/lib/adc-maas LOG_TAG=adc-maas-init mkdir -p "$STATE_DIR" "$CONF_DIR/conf.d" log() { echo "[$LOG_TAG] $*"; logger -t "$LOG_TAG" -- "$*" 2>/dev/null || true; } warn() { echo "[$LOG_TAG] UYARI: $*" >&2; logger -t "$LOG_TAG" -p user.warning -- "WARN: $*" 2>/dev/null || true; } done_flag() { echo "$STATE_DIR/$1.done"; } is_done() { [ -e "$(done_flag "$1")" ]; } mark_done() { date -Is > "$(done_flag "$1")"; } # --------------------------------------------------------------------------- load_config() { # shellcheck disable=SC1091 [ -r "$CONF_DIR/adc-maas.conf" ] && . "$CONF_DIR/adc-maas.conf" local f for f in "$CONF_DIR"/conf.d/*.conf; do [ -r "$f" ] || continue log "loading configuration: $f" # shellcheck disable=SC1090 . "$f" done AD_ENABLED="${AD_ENABLED:-true}" AD_MODE="${AD_MODE:-none}" # none | provision | join AD_REALM="${AD_REALM:-}" # EXAMPLE.LAN (uppercase) AD_DOMAIN="${AD_DOMAIN:-}" # EXAMPLE (NetBIOS, <= 15 chars) AD_ADMIN_PASSWORD="${AD_ADMIN_PASSWORD:-}" AD_ADMIN_PASSWORD_FILE="${AD_ADMIN_PASSWORD_FILE:-}" AD_JOIN_USER="${AD_JOIN_USER:-Administrator}" AD_JOIN_PEER="${AD_JOIN_PEER:-}" # an existing DC; empty = find via DNS AD_DNS_BACKEND="${AD_DNS_BACKEND:-SAMBA_INTERNAL}" AD_DNS_FORWARDER="${AD_DNS_FORWARDER:-}" AD_FUNCTION_LEVEL="${AD_FUNCTION_LEVEL:-2008_R2}" AD_SITE="${AD_SITE:-Default-First-Site-Name}" AD_USE_RFC2307="${AD_USE_RFC2307:-true}" AD_INTERFACE="${AD_INTERFACE:-}" # empty = the default-route interface AD_NTP_ALLOW="${AD_NTP_ALLOW:-}" # subnet allowed to use this DC as a time source AD_WAIT="${AD_WAIT:-900}" AD_RETRIES="${AD_RETRIES:-5}" AD_WIPE_SECRETS="${AD_WIPE_SECRETS:-true}" AD_SYSVOL_SYNC="${AD_SYSVOL_SYNC:-auto}" # auto | off | on AD_SYSVOL_SOURCE="${AD_SYSVOL_SOURCE:-}" # DC to pull SYSVOL from; default = join peer AD_SYSVOL_INTERVAL="${AD_SYSVOL_INTERVAL:-5min}" } admin_password() { if [ -n "$AD_ADMIN_PASSWORD_FILE" ] && [ -r "$AD_ADMIN_PASSWORD_FILE" ]; then head -1 "$AD_ADMIN_PASSWORD_FILE" else printf '%s' "$AD_ADMIN_PASSWORD" fi } primary_iface() { [ -n "$AD_INTERFACE" ] && { echo "$AD_INTERFACE"; return; } ip -4 -o route show default 2>/dev/null \ | awk '{for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1); exit}}' } primary_ip() { ip -4 -o route get 1.1.1.1 2>/dev/null \ | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}' } wait_for_network() { local i for ((i = 0; i < 120; i += 5)); do [ -n "$(primary_ip)" ] && return 0 sleep 5 done return 1 } wait_for_cloud_init() { command -v cloud-init >/dev/null 2>&1 || return 0 log "waiting for cloud-init" # The unit deliberately carries no cloud-init ordering — see the unit file. timeout 900 cloud-init status --wait >/dev/null 2>&1 log "cloud-init: $(cloud-init status 2>/dev/null | head -1)" return 0 } realm_lower() { printf '%s' "${AD_REALM,,}"; } # --------------------------------------------------------------------------- # hosts # --------------------------------------------------------------------------- stage_hosts() { is_done hosts && return 0 local host fqdn ip keep host="$(hostname -s)" ip="$(primary_ip)" [ -n "$ip" ] || { warn "no management address found"; return 1; } if [ -n "$AD_REALM" ]; then fqdn="${host}.$(realm_lower)" else fqdn="$(hostname -f 2>/dev/null || echo "$host")" fi log "/etc/hosts: ${ip} ${fqdn} ${host}" cp -a /etc/hosts "$STATE_DIR/hosts.orig" 2>/dev/null || true keep="$(grep -vE "^[[:space:]]*(127\.0\.0\.1|127\.0\.1\.1|::1|ff02::[12])[[:space:]]" /etc/hosts 2>/dev/null \ | grep -vE "[[:space:]]${host}([[:space:]]|\$)" || true)" { echo "127.0.0.1 localhost.localdomain localhost" echo "${ip} ${fqdn} ${host}" [ -n "$keep" ] && echo "$keep" echo echo "::1 localhost ip6-localhost ip6-loopback" echo "ff02::1 ip6-allnodes" echo "ff02::2 ip6-allrouters" } > /etc/hosts # cloud-init rewrites /etc/hosts on every boot otherwise, undoing this. cat > /etc/cloud/cloud.cfg.d/99-adc-maas-hosts.cfg <<'EOF' # /etc/hosts is managed by adc-maas-init. manage_etc_hosts: false EOF mark_done hosts } # --------------------------------------------------------------------------- # identity — nothing here may be shared between machines built from one image # --------------------------------------------------------------------------- stage_identity() { is_done identity && return 0 if [ ! -s /etc/iscsi/initiatorname.iscsi ] && command -v iscsi-iname >/dev/null 2>&1; then mkdir -p /etc/iscsi echo "InitiatorName=$(iscsi-iname)" > /etc/iscsi/initiatorname.iscsi chmod 0600 /etc/iscsi/initiatorname.iscsi fi mark_done identity } # --------------------------------------------------------------------------- # time — Kerberos refuses tickets when the clock is more than five minutes out # --------------------------------------------------------------------------- stage_time() { is_done time && return 0 command -v chronyd >/dev/null 2>&1 || { warn "chrony missing"; mark_done time; return 0; } log "configuring chrony (Kerberos requires clock skew under 5 minutes)" # ntpsigndsocket lets Windows clients use this DC as a signed NTP source, # which is what domain members expect. cat > /etc/chrony/conf.d/adc-maas.conf <> /etc/chrony/conf.d/adc-maas.conf else warn "AD_NTP_ALLOW unset — domain members cannot use this DC as a time source" fi systemctl restart chrony >/dev/null 2>&1 || systemctl restart chronyd >/dev/null 2>&1 || true mark_done time } # --------------------------------------------------------------------------- # resolver # --------------------------------------------------------------------------- write_resolv() { local ns="$1" search="$2" [ -L /etc/resolv.conf ] && rm -f /etc/resolv.conf { [ -n "$search" ] && echo "search ${search}" echo "nameserver ${ns}" } > /etc/resolv.conf # Otherwise cloud-init or netplan puts the old resolver back on next boot. cat > /etc/cloud/cloud.cfg.d/99-adc-maas-resolv.cfg <<'EOF' manage_resolv_conf: false EOF } stage_resolver() { is_done resolver && return 0 [ -n "$AD_REALM" ] || { mark_done resolver; return 0; } # Before the domain exists locally, point at whichever DC we are joining. # stage_domain switches this to 127.0.0.1 once Samba serves DNS here. local ns if [ "$AD_MODE" = "join" ] && [ -n "$AD_JOIN_PEER" ]; then ns="$AD_JOIN_PEER" log "resolver -> ${ns} (the DC being joined)" else ns="$(primary_ip)" log "resolver -> ${ns} (this host)" fi cp -a /etc/resolv.conf "$STATE_DIR/resolv.conf.orig" 2>/dev/null || true write_resolv "$ns" "$(realm_lower)" mark_done resolver } # --------------------------------------------------------------------------- # domain # --------------------------------------------------------------------------- stop_samba_services() { systemctl stop samba-ad-dc smbd nmbd winbind >/dev/null 2>&1 || true } wait_for_peer() { local peer="$1" i log "waiting for ${peer}:389 (LDAP) for up to ${AD_WAIT}s" for ((i = 0; i < AD_WAIT; i += 5)); do if timeout 4 bash -c "exec 3<>/dev/tcp/${peer}/389" 2>/dev/null; then return 0; fi sleep 5 done return 1 } configure_smb_extras() { # samba-tool writes a minimal smb.conf; add what a DC in this setup needs. local iface conf=/etc/samba/smb.conf iface="$(primary_iface)" grep -q 'adc-maas' "$conf" 2>/dev/null && return 0 python3 - "$conf" "$iface" "$AD_DNS_FORWARDER" <<'PY' import sys path, iface, forwarder = sys.argv[1], sys.argv[2], sys.argv[3] lines = open(path).read().splitlines() out, injected = [], False for line in lines: out.append(line) if not injected and line.strip().lower() == "[global]": out.append("\t# --- adc-maas ---") if forwarder: out.append("\tdns forwarder = %s" % forwarder) if iface: out.append("\tinterfaces = lo %s" % iface) out.append("\tbind interfaces only = yes") # Windows clients still negotiate SMB2 at minimum; refuse SMB1 outright. out.append("\tserver min protocol = SMB2_10") out.append("\t# --- end adc-maas ---") injected = True open(path, "w").write("\n".join(out) + "\n") PY } link_krb5_conf() { if [ -f /var/lib/samba/private/krb5.conf ]; then cp -a /etc/krb5.conf "$STATE_DIR/krb5.conf.orig" 2>/dev/null || true ln -sf /var/lib/samba/private/krb5.conf /etc/krb5.conf fi } stage_domain() { is_done domain && return 0 [ "$AD_MODE" = "none" ] && { log "AD_MODE=none, nothing to do"; mark_done domain; return 0; } local pw; pw="$(admin_password)" [ -n "$AD_REALM" ] || { warn "AD_REALM is required"; return 1; } [ -n "$AD_DOMAIN" ] || { warn "AD_DOMAIN (NetBIOS name) is required"; return 1; } [ -n "$pw" ] || { warn "AD_ADMIN_PASSWORD is required"; return 1; } if [ -f /var/lib/samba/private/sam.ldb ]; then log "this host already holds a domain database, skipping" mark_done domain return 0 fi stop_samba_services rm -f /etc/samba/smb.conf local -a rfc=() [ "$AD_USE_RFC2307" = "true" ] && rfc=(--use-rfc2307) case "$AD_MODE" in provision) log "provisioning a new domain: realm=${AD_REALM} netbios=${AD_DOMAIN}" if ! samba-tool domain provision \ --realm="$AD_REALM" \ --domain="$AD_DOMAIN" \ --server-role=dc \ --dns-backend="$AD_DNS_BACKEND" \ --function-level="$AD_FUNCTION_LEVEL" \ --adminpass="$pw" \ "${rfc[@]}"; then warn "samba-tool domain provision failed" warn "a common cause is AD_ADMIN_PASSWORD not meeting complexity rules" return 1 fi ;; join) local peer="$AD_JOIN_PEER" if [ -n "$peer" ]; then wait_for_peer "$peer" || { warn "${peer} did not answer on 389"; return 1; } else warn "AD_JOIN_PEER unset — relying on DNS to locate a DC" fi local i rc=1 for ((i = 1; i <= AD_RETRIES; i++)); do log "joining ${AD_REALM} as a domain controller (attempt ${i}/${AD_RETRIES})" if samba-tool domain join "$AD_REALM" DC \ -U"${AD_DOMAIN}\\${AD_JOIN_USER}" \ --password="$pw" \ --dns-backend="$AD_DNS_BACKEND" \ --site="$AD_SITE"; then rc=0; break fi warn "join failed, retrying in 30s" sleep 30 done [ "$rc" -eq 0 ] || { warn "join failed after ${AD_RETRIES} attempts"; return 1; } ;; *) warn "invalid AD_MODE=${AD_MODE}" mark_done domain return 0 ;; esac configure_smb_extras link_krb5_conf mark_done domain } # --------------------------------------------------------------------------- # services — Debian ships the standalone file-server daemons enabled; a DC runs # a single samba daemon instead, and the two sets conflict. # --------------------------------------------------------------------------- stage_services() { is_done services && return 0 [ -f /var/lib/samba/private/sam.ldb ] || { mark_done services; return 0; } log "switching to the samba-ad-dc daemon" systemctl disable --now smbd nmbd winbind >/dev/null 2>&1 || true systemctl mask smbd nmbd winbind >/dev/null 2>&1 || true systemctl unmask samba-ad-dc >/dev/null 2>&1 || true systemctl enable --now samba-ad-dc >/dev/null 2>&1 || { warn "samba-ad-dc did not start"; return 1; } # A DC serves its own domain's DNS; point the resolver at it now. write_resolv 127.0.0.1 "$(realm_lower)" mark_done services } # --------------------------------------------------------------------------- # sysvol — Samba implements neither DFS-R nor FRS, so SYSVOL (where Group # Policy lives) does not replicate between DCs on its own. # --------------------------------------------------------------------------- stage_sysvol() { is_done sysvol && return 0 [ -f /var/lib/samba/private/sam.ldb ] || { mark_done sysvol; return 0; } local source="${AD_SYSVOL_SOURCE:-$AD_JOIN_PEER}" local want="$AD_SYSVOL_SYNC" [ "$want" = "auto" ] && { [ "$AD_MODE" = "join" ] && want=on || want=off; } if [ "$want" != "on" ]; then log "SYSVOL replication not enabled on this host" [ "$AD_MODE" = "provision" ] && \ log "this DC is the replication source; additional DCs pull from it" mark_done sysvol return 0 fi if [ -z "$source" ]; then warn "AD_SYSVOL_SOURCE unset and no join peer — SYSVOL will not replicate" warn "Group Policy changes made on other DCs will not appear here" mark_done sysvol return 0 fi log "enabling SYSVOL replication from ${source} every ${AD_SYSVOL_INTERVAL}" cat > /etc/adc-maas/sysvol-sync.conf </dev/null || true systemctl daemon-reload systemctl enable --now adc-sysvol-sync.timer >/dev/null 2>&1 \ || warn "could not enable adc-sysvol-sync.timer" mark_done sysvol } # --------------------------------------------------------------------------- # selftest # --------------------------------------------------------------------------- stage_selftest() { is_done selftest && return 0 [ -f /var/lib/samba/private/sam.ldb ] || { mark_done selftest; return 0; } local ok=0 pw; pw="$(admin_password)" if samba-tool domain level show >/dev/null 2>&1; then log "domain level: $(samba-tool domain level show 2>/dev/null | tr '\n' ' ')" else warn "samba-tool domain level show failed"; ok=1 fi if command -v smbclient >/dev/null 2>&1; then if smbclient -L localhost -N >/dev/null 2>&1; then log "SMB responds; SYSVOL and NETLOGON should be listed" else warn "smbclient could not list shares"; ok=1 fi fi if [ -n "$pw" ] && command -v kinit >/dev/null 2>&1; then if printf '%s' "$pw" | kinit "Administrator@${AD_REALM}" >/dev/null 2>&1; then log "Kerberos: got a ticket for Administrator@${AD_REALM}" kdestroy >/dev/null 2>&1 || true else warn "kinit failed — check the clock and the DNS records"; ok=1 fi fi [ "$ok" -eq 0 ] || warn "self-test reported problems; see the messages above" mark_done selftest } wipe_secrets() { [ "$AD_WIPE_SECRETS" = "true" ] || return 0 local f for f in "$CONF_DIR"/conf.d/*.conf; do [ -r "$f" ] || continue grep -q 'AD_ADMIN_PASSWORD' "$f" || continue log "scrubbing credentials from $f" sed -i -E "s/^([[:space:]]*AD_ADMIN_PASSWORD=).*/\1''/" "$f" done if [ -n "$AD_ADMIN_PASSWORD_FILE" ] && [ -f "$AD_ADMIN_PASSWORD_FILE" ]; then shred -u "$AD_ADMIN_PASSWORD_FILE" 2>/dev/null || rm -f "$AD_ADMIN_PASSWORD_FILE" fi } # --------------------------------------------------------------------------- main() { load_config [ "$AD_ENABLED" = "true" ] || { log "AD_ENABLED=false, doing nothing"; exit 0; } log "starting (hostname=$(hostname -s))" wait_for_cloud_init load_config # cloud-init may have just written conf.d if ! wait_for_network; then warn "no management address; will retry on the next boot" exit 1 fi local failed=0 stage_hosts || failed=1 stage_identity || failed=1 stage_time || failed=1 stage_resolver || failed=1 stage_domain || failed=1 stage_services || failed=1 stage_sysvol || failed=1 stage_selftest || failed=1 if [ "$failed" -eq 0 ] \ && is_done hosts && is_done identity && is_done time && is_done resolver \ && is_done domain && is_done services && is_done sysvol && is_done selftest; then wipe_secrets date -Is > "$STATE_DIR/complete" log "all stages complete" else warn "some stages did not complete; the service retries on the next boot" warn "details: journalctl -u adc-maas-init" exit 1 fi } main "$@"