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

23
.dockerignore Normal file
View file

@ -0,0 +1,23 @@
# Never bake state or secrets into the image.
# Mirrors .gitignore: anything under data/, conf/, secrets/ or backups/ is runtime state or
# key material. The image must be buildable from image/, nothing else.
data/
conf/
conf-dc2/
secrets/
backups/
*.tgz
*.tar.bz2
*.sha256
*.key
*.crt
*.pem
*.p12
*.pfx
.env
.env.*
.git/
.gitignore
__pycache__/
*.log
*.tmp

71
.env.example Normal file
View file

@ -0,0 +1,71 @@
# =====================================================================================
# Copy to .env and edit. Everything here is consumed by docker compose interpolation
# (hostname/container_name/paths) AND passed into the container environment.
#
# cp .env.example .env && chmod 600 .env
# =====================================================================================
# --- Identity -------------------------------------------------------------------------
# Realm: uppercase, no underscores, must be a DNS domain you control. Use a *sub*domain
# (ad.example.com) - never your registered mail domain root.
# Quoting: docker-compose accepts unquoted multi-word values, but a plain shell does NOT
# (AD_DNS_FORWARDER=1.1.1.1 8.8.8.8 would try to EXECUTE "8.8.8.8"). The scripts here
# parse .env rather than execute it, but please QUOTE any value containing spaces anyway.
AD_REALM=AD.EXAMPLE.COM
AD_DNS_DOMAIN=ad.example.com
# Short container/DC hostname -> FQDN becomes dc1.ad.example.com
AD_HOSTNAME=dc1
# Docker container name (used by scripts/*.sh; override with AD_CONTAINER=... )
AD_CONTAINER=ad-dc
# --- Network -------------------------------------------------------------------------
# The address that lands in the DC's DNS A record. MUST be reachable by domain clients.
# With network_mode: host this is the host's LAN IP. Set it explicitly if the host has
# more than one address or if DHCP may move it (get a DHCP reservation!).
AD_IP=192.168.5.12
# Optional: static hosts injected into /etc/hosts (semicolon separated), useful for
# replicas that must reach the first DC before DNS is trustworthy:
#AD_EXTRA_HOSTS=192.168.5.20 dc1.ad.example.com dc1
#AD_IP6=auto # "auto" = discover a global IPv6; empty = no AAAA record
AD_DNS_FORWARDER="1.1.1.1 8.8.8.8"
AD_MANAGE_RESOLV=yes # yes = container points its own resolver at 127.0.0.1
# --- Directory -----------------------------------------------------------------------
AD_DNS_BACKEND=SAMBA_INTERNAL # SAMBA_INTERNAL | BIND9_DLZ
AD_SITE=Default-First-Site-Name
AD_FUNCTION_LEVEL=2008_R2 # 2000|2003|2008|2008_R2|2016 (Win11 fine at 2008_R2)
AD_BASE_SCHEMA=2019 # 4.22 default; do NOT run Windows adprep against it
AD_USE_RFC2307=yes # posix uid/gid in AD (handy for joined Linux hosts)
AD_ADMIN_USER=administrator
# --- Credentials ---------------------------------------------------------------------
# Preferred: leave AD_ADMIN_PASSWORD empty and put the password in secrets/ad_admin.password
# with mode 600. NOTE: during the *first* provision Samba passes it via argv, so
# `docker top` can see it for a few seconds - rotate the password afterwards if that
# matters to you: docker exec -it ad-dc samba-tool user setpassword administrator
AD_ADMIN_PASSWORD=
# --- Hardening (rendered into smb.conf [global] on every start) ----------------------
AD_APPLY_HARDENING=yes
AD_SMB_SIGNING=required # Win11: sign or encrypt always
AD_SMB_ENCRYPT=desired # required = force SMB3 encryption (old clients may break)
AD_NTLM_AUTH=ntlmv2-only # disabled = Kerberos only (set after migration audit)
AD_RESTRICT_ANONYMOUS=2
AD_SMB_MIN_PROTOCOL=SMB2_02
AD_LOG_LEVEL=1
# --- LDAPS ---------------------------------------------------------------------------
# Generate with scripts/tls.sh, then uncomment. The leaf MUST contain SAN=DC=<fqdn>.
#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
# --- Additional domain controller (replica) ------------------------------------------
# Only used on a fresh data volume; otherwise the existing database decides.
#AD_JOIN_SERVER=dc1.ad.example.com
#AD_JOIN_USER=administrator
#AD_JOIN_PASSWORD=
# --- Ops ----------------------------------------------------------------------------
AD_DNS_REFRESH_SECONDS=300

81
.gitignore vendored Normal file
View file

