First Commit
This commit is contained in:
commit
2e5a4751a7
30 changed files with 2517 additions and 0 deletions
51
scripts/add-replica.sh
Executable file
51
scripts/add-replica.sh
Executable 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
74
scripts/backup.sh
Executable 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
42
scripts/envfile.sh
Normal 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
48
scripts/mksecrets.sh
Executable 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
114
scripts/restore.sh
Executable 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
130
scripts/tls.sh
Executable 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
216
scripts/verify.sh
Executable 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
84
scripts/win11-join.ps1
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue