313 lines
18 KiB
Markdown
313 lines
18 KiB
Markdown
# 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`.
|