First Commit

This commit is contained in:
Lonny Lowe 2026-09-24 11:29:54 -05:00
commit 2e5a4751a7
30 changed files with 2517 additions and 0 deletions

51
scripts/add-replica.sh Executable file
View file

@ -0,0 +1,51 @@
#!/usr/bin/env bash
# =====================================================================================
# Add a second domain controller (replica) for availability.
#
# This runs the `replica` compose profile, which needs its own hostname/IP and the join
# credentials of an existing DC. Because the profile uses host networking it is meant to
# run on a SECOND MACHINE that has this repo synced to it (otherwise two containers fight
# over UDP/TCP 53, 88, 445...). For one machine, use macvlan instead.
#
# Required in .env on the replica machine:
# AD_DNS_DOMAIN / AD_REALM = the existing domain
# AD_HOSTNAME2=dc2
# AD_IP=<this machine's LAN ip>
# AD_JOIN_SERVER=dc1.ad.example.com
# AD_JOIN_USER=administrator
# AD_JOIN_PASSWORD (or secrets/join.password)
# =====================================================================================
set -euo pipefail
cd "$(dirname "$0")/.."
. "$(dirname "$0")/envfile.sh"; ad_env_load .env # parsed, never executed (see envfile.sh)
[ -n "${AD_DNS_DOMAIN:-}" ] || { echo "set AD_DNS_DOMAIN to the existing domain in .env" >&2; exit 2; }
[ -n "${AD_HOSTNAME2:-}" ] || { echo "set AD_HOSTNAME2 (e.g. dc2) in .env" >&2; exit 2; }
[ -n "${AD_IP:-}" ] || { echo "set AD_IP to the reachable LAN address of this machine" >&2; exit 2; }
[ -n "${AD_JOIN_SERVER:-}" ] || { echo "set AD_JOIN_SERVER (existing DC FQDN) in .env" >&2; exit 2; }
echo ">> replica: ${AD_HOSTNAME2}.${AD_DNS_DOMAIN} joining via ${AD_JOIN_SERVER}"
# the joining DC must be able to resolve the existing DC before its own DNS is trustworthy
if ! getent hosts "$AD_JOIN_SERVER" >/dev/null 2>&1; then
echo "!! ${AD_JOIN_SERVER} does not resolve from this host."
echo " add AD_EXTRA_HOSTS=\"<ip> ${AD_JOIN_SERVER} ${AD_JOIN_SERVER%%.*}\" to .env"
exit 1
fi
# sanity: realm must be reachable over Kerberos before we attempt the join
if command -v kinit >/dev/null 2>&1; then
echo " (tip: pre-auth once with 'kinit administrator@${AD_REALM:-}' to catch realm/ clock problems early)"
fi
docker compose --profile replica up -d ad-dc2
sleep 20
AD_CONTAINER="${AD_CONTAINER2:-ad-dc2}" ./scripts/verify.sh || true
echo
echo "Post-join checklist"
echo " 1. DNS: point clients at BOTH DCs (DHCP option 006 = dc1,dc2)"
echo " 2. docker exec ${AD_CONTAINER2:-ad-dc2} samba-tool drs showrepl -> no failing inbound/outbound"
echo " 3. docker exec ${AD_CONTAINER2:-ad-dc2} samba-tool ntacl sysvolcheck"
echo " 4. from a joined Windows host: nltest /dsgetdc:${AD_DNS_DOMAIN^^} (should list both DCs)"
echo " 5. time sources: both DCs must be NTP-synced from outside; clients follow the PDC emulator"

74
scripts/backup.sh Executable file
View file

