# 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:,…` | | 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**: `\\\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/.tgz + sha256 make restore BACKUP=backups/.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/.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--.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`.