@ -0,0 +1,81 @@
# =====================================================================================
# Two kinds of things belong on this list:
# (a) SECRETS - leaking these is a domain compromise, not a typo
# (b) RUNNING CONTAINER STATE - reproducible from this repo; committing it would shadow
# the config that the entrypoint renders from .env on every start
#
# What you need to build a container stays tracked: image/, scripts/, tests/, docs/,
# docker-compose*.yml, Makefile, README.md, .env.example, .dockerignore, this file.
# =====================================================================================
# --- secrets --------------------------------------------------------------------
# Real configuration: holds the domain administrator password.
# `.env.*` also catches restore scratch/backups (.env.pre-restore-<ts>, .env.tmp).
# .env.example is the only member of this family that belongs in git.
.env
.env.*
!.env.example
# Admin password file, LDAPS CA + leaf + keys, throwaway lab CAs (secrets/tls-lab).
# NOTE: secrets/tls/ca.key is the trust anchor every joined client has installed. Leak it
# and someone can mint certificates your whole fleet trusts.
secrets/*
!secrets/.gitkeep
# Belt-and-braces: never let key material sneak in from anywhere else either.
*.key
*.pem
# (public certs are harmless but only ever live under secrets/, so ignore them too)
*.crt
*.p12
*.pfx
# --- running container state ----------------------------------------------------
# smb.conf as generated/rendered by the entrypoint (source of truth is .env + entrypoint).
conf/*
!conf/.gitkeep
conf-dc2/*
!conf-dc2/.gitkeep
# sam.ldb (NT hashes, Kerberos keys, Backup Key) + SYSVOL + machine account keys.
# This IS the domain. Treat it as production data, never as source.
data/*
!data/.gitkeep
data/samba
!data/samba
data/samba/*
!data/samba/.gitkeep
data/samba-dc2
!data/samba-dc2
data/samba-dc2/*
!data/samba-dc2/.gitkeep
# --- backups ------------------------------------------------------------------
# `samba-tool domain backup` archives are whole-domain copies: password hashes and the
# Backup Key included. They go off-host encrypted, not into version control.
backups/*
!backups/.gitkeep
*.tar.bz2
*.tgz
*.sha256
# --- per-machine compose overrides ----------------------------------------------
# Usually local-only (different IP/realm per box). Force-add if you mean to share it:
# git add -f docker-compose.override.yml
docker-compose.override.yml
docker-compose.*.local.yml
# --- scratch, logs, editor and OS junk ------------------------------------------
*.log
*.tmp
*.bak
*.swp
*~
.DS_Store
Thumbs.db
# test harness scratch that landed inside the tree
tests/.tmp/
# --- python -------------------------------------------------------------------
__pycache__/
*.py[co]

100
Makefile Normal file
View file

@ -0,0 +1,100 @@
# Samba AD DC - operational entry points. `make` prints this list.
SHELL := /bin/bash
IMAGE ?= local/samba-ad-dc:4.22-debian13
CID ?= $(shell sed -n 's/^AD_CONTAINER=//p' .env 2>/dev/null | head -1)
CID := $(if $(CID),$(CID),ad-dc)
.PHONY: help build up down restart logs ps verify verify-full backup tls lab labdown smoke drill ldaps test \
shell secrets passwd fsmo repl-status clean
help:
@echo "Samba AD DC on Debian 13 (container: $(IMAGE), name: $(CID))"
@echo
@echo " make secrets create secrets/ad_admin.password (random, 0600)"
@echo " make build build the image"
@echo " make up start the DC (host networking - check ports 53/88/445 first)"
@echo " make down stop it make restart: same, harder"
@echo " make logs follow container logs"
@echo " make verify run the verification battery (add FULL=1 for dbcheck)"
@echo " make backup online full-domain backup into ./backups"
@echo " make tls build internal CA + LDAPS leaf cert"
@echo " make passwd rotate the administrator password (interactive)"
@echo " make fsmo show FSMO role ownership"
@echo " make repl-status replication status (meaningful once a replica exists)"
@echo
@echo " make lab isolated TESTLAB.LOCAL lab DC (no host ports)"
@echo " make labdown destroy the lab"
@echo " make smoke full isolated end-to-end test (build -> provision -> verify -> restart)"
@echo " make drill disaster-recovery drill (backup -> restore -> SID + data assertions)"
@echo " make ldaps LDAPS: chain verifies, SAN matches, untrusted clients rejected"
@echo " make test smoke + drill + ldaps (run before every upgrade)"
@echo " make restore BACKUP=backups/xxx.tgz disaster recovery drill"
build:
docker build -t $(IMAGE) -f image/Dockerfile .
up:
docker compose up -d
down:
docker compose down
restart:
docker compose restart
logs:
docker compose logs -f --tail=100
ps:
@docker compose ps -a
verify:
AD_CONTAINER=$(CID) ./scripts/verify.sh $(if $(FULL),--full,)
backup:
./scripts/backup.sh
tls:
./scripts/tls.sh
secrets:
./scripts/mksecrets.sh
passwd:
docker exec -it $(CID) samba-tool user setpassword $(shell grep -m1 '^AD_ADMIN_USER=' .env 2>/dev/null | cut -d= -f2 || echo administrator)
fsmo:
docker exec -it $(CID) samba-tool fsmo show
repl-status:
docker exec -it $(CID) samba-tool drs showrepl
lab:
docker compose -f docker-compose.lab.yml up -d
@echo "lab up: realm TESTLAB.LOCAL, host alias dc1.testlab.local -> 172.28.90.10"
@echo "destroy with: make labdown"
labdown:
docker compose -f docker-compose.lab.yml down -v
smoke:
./tests/smoke.sh
drill:
./tests/dr-drill.sh
ldaps:
./scripts/tls.sh
./tests/ldaps-test.sh
test: smoke drill ldaps
@echo "all test suites passed"
restore:
@test -n "$(BACKUP)" || { echo "usage: make restore BACKUP=backups/<file>.tgz"; exit 2; }
./scripts/restore.sh $(BACKUP)
clean:
docker compose down -v --remove-orphans || true
docker compose -f docker-compose.lab.yml down -v --remove-orphans || true
@echo "left ./conf ./data ./secrets ./backups in place (delete manually if you really mean it)"

313
README.md Normal file
View file

@ -0,0 +1,313 @@
# WindowsAD — Samba 4.22 Active Directory Domain Controller in Docker (Debian 13)
A containerised AD Domain Controller that Windows 11 machines can join: DNS SRV bootstrap,
Kerberos (AES), LDAP/LDAPS, Global Catalog, SYSVOL/NETLOGON, secure channel, Group Policy.
Built on Debian 13 `samba-ad-dc` 4.22.11 — one `samba` process serves the whole DC role.
```
/dockers/WindowsAD
├── image/
│ ├── Dockerfile debian:trixie-slim + samba-ad-dc (version-pinned ARG)
│ ├── ad-entrypoint.sh idempotent: validate -> provision|reuse -> harden -> start
│ └── ad-healthcheck.sh credential-free health probe
├── docker-compose.yml production: host networking, primary + `replica` profile
├── docker-compose.lab.yml isolated TESTLAB.LOCAL lab on an internal network
├── docs/docker-compose.macvlan.yml.example LAN-addressed DC without host networking
├── .env.example every knob, documented
├── scripts/
│ ├── mksecrets.sh secrets/ad_admin.password (0600, random)
│ ├── tls.sh internal CA + LDAPS leaf (SAN required by Win11)
│ ├── verify.sh full verification battery (add --full for dbcheck)
│ ├── backup.sh / restore.sh online domain backup + DR drill
│ ├── add-replica.sh second DC
│ └── win11-join.ps1 Windows-side: DNS -> CA -> join -> verify
├── tests/smoke.sh end-to-end: build -> provision -> verify -> restart -> verify
└── Makefile make (self-documenting)
```
## How it behaves
`ad-entrypoint.sh` is idempotent — restart it as often as you like:
1. validate realm / hostname / NetBIOS / password complexity, fail fast
2. resolve own IPv4 (`AD_IP`, or auto-detect) and write `/etc/hosts`
3. render `/etc/krb5.conf` with **explicit** KDCs (`dns_lookup_kdc = false`) and
`udp_preference_limit = 1465` (Win11 + big PACs)
4. if `sam.ldb` missing → provision a new forest; if `AD_JOIN_SERVER` set → join as a
replica; otherwise → **reuse** the existing directory (never re-provisions)
5. upsert hardening params into `smb.conf [global]` on *every* start (so `.env` edits
apply on restart), then **refuse to start** if `testparm` rejects anything
6. self-referencing resolver: container's `resolv.conf` → `nameserver 127.0.0.1`
(upstream goes out via `dns forwarder`); in host mode the host's files are untouched
7. background `samba_dnsupdate` every 5 min keeps A records aligned with the current IP
8. `exec samba --foreground --no-process-group` — same invocation as the Debian unit, so
`docker stop` shuts the directory down cleanly
## Quickstart
```bash
cd /dockers/WindowsAD
cp .env.example .env && chmod 600 .env # EDIT realm/hostname/IP first
make secrets # random admin password -> secrets/
make build
make up # host networking: see port checklist below
make verify
```
Then `make backup` before you let any Windows machine near it.
### First, check nothing owns the DC's ports on the host
```bash
ss -ltnup | awk '$5 ~ /:(53|88|135|139|389|445|464|636|3268|3269)$/ {print}'
systemctl is-active systemd-resolved # must be inactive (or the :53 stub disabled)
```
`systemd-resolved`'s stub on `127.0.0.53:53` is the classic collision. Disable it, or run
the DC in macvlan mode.
### Configure `.env` correctly (the three that bite people)
| var | why it matters |
|---|---|
| `AD_REALM` | uppercase, no underscores, a subdomain you control (`ad.example.com`). Never your mail domain root. |
| `AD_IP` | goes into the DC's DNS **A record**. Must be the address clients can actually route to. Get a DHCP reservation — a lease change silently rots every record. |
| `AD_DNS_FORWARDER` | the DC answers its own zone and forwards everything else. |
## Windows 11 join
```powershell
# elevated PowerShell on the Win11 box (copy scripts/tls.sh output ca.crt over first)
Set-Location \\server\dockers\WindowsAD # or copy the script locally
.\win11-join.ps1 -DnsServer 192.168.5.12 -Domain ad.example.com `
-DcHost dc1.ad.example.com -CaCert C:\adca.crt
```
Or manually:
```powershell
Set-DnsClientServerAddress -InterfaceIndex (Get-NetAdapter).ifIndex -ServerAddresses 192.168.5.12
Resolve-DnsName _ldap._tcp.dc._msdcs.ad.example.com -Type SRV # must answer
Add-Computer -DomainName ad.example.com -Restart
# after reboot
Test-ComputerSecureChannel -Verbose ; nltest /dsgetdc:ad ; gpupdate /force ; gpresult /r
```
RSAT (to author GPOs/OU structure against Samba):
```powershell
Add-WindowsCapability -Online -Name Rsat.ActiveDirectory.DS-LDS.Tools~~~~0.0.1.0
Add-WindowsCapability -Online -Name Rsat.GroupPolicy.Management.Tools~~~~0.0.1.0
```
## Hardening map (`.env` → `smb.conf`)
| Windows 11 requirement | this project |
|---|---|
| SMB1 is gone | `AD_SMB_MIN_PROTOCOL=SMB2_02` |
| sign or encrypt always / 24H2 refuses unsigned channels | `AD_SMB_SIGNING=required` |
| SMB3 session encryption | `AD_SMB_ENCRYPT=desired` → `required` once all clients support it |
| NTLM retirement | `AD_NTLM_AUTH=ntlmv2-only` → `disabled` after you audit for NTLM users |
| no anonymous enumeration | `AD_RESTRICT_ANONYMOUS=2`, `map to guest = never` |
| AES Kerberos, PAC > 1465 bytes | `udp_preference_limit = 1465` in rendered `krb5.conf` |
| Schannel needs SAN, not CN | `scripts/tls.sh` issues `SAN=DNS:<dc fqdn>,…` |
| LDAP signing | push the GPO “Domain controller: LDAP server signing requirements = Require signing”; serve LDAPS 636 with a trusted cert so clients never need the unsigned path |
| time skew < 5 min | host must run chrony/ntp; joined clients sync from the DC (MS-SNTP served by `samba`) |
### LDAPS and the trust chain
```bash
make tls # internal CA + leaf with SAN, then enable in .env
# add to .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
docker compose up -d --force-recreate ad-dc
./scripts/verify.sh # chain, SAN, strict bind + two rejection tests
```
`make tls` also **publishes the CA where joined machines can fetch it**:
`\\<domain>\NETLOGON\ad-root-ca.crt` (NETLOGON is read-only and readable by any domain
credential — the same place Windows ships its own CA). If that extra file disturbs the SYSVOL
NT ACLs, the script detects it via `sysvolcheck` and rolls the copy back.
Trust it on each Windows client (**this is the step that removes the popups**):
```powershell
certutil -addstore -f ROOT C:\path\to\ca.crt
certutil -addstore -f ROOT "\\premier.local\NETLOGON\ad-root-ca.crt" # or straight off the DC
```
Install into the **LOCAL COMPUTER** store, not the current-user store: LDAP/Schannel binds run
as SYSTEM and service accounts, which cannot see per-user trust. Fleet-wide: GPO → Computer
Configuration → Policies → Windows Settings → Security Settings → Public Key Policies →
Trusted Root Certification Authorities.
The entrypoint **stages** the mounted cert/key into `/var/lib/samba/private/tls/` as root with
mode `0600`, because Samba refuses to load a key that is not root-owned (CVE-2013-4476) —
which is exactly what a bind-mounted host cert looks like from inside the container. It also
checks the key matches the cert and that the SAN contains the DC FQDN, failing fast otherwise.
So the host-side ownership of `secrets/` does not matter.
### Buy a public cert (EasySSL / Let's Encrypt) instead? Mostly no.
For a **domain-joined** machine an internal CA gives you strictly fewer popups, not more: once
the root sits in the machine trust store the certificate is unremarkable, and you never touch
it again for ~2 years (`make tls` re-issues against the same CA).
| | internal CA (`make tls`) | public CA (EasySSL / LE) |
|---|---|---|
| works for `.local`, `.lan`, or a private name | yes | **no** — requires a publicly resolvable, validatable hostname |
| popups on joined Win11 clients | none once the root is trusted | none once the chain validates |
| renewal | ~2.3 yr leaf, 10 yr CA, re-issue in place | 90 days (LE) / 1 yr, automation mandatory; expiry = LDAPS/app outage |
| where the key lives | your `secrets/tls/` | fine, but you must automate fetching it into the container |
| information leak | none | hostname (and any SAN) is published in Certificate Transparency logs |
| LDAP signing / channel binding | unaffected either way | unaffected either way |
**Use a public cert when** a *non-domain-joined* or *external* client must reach LDAPS/HTTPS and
you cannot install a root there (SaaS connectors, hosted apps, appliances). For the LAN and
domain-joined Win11 boxes, the internal CA is the right tool.
**Trust-anchor hygiene:** `secrets/tls/ca.key` is the thing to guard. Back it up offline and
encrypted — lose it and every cert it signed must be reissued and re-trusted; leak it and an
attacker can mint certificates your whole fleet trusts. Rotating the leaf is cheap (`make tls`,
CA untouched); rotating the CA means redeploying trust everywhere.
## Ops
```bash
make verify # ~15 checks; exits non-zero on failure
make verify FULL=1 # + samba-tool dbcheck --cross-ncs (slow, DB heavy)
make backup # online consistent backup -> ./backups/<ts>.tgz + sha256
make restore BACKUP=backups/<ts>.tgz # DR drill into a fresh data dir (asks RESTORE)
make fsmo # FSMO ownership
make repl-status # samba-tool drs showrepl
make passwd # rotate administrator
make shell # docker exec -it ad-dc bash
```
Rotate the backup to off-host storage and **practise the restore**. `scripts/restore.sh`
preserves the previous data dir instead of deleting it.
Two DCs: `scripts/add-replica.sh` (run it on a *second machine* — host networking means
two DCs on one host fight over 53/88/445; use the macvlan variant for one host).
## Testing
```bash
make smoke # isolated TESTLAB.LOCAL: build -> provision -> verify -> restart -> verify
make drill # backup -> restore -> domain SID + planted OU must survive
make ldaps # LDAPS chain verifies, SAN matches, untrusted clients rejected
make test # all three (run before every upgrade)
```
What each suite asserts beyond "it came up":
| suite | the checks that actually matter |
|---|---|
| `tests/smoke.sh` | fresh forest serves DNS SRV + Kerberos + LDAP/GC + SYSVOL; the hardened `smb.conf` parameters really landed; **a restart reuses the database instead of re-provisioning**; anonymous LDAP enumeration and SMB1 are refused |
| `tests/dr-drill.sh` | plants an OU, backs up, restores into a fresh volume **as a new DC**, then asserts the restored DC has the **same domain SID** and still contains the planted OU, and never ran a fresh provisioning. A restore that looks healthy but lost your data is the worst failure mode, so this is the important one. |
| `tests/ldaps-test.sh` | 636 verifies against the generated CA, SAN contains the DC FQDN, and a client **without** the CA is rejected |
The lab is fully isolated: `internal: true` docker network, throwaway `TESTLAB.LOCAL` realm,
no route to your LAN, named volumes. `make labdown` destroys it.
### Validated here (Debian 13.6, Docker 29.7.2, Samba 4.22.11)
* `smoke`: 25 passed / 0 failed on a fresh forest, and again after a container restart
(log-verified that the restart reused the database)
* `drill`: restored DC presented the **same domain SID** as the source and still held the
planted `DR-MARKER-*` OU; no fresh provisioning occurred
* `ldaps`: 26 passed / 0 failed, chain verified against the generated CA, untrusted
handshake rejected
Gotchas discovered and handled the hard way (all now encoded in the entrypoint/tests):
1. `samba-ad-provision` is a separate Debian package — without it provisioning can't find the AD schema.
2. Provisioning dies on SYSVOL NT-ACL writes unless the container has `CAP_SYS_ADMIN`.
3. Pre-creating `smb.conf` before provisioning makes `samba-tool domain provision` refuse
(no `[netlogon]` share) — pass the realm with `--option="realm=..."` instead.
4. Samba's backup archives are `.tar.bz2`, not `.tgz`.
5. `samba-tool domain backup restore` rejects a non-empty target dir, so the image keeps
`/var/lib/samba` empty; and it restores as a **new** DC name.
6. Samba refuses a TLS key that isn't root-owned (CVE-2013-4476) — hence the cert staging step.
7. `libsasl2-modules-gssapi-mit` is required for `ldapsearch -Y GSSAPI` inside the image.
8. Under `set -o pipefail`, `cmd | head -1` turns a normal SIGPIPE into a fatal error.
## Disaster recovery — read this before you need it
`samba-tool domain backup restore` is **restore-as-a-new-domain-controller**: it unpacks the
database and then *joins* the target host into it. Consequences:
* You **cannot** restore under the same hostname the backup came from — that computer account
already exists in the restored DB (`Entry CN=... already exists`). Use a new name and a free
IP: `./scripts/restore.sh backups/<archive>.tar.bz2 dc3 192.168.5.55`
* `restore.sh` saves `.env`, moves the live `data/samba` aside (timestamped, never deleted),
rewrites `AD_HOSTNAME`/`AD_IP`, restores, starts, verifies, and prints rollback commands
on failure.
* Afterwards point DHCP/DNS at the new DC and clean up the dead DC's stale computer/DNS
objects (the script prints the commands).
* The restore target must be **empty**, so the image deliberately leaves `/var/lib/samba`
empty — otherwise a fresh named volume gets seeded with content and the restore refuses to run.
* Archives are `samba-backup-<domain>-<ts>.tar.bz2` + `.sha256` and contain the **domain
secrets**. Store them encrypted, off-host, and practise the restore on a schedule.
## Network modes
| mode | how | Win11 clients on the LAN | notes |
|---|---|---|---|
| host (default) | `network_mode: host` | ✅ | host IP == DC IP; needs 53/88/135/445/3268 free |
| macvlan | `docs/docker-compose.macvlan.yml.example` | ✅ | own LAN IP; container↔host blocked by default |
| bridge/lab | `docker-compose.lab.yml` | ❌ | isolated; internal-only or port-published for local experiments |
Port publishing does **not** work for real joins: the DC advertises its own (unroutable)
address in DNS and DCE-RPC allocates dynamic ports. That is why host/macvlan exist.
## Limitations — read before production
1. **Homogeneous DC topology.** Samba dropped FRS. Do **not** mix a Windows Server DC into a
Samba domain (or vice versa) for SYSVOL replication. Samba↔Samba replicas only.
2. **No AD CS** — no enterprise CA, no certificate auto-enrollment, no AD-integrated PKI.
LDAPS comes from `scripts/tls.sh`, ACME DNS-01, or your existing PKI.
3. **No Entra ID hybrid.** Azure AD Connect is unsupported against Samba. If you need M365
SSO, Entra ID should be the authority and this box plays a different role.
4. **Don't run `adprep` from newer Windows media** against the Samba-provisioned schema.
5. GPO authoring works from RSAT/GPMC, but validate each client-side extension you rely on;
some newer policy extensions call AD features Samba doesn't implement.
6. Heavy AD-coupled products (Exchange, SCCM/MECM, AD-joined PKI flows) are off the table.
7. **LDAPS is server authentication only.** No NTAuth store, no certificate-based *client*
authentication, and Samba does not implement LDAP channel binding (GS2-TBS). Windows only
*demands* channel binding when the DC-side policy is set to "Always" — a policy Samba does
not implement — so clients fall back and joins work; just don't point clients at this DC if
they are configured to require CB unconditionally. SASL LDAP signing/sealing is supported.
8. **Do not use `.local` for the realm.** RFC 6762 reserves it for multicast DNS, so macOS,
iOS, Android and Linux hosts with avahi/systemd-resolved will never send those names to your
DNS server. Use a subdomain you own (`ad.example.com`), or `.lan` / `.internal` /
`home.arpa` (RFC 8375). A single-label realm (just `PREMIER`) is worse again: SRV and
Kerberos locator lookups depend on a real DNS suffix.
## Upgrades
`SAMBA_VERSION` is pinned in `docker-compose.yml` and the Dockerfile ARG. Upgrade in a
controlled way:
1. check Samba's supported upgrade-path matrix (e.g. 4.22 → 4.24 is a supported step)
2. `make backup` and verify the archive restores
3. change the pin, `make build`, `make up`
4. inside the container: `samba_upgradedns`, then `samba-tool dbcheck --cross-ncs`,
`samba-tool ntacl sysvolcheck`, `make verify`
5. upgrades inside a running container image are **not** automatic — a rebuild is deliberate
Debian 13 also offers `samba` 4.24 in `trixie-backports` if you want the newer line; do
the same drill with `SAMBA_VERSION=2:4.24.5+dfsg-1~bpo13+1`.
## Security notes
- `secrets/` is `0600` and mounted read-only at `/run/ad-secrets`; never baked into the image
(`.dockerignore` blocks it).
- During the **first** provision Samba passes the admin password via `argv`, so `docker top`
can see it for a few seconds. If that matters, rotate immediately: `make passwd`.
- Container runs `cap_drop: ALL` plus the capabilities Samba actually needs. **`SYS_ADMIN`
is required**: Samba persists NT ACLs in the `security.NTACL` xattr and the kernel refuses
that inside a container without it (provisioning dies writing SYSVOL ACLs). If you must
avoid `SYS_ADMIN`, switch SYSVOL/NETLOGON to the `acl_xattr` VFS module (`vfs objects =
acl_xattr`, `acl_xattr:xattr_names = user.NTACL user.DOSATTRIB`, `acl_xattr:ignore system
acls = yes`, `map acl inherit = yes`, `store dos attributes = yes`) in `conf/smb.conf`
and set `AD_APPLY_HARDENING=no` — supported but a non-standard ACL backend; validate GPO
application carefully before trusting it.
- The healthcheck is credential-free by design so it can never lock out an account.
- The lab compose file contains a throwaway password on purpose (`TESTLAB.LOCAL`, no route
to any network). Do not copy those values into `.env`.

0
backups/.gitkeep Normal file
View file

0
conf-dc2/.gitkeep Normal file
View file

0
conf/.gitkeep Normal file
View file

0
data/.gitkeep Normal file
View file

0
data/samba-dc2/.gitkeep Normal file
View file

0
data/samba/.gitkeep Normal file
View file

65
docker-compose.lab.yml Normal file
View file

@ -0,0 +1,65 @@
# =====================================================================================
# ISOLATED LAB DC - used by tests/smoke.sh, handy for local experimentation.
#
# * realm TESTLAB.LOCAL, throwaway credentials committed here ON PURPOSE (test only)
# * `internal: true` network: the container has NO route to your LAN and no LAN route to
# it, so binding privileged ports here is harmless
# * named volumes, so it never touches ./conf ./data ./secrets
#
# docker compose -f docker-compose.lab.yml up -d
# docker compose -f docker-compose.lab.yml down -v # destroy
# =====================================================================================
name: ad-lab
services:
ad-lab:
image: local/samba-ad-dc:4.22-debian13
container_name: ad-lab
hostname: dc1.testlab.local
networks:
adlab:
ipv4_address: 172.28.90.10
environment:
AD_REALM: TESTLAB.LOCAL
AD_DNS_DOMAIN: testlab.local
AD_HOSTNAME: dc1
AD_IP: 172.28.90.10
AD_CONTAINER: ad-lab
AD_DNS_FORWARDER: 1.1.1.1 8.8.8.8
AD_ADMIN_PASSWORD: 'Lab!Only9Tester#2026'
AD_SMB_SIGNING: required
AD_SMB_ENCRYPT: desired
AD_NTLM_AUTH: ntlmv2-only
AD_LOG_LEVEL: '1'
volumes:
- adlab-conf:/etc/samba
- adlab-lib:/var/lib/samba
stop_grace_period: 60s
restart: unless-stopped
cap_drop: [ALL]
# SYS_ADMIN is required: Samba persists NT ACLs in the security.NTACL xattr, which
# the kernel refuses inside a container without it (SYSVOL/NETLOGON ACL writes).
cap_add:
- CHOWN
- FOWNER
- SETUID
- SETGID
- DAC_OVERRIDE
- NET_BIND_SERVICE
- SYS_NICE
- SYS_RESOURCE
- AUDIT_WRITE
- SYS_ADMIN
networks:
adlab:
name: adlab
driver: bridge
internal: true
ipam:
config:
- subnet: 172.28.90.0/24
volumes:
adlab-conf:
adlab-lib:

93
docker-compose.yml Normal file
View file

@ -0,0 +1,93 @@
# =====================================================================================
# Samba AD Domain Controller - production shape.
#
# cp .env.example .env && chmod 600 .env
# make build && make up && make verify
#
# WHY host networking by default
# ----------------------------
# An AD DC writes *its own IP* into DNS A records and Windows clients then connect to
# that IP on 88/389/445/3268 plus dynamically allocated DCE-RPC ports. NAT/port
# publishing breaks that contract: the DC advertises an address the client cannot route
# to, and group policy / Kerberos / secure channel fail in ways that look like auth bugs.
# With network_mode: host, host IP == DC IP == the published record: self-consistent.
#
# Before `make up`:
# * nothing may hold TCP/UDP 53 (systemd-resolved stub!), 88, 135, 139, 445, 3268
# * get the host a DHCP reservation. A DHCP lease change silently rots the DNS A record.
#
# Other modes:
# AD_NETWORK_MODE=adlan -> use the named bridge below (lab; needs published ports)
# macvlan -> see docs/docker-compose.macvlan.yml.example
# =====================================================================================
name: windowsad
x-ad-dc: &ad-dc
image: local/samba-ad-dc:4.22-debian13
build:
context: .
dockerfile: image/Dockerfile
args:
SAMBA_VERSION: "2:4.22.11+dfsg-0+deb13u1" # bump deliberately, see README "Upgrades"
env_file:
- .env
volumes:
- ./conf:/etc/samba # smb.conf lives here so you can read it on the host
- ./data/samba:/var/lib/samba # sam.ldb + SYSVOL + private keys -> BACK THIS UP
- ./secrets:/run/ad-secrets:ro
- /etc/localtime:/etc/localtime:ro # password expiry / logs follow host TZ
stop_grace_period: 90s # let SMB sessions and the LDB/BDK close cleanly
restart: unless-stopped
# Least privilege this image actually runs with (validated by tests/smoke.sh).
cap_drop: [ALL]
cap_add:
- CHOWN
- FOWNER
- SETUID
- SETGID
- DAC_OVERRIDE
- NET_BIND_SERVICE
- SYS_NICE
- SYS_RESOURCE
- AUDIT_WRITE
# Required: Samba stores NT ACLs in the security.NTACL xattr, which the kernel
# refuses without CAP_SYS_ADMIN inside a container (provisioning dies on SYSVOL ACLs).
- SYS_ADMIN
services:
# ---- only / primary domain controller ---------------------------------------------
ad-dc:
<<: *ad-dc
container_name: ${AD_CONTAINER:-ad-dc}
hostname: ${AD_HOSTNAME:-dc1}.${AD_DNS_DOMAIN:-ad.example.com}
network_mode: ${AD_NETWORK_MODE:-host}
# ---- second domain controller (HA / replica) --------------------------------------
# Because this uses host networking, run this profile on a SECOND MACHINE with the repo
# synced to it (or switch to macvlan so each DC gets its own LAN address).
# In .env set: AD_HOSTNAME2=dc2 AD_IP=<that machine's ip> AD_JOIN_SERVER=dc1.<domain>
# AD_JOIN_USER=administrator AD_JOIN_PASSWORD=<or secrets/join.password>
# Then: docker compose --profile replica up -d
ad-dc2:
<<: *ad-dc
profiles: ["replica"]
container_name: ${AD_CONTAINER2:-ad-dc2}
hostname: ${AD_HOSTNAME2:-dc2}.${AD_DNS_DOMAIN:-ad.example.com}
network_mode: ${AD_NETWORK_MODE:-host}
volumes:
- ./conf-dc2:/etc/samba
- ./data/samba-dc2:/var/lib/samba
- ./secrets:/run/ad-secrets:ro
- /etc/localtime:/etc/localtime:ro
networks:
# Only used when AD_NETWORK_MODE=adlan. Kept here so `make up` can be switched to a
# sandboxed bridge without editing YAML. Real Win11 joins from the LAN will NOT work in
# this mode - see docs/ for macvlan if you need LAN-reachable without host networking.
adlan:
name: adlan
driver: bridge
ipam:
config:
- subnet: 172.28.90.0/24

View file

@ -0,0 +1,46 @@
# Standalone macvlan variant: the DC owns its own address on the LAN segment, so physical
# Windows 11 clients can reach it without giving the container the host's network stack.
#
# Two things to know about macvlan:
# * by default a macvlan container CANNOT talk to its own host. If you need that, add a
# macvlan interface on the host too (`ip link add link <if> macvlan1 type macvlan
# mode bridge ...`) or just use network_mode: host from the base compose file.
# * pick an IP that your DHCP server will never hand out (or reserve it).
name: windowsad-lan
services:
ad-dc:
image: local/samba-ad-dc:4.22-debian13
container_name: ad-dc
hostname: dc1.ad.example.com
env_file:
- .env
environment:
# MUST match ipv4_address below - this is what lands in the DC's DNS A record.
AD_IP: 192.168.5.50
networks:
lan:
ipv4_address: 192.168.5.50
volumes:
- ./conf:/etc/samba
- ./data/samba:/var/lib/samba
- ./secrets:/run/ad-secrets:ro
- /etc/localtime:/etc/localtime:ro
stop_grace_period: 90s
restart: unless-stopped
cap_drop: [ALL]
cap_add: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE,
SYS_NICE, SYS_RESOURCE, AUDIT_WRITE,
SYS_ADMIN] # security.NTACL xattr writes (SYSVOL/NETLOGON ACLs)
networks:
lan:
name: adlan
driver: macvlan
driver_opts:
parent: br0 # <- your LAN interface or bridge
ipam:
config:
- subnet: 192.168.5.0/24
gateway: 192.168.5.1
ip_range: 192.168.5.48/28

91
image/Dockerfile Normal file
View file

@ -0,0 +1,91 @@
# syntax=docker/dockerfile:1.7
# -------------------------------------------------------------------------------------
# Samba 4.22 Active Directory Domain Controller on Debian 13 (trixie)
#
# One `samba` process serves the whole DC role:
# DNS 53 LDAP 389 / LDAPS 636 Global Catalog 3268 / 3269
# Kerberos 88 kpasswd 464 RPC epmapper 135 SMB 445 (SYSVOL/NETLOGON)
# MS-SNTP 123 (Windows clients sync time from the DC after domain join)
#
# Build: docker build -t local/samba-ad-dc:4.22-debian13 -f image/Dockerfile .
# -------------------------------------------------------------------------------------
FROM debian:trixie-slim
# Pin every Samba-built package to the SAME version string (they carry exact-version
# depends on each other, so pinning only `samba` produces unsatisfiable deps once the
# archive carries two point releases). Empty => whatever trixie+security currently offer.
ARG SAMBA_VERSION=2:4.22.11+dfsg-0+deb13u1
LABEL org.opencontainers.image.title="samba-ad-dc" \
org.opencontainers.image.description="Samba AD Domain Controller (Windows 11 join compatible) on Debian 13" \
org.opencontainers.image.source="https://github.com/local/samba-ad-dc" \
org.opencontainers.image.licenses="GPL-3.0-or-later"
ENV DEBIAN_FRONTEND=noninteractive
# Package postinsts must never try to start services during an image build.
RUN printf '#!/bin/sh\nexit 101\n' > /usr/sbin/policy-rc.d && chmod 0755 /usr/sbin/policy-rc.d
RUN set -eux; \
pin=""; \
if [ -n "${SAMBA_VERSION}" ]; then pin="=${SAMBA_VERSION}"; fi; \
apt-get update; \
apt-get install -y --no-install-recommends \
"samba${pin}" \
"samba-ad-dc${pin}" \
"samba-common${pin}" \
"samba-common-bin${pin}" \
"samba-dsdb-modules${pin}" \
"samba-ad-provision${pin}" \
"python3-samba${pin}" \
"winbind${pin}" \
python3-setproctitle \
krb5-user \
libsasl2-modules-gssapi-mit \
ldap-utils \
smbclient \
bind9-dnsutils \
python3-dnspython \
ca-certificates \
openssl \
iproute2 \
procps \
tzdata; \
rm -f /usr/sbin/policy-rc.d; \
rm -rf /var/lib/apt/lists/*; \
# The stock smb.conf/krb5.conf shipped by the packages must NOT exist:
# the entrypoint uses their absence to detect a virgin data volume, and named
# volumes would otherwise be seeded with the stock (non-AD) config.
rm -f /etc/samba/smb.conf /etc/krb5.conf; \
mkdir -p /etc/samba /var/backups/ad-dc /run/ad-secrets; \
# Leave the AD state dir EMPTY. Docker seeds named volumes from the image's content,
# and `samba-tool domain backup restore` refuses a non-empty target dir. Provisioning
# creates these directories itself.
rm -rf /var/lib/samba; \
mkdir -p /var/lib/samba; \
# sanity: this image is useless without the AD DC bits
test -x /usr/sbin/samba; \
/usr/bin/samba-tool --help >/dev/null; \
# the AD schema lives in a separate Debian package (samba-ad-provision) - fail the
# build here rather than at first-run provisioning
ls /usr/share/samba/setup/ad-schema/AD_DS_Attributes_Windows_Server_v1903.ldf >/dev/null; \
# ldap-client GSSAPI and smbclient are used by the verification scripts
ls /usr/lib/*/sasl2/libgssapiv2.so >/dev/null; \
command -v smbclient >/dev/null; \
command -v kvno >/dev/null; \
samba -V
COPY image/ad-entrypoint.sh /usr/local/bin/ad-entrypoint
COPY image/ad-healthcheck.sh /usr/local/bin/ad-healthcheck
COPY image/ad-dnsupdate.sh /usr/local/bin/ad-dnsupdate
RUN chmod 0755 /usr/local/bin/ad-entrypoint /usr/local/bin/ad-healthcheck /usr/local/bin/ad-dnsupdate
# Documentation only (real ports come from the network mode you choose).
EXPOSE 53/tcp 53/udp 88/tcp 88/udp 123/udp 135/tcp 137/udp 138/udp 139/tcp \
389/tcp 445/tcp 464/tcp 464/udp 636/tcp 3268/tcp 3269/tcp
# start_period is generous: first-run provisioning takes ~30-90s.
HEALTHCHECK --interval=30s --timeout=20s --start-period=120s --retries=4 \
CMD ["/usr/local/bin/ad-healthcheck"]
ENTRYPOINT ["/usr/local/bin/ad-entrypoint"]

24
image/ad-dnsupdate.sh Normal file
View file

@ -0,0 +1,24 @@
#!/bin/sh
# =====================================================================================
# Samba dnsupdate wrapper: pins DNS registration to the address(es) this DC advertises.
#
# Why: a Docker host is multi-homed (docker0 + every user-defined bridge + whatever stale
# DHCP addresses linger). Samba's updater registers ALL non-loopback interfaces, so the DC's
# A record ends up containing 172.17.0.1 etc. Clients that pick one of those get connection
# failures that look like Kerberos/SMB bugs. Wired in via smb.conf:
# dns update command = /usr/local/bin/ad-dnsupdate
# so it governs the daemon's internal updater as well as manual invocations.
#
# The address list is written by ad-entrypoint to /run/ad-dc-addresses (one per line).
# =====================================================================================
IPFILE=/run/ad-dc-addresses
if [ -f "$IPFILE" ]; then
set -- --current-ip="$(head -n 1 "$IPFILE")" "$@"
# remaining lines (e.g. IPv6) -> additional --current-ip arguments
for line in $(tail -n +2 "$IPFILE"); do
set -- "$@" --current-ip="$line"
done
fi
exec /usr/sbin/samba_dnsupdate "$@"

460
image/ad-entrypoint.sh Executable file
View file

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