@ -0,0 +1,74 @@
#!/usr/bin/env bash
# =====================================================================================
# Online full-domain backup (AD database + SYSVOL + secrets) via `samba-tool domain backup`.
# Copied out of the container into ./backups/<timestamp>.tgz and checksummed.
#
# ./scripts/backup.sh # uses $AD_CONTAINER / .env
# KEEP=14 ./scripts/backup.sh # prune to the newest 14 backups
#
# A backup you have not restored is a rumour. Restore drill: ./scripts/restore.sh
# =====================================================================================
set -euo pipefail
cd "$(dirname "$0")/.."
. "$(dirname "$0")/envfile.sh"; ad_env_load .env # parsed, never executed (see envfile.sh)
CID="${AD_CONTAINER:-ad-dc}"
REALM="${AD_REALM:?}"
HOST="${AD_HOSTNAME:-dc1}"
ZONE="${AD_DNS_DOMAIN:?}"
FQDN="${HOST}.${ZONE}"
CC=/tmp/backup_ccache
DEST="${AD_BACKUP_DIR:-backups}"
KEEP="${KEEP:-14}"
PW="${AD_ADMIN_PASSWORD:-}"
[ -z "$PW" ] && [ -r secrets/ad_admin.password ] && PW="$(tr -d '\r\n' < secrets/ad_admin.password)"
[ -n "$PW" ] || { echo "no administrator password available" >&2; exit 2; }
docker inspect -f '{{.State.Status}}' "$CID" | grep -q running || { echo "$CID is not running" >&2; exit 1; }
mkdir -p "$DEST"
# Preflight: a backup that dies after 10 minutes of work is worse than one that never starts.
# Docker creates bind-mount source dirs as root, and `docker cp` extracts to the host as the
# *invoking* user - so a root-owned backups/ fails with a cryptic "openat: permission denied".
if [ ! -w "$DEST" ]; then
echo "!! backup dir '$DEST' is not writable by $(whoami) (owned $(stat -c '%U:%G %a' "$DEST"))" >&2
echo " typically created root-owned by Docker for a bind mount. Fix with:" >&2
echo " sudo chown $(whoami) \"$DEST\"" >&2
exit 1
fi
ts="$(date -u +%Y%m%dT%H%M%SZ)"
echo ">> backing up ${FQDN} (online, consistent snapshot) -> ${DEST}/"
printf '%s\n' "$PW" | docker exec -i -e KRB5CCNAME="FILE:${CC}" -e AD_FQDN="$FQDN" "$CID" \
bash -euo pipefail -c '
kinit "${AD_ADMIN_USER:-administrator}@${AD_REALM}"
samba-tool domain backup online \
--server="$AD_FQDN" \
--targetdir=/var/backups/ad-dc \
--use-krb5-ccache="$KRB5CCNAME"
'
# Samba names the archive samba-backup-<domain>-<ts>.tar.bz2 (older builds: .tgz)
newest="$(docker exec "$CID" bash -c 'ls -1t /var/backups/ad-dc/*.tar.bz2 /var/backups/ad-dc/*.tgz 2>/dev/null | head -1')"
[ -n "$newest" ] || { echo "backup produced no archive" >&2; exit 1; }
docker cp "${CID}:${newest}" "${DEST}/${ts}-$(basename "$newest")"
out="${DEST}/${ts}-$(basename "$newest")"
( cd "$DEST" && sha256sum "$(basename "$out")" > "$(basename "$out").sha256" )
# Don't leave archives inside the container layer - they survive restarts and silently grow it.
docker exec "$CID" rm -f "$newest" 2>/dev/null || true
size="$(du -h "$out" | cut -f1)"
echo ">> wrote $out ($size)"
echo " checksum: $(cat "$out.sha256")"
# rotation
mapfile -t existing < <(ls -1t "$DEST"/*.tar.bz2 "$DEST"/*.tgz 2>/dev/null || true)
if [ "${#existing[@]}" -gt "$KEEP" ]; then
for old in "${existing[@]:$KEEP}"; do
echo " pruning $old"
rm -f "$old" "$old.sha256"
done
fi
echo ">> done. Copy $out off this host (backup of a backup) and keep the AD_DB key safe."

42
scripts/envfile.sh Normal file
View file

@ -0,0 +1,42 @@
#!/bin/sh
# =====================================================================================
# Safe .env reader for the host-side scripts.
#
# Do NOT `. ./.env`. A file that is perfectly valid for docker-compose is NOT valid shell:
# AD_DNS_FORWARDER=1.1.1.1 8.8.8.8 # compose: fine. shell: runs "8.8.8.8"!
# That exact thing silently broke a backup run. So we parse the file instead of executing it.
#
# Behaviour:
# * KEY=VALUE, first '=' splits; the value keeps any later '=' (LDAP URIs, passwords)
# * surrounding quotes are stripped; inline " # ..." is dropped on unquoted values
# * full-line comments and blanks are ignored
# * an existing environment variable WINS, so tests/CI can override:
# AD_ADMIN_PASSWORD=x ./scripts/backup.sh
#
# Usage (bash/dash): . scripts/envfile.sh && ad_env_load .env
# =====================================================================================
ad_env_load() {
_f="${1:-.env}"
[ -f "$_f" ] || return 0
while IFS='=' read -r _k _v || [ -n "$_k" ]; do
case "$_k" in ''|[\ \#]*) continue ;; esac
# key: strip whitespace/CR
_k="$(printf '%s' "$_k" | tr -d '\r' | sed 's/[[:space:]]*$//')"
case "$_k" in ''|\#*) continue ;; esac
# value: trim, strip one layer of quotes, or drop an inline comment
_v="${_v%$'\r'}"
_v="${_v#"${_v%%[![:space:]]*}"}"
case "$_v" in
\"*\") _v="${_v#\"}"; _v="${_v%\"}" ;;
\'*\') _v="${_v#\'}"; _v="${_v%\'}" ;;
*) _v="${_v%%[[:space:]]#*}" ;;
esac
_v="${_v%"${_v##*[![:space:]]}"}" # rtrim
# don't clobber a value already provided in the environment
if [ -z "$(printenv "$_k" 2>/dev/null)" ]; then
export "$_k=$_v"
fi
done < "$_f"
unset _f _k _v
}

48
scripts/mksecrets.sh Executable file
View file

@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Create the secrets directory with a strong random administrator password (or keep your own).
# ./scripts/mksecrets.sh -> generates secrets/ad_admin.password (0600)
# ./scripts/mksecrets.sh 'mypw' -> stores the given password
set -euo pipefail
cd "$(dirname "$0")/.."
umask 077
mkdir -p secrets
# Same trap as backup.sh: Docker may have created this dir root-owned for a bind mount.
if [ ! -w secrets ]; then
echo "!! 'secrets' is not writable by $(whoami) (owned $(stat -c '%U:%G %a' secrets)) - fix: sudo chown $(whoami) secrets" >&2
exit 1
fi
F=secrets/ad_admin.password
if [ -e "$F" ] && [ -z "${1:-}" ]; then
echo "refusing to overwrite existing $F" >&2
echo "use 'make passwd' to rotate, or delete it first" >&2
exit 1
fi
if [ -n "${1:-}" ]; then
printf '%s' "$1" > "$F"
else
# 24 chars, upper+lower+digit+symbol -> comfortably clears AD complexity.
# Deliberately no `tr ... | head -c N`: under `set -o pipefail` the SIGPIPE that kills
# `tr` when head exits makes the whole pipeline fail (exit 141). One byte per dd instead.
gen_class() { # $1 = char set, $2 = how many chars
s=""
while [ "${#s}" -lt "$2" ]; do
c="$(dd if=/dev/urandom bs=1 count=1 2>/dev/null | tr -dc "$1")"
[ -n "$c" ] && s="${s}${c}"
done
printf '%s' "$s"
}
pw="$(gen_class 'A-HJ-NP-Za-km-z2-9' 6)"
pw+="$(gen_class 'A-Z' 3)"
pw+="$(gen_class 'a-z' 8)"
pw+="$(gen_class '#@%!$^&*' 3)"
printf '%s' "$pw" > "$F"
fi
chmod 600 "$F"
echo "wrote $F (mode $(stat -c '%a' "$F"))"
echo " password length: $(tr -d '\n' < "$F" | wc -c)"
echo " store it in your password vault now; nothing else prints it again."
echo " AD also needs an admin UPN; login as ${AD_REALM:-AD.EXAMPLE.COM}\\${AD_ADMIN_USER:-administrator}"

114
scripts/restore.sh Executable file
View file

@ -0,0 +1,114 @@
#!/usr/bin/env bash
# =====================================================================================
# Disaster recovery: bring the domain back from an online backup archive.
#
# ./scripts/restore.sh backups/<archive>.tar.bz2 <new-dc-hostname> [new-ip]
#
# HOW SAMBA RESTORE ACTUALLY WORKS (this surprises people)
# `samba-tool domain backup restore` performs a *join*: it unpacks the database into a
# target dir and then ADDS the target host as a NEW domain controller. Restoring under the
# same server name that the archive was taken from fails with
# "Entry CN=<NAME>,OU=Domain Controllers,... already exists"
# So: pick a NEW hostname (and an IP nothing else is using). The old DC name stays in DNS
# until you clean it up; point clients at the new DC.
#
# Sequence: backup .env -> stop live DC -> move data dir aside (never deleted) ->
# restore into fresh tree -> rewrite .env identity -> up -> verify
# =====================================================================================
set -uo pipefail
cd "$(dirname "$0")/.."
[ -f .env ] || { echo "no .env in $(pwd) - restore expects the production env file" >&2; exit 2; }
. "$(dirname "$0")/envfile.sh"; ad_env_load .env # parsed, never executed (see envfile.sh)
BACKUP="${1:-}"
NEW_HOST="${2:-${AD_RESTORE_AS:-}}"
NEW_IP="${3:-${AD_RESTORE_IP:-$AD_IP}}"
[ -n "$BACKUP" ] && [ -r "$BACKUP" ] || { echo "usage: $0 <backup.tar.bz2> <new-dc-hostname> [new-ip]" >&2; exit 2; }
[ -n "$NEW_HOST" ] || { echo "A Samba restore lands as a NEW domain controller. Give it a hostname, e.g. dc3" >&2; exit 2; }
if [ "${NEW_HOST,,}" = "$(tar -f "$BACKUP" -O ./backup.txt 2>/dev/null | sed -n "s/.*using DC '\([^']*\)'.*/\1/p" | head -1 | cut -d. -f1 | tr 'A-Z' 'a-z')" ]; then
echo "refusing: '$NEW_HOST' is the hostname the backup was taken from" >&2
exit 2
fi
src_dc="$(tar -f "$BACKUP" -O ./backup.txt 2>/dev/null | sed -n "s/.*using DC '\([^']*\)'.*/\1/p" | head -1)"
echo "archive: $(basename "$BACKUP")"
echo "from DC: ${src_dc:-unknown}"
echo "restore as: ${NEW_HOST}.${AD_DNS_DOMAIN} @ ${NEW_IP}"
read -r -p "Type RESTORE to proceed: " confirm
[ "$confirm" = "RESTORE" ] || { echo "aborted"; exit 1; }
if [ -f "${BACKUP}.sha256" ] && ( cd "$(dirname "$BACKUP")" && sha256sum -c "$(basename "$BACKUP").sha256" ); then
echo ">> archive checksum OK"
else
echo ">> no checksum (or mismatch) - continuing, you were warned"
fi
ts="$(date -u +%Y%m%dT%H%M%SZ)"
cid="${AD_CONTAINER:-ad-dc}"
echo ">> stopping live DC"
docker compose stop ad-dc || true
echo ">> preserving current data/config so nothing is destroyed"
[ -f .env ] && cp -a .env ".env.pre-restore-${ts}"
if [ -d data/samba ] && [ -n "$(ls -A data/samba 2>/dev/null || true)" ]; then
mv data/samba "data/samba.pre-restore-${ts}"
echo " data/samba -> data/samba.pre-restore-${ts}"
fi
mkdir -p data/samba
echo ">> rewriting identity in .env (AD_HOSTNAME=${NEW_HOST}, AD_IP=${NEW_IP})"
cp -a .env .env.tmp
sed -i "s/^AD_HOSTNAME=.*/AD_HOSTNAME=${NEW_HOST}/; s/^AD_IP=.*/AD_IP=${NEW_IP}/" .env.tmp
mv .env.tmp .env
echo ">> restoring into the fresh tree (one-shot container; does not start the DC)"
docker run --rm \
--name "ad-restore-${ts}" \
--cap-drop ALL \
--cap-add SYS_ADMIN --cap-add CHOWN --cap-add FOWNER --cap-add DAC_OVERRIDE \
--cap-add SETUID --cap-add SETGID --cap-add NET_BIND_SERVICE \
-v "$PWD/$(dirname "$BACKUP"):/restore:ro" \
-v "$PWD/data/samba:/var/lib/samba" \
-v "$PWD/conf:/etc/samba" \
-v /etc/localtime:/etc/localtime:ro \
--env-file .env \
-e "AD_RESTORE_FROM=/restore/$(basename "$BACKUP")" \
-e AD_RESTORE_CONFIRM=yes \
"${AD_RESTORE_IMAGE:-local/samba-ad-dc:4.22-debian13}" || {
echo "!! restore failed - roll back with: cp .env.pre-restore-${ts} .env" >&2
exit 1
}
echo ">> starting the restored DC"
docker compose up -d ad-dc
echo ">> waiting for health (max 200s)"
h=none
for _ in $(seq 1 50); do
h="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$cid" 2>/dev/null || echo none)"
[ "$h" = healthy ] && break
sleep 4
done
echo " health: $h"
AD_CONTAINER="$cid" ./scripts/verify.sh || {
echo "verification failed. Roll back:"
echo " docker compose down"
echo " cp .env.pre-restore-${ts} .env"
echo " rm -rf data/samba && mv data/samba.pre-restore-${ts} data/samba"
exit 1
}
cat <<EOF
Restored as ${NEW_HOST}.${AD_DNS_DOMAIN} (${NEW_IP}).
Remaining manual steps:
1. DHCP/DNS: hand clients the new DC address (${NEW_IP}) - option 006.
2. If the old DC object (${src_dc:-unknown}) is still in the directory and will never
come back, remove its stale computer/DNS objects so clients stop trying to reach it:
docker exec -it $cid samba-tool dns serverdelete 127.0.0.1 <old-fqdn> -U administrator
docker exec -it $cid ldbdel -H /var/lib/samba/private/sam.ldb "<old computer DN>" # careful
3. Take a fresh backup immediately: make backup
EOF

