Initial commit: MAAS-deployable Samba Active Directory domain controller
Builds a Debian image that MAAS deploys to bare metal as an Active Directory
domain controller, with first-boot automation that either creates a domain or
joins an existing one without anyone logging in. To a Windows client the result
is an AD domain: same Kerberos, same LDAP, same Group Policy, same domain join.
The request was for "primary and backup domain controllers", which is NT 4
terminology. Active Directory has no such split — every DC holds a writable copy
and any of them can service a logon. What lives on one DC at a time are the five
FSMO roles, one confusingly named "PDC Emulator". The image therefore offers
provision (create the domain) and join (add another equal DC), which is the
distinction that actually exists.
Contents:
* customize-samba-ad.sh, run inside the packer-maas build VM: installs samba,
winbind, Kerberos, chrony and rsync, swaps the cloud kernel for the generic
one, and deletes every trace of a domain so one image can produce many DCs
* adc-maas-init, a first-boot state machine covering /etc/hosts, node-unique
identifiers, time, the resolver, provisioning or joining, the service
switchover and a self-test that proves Kerberos issues a ticket
* adc-sysvol-sync, a systemd timer implementing the SYSVOL workaround
* a MAAS curtin preseed, cloud-init examples, and the release pipeline
Two findings shaped the design.
Debian builds Samba against its bundled Heimdal rather than system MIT Kerberos
— samba-ad-dc does not depend on krb5-kdc — so the AD DC role is in Samba's
supported configuration, not the experimental MIT one. The build asserts this
and fails if it ever changes.
Samba implements neither DFS-R nor FRS, so SYSVOL — where Group Policy lives —
does not replicate between DCs. Left alone, a policy created on one DC never
reaches the others and clients behave differently depending on which DC answered
them, with nothing reporting an error. adc-sysvol-sync applies the Samba wiki's
rsync workaround: sync idmap.ldb once so SID-to-uid mappings agree, rsync the
tree, then samba-tool ntacl sysvolreset because rsync carries POSIX bits while
the Windows ACLs live in AD. Without an SSH key to the source DC it exits with
an explanation rather than letting Group Policy diverge quietly.
Also carried over from maas-proxmox, where they were verified on real hardware:
the curtin-hooks that skip the kernel install and pin interface names by MAC,
Type=simple on the first-boot unit to avoid the systemd ordering cycle, and
shipping networking.service disabled so MAAS owns the network on first boot.
Specific to this image, the Debian cloud kernel is replaced with the generic one
— a bare-metal node booted with the cloud kernel can come up with no disk.
Nothing here has been built or deployed. The README says so at the top and in a
Verified status section that separates what was checked from what was not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
116
overlay/curtin/curtin-hooks
Executable file
116
overlay/curtin/curtin-hooks
Executable file
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (C) 2026 Ilker Manap
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#
|
||||
# curtin-hooks - MAAS/curtin kurulum kancasi (Proxmox VE imaji icin)
|
||||
#
|
||||
# Curtin, hedef sistemde /curtin/curtin-hooks varsa dahili curthooks yerine
|
||||
# bu script'i calistirir (curtin.util.run_hook_if_exists).
|
||||
#
|
||||
# Amac: curtin'in APT uzerinden cekirdek kurmaya calismasini engellemek.
|
||||
# Proxmox cekirdegi (proxmox-default-kernel) imajin icinde hazir gelir;
|
||||
# curtin'in onu yeniden kurmasi gereksizdir ve dugumun deploy sirasinda
|
||||
# download.proxmox.com'a erisebilmesini zorunlu kilar (izole aglarda calismaz).
|
||||
#
|
||||
# Not: Bunu preseed'de "kernel: null" ile yapmak mumkun degil - MAAS ile
|
||||
# gelen curtin surumlerinin bir kismi bu degeri desteklemiyor ve
|
||||
# install_kernel icinde AttributeError ile patliyor. Kancayi kullanmak
|
||||
# curtin surumunden bagimsiz olarak calisir.
|
||||
#
|
||||
import shutil
|
||||
import os
|
||||
|
||||
from curtin.commands import curthooks
|
||||
from curtin.config import load_command_config
|
||||
from curtin.util import load_command_environment
|
||||
|
||||
|
||||
def disable_kernel_install():
|
||||
"""curtin'in cekirdek kurulum adimini etkisiz hale getir.
|
||||
|
||||
Fonksiyon adi curtin surumune gore degisiyor (install_kernel ->
|
||||
curthook_install_kernel), ikisini de yakaliyoruz.
|
||||
"""
|
||||
disabled = []
|
||||
for name in ("install_kernel", "curthook_install_kernel"):
|
||||
if hasattr(curthooks, name):
|
||||
setattr(curthooks, name, lambda *args, **kwargs: None)
|
||||
disabled.append(name)
|
||||
print("curtin-hooks: cekirdek kurulumu devre disi: %s" % (disabled or "hicbiri bulunamadi"))
|
||||
|
||||
|
||||
def pin_interface_names(config, target):
|
||||
"""MAAS'in bildigi arayuz adlarini hedef sistemde MAC uzerinden sabitle.
|
||||
|
||||
MAAS commissioning'i Ubuntu ephemeral ortaminda yapiyor ve arayuzu orada
|
||||
gordugu adla (or. enp6s18) kaydediyor. Deploy edilen Debian 13 / Proxmox
|
||||
ise udev'in farkli isimlendirme semasi yuzunden ayni karti ens18 diye
|
||||
adlandirabiliyor. Bu durumda cloud-init acilista arayuzu yeniden
|
||||
adlandirmaya calisiyor, su hatayi aliyor:
|
||||
|
||||
Failed to rename devices: [busy] Error renaming
|
||||
mac=... from ens18 to enp6s18
|
||||
|
||||
...ve arayuzu KAPALI birakiyor; dugum agini tamamen kaybediyor.
|
||||
|
||||
Cozum: MAC -> ad eslemesini systemd .link dosyasi olarak yaziyoruz, boylece
|
||||
udev karti en bastan MAAS'in bekledigi adla olusturuyor ve yeniden
|
||||
adlandirmaya hic gerek kalmiyor.
|
||||
"""
|
||||
net = config.get("network") or {}
|
||||
pairs = []
|
||||
|
||||
# netplan / v2 bicimi
|
||||
for name, cfg in (net.get("ethernets") or {}).items():
|
||||
cfg = cfg or {}
|
||||
mac = (cfg.get("match") or {}).get("macaddress")
|
||||
if mac:
|
||||
pairs.append((cfg.get("set-name") or name, mac))
|
||||
|
||||
# curtin v1 bicimi
|
||||
for entry in net.get("config") or []:
|
||||
if not isinstance(entry, dict) or entry.get("type") != "physical":
|
||||
continue
|
||||
if entry.get("name") and entry.get("mac_address"):
|
||||
pairs.append((entry["name"], entry["mac_address"]))
|
||||
|
||||
if not pairs:
|
||||
print("curtin-hooks: arayuz adi sabitlenmedi (ag yapilandirmasi bos)")
|
||||
return
|
||||
|
||||
link_dir = os.path.join(target, "etc/systemd/network")
|
||||
os.makedirs(link_dir, exist_ok=True)
|
||||
for name, mac in pairs:
|
||||
path = os.path.join(link_dir, "10-maas-%s.link" % name)
|
||||
with open(path, "w") as fh:
|
||||
fh.write(
|
||||
"# MAAS tarafindan beklenen arayuz adi - pve-maas imaji\n"
|
||||
"# curtin-hooks tarafindan olusturuldu.\n"
|
||||
"[Match]\n"
|
||||
"MACAddress=%s\n"
|
||||
"\n"
|
||||
"[Link]\n"
|
||||
"Name=%s\n" % (mac.lower(), name)
|
||||
)
|
||||
print("curtin-hooks: arayuz adi sabitlendi %s -> %s" % (mac, name))
|
||||
|
||||
|
||||
def cleanup():
|
||||
"""Kancayi hedef sistemden kaldir - iz birakma."""
|
||||
curtin_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
shutil.rmtree(curtin_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def main():
|
||||
state = load_command_environment()
|
||||
config = load_command_config(None, state)
|
||||
target = state["target"]
|
||||
|
||||
disable_kernel_install()
|
||||
curthooks.builtin_curthooks(config, target, state)
|
||||
pin_interface_names(config, target)
|
||||
cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
108
overlay/etc/adc-maas/adc-maas.conf
Normal file
108
overlay/etc/adc-maas/adc-maas.conf
Normal file
@@ -0,0 +1,108 @@
|
||||
# Copyright (C) 2026 Ilker Manap
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#
|
||||
# /etc/adc-maas/adc-maas.conf
|
||||
#
|
||||
# Defaults for adc-maas-init. DO NOT EDIT THIS FILE. Put per-node settings in
|
||||
# /etc/adc-maas/conf.d/*.conf, which cloud-init writes from the user-data MAAS
|
||||
# passes at deploy time; those values override everything here.
|
||||
#
|
||||
# A note on terminology, because it trips people up. "Primary" and "backup"
|
||||
# domain controllers are Windows NT 4 concepts. Active Directory, from Windows
|
||||
# 2000 onwards, has no such split: every DC is a writable, equal peer and they
|
||||
# replicate to each other. What does live on exactly one DC at a time are the
|
||||
# five FSMO roles, one of which is confusingly named "PDC Emulator".
|
||||
#
|
||||
# So the real distinction is not primary versus backup, it is:
|
||||
#
|
||||
# AD_MODE=provision the FIRST DC, which creates the domain
|
||||
# AD_MODE=join every DC after that, which joins the existing domain
|
||||
#
|
||||
# All of them then serve logons equally. Losing the first one costs you the FSMO
|
||||
# roles, which you seize onto another DC — it does not cost you the domain.
|
||||
|
||||
# ---------------------------------------------------------------- general
|
||||
AD_ENABLED=true
|
||||
|
||||
# none | provision | join
|
||||
AD_MODE=none
|
||||
|
||||
# ---------------------------------------------------------------- identity
|
||||
# The Kerberos realm: your DNS domain in UPPERCASE. Use a domain you control and
|
||||
# that is not your public web domain — EXAMPLE.LAN, AD.EXAMPLE.COM, and so on.
|
||||
# Never use a bare ".local", which collides with mDNS.
|
||||
AD_REALM=
|
||||
|
||||
# The NetBIOS name: the short, legacy form. Uppercase, at most 15 characters,
|
||||
# no dots. Conventionally the first label of the realm.
|
||||
AD_DOMAIN=
|
||||
|
||||
# For AD_MODE=provision this becomes the domain Administrator password.
|
||||
# For AD_MODE=join these are the credentials used to join.
|
||||
#
|
||||
# Active Directory enforces password complexity by default: at least 7
|
||||
# characters and three of upper case, lower case, digit, symbol. A weak password
|
||||
# makes provisioning fail with an error that does not say so clearly.
|
||||
AD_ADMIN_PASSWORD=
|
||||
AD_ADMIN_PASSWORD_FILE=
|
||||
AD_JOIN_USER=Administrator
|
||||
|
||||
# An existing DC to join, by IP or FQDN. Empty means "find one via DNS", which
|
||||
# only works if this host's resolver already points at the domain.
|
||||
AD_JOIN_PEER=
|
||||
|
||||
# ---------------------------------------------------------------- DNS
|
||||
# SAMBA_INTERNAL is the Samba-provided DNS server. It is the recommended default
|
||||
# and needs no separate configuration. BIND9_DLZ hands DNS to BIND with an AD
|
||||
# backend, which you only want if you need BIND features such as complex views
|
||||
# or DNSSEC.
|
||||
AD_DNS_BACKEND=SAMBA_INTERNAL
|
||||
|
||||
# Where the DC sends queries it is not authoritative for. Without this the DC
|
||||
# resolves your domain but nothing else.
|
||||
AD_DNS_FORWARDER=
|
||||
|
||||
# ---------------------------------------------------------------- domain shape
|
||||
# 2008_R2 is the safe default and interoperates with everything current.
|
||||
# Raise it only when every DC in the domain supports the higher level.
|
||||
AD_FUNCTION_LEVEL=2008_R2
|
||||
AD_SITE=Default-First-Site-Name
|
||||
|
||||
# Store POSIX uid/gid attributes in AD. Keep this on if Linux machines will also
|
||||
# authenticate against the domain; it makes uids consistent across them.
|
||||
AD_USE_RFC2307=true
|
||||
|
||||
# ---------------------------------------------------------------- networking
|
||||
# Empty means the interface holding the default route.
|
||||
AD_INTERFACE=
|
||||
|
||||
# Subnet allowed to use this DC as an NTP source, e.g. 192.0.2.0/24.
|
||||
# Domain members need working time: Kerberos rejects a skew over five minutes,
|
||||
# and the usual symptom is logins failing for no visible reason.
|
||||
AD_NTP_ALLOW=
|
||||
|
||||
# ---------------------------------------------------------------- SYSVOL
|
||||
# SYSVOL is the share holding Group Policy objects and logon scripts. Windows
|
||||
# replicates it between DCs with DFS-R. Samba implements neither DFS-R nor its
|
||||
# predecessor FRS, so without help a policy created on one DC never reaches the
|
||||
# others and clients behave differently depending on which DC answered them.
|
||||
#
|
||||
# The workaround, straight from the Samba wiki, is to pull SYSVOL over rsync
|
||||
# from the DC holding the PDC Emulator role and then reapply the ACLs from AD.
|
||||
#
|
||||
# auto replicate on nodes that joined, not on the one that provisioned
|
||||
# on replicate here
|
||||
# off do not replicate; you are handling it another way
|
||||
#
|
||||
# This needs a root SSH key on this host that is authorised on the source DC.
|
||||
# Without one the sync exits with an explanation instead of failing quietly.
|
||||
AD_SYSVOL_SYNC=auto
|
||||
AD_SYSVOL_SOURCE=
|
||||
AD_SYSVOL_INTERVAL=5min
|
||||
|
||||
# ---------------------------------------------------------------- misc
|
||||
AD_WAIT=900
|
||||
AD_RETRIES=5
|
||||
|
||||
# Scrub AD_ADMIN_PASSWORD from conf.d once the domain is up.
|
||||
AD_WIPE_SECRETS=true
|
||||
0
overlay/etc/adc-maas/conf.d/.keep
Normal file
0
overlay/etc/adc-maas/conf.d/.keep
Normal file
28
overlay/etc/systemd/system/adc-maas-init.service
Normal file
28
overlay/etc/systemd/system/adc-maas-init.service
Normal file
@@ -0,0 +1,28 @@
|
||||
# Copyright (C) 2026 Ilker Manap
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
[Unit]
|
||||
Description=Samba AD domain controller post-deployment configuration for MAAS
|
||||
Documentation=file:/etc/adc-maas/adc-maas.conf
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
ConditionPathExists=!/var/lib/adc-maas/complete
|
||||
|
||||
# Do NOT order this after cloud-init here.
|
||||
#
|
||||
# After=cloud-final.service together with WantedBy=multi-user.target forms an
|
||||
# ordering cycle on this image, and systemd breaks it by deleting THIS unit's
|
||||
# start job — the service then never runs and reports no error. The script waits
|
||||
# for cloud-init itself instead.
|
||||
|
||||
[Service]
|
||||
# Type=simple, not oneshot: a oneshot unit blocks multi-user.target, which
|
||||
# cloud-final.service is ordered after, so waiting for cloud-init inside the
|
||||
# script would deadlock. Stage tracking uses /var/lib/adc-maas/*.done anyway.
|
||||
Type=simple
|
||||
ExecStart=/usr/local/sbin/adc-maas-init
|
||||
TimeoutStartSec=0
|
||||
StandardOutput=journal+console
|
||||
StandardError=journal+console
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
12
overlay/etc/systemd/system/adc-sysvol-sync.service
Normal file
12
overlay/etc/systemd/system/adc-sysvol-sync.service
Normal file
@@ -0,0 +1,12 @@
|
||||
# Copyright (C) 2026 Ilker Manap
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
[Unit]
|
||||
Description=Replicate SYSVOL from another domain controller
|
||||
Documentation=https://wiki.samba.org/index.php/SysVol_replication_(DFS-R)
|
||||
After=samba-ad-dc.service
|
||||
Requires=samba-ad-dc.service
|
||||
ConditionPathExists=/etc/adc-maas/sysvol-sync.conf
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/sbin/adc-sysvol-sync
|
||||
14
overlay/etc/systemd/system/adc-sysvol-sync.timer
Normal file
14
overlay/etc/systemd/system/adc-sysvol-sync.timer
Normal file
@@ -0,0 +1,14 @@
|
||||
# Copyright (C) 2026 Ilker Manap
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
[Unit]
|
||||
Description=Replicate SYSVOL periodically
|
||||
Documentation=https://wiki.samba.org/index.php/SysVol_replication_(DFS-R)
|
||||
|
||||
[Timer]
|
||||
OnBootSec=3min
|
||||
OnUnitActiveSec=5min
|
||||
AccuracySec=30s
|
||||
Unit=adc-sysvol-sync.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
491
overlay/usr/local/sbin/adc-maas-init
Executable file
491
overlay/usr/local/sbin/adc-maas-init
Executable file
@@ -0,0 +1,491 @@
|
||||
#!/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/<stage>.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 <<EOF
|
||||
# Managed by adc-maas-init.
|
||||
ntpsigndsocket /var/lib/samba/ntp_signd
|
||||
EOF
|
||||
if [ -n "$AD_NTP_ALLOW" ]; then
|
||||
echo "allow ${AD_NTP_ALLOW}" >> /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 <<EOF
|
||||
# Managed by adc-maas-init.
|
||||
SYSVOL_SOURCE=${source}
|
||||
EOF
|
||||
sed -i "s|^OnUnitActiveSec=.*|OnUnitActiveSec=${AD_SYSVOL_INTERVAL}|" \
|
||||
/etc/systemd/system/adc-sysvol-sync.timer 2>/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'<removed>'/" "$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 "$@"
|
||||
72
overlay/usr/local/sbin/adc-sysvol-sync
Executable file
72
overlay/usr/local/sbin/adc-sysvol-sync
Executable file
@@ -0,0 +1,72 @@
|
||||
#!/bin/bash
|
||||
# Copyright (C) 2026 Ilker Manap
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
#
|
||||
# adc-sysvol-sync - pulls SYSVOL from another domain controller.
|
||||
#
|
||||
# Samba implements neither DFS-R nor FRS, so the SYSVOL share — which holds
|
||||
# Group Policy objects and logon scripts — does not replicate between domain
|
||||
# controllers by itself. Without something like this, a policy created on one DC
|
||||
# is invisible to clients that authenticate against another, and the domain
|
||||
# silently behaves differently depending on which DC a client happened to reach.
|
||||
#
|
||||
# This is the rsync approach from the Samba wiki: pull from the DC holding the
|
||||
# PDC Emulator FSMO role, then reset the POSIX ACLs from what is stored in AD.
|
||||
#
|
||||
# Configuration: /etc/adc-maas/sysvol-sync.conf (SYSVOL_SOURCE=<dc fqdn or ip>)
|
||||
# Authentication: root SSH key to the source DC. Provide one at deploy time;
|
||||
# without it this exits with a clear message rather than failing silently.
|
||||
set -uo pipefail
|
||||
|
||||
CONF=/etc/adc-maas/sysvol-sync.conf
|
||||
SYSVOL=/var/lib/samba/sysvol
|
||||
LOG_TAG=adc-sysvol-sync
|
||||
|
||||
log() { echo "[$LOG_TAG] $*"; logger -t "$LOG_TAG" -- "$*" 2>/dev/null || true; }
|
||||
warn() { echo "[$LOG_TAG] WARN: $*" >&2; logger -t "$LOG_TAG" -p user.warning -- "$*" 2>/dev/null || true; }
|
||||
|
||||
[ -r "$CONF" ] || { log "no $CONF, nothing to do"; exit 0; }
|
||||
# shellcheck disable=SC1090
|
||||
. "$CONF"
|
||||
: "${SYSVOL_SOURCE:?SYSVOL_SOURCE not set in $CONF}"
|
||||
|
||||
[ -d "$SYSVOL" ] || { warn "$SYSVOL missing — is this host a DC?"; exit 1; }
|
||||
|
||||
SSH_OPTS="-o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10"
|
||||
if ! ssh $SSH_OPTS "root@${SYSVOL_SOURCE}" true 2>/dev/null; then
|
||||
warn "cannot reach root@${SYSVOL_SOURCE} over SSH — SYSVOL is NOT replicating"
|
||||
warn "install a root SSH key on this host that is authorised on ${SYSVOL_SOURCE},"
|
||||
warn "or replicate SYSVOL by some other means. Group Policy will diverge until then."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# idmap.ldb decides which POSIX uid/gid each SID maps to. If it differs between
|
||||
# DCs, the same file shows different ownership depending on which DC serves it.
|
||||
# The Samba wiki calls syncing it a prerequisite, not an optimisation.
|
||||
if [ ! -e /var/lib/adc-maas/idmap-synced ]; then
|
||||
log "syncing idmap.ldb from ${SYSVOL_SOURCE} (one time)"
|
||||
if rsync -a -e "ssh $SSH_OPTS" \
|
||||
"root@${SYSVOL_SOURCE}:/var/lib/samba/private/idmap.ldb" \
|
||||
/var/lib/samba/private/idmap.ldb; then
|
||||
mkdir -p /var/lib/adc-maas && date -Is > /var/lib/adc-maas/idmap-synced
|
||||
systemctl restart samba-ad-dc >/dev/null 2>&1 || true
|
||||
else
|
||||
warn "idmap.ldb sync failed; uid/gid mappings may differ between DCs"
|
||||
fi
|
||||
fi
|
||||
|
||||
log "pulling SYSVOL from ${SYSVOL_SOURCE}"
|
||||
if rsync -aAX --delete -e "ssh $SSH_OPTS" \
|
||||
"root@${SYSVOL_SOURCE}:${SYSVOL}/" "${SYSVOL}/"; then
|
||||
# rsync carries POSIX bits; the Windows ACLs come from AD and have to be
|
||||
# reapplied afterwards or clients get access-denied on Group Policy.
|
||||
if samba-tool ntacl sysvolreset >/dev/null 2>&1; then
|
||||
log "SYSVOL synced and ACLs reset"
|
||||
else
|
||||
warn "sysvolreset failed — clients may be denied access to Group Policy"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
warn "rsync failed"
|
||||
exit 1
|
||||
fi
|
||||
Reference in New Issue
Block a user