commit 2e5a4751a72af4bfe0b583edb00f3f3cbedc6de6 Author: Lonny Lowe Date: Thu Sep 24 11:29:54 2026 -0500 First Commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..92b00b1 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a3f448c --- /dev/null +++ b/.env.example @@ -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=. +#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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3df6e51 --- /dev/null +++ b/.gitignore @@ -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-, .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] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7b43ed5 --- /dev/null +++ b/Makefile @@ -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/.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)" diff --git a/README.md b/README.md new file mode 100644 index 0000000..0e4c4de --- /dev/null +++ b/README.md @@ -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:,…` | +| 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`. diff --git a/backups/.gitkeep b/backups/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/conf-dc2/.gitkeep b/conf-dc2/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/conf/.gitkeep b/conf/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/samba-dc2/.gitkeep b/data/samba-dc2/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/samba/.gitkeep b/data/samba/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.lab.yml b/docker-compose.lab.yml new file mode 100644 index 0000000..a9f56b4 --- /dev/null +++ b/docker-compose.lab.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..357572e --- /dev/null +++ b/docker-compose.yml @@ -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= AD_JOIN_SERVER=dc1. + # AD_JOIN_USER=administrator AD_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 diff --git a/docs/docker-compose.macvlan.yml.example b/docs/docker-compose.macvlan.yml.example new file mode 100644 index 0000000..7de273a --- /dev/null +++ b/docs/docker-compose.macvlan.yml.example @@ -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 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 diff --git a/image/Dockerfile b/image/Dockerfile new file mode 100644 index 0000000..2ed6e32 --- /dev/null +++ b/image/Dockerfile @@ -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"] diff --git a/image/ad-dnsupdate.sh b/image/ad-dnsupdate.sh new file mode 100644 index 0000000..a1fc56c --- /dev/null +++ b/image/ad-dnsupdate.sh @@ -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 "$@" diff --git a/image/ad-entrypoint.sh b/image/ad-entrypoint.sh new file mode 100755 index 0000000..eeae353 --- /dev/null +++ b/image/ad-entrypoint.sh @@ -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 /' +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=,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" diff --git a/image/ad-healthcheck.sh b/image/ad-healthcheck.sh new file mode 100755 index 0000000..6229fe3 --- /dev/null +++ b/image/ad-healthcheck.sh @@ -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 diff --git a/scripts/add-replica.sh b/scripts/add-replica.sh new file mode 100755 index 0000000..efc6ffe --- /dev/null +++ b/scripts/add-replica.sh @@ -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= +# 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=\" ${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" diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100755 index 0000000..345424b --- /dev/null +++ b/scripts/backup.sh @@ -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/.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--.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." diff --git a/scripts/envfile.sh b/scripts/envfile.sh new file mode 100644 index 0000000..438d9e4 --- /dev/null +++ b/scripts/envfile.sh @@ -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 +} diff --git a/scripts/mksecrets.sh b/scripts/mksecrets.sh new file mode 100755 index 0000000..53cc719 --- /dev/null +++ b/scripts/mksecrets.sh @@ -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}" diff --git a/scripts/restore.sh b/scripts/restore.sh new file mode 100755 index 0000000..a713a0d --- /dev/null +++ b/scripts/restore.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# ===================================================================================== +# Disaster recovery: bring the domain back from an online backup archive. +# +# ./scripts/restore.sh backups/.tar.bz2 [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=,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 [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 < -U administrator + docker exec -it $cid ldbdel -H /var/lib/samba/private/sam.ldb "" # careful + 3. Take a fresh backup immediately: make backup +EOF diff --git a/scripts/tls.sh b/scripts/tls.sh new file mode 100755 index 0000000..939a980 --- /dev/null +++ b/scripts/tls.sh @@ -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= 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 < 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 diff --git a/scripts/verify.sh b/scripts/verify.sh new file mode 100755 index 0000000..a90d12e --- /dev/null +++ b/scripts/verify.sh @@ -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 &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" diff --git a/scripts/win11-join.ps1 b/scripts/win11-join.ps1 new file mode 100644 index 0000000..c13caff --- /dev/null +++ b/scripts/win11-join.ps1 @@ -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 diff --git a/secrets/.gitkeep b/secrets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/dr-drill.sh b/tests/dr-drill.sh new file mode 100755 index 0000000..2f5fdfa --- /dev/null +++ b/tests/dr-drill.sh @@ -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" diff --git a/tests/ldaps-test.sh b/tests/ldaps-test.sh new file mode 100755 index 0000000..d274913 --- /dev/null +++ b/tests/ldaps-test.sh @@ -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 &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 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" diff --git a/tests/smoke.sh b/tests/smoke.sh new file mode 100755 index 0000000..fc148dc --- /dev/null +++ b/tests/smoke.sh @@ -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