130
scripts/tls.sh Executable file
View file

@ -0,0 +1,130 @@
#!/usr/bin/env bash
# =====================================================================================
# LDAPS certificates for the domain controller.
#
# Windows Schannel requires a SubjectAltName (CN-only certs are rejected), and it must be
# DNS=<dc fqdn> matching what the client dials. Samba's built-in auto-generated cert is
# self-signed and untrusted -> Windows shows trust errors and refusing it breaks LDAPS.
#
# This builds a throwaway-but-proper internal CA + a DC leaf cert:
# secrets/tls/ca.crt import into Windows "Trusted Root Certification Authorities"
# (best shipped via GPO so every joined host trusts it)
# secrets/tls/dc.crt served by Samba on 636
# secrets/tls/dc.key 0600, never leaves the host
#
# For anything long-lived/external prefer ACME DNS-01 against the DC's own DNS
# (nsupdate -g) or your corporate PKI instead of this CA.
# =====================================================================================
set -euo pipefail
cd "$(dirname "$0")/.."
. "$(dirname "$0")/envfile.sh"; ad_env_load .env # parsed, never executed (see envfile.sh)
HOST="${AD_HOSTNAME:-dc1}"
ZONE="${AD_DNS_DOMAIN:-ad.example.com}"
FQDN="${HOST}.${ZONE}"
D="${AD_TLS_DIR:-secrets/tls}"
DAYS_CA="${AD_TLS_CA_DAYS:-3650}"
DAYS_LEAF="${AD_TLS_LEAF_DAYS:-825}"
umask 077
mkdir -p "$D"
if [ -f "$D/ca.key" ] && [ "${1:-}" != "--rotate-leaf-only" ]; then
echo "CA already exists at $D/ca.key (leaf will be re-issued against it)."
fi
# ---- root CA (only if missing) ------------------------------------------------------
if [ ! -f "$D/ca.key" ]; then
echo ">> generating internal root CA for ${FQDN}"
openssl req -x509 -newkey rsa:4096 -sha256 -days "$DAYS_CA" -nodes \
-keyout "$D/ca.key" -out "$D/ca.crt" \
-subj "/C=US/O=${ZONE} /CN=${ZONE} Root CA" \
-addext "basicConstraints=critical,CA:TRUE,pathlen:1" \
-addext "keyUsage=critical,keyCertSign,cRLSign"
chmod 600 "$D/ca.key"
fi
# ---- leaf ------------------------------------------------------------------------
echo ">> issuing DC leaf: ${FQDN} (+ ${HOST})"
# SAN must cover every name/address a client may dial. Windows (Schannel) requires a SAN - a
# CN-only cert is rejected outright - and tools that dial the DC by address need an IP SAN or
# they fail hostname verification even though the chain is fine.
SAN="DNS:${FQDN},DNS:${HOST}"
[ -n "${AD_IP:-}" ] && SAN="${SAN},IP:${AD_IP}"
[ -n "${AD_IP6:-}" ] && SAN="${SAN},IP:${AD_IP6}"
[ -n "${AD_TLS_SAN_EXTRA:-}" ] && SAN="${SAN},${AD_TLS_SAN_EXTRA}"
SAN="$(printf '%s' "$SAN" | tr ',' '\n' | awk '!seen[$0]++' | paste -sd, -)" # dedupe
openssl req -newkey rsa:2048 -sha256 -nodes \
-keyout "$D/dc.key" -out "$D/dc.csr" \
-subj "/C=US/O=${ZONE}/CN=${FQDN}"
openssl x509 -req -in "$D/dc.csr" -CA "$D/ca.crt" -CAkey "$D/ca.key" \
-CAcreateserial -days "$DAYS_LEAF" -sha256 \
-out "$D/dc.crt" \
-extfile <(printf '%s\n' \
"subjectAltName=${SAN}" \
"extendedKeyUsage=serverAuth" \
"keyUsage=critical,digitalSignature,keyEncipherment" \
"basicConstraints=critical,CA:FALSE")
chmod 600 "$D/dc.key"
rm -f "$D/dc.csr"
openssl verify -CAfile "$D/ca.crt" "$D/dc.crt"
echo ">> SAN of issued cert:"
openssl x509 -in "$D/dc.crt" -noout -ext subjectAltName | sed 's/^/ /'
# ---- publish the CA where joined machines can fetch it. NETLOGON is read-only and
# world-readable by design, which is exactly why Windows ships its own CA there too.
# Re-checked with sysvolcheck afterwards: if the extra file upsets the NT ACLs we undo it
# rather than leave SYSVOL inconsistent.
# AD_TLS_PUBLISH=no skips this entirely - MANDATORY for lab/test certs, otherwise a test run
# would push a throwaway CA into the production DC's SYSVOL.
CID="${AD_CONTAINER:-ad-dc}"
if [ "${AD_TLS_PUBLISH:-yes}" != "yes" ]; then
echo ">> CA publication skipped (AD_TLS_PUBLISH=no)"
elif [ "$(docker inspect -f '{{.State.Running}}' "$CID" 2>/dev/null || echo false)" = "true" ]; then
NETLOGON_DIR="/var/lib/samba/sysvol/${ZONE}/scripts"
if docker exec "$CID" test -d "$NETLOGON_DIR" 2>/dev/null; then
if docker exec "$CID" cp /run/ad-secrets/tls/ca.crt "$NETLOGON_DIR/ad-root-ca.crt" 2>/dev/null \
|| docker cp "$D/ca.crt" "${CID}:${NETLOGON_DIR}/ad-root-ca.crt" 2>/dev/null; then
docker exec "$CID" chmod 644 "$NETLOGON_DIR/ad-root-ca.crt" 2>/dev/null || true
if docker exec "$CID" samba-tool ntacl sysvolcheck >/dev/null 2>&1; then
echo ">> CA published for clients: \\\\${ZONE}\\NETLOGON\\ad-root-ca.crt"
else
docker exec "$CID" rm -f "$NETLOGON_DIR/ad-root-ca.crt" 2>/dev/null || true
echo ">> CA publication rolled back (it broke the SYSVOL ACL check) - distribute ca.crt manually"
fi
fi
else
echo ">> no NETLOGON scripts dir in ${CID}; distribute ca.crt manually"
fi
else
echo ">> ${CID} is not running; skipping CA publication (run this again after 'make up')"
fi
echo ">> CA SHA-256 fingerprint (verify out-of-band before trusting):"
openssl x509 -in "$D/ca.crt" -noout -fingerprint -sha256 | sed 's/^/ /'
cat <<EOF
Next steps
1) .env:
AD_TLS_CERT=/run/ad-secrets/tls/dc.crt
AD_TLS_KEY=/run/ad-secrets/tls/dc.key
AD_TLS_CA=/run/ad-secrets/tls/ca.crt
2) docker compose up -d --force-recreate ad-dc
3) verify : openssl s_client -connect ${FQDN}:636 -CAfile $D/ca.crt </dev/null
4) trust it on Windows 11 (admin PowerShell). This is the step that removes the popups -
a joined machine that trusts the issuing CA sees a perfectly normal certificate:
certutil -addstore -f ROOT "C:\\path\\to\\ca.crt"
or straight from the DC (needs any domain credential):
certutil -addstore -f ROOT "\\\\${ZONE}\\NETLOGON\\ad-root-ca.crt"
IMPORTANT: install into the LOCAL COMPUTER store (certutil -addstore ROOT, elevated),
not the current-user store. LDAP/Schannel binds run as SYSTEM/service accounts and will
not see a per-user trust.
For a fleet, publish ca.crt through GPO: Computer Configuration > Policies >
Windows Settings > Security Settings > Public Key Policies > Trusted Root CA.
5) BACK UP $D/ca.key OFFLINE. It is the trust anchor: lose it and every cert signed by it
must be reissued and re-trusted; leak it and someone can mint certs your clients trust.
EOF