61
image/ad-healthcheck.sh Executable file
View file

@ -0,0 +1,61 @@
#!/usr/bin/env bash
# =====================================================================================
# Container healthcheck. Credential-free on purpose: it must never risk locking out an
# account by testing authentication. Authenticated checks live in scripts/verify.sh.
# - process alive
# - LDAP rootDSE answers (anonymous base object is allowed by design)
# - own DNS answers the AD SRV records
# - Kerberos / SMB / Global Catalog ports accept a connection
# - smb.conf still parses
# =====================================================================================
set -uo pipefail
problems=()
pgrep -x samba >/dev/null 2>&1 || problems+=("samba process not running")
# LDAP rootDSE
if ! ldapsearch -x -H ldap://127.0.0.1:389 -s base -b '' defaultNamingContext 2>/dev/null \
| grep -q '^defaultNamingContext:'; then
problems+=("LDAP rootDSE did not answer")
fi
# authoritative SRV lookup against ourselves
zone="$(sed -n 's/^[[:space:]]*search[[:space:]]\{1,\}\([^[:space:]]\{1,\}\).*/\1/p' /etc/resolv.conf 2>/dev/null | head -1)"
if [ -z "$zone" ]; then
zone="$(hostname -d 2>/dev/null || true)"
fi
if [ -n "$zone" ]; then
if ! dig +time=2 +tries=1 +short @127.0.0.1 "_ldap._tcp.dc._msdcs.${zone}" SRV 2>/dev/null | grep -q .; then
problems+=("_ldap._tcp.dc._msdcs.${zone} SRV returned nothing")
fi
else
problems+=("cannot determine DNS zone")
fi
for port in 88 135 445 3268; do
if ! timeout 3 bash -c "exec 3<>/dev/tcp/127.0.0.1/${port}" 2>/dev/null; then
problems+=("tcp/${port} refused")
fi
done
# kpasswd (464) is UDP-only for most clients; check the KDC UDP path instead
if ! timeout 3 bash -c 'exec 3<>/dev/udp/127.0.0.1/88' 2>/dev/null; then
problems+=("udp/88 unusable")
fi
testparm -s /etc/samba/smb.conf >/dev/null 2>&1 || problems+=("testparm failed")
if ((${#problems[@]} == 0)); then
echo "healthy"
exit 0
fi
printf 'UNHEALTHY:'
printf ' %s;' "${problems[@]}"
# Surface the daemon's own last words: samba writes the real reason to its log file and this
# shows up in `docker inspect --format '{{json .State.Health}}'`.
if [ -r /var/log/samba/log.samba ]; then
printf '\n log.samba tail: %s\n' "$(tail -3 /var/log/samba/log.samba | tr '\n' '|')"
fi
echo
exit 1

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

0
secrets/.gitkeep Normal file
View file

144
tests/dr-drill.sh Executable file
View file

@ -0,0 +1,144 @@
#!/usr/bin/env bash
# =====================================================================================
# Disaster-recovery drill - self-contained and paranoid.
#
# Proves that a restore actually recovers THE data, not just a working-looking directory:
# lab DC -> marker OU -> online backup -> restore as a NEW DC -> marker + SID must match
#
# This exists because `samba-tool domain backup restore` can silently leave you with a
# *fresh forest* if the restore step is skipped and the container then self-provisions.
# A restore that passes health checks but lost your data is the worst failure mode there is.
#
# ./tests/dr-drill.sh # tear down afterwards
# KEEP=1 ./tests/dr-drill.sh # leave lab + restored DC running
# =====================================================================================
set -uo pipefail
cd "$(dirname "$0")/.."
IMG=local/samba-ad-dc:4.22-debian13
PW='Lab!Only9Tester#2026'
REALM=TESTLAB.LOCAL
ZONE=testlab.local
BASEDN="DC=testlab,DC=local"
LAB=ad-lab
DRILL=ad-drill
DRILL_IP=172.28.90.30
STAMP="$(date -u +%H%M%S)"
MARKER="DR-MARKER-${STAMP}"
FAIL=0
note() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
die() { printf '\033[31mDRILL FAILED: %s\033[0m\n' "$*" >&2; FAIL=1; }
sid_of() { # $1 container -> base64 domain objectSid (authenticates inside the container)
docker exec "$1" bash -c "printf '%s\n' '$PW' | kinit administrator@${REALM} >/dev/null 2>&1; \
ldapsearch -H ldap://127.0.0.1 -Y GSSAPI -s base -b '${BASEDN}' '(objectClass=domainDNS)' objectSid 2>/dev/null \
| sed -n 's/^[Oo]bjectSid:: //p' | tr -d '\r'"
}
cleanup() {
if [ "${KEEP:-0}" != 1 ]; then
docker rm -f "$DRILL" >/dev/null 2>&1 || true
docker volume rm drill-lib drill-conf >/dev/null 2>&1 || true
docker compose -f docker-compose.lab.yml down -v >/dev/null 2>&1 || true
fi
}
trap cleanup EXIT INT TERM
caps=(--cap-drop ALL)
for c in CHOWN FOWNER SETUID SETGID DAC_OVERRIDE NET_BIND_SERVICE SYS_NICE SYS_RESOURCE AUDIT_WRITE SYS_ADMIN; do
caps+=(--cap-add "$c")
done
note "1/7 fresh lab DC (this is the 'production' we are protecting)"
docker compose -f docker-compose.lab.yml up -d >/dev/null 2>&1 || die "could not start the lab"
h=none
for _ in $(seq 1 60); do
h="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$LAB" 2>/dev/null)"
[ "$h" = healthy ] && break
sleep 4
done
[ "$h" = healthy ] || die "lab not healthy ($h)"
src_sid="$(sid_of "$LAB")"
[ -n "$src_sid" ] || die "could not read the source domain SID"
printf ' %s healthy, domain SID %s\n' "$LAB" "$src_sid"
note "2/7 plant a marker OU so we can prove data survived"
docker exec "$LAB" bash -c "printf '%s\n' '$PW' | kinit administrator@${REALM} 2>/dev/null && samba-tool ou create 'OU=${MARKER}'" >/dev/null 2>&1 \
|| die "could not create the marker OU"
printf ' planted OU=%s\n' "$MARKER"
note "3/7 online backup"
AD_CONTAINER="$LAB" AD_REALM="$REALM" AD_DNS_DOMAIN="$ZONE" AD_HOSTNAME=dc1 AD_ADMIN_PASSWORD="$PW" \
./scripts/backup.sh >/tmp/dr-backup.log 2>&1 || { tail -20 /tmp/dr-backup.log; die "backup.sh failed"; }
BK="$(ls -1t backups/*.tar.bz2 backups/*.tgz 2>/dev/null | head -1)"
[ -n "$BK" ] || die "no archive produced"
printf ' %s (%s)\n' "$(basename "$BK")" "$(du -h "$BK" | cut -f1)"
note "4/7 restore into a fresh volume AS A NEW DC (dc3) - samba's restore does a join"
# Remove the drill container FIRST: a still-mounted volume cannot be removed, and reusing a
# stale volume would silently test the wrong tree.
docker rm -f "$DRILL" >/dev/null 2>&1 || true
docker volume rm drill-lib drill-conf >/dev/null 2>&1 || true
if docker volume inspect drill-lib >/dev/null 2>&1; then
die "could not remove the drill volume (something still holds it) - refusing to test a stale tree"
fi
docker run --rm --network adlab --ip "$DRILL_IP" \
--cap-drop ALL --cap-add SYS_ADMIN --cap-add CHOWN --cap-add FOWNER \
--cap-add DAC_OVERRIDE --cap-add SETUID --cap-add SETGID \
-v "$PWD/backups:/restore:ro" -v drill-lib:/var/lib/samba -v drill-conf:/etc/samba \
-e AD_REALM="$REALM" -e AD_DNS_DOMAIN="$ZONE" -e AD_HOSTNAME=dc3 -e AD_IP="$DRILL_IP" \
-e AD_RESTORE_FROM="/restore/$(basename "$BK")" -e AD_RESTORE_CONFIRM=yes \
"$IMG" >/tmp/dr-restore.log 2>&1
rc=$?
tail -4 /tmp/dr-restore.log | sed 's/^/ /'
[ "$rc" -eq 0 ] || die "restore exited $rc"
grep -q 'restore complete' /tmp/dr-restore.log || die "restore did not report completion"
note "5/7 the fresh volume must already hold sam.ldb BEFORE anything starts serving"
has=$(docker run --rm -v drill-lib:/var/lib/samba --entrypoint bash "$IMG" \
-c '[ -f /var/lib/samba/private/sam.ldb ] && echo yes || echo no' | tail -1)
[ "$has" = yes ] || die "restored volume has no sam.ldb"
printf ' sam.ldb present; marker: %s\n' "$(docker run --rm -v drill-lib:/var/lib/samba --entrypoint bash "$IMG" -c 'head -1 /var/lib/samba/.ad-provisioned')"
note "6/7 serve the restored DC"
docker rm -f "$DRILL" >/dev/null 2>&1 || true
docker run -d --name "$DRILL" --hostname "dc3.${ZONE}" "${caps[@]}" --network adlab --ip "$DRILL_IP" \
-v drill-lib:/var/lib/samba -v drill-conf:/etc/samba \
-e AD_REALM="$REALM" -e AD_DNS_DOMAIN="$ZONE" -e AD_HOSTNAME=dc3 -e AD_IP="$DRILL_IP" \
-e AD_ADMIN_PASSWORD="$PW" -e AD_DNS_FORWARDER='1.1.1.1' "$IMG" >/dev/null || die "could not start restored DC"
h=none
for _ in $(seq 1 60); do
h="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$DRILL" 2>/dev/null)"
[ "$h" = healthy ] && break
sleep 4
done
[ "$h" = healthy ] || { docker logs --tail 40 "$DRILL"; die "restored DC not healthy"; }
printf ' %s healthy on %s\n' "$DRILL" "$DRILL_IP"
note "7/7 integrity assertions"
if docker logs "$DRILL" 2>&1 | grep -q 'provisioning NEW forest'; then
die "restored DC provisioned a NEW forest instead of serving the restore"
fi
new_sid="$(sid_of "$DRILL")"
if [ -z "$new_sid" ]; then
die "could not read the restored domain SID (empty) - refusing to call this a match"
elif [ "$new_sid" = "$src_sid" ]; then
printf ' SID matches the source domain ✔ (%s)\n' "$new_sid"
else
die "domain SID mismatch (src=$src_sid restored=$new_sid)"
fi
found=$(docker exec "$DRILL" bash -c "printf '%s\n' '$PW' | kinit administrator@${REALM} >/dev/null 2>&1; ldapsearch -H ldap://127.0.0.1 -Y GSSAPI -s base -b 'OU=${MARKER},${BASEDN}' dn 2>/dev/null | grep -c '^dn:'" || true)
if [ "${found:-0}" -ge 1 ]; then
printf ' marker OU survived the restore ✔\n'
else
die "marker OU '${MARKER}' is missing after restore - data loss"
fi
AD_SKIP_DOTENV=1 AD_CONTAINER="$DRILL" AD_REALM="$REALM" AD_DNS_DOMAIN="$ZONE" \
AD_HOSTNAME=dc3 AD_ADMIN_PASSWORD="$PW" ./scripts/verify.sh || die "verify failed against restored DC"
if [ "$FAIL" -eq 0 ]; then
printf '\n\033[32mDR DRILL PASSED\033[0m archive=%s -> %s (dc3)\n' "$(basename "$BK")" "$DRILL"
else
printf '\n\033[31mDR DRILL FAILED\033[0m see messages above\n'
fi
exit "$FAIL"

77
tests/ldaps-test.sh Executable file
View file

@ -0,0 +1,77 @@
#!/usr/bin/env bash
# =====================================================================================
# LDAPS test. Proves three separate things:
# * the DC serves 636 and the chain verifies against our CA
# * the SAN actually matches the DC FQDN (Windows requires SAN, not CN)
# * a client WITHOUT our CA is rejected (i.e. we are not accidentally self-trusted)
# Run ./scripts/tls.sh first to create secrets/tls/.
# ./tests/ldaps-test.sh # tears down afterwards
# =====================================================================================
# LDAPS end-to-end check against a freshly provisioned DC with our generated certs.
set -uo pipefail
cd /dockers/WindowsAD
PW='Lab!Only9Tester#2026'
LABTLS="$PWD/secrets/tls-lab"
trap '[ "${KEEP:-0}" = 1 ] || { docker rm -f ad-tlslab >/dev/null 2>&1; docker volume rm tlslab-lib >/dev/null 2>&1; rm -rf "$LABTLS"; }' EXIT
# certs for THIS lab only. Never reuse secrets/tls: that is the production chain,
# regenerating it here would silently destroy the trust anchor every client has installed.
echo "=== generating throwaway testlab CA + leaf (isolated dir, no SYSVOL publish) ==="
mkdir -p "$LABTLS"
AD_TLS_DIR="$LABTLS" AD_TLS_PUBLISH=no AD_REALM=TESTLAB.LOCAL AD_DNS_DOMAIN=testlab.local \
AD_HOSTNAME=dc1 AD_IP=172.28.90.10 AD_CONTAINER=ad-tlslab-absent ./scripts/tls.sh >/tmp/ldaps-tls.log 2>&1 \
|| { echo "cert generation failed:"; tail -15 /tmp/ldaps-tls.log; exit 1; }
# Guard: refuse to run against a cert that is not ours, otherwise this test silently tests the
# production chain and can "pass" for the wrong reason (or fail confusingly).
openssl x509 -in "$LABTLS/dc.crt" -noout -subject | grep -q 'testlab.local' \
|| { echo "!! $LABTLS/dc.crt is not a testlab cert - refusing to run this test"; exit 1; }
openssl x509 -in "$LABTLS/dc.crt" -noout -ext subjectAltName | sed 's/^/ /'
docker rm -f ad-tlslab >/dev/null 2>&1 || true
docker volume rm tlslab-lib >/dev/null 2>&1 || true
docker network rm adlab >/dev/null 2>&1 || true
docker network create --subnet 172.28.90.0/24 --internal adlab >/dev/null
docker run -d --name ad-tlslab --hostname dc1.testlab.local --network adlab --ip 172.28.90.10 \
--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/secrets:/run/ad-secrets:ro" -v "$LABTLS:/run/ad-secrets/tls:ro" -v tlslab-lib:/var/lib/samba \
-e AD_REALM=TESTLAB.LOCAL -e AD_DNS_DOMAIN=testlab.local -e AD_HOSTNAME=dc1 \
-e AD_IP=172.28.90.10 -e AD_ADMIN_PASSWORD="$PW" \
-e AD_TLS_CERT=/run/ad-secrets/tls/dc.crt -e AD_TLS_KEY=/run/ad-secrets/tls/dc.key \
-e AD_TLS_CA=/run/ad-secrets/tls/ca.crt \
local/samba-ad-dc:4.22-debian13 >/dev/null || { echo "start failed"; exit 1; }
h=none
for _ in $(seq 1 60); do
h="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' ad-tlslab 2>/dev/null || echo gone)"
[ "$h" = healthy ] && break
sleep 4
done
echo "health: $h"
st="$(docker inspect -f '{{.State.Status}} exit={{.State.ExitCode}}' ad-tlslab 2>/dev/null)"
echo "state: $st"
[ "$h" = healthy ] || { echo "--- log ---"; docker logs ad-tlslab 2>&1 | grep -vE 'Applied |Skip ' | tail -20; exit 1; }
echo "=== 636 listening? ==="
docker exec ad-tlslab ss -ltn | grep ':636' || { echo "636 not listening"; exit 1; }
echo "=== handshake, trusting our CA (must verify) ==="
docker exec ad-tlslab openssl s_client -connect 127.0.0.1:636 \
-CAfile /run/ad-secrets/tls/ca.crt -verify_return_error </dev/null 2>&1 \
| grep -E 'Verify return code|subject=|CN' | head -4
echo "=== handshake with default trust store (must FAIL, proves certs are not self-trusted) ==="
if docker exec ad-tlslab openssl s_client -connect 127.0.0.1:636 -verify_return_error </dev/null >/dev/null 2>&1; then
echo "UNEXPECTED: untrusted handshake succeeded"; RC=1
else
echo "rejected as expected ✔"
fi
echo "=== full battery with LDAPS enabled ==="
AD_SKIP_DOTENV=1 AD_CONTAINER=ad-tlslab AD_REALM=TESTLAB.LOCAL AD_DNS_DOMAIN=testlab.local \
AD_HOSTNAME=dc1 AD_ADMIN_PASSWORD="$PW" \
AD_TLS_CERT=/run/ad-secrets/tls/dc.crt AD_TLS_CA=/run/ad-secrets/tls/ca.crt \
./scripts/verify.sh | grep -E 'LDAPS|passed,'
RC=${RC:-0}
exit "$RC"

109
tests/smoke.sh Executable file
View file

@ -0,0 +1,109 @@
#!/usr/bin/env bash
# =====================================================================================
# End-to-end smoke test. Fully isolated: realm TESTLAB.LOCAL on an internal-only docker
# network, named volumes, no host ports, throwaway credentials.
#
# Proves, in order:
# 1. the image builds
# 2. a fresh volume provisions a working AD DC (DNS/Kerberos/LDAP/GC/SYSVOL)
# 3. hardening params actually land in smb.conf
# 4. a restart REUSES the database instead of re-provisioning (idempotency)
# 5. the full verify battery passes both before and after the restart
#
# ./tests/smoke.sh # build + test + destroy
# KEEP=1 ./tests/smoke.sh # leave the lab container running for poking at
# =====================================================================================
set -uo pipefail
cd "$(dirname "$0")/.."
LAB_FILE=docker-compose.lab.yml
CID=ad-lab
LAB_PW='Lab!Only9Tester#2026'
IMAGE=local/samba-ad-dc:4.22-debian13
fail_hard() {
echo
echo "!! ${1}" >&2
echo "---- recent log (schema-apply noise filtered) ----" >&2
docker compose -f "$LAB_FILE" logs --tail 400 2>&1 \
| grep -vE '(Applied|Skip) (Domain|Forest) Update' | tail -40 | sed 's/^/ /' >&2
[ "${KEEP:-0}" = 1 ] || docker compose -f "$LAB_FILE" down -v >/dev/null 2>&1 || true
exit 1
}
echo "==> 1/6 build image"
if ! docker build -t "$IMAGE" -f image/Dockerfile . ; then
echo "!! image build failed" >&2
exit 1
fi
echo "==> 2/6 start lab DC"
docker compose -f "$LAB_FILE" down -v >/dev/null 2>&1 || true # always start from a clean slate
docker compose -f "$LAB_FILE" up -d >/dev/null 2>&1 || fail_hard "compose up failed"
wait_healthy() { # $1 = seconds
local deadline=$(( SECONDS + ${1:-240} )) st=""
while [ "$SECONDS" -lt "$deadline" ]; do
st="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$CID" 2>/dev/null || echo missing)"
[ "$st" = healthy ] && return 0
[ "$st" = unhealthy ] && return 2
printf '.'
sleep 4
done
return 1
}
printf ' waiting for healthy '
wait_healthy 300; rc=$?
echo " -> $rc"
[ "$rc" = 0 ] || fail_hard "lab DC never became healthy (rc=$rc)"
if ! docker compose -f "$LAB_FILE" logs 2>&1 | grep -q 'provisioning complete'; then
fail_hard "expected the entrypoint to report a fresh provisioning run"
fi
echo " fresh provisioning reported"
verify() {
AD_SKIP_DOTENV=1 AD_CONTAINER="$CID" \
AD_REALM=TESTLAB.LOCAL AD_DNS_DOMAIN=testlab.local AD_HOSTNAME=dc1 \
AD_ADMIN_PASSWORD="$LAB_PW" ./scripts/verify.sh
}
echo "==> 3/6 verification battery (fresh forest)"
verify || fail_hard "verification failed on a freshly provisioned forest"
echo "==> 4/6 hardening landed in smb.conf"
for kv in 'server signing = required' 'smb encrypt = desired' 'ntlm auth = ntlmv2-only' 'restrict anonymous = 2'; do
if docker exec "$CID" grep -q "^[[:space:]]*${kv}[[:space:]]*$" /etc/samba/smb.conf; then
echo " ok: $kv"
else
fail_hard "missing '$kv' in rendered smb.conf"
fi
done
echo "==> 5/6 restart must NOT re-provision"
# Count log lines first and diff afterwards: `docker logs --since` is interpreted in the
# daemon's local timezone, which silently mis-filters when the host is not UTC.
log_before="$(docker compose -f "$LAB_FILE" logs 2>/dev/null | wc -l)"
docker restart "$CID" >/dev/null 2>&1 || fail_hard "restart failed"
printf ' waiting for healthy after restart '
wait_healthy 180; rc=$?
echo " -> $rc"
[ "$rc" = 0 ] || fail_hard "not healthy after restart"
log="$(docker compose -f "$LAB_FILE" logs 2>/dev/null | tail -n +$((log_before + 1)))"
if printf '%s' "$log" | grep -q 'provisioning NEW forest'; then
fail_hard "restart tried to re-provision the domain (idempotency broken)"
fi
printf '%s' "$log" | grep -q 'existing AD database found' || fail_hard "restart did not detect the existing database"
echo " entrypoint reused the existing database"
echo "==> 6/6 verification battery (after restart)"
verify || fail_hard "verification failed after restart"
echo
echo -e "\033[32mSMOKE TEST PASSED\033[0m (image: $IMAGE)"
if [ "${KEEP:-0}" = 1 ]; then
echo "lab left running: docker compose -f $LAB_FILE logs -f | docker exec -it $CID bash"
else
docker compose -f "$LAB_FILE" down -v >/dev/null 2>&1 || true
echo "lab destroyed"
fi