216
scripts/verify.sh Executable file
View file

@ -0,0 +1,216 @@
#!/usr/bin/env bash
# =====================================================================================
# Post-deployment verification battery for the AD DC container.
# Runs everything through `docker exec` inside the running container so the checks hit
# the same code paths a client would, without needing host-level AD tooling.
#
# ./scripts/verify.sh # standard checks
# ./scripts/verify.sh --full # + ldb consistency check (slow, DB heavy)
# =====================================================================================
set -uo pipefail
cd "$(dirname "$0")/.."
. "$(dirname "$0")/envfile.sh"; ad_env_load .env # parsed, never executed (see envfile.sh)
FULL=0; [ "${1:-}" = "--full" ] && FULL=1
CID="${AD_CONTAINER:-ad-dc}"
cenv(){ docker exec "$CID" printenv "$1" 2>/dev/null | head -n1; } # fall back to container env
REALM="${AD_REALM:-$(cenv AD_REALM)}"
ZONE="${AD_DNS_DOMAIN:-${REALM,,}}"
HOST="${AD_HOSTNAME:-$(cenv AD_HOSTNAME)}"
[ -n "$REALM" ] || { echo "AD_REALM unknown (set it, or run against a container that has it)" >&2; exit 2; }
[ -n "$ZONE" ] || { echo "AD_DNS_DOMAIN unknown" >&2; exit 2; }
FQDN="${HOST}.${ZONE}"
BASEDN="DC=$(printf '%s' "$ZONE" | sed 's/\./,DC=/g')"
CC=/tmp/verify_ccache
PW="${AD_ADMIN_PASSWORD:-}"
if [ -z "$PW" ]; then
for f in secrets/ad_admin.password /run/ad-secrets/ad_admin.password; do
[ -r "$f" ] && { PW="$(tr -d '\r\n' < "$f")"; break; }
done
fi
[ -n "$PW" ] || PW="$(cenv AD_ADMIN_PASSWORD)"
[ -n "$PW" ] || { echo "cannot find administrator password (AD_ADMIN_PASSWORD or secrets/ad_admin.password)" >&2; exit 2; }
pass=0; fail=0; skip=0
ok() { printf ' \033[32m✔\033[0m %s\n' "$1"; pass=$((pass+1)); }
bad() { printf ' \033[31m✘\033[0m %s\n' "$1"; [ -n "${2:-}" ] && printf ' %s\n' "$(printf '%s' "$2" | tail -4 | tr '\n' '|')"; fail=$((fail+1)); }
skp() { printf ' \033[33m○\033[0m %s\n' "$1"; skip=$((skip+1)); }
dexec(){ docker exec -i -e "KRB5CCNAME=FILE:${CC}" "$CID" "$@" 2>&1; }
# expect a non-zero exit (used for "this must be denied" checks)
dexec_expect_denied(){ ! docker exec -i "$CID" "$@" >/dev/null 2>&1; }
printf '\n\033[1mVerifying %s (realm %s, container %s)\033[0m\n' "$FQDN" "$REALM" "$CID"
echo "----------------------------------------------------------------"
# --- 0. container / state ----------------------------------------------------------
st="$(docker inspect -f '{{.State.Status}}' "$CID" 2>/dev/null || echo missing)"
[ "$st" = running ] && ok "container running" || { bad "container not running (state: $st)"; echo; exit 1; }
health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$CID")"
case "$health" in
healthy) ok "docker health: healthy" ;;
starting) skp "docker health: starting (re-run in ~60s)" ;;
*) bad "docker health: $health" "$(docker inspect -f '{{range .State.Health.Log}}{{.Output}}{{end}}' "$CID" | tail -5)" ;;
esac
dexec test -f /var/lib/samba/private/sam.ldb && ok "AD database present (sam.ldb)" \
|| { bad "no AD database - provision did not complete"; echo; exit 1; }
# --- 1. DNS: SRV bootstrap records Windows depends on ------------------------------
# SRV records a Windows 11 client actually needs for domain/DC location. Verified against
# this Samba build. Samba does NOT register the NT4-era `_pdc._tcp` under the domain, the
# `_gc._tcp.dc._msdcs` variant, or MIT-style `_kerberos-adm._tcp` - clients don't ask.
for rec in _ldap._tcp.dc._msdcs _kerberos._tcp.dc._msdcs _ldap._tcp.gc._msdcs \
_ldap._tcp.pdc._msdcs _gc._tcp _ldap._tcp _kerberos._udp _kerberos._tcp \
_kpasswd._udp _kpasswd._tcp; do
out="$(dexec dig +time=3 +tries=1 +short @127.0.0.1 "${rec}.${ZONE}" SRV)"
if [ -n "$out" ]; then
printf ' \033[32m✔\033[0m SRV %-34s -> %s\n' "$rec" "$(printf '%s' "$out" | head -1 | tr -s ' ')"
pass=$((pass+1))
else
printf ' \033[31m✘\033[0m SRV %-34s -> (empty)\n' "$rec"
fail=$((fail+1))
fi
done
a_rec="$(dexec dig +time=3 +tries=1 +short @127.0.0.1 "$FQDN" A)"
[ -n "$a_rec" ] && ok "A record ${FQDN} -> $(echo "$a_rec" | tr '\n' ' ')" || bad "A record for ${FQDN} missing"
# Regression guard: a multi-homed Docker host (docker0/bridges/stale DHCP) can leak
# unreachable addresses into the DC's A record, and clients that pick one of those fail in
# ways that look like Kerberos bugs. The recordset must be exactly what the DC advertises.
# The container's own AD_IP is the source of truth - the host .env may describe a different
# DC (lab container, replica), and AD_IP_EXPECT overrides explicitly.
EXPECT_IP="${AD_IP_EXPECT:-$(cenv AD_IP)}"
[ -n "$EXPECT_IP" ] || EXPECT_IP="${AD_IP:-}"
if [ -z "$EXPECT_IP" ]; then
skp "A recordset pin check skipped (no AD_IP known)"
else
extra_ips="$(printf '%s\n' $a_rec | grep -v "^${EXPECT_IP}$" | tr '\n' ' ')"
if [ -n "${extra_ips// /}" ]; then
bad "A recordset for ${FQDN} has non-advertised address(es): ${extra_ips}(only ${EXPECT_IP} may be published)"
else
ok "A recordset is exactly the advertised ${EXPECT_IP} (no Docker bridge / stale addresses)"
fi
fi
# --- 2. Kerberos -----------------------------------------------------------------
out="$(printf '%s\n' "$PW" | dexec kinit "${AD_ADMIN_USER:-administrator}@${REALM}")" \
&& ok "kinit ${AD_ADMIN_USER:-administrator}@${REALM} issued a TGT" \
|| bad "kinit failed" "$out"
out="$(dexec kvno "cifs/${FQDN}")"
if printf '%s' "$out" | grep -q 'kvno = '; then
enc="$(dexec klist | grep -oiE 'aes[0-9]+[a-z0-9-]*' | sort -u | tr '\n' ' ')"
ok "service ticket issued for cifs/${FQDN}${enc:+ (${enc})}"
else
bad "kvno cifs/${FQDN} failed" "$out"
fi
# --- 3. LDAP / GC over GSSAPI ----------------------------------------------------
out="$(dexec ldapsearch -H "ldap://${FQDN}" -Y GSSAPI -b "$BASEDN" '(objectClass=domain)' dn)"
printf '%s' "$out" | grep -q "^dn: ${BASEDN}" && ok "LDAP + GSSAPI bind, domain object found" || bad "LDAP GSSAPI search failed" "$out"
out="$(dexec ldapsearch -H "ldap://${FQDN}:3268" -Y GSSAPI -b '' -s base defaultNamingContext)"
printf '%s' "$out" | grep -q "^defaultNamingContext: ${BASEDN}" && ok "Global Catalog (3268) serving ${BASEDN}" || bad "GC on 3268 failed" "$out"
out="$(dexec ldapsearch -H "ldap://${FQDN}" -Y GSSAPI -b "$BASEDN" '(sAMAccountName=Domain Computers)' dn)"
printf '%s' "$out" | grep -q 'dn: ' && ok "machine-account group readable (join will work)" || bad "cannot read Domain Computers group" "$out"
# --- 4. LDAPS -------------------------------------------------------------------
# NB: the OpenLDAP client reads LDAPTLS_* environment variables (bare TLS_* is the
# ldap.conf keyword, not the env var). Using the wrong name silently leaves TLS_REQCERT at
# its default, and a verification failure then surfaces as the very misleading
# "Can't contact LDAP server (-1)".
# LDAPS status and the CA path are read from the CONTAINER we are verifying, not the host
# .env - a host .env describing the production DC must not make us run LDAPS assertions
# against a lab/replica container that has no TLS configured.
LDAPS_ON="$(cenv AD_TLS_CERT)"
LDAPS_CA="$(cenv AD_TLS_CA)"
[ -n "$LDAPS_CA" ] || LDAPS_CA="/run/ad-secrets/tls/ca.crt"
if [ -n "$LDAPS_ON" ]; then
out="$(dexec openssl s_client -connect "${FQDN}:636" -CAfile "$LDAPS_CA" -verify_return_error </dev/null 2>&1)"
printf '%s' "$out" | grep -q 'Verify return code: 0 (ok)' \
&& ok "LDAPS 636 chain verifies against the CA" \
|| bad "LDAPS verification failed" "$out"
# Real TLS-strict authenticated search: proves chain AND hostname match AND a bind.
out="$(docker exec -e LDAPTLS_CACERT="$LDAPS_CA" -e LDAPTLS_REQCERT=demand "$CID" \
ldapsearch -H "ldaps://${FQDN}" -D "CN=${AD_ADMIN_USER:-administrator},CN=Users,${BASEDN}" \
-w "$PW" -b "$BASEDN" '(objectClass=domainDNS)' dn 2>&1)"
printf '%s' "$out" | grep -q '^dn: ' \
&& ok "LDAPS authenticated search with TLS_REQCERT=demand" \
|| bad "LDAPS authenticated search failed" "$out"
# Negative controls: strict trust must actually be strict, or the whole exercise is theatre.
out="$(docker exec -e LDAPTLS_REQCERT=demand -e LDAPTLS_CACERT=/nonexistent "$CID" \
ldapsearch -H "ldaps://${FQDN}" -x -b "$BASEDN" '(objectClass=domainDNS)' dn 2>&1)"
printf '%s' "$out" | grep -qi "Can't contact LDAP server" \
&& ok "strict client WITHOUT the CA is rejected" \
|| bad "untrusted LDAPS client was NOT rejected (trust is not being enforced)" "$out"
out="$(docker exec -e LDAPTLS_CACERT="$LDAPS_CA" -e LDAPTLS_REQCERT=demand "$CID" \
ldapsearch -H "ldaps://127.0.0.1" -x -b "$BASEDN" '(objectClass=domainDNS)' dn 2>&1)"
printf '%s' "$out" | grep -qi "Can't contact LDAP server" \
&& ok "hostname/SAN mismatch is rejected (dialled 127.0.0.1)" \
|| bad "SAN mismatch NOT rejected - cert accepted for a name it does not own" "$out"
else
skp "LDAPS not configured on ${CID} (set AD_TLS_* and run ./scripts/tls.sh)"
fi
# --- 5. SYSVOL / NETLOGON --------------------------------------------------------
out="$(dexec samba-tool ntacl sysvolcheck)"
printf '%s' "$out" | grep -qi 'ERROR' \
&& bad "SYSVOL ACL drift (fix: samba-tool ntacl sysvolreset)" "$out" \
|| ok "SYSVOL ACLs consistent"
out="$(printf 'username=%s\npassword=%s\n' "${AD_ADMIN_USER:-administrator}" "$PW" \
| dexec smbclient -L //127.0.0.1 -A /dev/stdin -m SMB3)"
printf '%s' "$out" | grep -qi 'NETLOGON' && printf '%s' "$out" | grep -qi 'SYSVOL' \
&& ok "SMB3 lists NETLOGON + SYSVOL (GPO/slogans path)" \
|| bad "NETLOGON/SYSVOL not listed over SMB3" "$out"
# --- 6. Replication / DB ---------------------------------------------------------
out="$(dexec samba-tool drs showrepl)"
if printf '%s' "$out" | grep -q 'DSA object GUID:'; then
failing="$(printf '%s' "$out" | grep -cE 'failing (inbound|outbound)' || true)"
if [ "${failing:-0}" -eq 0 ]; then
ok "DRS healthy (no failing replication requests)"
else
bad "DRS shows ${failing} failing replication request(s)" "$out"
fi
else
bad "DRS did not report the DSA object" "$out"
fi
out="$(dexec samba-tool domain level show)"
printf ' %s\n' "$(printf '%s' "$out" | tr -d '\r' | grep -iE 'function|level' | head -3 | tr '\n' ' ')"
ok "functional levels readable"
if [ "$FULL" = 1 ]; then
echo " .. running ldb consistency check (this can take minutes)"
out="$(dexec samba-tool dbcheck --cross-ncs --yes)"
printf '%s' "$out" | grep -qiE 'ERROR|bad referral' \
&& bad "dbcheck reported errors" "$out" || ok "dbcheck clean"
else
skp "dbcheck skipped (use --full)"
fi
# --- 7. Negative checks: what must NOT be reachable ------------------------------
if dexec_expect_denied ldapsearch -x -H "ldap://127.0.0.1:389" -b "$BASEDN" '(objectClass=user)' dn; then
ok "anonymous LDAP search of user objects denied"
else
bad "anonymous LDAP search of user objects SUCCEEDED (restrict anonymous?)"
fi
if dexec_expect_denied smbclient -L //127.0.0.1 -g -m SMB1; then
ok "SMB1 refused"
else
bad "SMB1 not refused"
fi
echo "----------------------------------------------------------------"
printf '\033[1m%d passed, %d failed, %d skipped\033[0m\n' "$pass" "$fail" "$skip"
[ "$fail" -eq 0 ] || echo "AD NOT READY - fix failures above before joining Windows hosts"
exit "$fail"

84
scripts/win11-join.ps1 Normal file
View file

@ -0,0 +1,84 @@
# =====================================================================================
# Windows 11 client side: DNS -> optional CA trust -> domain join -> verification.
# Run in an elevated PowerShell on the Windows 11 machine.
#
# .\win11-join.ps1 -DnsServer 192.168.5.12 -Domain ad.example.com -CaCert C:\ca.crt
# .\win11-join.ps1 -VerifyOnly
# =====================================================================================
#Requires -RunAsAdministrator
param(
[string]$DnsServer = "192.168.5.12",
[string]$Domain = "ad.example.com",
[string]$DcHost = "dc1.ad.example.com",
[string]$OUPath = "", # e.g. "OU=Wks,DC=ad,DC=example,DC=com"
[string]$CaCert = "", # ca.crt path - local, or straight off the DC:
# \\premier.local\NETLOGON\ad-root-ca.crt
[switch]$VerifyOnly,
[switch]$NoRestart
)
$ErrorActionPreference = 'Stop'
function Step($m) { Write-Host "`n==> $m" -ForegroundColor Cyan }
function Warn($m) { Write-Host " ! $m" -ForegroundColor Yellow }
if ($VerifyOnly) {
Step "Secure channel"
Test-ComputerSecureChannel -Verbose
Step "DC discovery for $Domain"
& nltest /dsgetdc:$Domain
Step "Group policy"
& gpupdate /force
& gpresult /r
Step "Kerberos tickets (look for cifs/$DcHost and HTTP/$DcHost)"
& klist
Step "Last 15 logon / netlogon errors"
Get-WinEvent -FilterHashtable @{ LogName = 'System'; Level = 1,2,3; StartTime = (Get-Date).AddHours(-4) } `
-MaxEvents 15 -ErrorAction SilentlyContinue |
Format-Table TimeCreated, ProviderName, Id, @{n='Msg';e={$_.Message.Substring(0, [Math]::Min(120, $_.Message.Length))}} -AutoSize
return
}
Step "Point DNS at the domain controller ($DnsServer)"
$adapters = Get-NetAdapter | Where-Object { $_.Status -eq 'Up' } | Sort-Object LinkSpeed -Descending
$adapter = $adapters[0]
Set-DnsClientServerAddress -InterfaceIndex $adapter.ifIndex -ServerAddresses ($DnsServer, '8.8.8.8')
Write-Host " adapter: $($adapter.Name) -> $($DnsServer), 8.8.8.8"
ipconfig /flushdns | Out-Null
Step "SRV bootstrap records"
try {
Resolve-DnsName -Name "_ldap._tcp.dc._msdcs.$Domain" -Type SRV -ErrorAction Stop |
Select-Object Name, Strings | Format-Table -AutoSize
} catch {
Warn "no SRV answer for _ldap._tcp.dc._msdcs.$Domain - DNS is not pointing at the DC yet"
throw
}
Step "Connectivity matrix"
foreach ($p in 53, 88, 389, 445, 636, 3268) {
$r = Test-NetConnection -ComputerName $DcHost -Port $p -WarningAction SilentlyContinue
Write-Host (" {0,-5} {1}" -f $p, $(if ($r.TcpTestSucceeded) { 'open' } else { 'CLOSED' }))
}
if ($CaCert -and (Test-Path $CaCert)) {
Step "Importing LDAPS root CA into the machine trust store"
Import-Certificate -FilePath $CaCert -CertStoreLocation 'Cert:\LocalMachine\Root' | Out-Null
Write-Host " imported $CaCert"
} else {
Warn "no -CaCert supplied: LDAPS will present an untrusted certificate"
}
Step "Joining $Domain as $(hostname)"
$cred = Get-Credential -Message "Domain join credentials (use ${Domain.ToUpper()}\administrator)"
$joinArgs = @{ DomainName = $Domain; Credential = $cred }
if ($OUPath) { $joinArgs['OUPath'] = $OUPath }
if ($NoRestart) { $joinArgs['NoRestart'] = $true }
Add-Computer @joinArgs
Write-Host ""
Write-Host "Joined. Reboot now (or run: Restart-Computer -Force) and log in with a domain account." -ForegroundColor Green
Write-Host "Then re-run this script with -VerifyOnly to confirm secure channel + GPO." -ForegroundColor Green