First Commit
This commit is contained in:
commit
2e5a4751a7
30 changed files with 2517 additions and 0 deletions
23
.dockerignore
Normal file
23
.dockerignore
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Never bake state or secrets into the image.
|
||||
# Mirrors .gitignore: anything under data/, conf/, secrets/ or backups/ is runtime state or
|
||||
# key material. The image must be buildable from image/, nothing else.
|
||||
data/
|
||||
conf/
|
||||
conf-dc2/
|
||||
secrets/
|
||||
backups/
|
||||
*.tgz
|
||||
*.tar.bz2
|
||||
*.sha256
|
||||
*.key
|
||||
*.crt
|
||||
*.pem
|
||||
*.p12
|
||||
*.pfx
|
||||
.env
|
||||
.env.*
|
||||
.git/
|
||||
.gitignore
|
||||
__pycache__/
|
||||
*.log
|
||||
*.tmp
|
||||
71
.env.example
Normal file
71
.env.example
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# =====================================================================================
|
||||
# Copy to .env and edit. Everything here is consumed by docker compose interpolation
|
||||
# (hostname/container_name/paths) AND passed into the container environment.
|
||||
#
|
||||
# cp .env.example .env && chmod 600 .env
|
||||
# =====================================================================================
|
||||
|
||||
# --- Identity -------------------------------------------------------------------------
|
||||
# Realm: uppercase, no underscores, must be a DNS domain you control. Use a *sub*domain
|
||||
# (ad.example.com) - never your registered mail domain root.
|
||||
# Quoting: docker-compose accepts unquoted multi-word values, but a plain shell does NOT
|
||||
# (AD_DNS_FORWARDER=1.1.1.1 8.8.8.8 would try to EXECUTE "8.8.8.8"). The scripts here
|
||||
# parse .env rather than execute it, but please QUOTE any value containing spaces anyway.
|
||||
AD_REALM=AD.EXAMPLE.COM
|
||||
AD_DNS_DOMAIN=ad.example.com
|
||||
# Short container/DC hostname -> FQDN becomes dc1.ad.example.com
|
||||
AD_HOSTNAME=dc1
|
||||
|
||||
# Docker container name (used by scripts/*.sh; override with AD_CONTAINER=... )
|
||||
AD_CONTAINER=ad-dc
|
||||
|
||||
# --- Network -------------------------------------------------------------------------
|
||||
# The address that lands in the DC's DNS A record. MUST be reachable by domain clients.
|
||||
# With network_mode: host this is the host's LAN IP. Set it explicitly if the host has
|
||||
# more than one address or if DHCP may move it (get a DHCP reservation!).
|
||||
AD_IP=192.168.5.12
|
||||
# Optional: static hosts injected into /etc/hosts (semicolon separated), useful for
|
||||
# replicas that must reach the first DC before DNS is trustworthy:
|
||||
#AD_EXTRA_HOSTS=192.168.5.20 dc1.ad.example.com dc1
|
||||
#AD_IP6=auto # "auto" = discover a global IPv6; empty = no AAAA record
|
||||
AD_DNS_FORWARDER="1.1.1.1 8.8.8.8"
|
||||
AD_MANAGE_RESOLV=yes # yes = container points its own resolver at 127.0.0.1
|
||||
|
||||
# --- Directory -----------------------------------------------------------------------
|
||||
AD_DNS_BACKEND=SAMBA_INTERNAL # SAMBA_INTERNAL | BIND9_DLZ
|
||||
AD_SITE=Default-First-Site-Name
|
||||
AD_FUNCTION_LEVEL=2008_R2 # 2000|2003|2008|2008_R2|2016 (Win11 fine at 2008_R2)
|
||||
AD_BASE_SCHEMA=2019 # 4.22 default; do NOT run Windows adprep against it
|
||||
AD_USE_RFC2307=yes # posix uid/gid in AD (handy for joined Linux hosts)
|
||||
AD_ADMIN_USER=administrator
|
||||
|
||||
# --- Credentials ---------------------------------------------------------------------
|
||||
# Preferred: leave AD_ADMIN_PASSWORD empty and put the password in secrets/ad_admin.password
|
||||
# with mode 600. NOTE: during the *first* provision Samba passes it via argv, so
|
||||
# `docker top` can see it for a few seconds - rotate the password afterwards if that
|
||||
# matters to you: docker exec -it ad-dc samba-tool user setpassword administrator
|
||||
AD_ADMIN_PASSWORD=
|
||||
|
||||
# --- Hardening (rendered into smb.conf [global] on every start) ----------------------
|
||||
AD_APPLY_HARDENING=yes
|
||||
AD_SMB_SIGNING=required # Win11: sign or encrypt always
|
||||
AD_SMB_ENCRYPT=desired # required = force SMB3 encryption (old clients may break)
|
||||
AD_NTLM_AUTH=ntlmv2-only # disabled = Kerberos only (set after migration audit)
|
||||
AD_RESTRICT_ANONYMOUS=2
|
||||
AD_SMB_MIN_PROTOCOL=SMB2_02
|
||||
AD_LOG_LEVEL=1
|
||||
|
||||
# --- LDAPS ---------------------------------------------------------------------------
|
||||
# Generate with scripts/tls.sh, then uncomment. The leaf MUST contain SAN=DC=<fqdn>.
|
||||
#AD_TLS_CERT=/run/ad-secrets/tls/dc.crt
|
||||
#AD_TLS_KEY=/run/ad-secrets/tls/dc.key
|
||||
#AD_TLS_CA=/run/ad-secrets/tls/ca.crt
|
||||
|
||||
# --- Additional domain controller (replica) ------------------------------------------
|
||||
# Only used on a fresh data volume; otherwise the existing database decides.
|
||||
#AD_JOIN_SERVER=dc1.ad.example.com
|
||||
#AD_JOIN_USER=administrator
|
||||
#AD_JOIN_PASSWORD=
|
||||
|
||||
# --- Ops ----------------------------------------------------------------------------
|
||||
AD_DNS_REFRESH_SECONDS=300
|
||||
81
.gitignore
vendored
Normal file
81
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# =====================================================================================
|
||||
# Two kinds of things belong on this list:
|
||||
# (a) SECRETS - leaking these is a domain compromise, not a typo
|
||||
# (b) RUNNING CONTAINER STATE - reproducible from this repo; committing it would shadow
|
||||
# the config that the entrypoint renders from .env on every start
|
||||
#
|
||||
# What you need to build a container stays tracked: image/, scripts/, tests/, docs/,
|
||||
# docker-compose*.yml, Makefile, README.md, .env.example, .dockerignore, this file.
|
||||
# =====================================================================================
|
||||
|
||||
# --- secrets --------------------------------------------------------------------
|
||||
# Real configuration: holds the domain administrator password.
|
||||
# `.env.*` also catches restore scratch/backups (.env.pre-restore-<ts>, .env.tmp).
|
||||
# .env.example is the only member of this family that belongs in git.
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Admin password file, LDAPS CA + leaf + keys, throwaway lab CAs (secrets/tls-lab).
|
||||
# NOTE: secrets/tls/ca.key is the trust anchor every joined client has installed. Leak it
|
||||
# and someone can mint certificates your whole fleet trusts.
|
||||
secrets/*
|
||||
!secrets/.gitkeep
|
||||
|
||||
# Belt-and-braces: never let key material sneak in from anywhere else either.
|
||||
*.key
|
||||
*.pem
|
||||
# (public certs are harmless but only ever live under secrets/, so ignore them too)
|
||||
*.crt
|
||||
*.p12
|
||||
*.pfx
|
||||
|
||||
# --- running container state ----------------------------------------------------
|
||||
# smb.conf as generated/rendered by the entrypoint (source of truth is .env + entrypoint).
|
||||
conf/*
|
||||
!conf/.gitkeep
|
||||
conf-dc2/*
|
||||
!conf-dc2/.gitkeep
|
||||
|
||||
# sam.ldb (NT hashes, Kerberos keys, Backup Key) + SYSVOL + machine account keys.
|
||||
# This IS the domain. Treat it as production data, never as source.
|
||||
data/*
|
||||
!data/.gitkeep
|
||||
data/samba
|
||||
!data/samba
|
||||
data/samba/*
|
||||
!data/samba/.gitkeep
|
||||
data/samba-dc2
|
||||
!data/samba-dc2
|
||||
data/samba-dc2/*
|
||||
!data/samba-dc2/.gitkeep
|
||||
|
||||
# --- backups ------------------------------------------------------------------
|
||||
# `samba-tool domain backup` archives are whole-domain copies: password hashes and the
|
||||
# Backup Key included. They go off-host encrypted, not into version control.
|
||||
backups/*
|
||||
!backups/.gitkeep
|
||||
*.tar.bz2
|
||||
*.tgz
|
||||
*.sha256
|
||||
|
||||
# --- per-machine compose overrides ----------------------------------------------
|
||||
# Usually local-only (different IP/realm per box). Force-add if you mean to share it:
|
||||
# git add -f docker-compose.override.yml
|
||||
docker-compose.override.yml
|
||||
docker-compose.*.local.yml
|
||||
|
||||
# --- scratch, logs, editor and OS junk ------------------------------------------
|
||||
*.log
|
||||
*.tmp
|
||||
*.bak
|
||||
*.swp
|
||||
*~
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
# test harness scratch that landed inside the tree
|
||||
tests/.tmp/
|
||||
|
||||
# --- python -------------------------------------------------------------------
|
||||
__pycache__/
|
||||
*.py[co]
|
||||
100
Makefile
Normal file
100
Makefile
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
# Samba AD DC - operational entry points. `make` prints this list.
|
||||
SHELL := /bin/bash
|
||||
IMAGE ?= local/samba-ad-dc:4.22-debian13
|
||||
CID ?= $(shell sed -n 's/^AD_CONTAINER=//p' .env 2>/dev/null | head -1)
|
||||
CID := $(if $(CID),$(CID),ad-dc)
|
||||
|
||||
.PHONY: help build up down restart logs ps verify verify-full backup tls lab labdown smoke drill ldaps test \
|
||||
shell secrets passwd fsmo repl-status clean
|
||||
|
||||
help:
|
||||
@echo "Samba AD DC on Debian 13 (container: $(IMAGE), name: $(CID))"
|
||||
@echo
|
||||
@echo " make secrets create secrets/ad_admin.password (random, 0600)"
|
||||
@echo " make build build the image"
|
||||
@echo " make up start the DC (host networking - check ports 53/88/445 first)"
|
||||
@echo " make down stop it make restart: same, harder"
|
||||
@echo " make logs follow container logs"
|
||||
@echo " make verify run the verification battery (add FULL=1 for dbcheck)"
|
||||
@echo " make backup online full-domain backup into ./backups"
|
||||
@echo " make tls build internal CA + LDAPS leaf cert"
|
||||
@echo " make passwd rotate the administrator password (interactive)"
|
||||
@echo " make fsmo show FSMO role ownership"
|
||||
@echo " make repl-status replication status (meaningful once a replica exists)"
|
||||
@echo
|
||||
@echo " make lab isolated TESTLAB.LOCAL lab DC (no host ports)"
|
||||
@echo " make labdown destroy the lab"
|
||||
@echo " make smoke full isolated end-to-end test (build -> provision -> verify -> restart)"
|
||||
@echo " make drill disaster-recovery drill (backup -> restore -> SID + data assertions)"
|
||||
@echo " make ldaps LDAPS: chain verifies, SAN matches, untrusted clients rejected"
|
||||
@echo " make test smoke + drill + ldaps (run before every upgrade)"
|
||||
@echo " make restore BACKUP=backups/xxx.tgz disaster recovery drill"
|
||||
|
||||
build:
|
||||
docker build -t $(IMAGE) -f image/Dockerfile .
|
||||
|
||||
up:
|
||||
docker compose up -d
|
||||
|
||||
down:
|
||||
docker compose down
|
||||
|
||||
restart:
|
||||
docker compose restart
|
||||
|
||||
logs:
|
||||
docker compose logs -f --tail=100
|
||||
|
||||
ps:
|
||||
@docker compose ps -a
|
||||
|
||||
verify:
|
||||
AD_CONTAINER=$(CID) ./scripts/verify.sh $(if $(FULL),--full,)
|
||||
|
||||
backup:
|
||||
./scripts/backup.sh
|
||||
|
||||
tls:
|
||||
./scripts/tls.sh
|
||||
|
||||
secrets:
|
||||
./scripts/mksecrets.sh
|
||||
|
||||
passwd:
|
||||
docker exec -it $(CID) samba-tool user setpassword $(shell grep -m1 '^AD_ADMIN_USER=' .env 2>/dev/null | cut -d= -f2 || echo administrator)
|
||||
|
||||
fsmo:
|
||||
docker exec -it $(CID) samba-tool fsmo show
|
||||
|
||||
repl-status:
|
||||
docker exec -it $(CID) samba-tool drs showrepl
|
||||
|
||||
lab:
|
||||
docker compose -f docker-compose.lab.yml up -d
|
||||
@echo "lab up: realm TESTLAB.LOCAL, host alias dc1.testlab.local -> 172.28.90.10"
|
||||
@echo "destroy with: make labdown"
|
||||
|
||||
labdown:
|
||||
docker compose -f docker-compose.lab.yml down -v
|
||||
|
||||
smoke:
|
||||
./tests/smoke.sh
|
||||
|
||||
drill:
|
||||
./tests/dr-drill.sh
|
||||
|
||||
ldaps:
|
||||
./scripts/tls.sh
|
||||
./tests/ldaps-test.sh
|
||||
|
||||
test: smoke drill ldaps
|
||||
@echo "all test suites passed"
|
||||
|
||||
restore:
|
||||
@test -n "$(BACKUP)" || { echo "usage: make restore BACKUP=backups/<file>.tgz"; exit 2; }
|
||||
./scripts/restore.sh $(BACKUP)
|
||||
|
||||
clean:
|
||||
docker compose down -v --remove-orphans || true
|
||||
docker compose -f docker-compose.lab.yml down -v --remove-orphans || true
|
||||
@echo "left ./conf ./data ./secrets ./backups in place (delete manually if you really mean it)"
|
||||
313
README.md
Normal file
313
README.md
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
# WindowsAD — Samba 4.22 Active Directory Domain Controller in Docker (Debian 13)
|
||||
|
||||
A containerised AD Domain Controller that Windows 11 machines can join: DNS SRV bootstrap,
|
||||
Kerberos (AES), LDAP/LDAPS, Global Catalog, SYSVOL/NETLOGON, secure channel, Group Policy.
|
||||
Built on Debian 13 `samba-ad-dc` 4.22.11 — one `samba` process serves the whole DC role.
|
||||
|
||||
```
|
||||
/dockers/WindowsAD
|
||||
├── image/
|
||||
│ ├── Dockerfile debian:trixie-slim + samba-ad-dc (version-pinned ARG)
|
||||
│ ├── ad-entrypoint.sh idempotent: validate -> provision|reuse -> harden -> start
|
||||
│ └── ad-healthcheck.sh credential-free health probe
|
||||
├── docker-compose.yml production: host networking, primary + `replica` profile
|
||||
├── docker-compose.lab.yml isolated TESTLAB.LOCAL lab on an internal network
|
||||
├── docs/docker-compose.macvlan.yml.example LAN-addressed DC without host networking
|
||||
├── .env.example every knob, documented
|
||||
├── scripts/
|
||||
│ ├── mksecrets.sh secrets/ad_admin.password (0600, random)
|
||||
│ ├── tls.sh internal CA + LDAPS leaf (SAN required by Win11)
|
||||
│ ├── verify.sh full verification battery (add --full for dbcheck)
|
||||
│ ├── backup.sh / restore.sh online domain backup + DR drill
|
||||
│ ├── add-replica.sh second DC
|
||||
│ └── win11-join.ps1 Windows-side: DNS -> CA -> join -> verify
|
||||
├── tests/smoke.sh end-to-end: build -> provision -> verify -> restart -> verify
|
||||
└── Makefile make (self-documenting)
|
||||
```
|
||||
|
||||
## How it behaves
|
||||
|
||||
`ad-entrypoint.sh` is idempotent — restart it as often as you like:
|
||||
|
||||
1. validate realm / hostname / NetBIOS / password complexity, fail fast
|
||||
2. resolve own IPv4 (`AD_IP`, or auto-detect) and write `/etc/hosts`
|
||||
3. render `/etc/krb5.conf` with **explicit** KDCs (`dns_lookup_kdc = false`) and
|
||||
`udp_preference_limit = 1465` (Win11 + big PACs)
|
||||
4. if `sam.ldb` missing → provision a new forest; if `AD_JOIN_SERVER` set → join as a
|
||||
replica; otherwise → **reuse** the existing directory (never re-provisions)
|
||||
5. upsert hardening params into `smb.conf [global]` on *every* start (so `.env` edits
|
||||
apply on restart), then **refuse to start** if `testparm` rejects anything
|
||||
6. self-referencing resolver: container's `resolv.conf` → `nameserver 127.0.0.1`
|
||||
(upstream goes out via `dns forwarder`); in host mode the host's files are untouched
|
||||
7. background `samba_dnsupdate` every 5 min keeps A records aligned with the current IP
|
||||
8. `exec samba --foreground --no-process-group` — same invocation as the Debian unit, so
|
||||
`docker stop` shuts the directory down cleanly
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
cd /dockers/WindowsAD
|
||||
cp .env.example .env && chmod 600 .env # EDIT realm/hostname/IP first
|
||||
make secrets # random admin password -> secrets/
|
||||
make build
|
||||
make up # host networking: see port checklist below
|
||||
make verify
|
||||
```
|
||||
|
||||
Then `make backup` before you let any Windows machine near it.
|
||||
|
||||
### First, check nothing owns the DC's ports on the host
|
||||
|
||||
```bash
|
||||
ss -ltnup | awk '$5 ~ /:(53|88|135|139|389|445|464|636|3268|3269)$/ {print}'
|
||||
systemctl is-active systemd-resolved # must be inactive (or the :53 stub disabled)
|
||||
```
|
||||
`systemd-resolved`'s stub on `127.0.0.53:53` is the classic collision. Disable it, or run
|
||||
the DC in macvlan mode.
|
||||
|
||||
### Configure `.env` correctly (the three that bite people)
|
||||
|
||||
| var | why it matters |
|
||||
|---|---|
|
||||
| `AD_REALM` | uppercase, no underscores, a subdomain you control (`ad.example.com`). Never your mail domain root. |
|
||||
| `AD_IP` | goes into the DC's DNS **A record**. Must be the address clients can actually route to. Get a DHCP reservation — a lease change silently rots every record. |
|
||||
| `AD_DNS_FORWARDER` | the DC answers its own zone and forwards everything else. |
|
||||
|
||||
## Windows 11 join
|
||||
|
||||
```powershell
|
||||
# elevated PowerShell on the Win11 box (copy scripts/tls.sh output ca.crt over first)
|
||||
Set-Location \\server\dockers\WindowsAD # or copy the script locally
|
||||
.\win11-join.ps1 -DnsServer 192.168.5.12 -Domain ad.example.com `
|
||||
-DcHost dc1.ad.example.com -CaCert C:\adca.crt
|
||||
```
|
||||
Or manually:
|
||||
```powershell
|
||||
Set-DnsClientServerAddress -InterfaceIndex (Get-NetAdapter).ifIndex -ServerAddresses 192.168.5.12
|
||||
Resolve-DnsName _ldap._tcp.dc._msdcs.ad.example.com -Type SRV # must answer
|
||||
Add-Computer -DomainName ad.example.com -Restart
|
||||
# after reboot
|
||||
Test-ComputerSecureChannel -Verbose ; nltest /dsgetdc:ad ; gpupdate /force ; gpresult /r
|
||||
```
|
||||
RSAT (to author GPOs/OU structure against Samba):
|
||||
```powershell
|
||||
Add-WindowsCapability -Online -Name Rsat.ActiveDirectory.DS-LDS.Tools~~~~0.0.1.0
|
||||
Add-WindowsCapability -Online -Name Rsat.GroupPolicy.Management.Tools~~~~0.0.1.0
|
||||
```
|
||||
|
||||
## Hardening map (`.env` → `smb.conf`)
|
||||
|
||||
| Windows 11 requirement | this project |
|
||||
|---|---|
|
||||
| SMB1 is gone | `AD_SMB_MIN_PROTOCOL=SMB2_02` |
|
||||
| sign or encrypt always / 24H2 refuses unsigned channels | `AD_SMB_SIGNING=required` |
|
||||
| SMB3 session encryption | `AD_SMB_ENCRYPT=desired` → `required` once all clients support it |
|
||||
| NTLM retirement | `AD_NTLM_AUTH=ntlmv2-only` → `disabled` after you audit for NTLM users |
|
||||
| no anonymous enumeration | `AD_RESTRICT_ANONYMOUS=2`, `map to guest = never` |
|
||||
| AES Kerberos, PAC > 1465 bytes | `udp_preference_limit = 1465` in rendered `krb5.conf` |
|
||||
| Schannel needs SAN, not CN | `scripts/tls.sh` issues `SAN=DNS:<dc fqdn>,…` |
|
||||
| LDAP signing | push the GPO “Domain controller: LDAP server signing requirements = Require signing”; serve LDAPS 636 with a trusted cert so clients never need the unsigned path |
|
||||
| time skew < 5 min | host must run chrony/ntp; joined clients sync from the DC (MS-SNTP served by `samba`) |
|
||||
|
||||
### LDAPS and the trust chain
|
||||
|
||||
```bash
|
||||
make tls # internal CA + leaf with SAN, then enable in .env
|
||||
# add to .env:
|
||||
# AD_TLS_CERT=/run/ad-secrets/tls/dc.crt
|
||||
# AD_TLS_KEY=/run/ad-secrets/tls/dc.key
|
||||
# AD_TLS_CA=/run/ad-secrets/tls/ca.crt
|
||||
docker compose up -d --force-recreate ad-dc
|
||||
./scripts/verify.sh # chain, SAN, strict bind + two rejection tests
|
||||
```
|
||||
|
||||
`make tls` also **publishes the CA where joined machines can fetch it**:
|
||||
`\\<domain>\NETLOGON\ad-root-ca.crt` (NETLOGON is read-only and readable by any domain
|
||||
credential — the same place Windows ships its own CA). If that extra file disturbs the SYSVOL
|
||||
NT ACLs, the script detects it via `sysvolcheck` and rolls the copy back.
|
||||
|
||||
Trust it on each Windows client (**this is the step that removes the popups**):
|
||||
```powershell
|
||||
certutil -addstore -f ROOT C:\path\to\ca.crt
|
||||
certutil -addstore -f ROOT "\\premier.local\NETLOGON\ad-root-ca.crt" # or straight off the DC
|
||||
```
|
||||
Install into the **LOCAL COMPUTER** store, not the current-user store: LDAP/Schannel binds run
|
||||
as SYSTEM and service accounts, which cannot see per-user trust. Fleet-wide: GPO → Computer
|
||||
Configuration → Policies → Windows Settings → Security Settings → Public Key Policies →
|
||||
Trusted Root Certification Authorities.
|
||||
|
||||
The entrypoint **stages** the mounted cert/key into `/var/lib/samba/private/tls/` as root with
|
||||
mode `0600`, because Samba refuses to load a key that is not root-owned (CVE-2013-4476) —
|
||||
which is exactly what a bind-mounted host cert looks like from inside the container. It also
|
||||
checks the key matches the cert and that the SAN contains the DC FQDN, failing fast otherwise.
|
||||
So the host-side ownership of `secrets/` does not matter.
|
||||
|
||||
### Buy a public cert (EasySSL / Let's Encrypt) instead? Mostly no.
|
||||
|
||||
For a **domain-joined** machine an internal CA gives you strictly fewer popups, not more: once
|
||||
the root sits in the machine trust store the certificate is unremarkable, and you never touch
|
||||
it again for ~2 years (`make tls` re-issues against the same CA).
|
||||
|
||||
| | internal CA (`make tls`) | public CA (EasySSL / LE) |
|
||||
|---|---|---|
|
||||
| works for `.local`, `.lan`, or a private name | yes | **no** — requires a publicly resolvable, validatable hostname |
|
||||
| popups on joined Win11 clients | none once the root is trusted | none once the chain validates |
|
||||
| renewal | ~2.3 yr leaf, 10 yr CA, re-issue in place | 90 days (LE) / 1 yr, automation mandatory; expiry = LDAPS/app outage |
|
||||
| where the key lives | your `secrets/tls/` | fine, but you must automate fetching it into the container |
|
||||
| information leak | none | hostname (and any SAN) is published in Certificate Transparency logs |
|
||||
| LDAP signing / channel binding | unaffected either way | unaffected either way |
|
||||
|
||||
**Use a public cert when** a *non-domain-joined* or *external* client must reach LDAPS/HTTPS and
|
||||
you cannot install a root there (SaaS connectors, hosted apps, appliances). For the LAN and
|
||||
domain-joined Win11 boxes, the internal CA is the right tool.
|
||||
|
||||
**Trust-anchor hygiene:** `secrets/tls/ca.key` is the thing to guard. Back it up offline and
|
||||
encrypted — lose it and every cert it signed must be reissued and re-trusted; leak it and an
|
||||
attacker can mint certificates your whole fleet trusts. Rotating the leaf is cheap (`make tls`,
|
||||
CA untouched); rotating the CA means redeploying trust everywhere.
|
||||
|
||||
|
||||
## Ops
|
||||
|
||||
```bash
|
||||
make verify # ~15 checks; exits non-zero on failure
|
||||
make verify FULL=1 # + samba-tool dbcheck --cross-ncs (slow, DB heavy)
|
||||
make backup # online consistent backup -> ./backups/<ts>.tgz + sha256
|
||||
make restore BACKUP=backups/<ts>.tgz # DR drill into a fresh data dir (asks RESTORE)
|
||||
make fsmo # FSMO ownership
|
||||
make repl-status # samba-tool drs showrepl
|
||||
make passwd # rotate administrator
|
||||
make shell # docker exec -it ad-dc bash
|
||||
```
|
||||
Rotate the backup to off-host storage and **practise the restore**. `scripts/restore.sh`
|
||||
preserves the previous data dir instead of deleting it.
|
||||
|
||||
Two DCs: `scripts/add-replica.sh` (run it on a *second machine* — host networking means
|
||||
two DCs on one host fight over 53/88/445; use the macvlan variant for one host).
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
make smoke # isolated TESTLAB.LOCAL: build -> provision -> verify -> restart -> verify
|
||||
make drill # backup -> restore -> domain SID + planted OU must survive
|
||||
make ldaps # LDAPS chain verifies, SAN matches, untrusted clients rejected
|
||||
make test # all three (run before every upgrade)
|
||||
```
|
||||
|
||||
What each suite asserts beyond "it came up":
|
||||
|
||||
| suite | the checks that actually matter |
|
||||
|---|---|
|
||||
| `tests/smoke.sh` | fresh forest serves DNS SRV + Kerberos + LDAP/GC + SYSVOL; the hardened `smb.conf` parameters really landed; **a restart reuses the database instead of re-provisioning**; anonymous LDAP enumeration and SMB1 are refused |
|
||||
| `tests/dr-drill.sh` | plants an OU, backs up, restores into a fresh volume **as a new DC**, then asserts the restored DC has the **same domain SID** and still contains the planted OU, and never ran a fresh provisioning. A restore that looks healthy but lost your data is the worst failure mode, so this is the important one. |
|
||||
| `tests/ldaps-test.sh` | 636 verifies against the generated CA, SAN contains the DC FQDN, and a client **without** the CA is rejected |
|
||||
|
||||
The lab is fully isolated: `internal: true` docker network, throwaway `TESTLAB.LOCAL` realm,
|
||||
no route to your LAN, named volumes. `make labdown` destroys it.
|
||||
|
||||
### Validated here (Debian 13.6, Docker 29.7.2, Samba 4.22.11)
|
||||
|
||||
* `smoke`: 25 passed / 0 failed on a fresh forest, and again after a container restart
|
||||
(log-verified that the restart reused the database)
|
||||
* `drill`: restored DC presented the **same domain SID** as the source and still held the
|
||||
planted `DR-MARKER-*` OU; no fresh provisioning occurred
|
||||
* `ldaps`: 26 passed / 0 failed, chain verified against the generated CA, untrusted
|
||||
handshake rejected
|
||||
|
||||
Gotchas discovered and handled the hard way (all now encoded in the entrypoint/tests):
|
||||
|
||||
1. `samba-ad-provision` is a separate Debian package — without it provisioning can't find the AD schema.
|
||||
2. Provisioning dies on SYSVOL NT-ACL writes unless the container has `CAP_SYS_ADMIN`.
|
||||
3. Pre-creating `smb.conf` before provisioning makes `samba-tool domain provision` refuse
|
||||
(no `[netlogon]` share) — pass the realm with `--option="realm=..."` instead.
|
||||
4. Samba's backup archives are `.tar.bz2`, not `.tgz`.
|
||||
5. `samba-tool domain backup restore` rejects a non-empty target dir, so the image keeps
|
||||
`/var/lib/samba` empty; and it restores as a **new** DC name.
|
||||
6. Samba refuses a TLS key that isn't root-owned (CVE-2013-4476) — hence the cert staging step.
|
||||
7. `libsasl2-modules-gssapi-mit` is required for `ldapsearch -Y GSSAPI` inside the image.
|
||||
8. Under `set -o pipefail`, `cmd | head -1` turns a normal SIGPIPE into a fatal error.
|
||||
|
||||
## Disaster recovery — read this before you need it
|
||||
|
||||
`samba-tool domain backup restore` is **restore-as-a-new-domain-controller**: it unpacks the
|
||||
database and then *joins* the target host into it. Consequences:
|
||||
|
||||
* You **cannot** restore under the same hostname the backup came from — that computer account
|
||||
already exists in the restored DB (`Entry CN=... already exists`). Use a new name and a free
|
||||
IP: `./scripts/restore.sh backups/<archive>.tar.bz2 dc3 192.168.5.55`
|
||||
* `restore.sh` saves `.env`, moves the live `data/samba` aside (timestamped, never deleted),
|
||||
rewrites `AD_HOSTNAME`/`AD_IP`, restores, starts, verifies, and prints rollback commands
|
||||
on failure.
|
||||
* Afterwards point DHCP/DNS at the new DC and clean up the dead DC's stale computer/DNS
|
||||
objects (the script prints the commands).
|
||||
* The restore target must be **empty**, so the image deliberately leaves `/var/lib/samba`
|
||||
empty — otherwise a fresh named volume gets seeded with content and the restore refuses to run.
|
||||
* Archives are `samba-backup-<domain>-<ts>.tar.bz2` + `.sha256` and contain the **domain
|
||||
secrets**. Store them encrypted, off-host, and practise the restore on a schedule.
|
||||
|
||||
## Network modes
|
||||
|
||||
| mode | how | Win11 clients on the LAN | notes |
|
||||
|---|---|---|---|
|
||||
| host (default) | `network_mode: host` | ✅ | host IP == DC IP; needs 53/88/135/445/3268 free |
|
||||
| macvlan | `docs/docker-compose.macvlan.yml.example` | ✅ | own LAN IP; container↔host blocked by default |
|
||||
| bridge/lab | `docker-compose.lab.yml` | ❌ | isolated; internal-only or port-published for local experiments |
|
||||
|
||||
Port publishing does **not** work for real joins: the DC advertises its own (unroutable)
|
||||
address in DNS and DCE-RPC allocates dynamic ports. That is why host/macvlan exist.
|
||||
|
||||
## Limitations — read before production
|
||||
|
||||
1. **Homogeneous DC topology.** Samba dropped FRS. Do **not** mix a Windows Server DC into a
|
||||
Samba domain (or vice versa) for SYSVOL replication. Samba↔Samba replicas only.
|
||||
2. **No AD CS** — no enterprise CA, no certificate auto-enrollment, no AD-integrated PKI.
|
||||
LDAPS comes from `scripts/tls.sh`, ACME DNS-01, or your existing PKI.
|
||||
3. **No Entra ID hybrid.** Azure AD Connect is unsupported against Samba. If you need M365
|
||||
SSO, Entra ID should be the authority and this box plays a different role.
|
||||
4. **Don't run `adprep` from newer Windows media** against the Samba-provisioned schema.
|
||||
5. GPO authoring works from RSAT/GPMC, but validate each client-side extension you rely on;
|
||||
some newer policy extensions call AD features Samba doesn't implement.
|
||||
6. Heavy AD-coupled products (Exchange, SCCM/MECM, AD-joined PKI flows) are off the table.
|
||||
7. **LDAPS is server authentication only.** No NTAuth store, no certificate-based *client*
|
||||
authentication, and Samba does not implement LDAP channel binding (GS2-TBS). Windows only
|
||||
*demands* channel binding when the DC-side policy is set to "Always" — a policy Samba does
|
||||
not implement — so clients fall back and joins work; just don't point clients at this DC if
|
||||
they are configured to require CB unconditionally. SASL LDAP signing/sealing is supported.
|
||||
8. **Do not use `.local` for the realm.** RFC 6762 reserves it for multicast DNS, so macOS,
|
||||
iOS, Android and Linux hosts with avahi/systemd-resolved will never send those names to your
|
||||
DNS server. Use a subdomain you own (`ad.example.com`), or `.lan` / `.internal` /
|
||||
`home.arpa` (RFC 8375). A single-label realm (just `PREMIER`) is worse again: SRV and
|
||||
Kerberos locator lookups depend on a real DNS suffix.
|
||||
|
||||
## Upgrades
|
||||
|
||||
`SAMBA_VERSION` is pinned in `docker-compose.yml` and the Dockerfile ARG. Upgrade in a
|
||||
controlled way:
|
||||
|
||||
1. check Samba's supported upgrade-path matrix (e.g. 4.22 → 4.24 is a supported step)
|
||||
2. `make backup` and verify the archive restores
|
||||
3. change the pin, `make build`, `make up`
|
||||
4. inside the container: `samba_upgradedns`, then `samba-tool dbcheck --cross-ncs`,
|
||||
`samba-tool ntacl sysvolcheck`, `make verify`
|
||||
5. upgrades inside a running container image are **not** automatic — a rebuild is deliberate
|
||||
|
||||
Debian 13 also offers `samba` 4.24 in `trixie-backports` if you want the newer line; do
|
||||
the same drill with `SAMBA_VERSION=2:4.24.5+dfsg-1~bpo13+1`.
|
||||
|
||||
## Security notes
|
||||
|
||||
- `secrets/` is `0600` and mounted read-only at `/run/ad-secrets`; never baked into the image
|
||||
(`.dockerignore` blocks it).
|
||||
- During the **first** provision Samba passes the admin password via `argv`, so `docker top`
|
||||
can see it for a few seconds. If that matters, rotate immediately: `make passwd`.
|
||||
- Container runs `cap_drop: ALL` plus the capabilities Samba actually needs. **`SYS_ADMIN`
|
||||
is required**: Samba persists NT ACLs in the `security.NTACL` xattr and the kernel refuses
|
||||
that inside a container without it (provisioning dies writing SYSVOL ACLs). If you must
|
||||
avoid `SYS_ADMIN`, switch SYSVOL/NETLOGON to the `acl_xattr` VFS module (`vfs objects =
|
||||
acl_xattr`, `acl_xattr:xattr_names = user.NTACL user.DOSATTRIB`, `acl_xattr:ignore system
|
||||
acls = yes`, `map acl inherit = yes`, `store dos attributes = yes`) in `conf/smb.conf`
|
||||
and set `AD_APPLY_HARDENING=no` — supported but a non-standard ACL backend; validate GPO
|
||||
application carefully before trusting it.
|
||||
- The healthcheck is credential-free by design so it can never lock out an account.
|
||||
- The lab compose file contains a throwaway password on purpose (`TESTLAB.LOCAL`, no route
|
||||
to any network). Do not copy those values into `.env`.
|
||||
0
backups/.gitkeep
Normal file
0
backups/.gitkeep
Normal file
0
conf-dc2/.gitkeep
Normal file
0
conf-dc2/.gitkeep
Normal file
0
conf/.gitkeep
Normal file
0
conf/.gitkeep
Normal file
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
0
data/samba-dc2/.gitkeep
Normal file
0
data/samba-dc2/.gitkeep
Normal file
0
data/samba/.gitkeep
Normal file
0
data/samba/.gitkeep
Normal file
65
docker-compose.lab.yml
Normal file
65
docker-compose.lab.yml
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# =====================================================================================
|
||||
# ISOLATED LAB DC - used by tests/smoke.sh, handy for local experimentation.
|
||||
#
|
||||
# * realm TESTLAB.LOCAL, throwaway credentials committed here ON PURPOSE (test only)
|
||||
# * `internal: true` network: the container has NO route to your LAN and no LAN route to
|
||||
# it, so binding privileged ports here is harmless
|
||||
# * named volumes, so it never touches ./conf ./data ./secrets
|
||||
#
|
||||
# docker compose -f docker-compose.lab.yml up -d
|
||||
# docker compose -f docker-compose.lab.yml down -v # destroy
|
||||
# =====================================================================================
|
||||
name: ad-lab
|
||||
|
||||
services:
|
||||
ad-lab:
|
||||
image: local/samba-ad-dc:4.22-debian13
|
||||
container_name: ad-lab
|
||||
hostname: dc1.testlab.local
|
||||
networks:
|
||||
adlab:
|
||||
ipv4_address: 172.28.90.10
|
||||
environment:
|
||||
AD_REALM: TESTLAB.LOCAL
|
||||
AD_DNS_DOMAIN: testlab.local
|
||||
AD_HOSTNAME: dc1
|
||||
AD_IP: 172.28.90.10
|
||||
AD_CONTAINER: ad-lab
|
||||
AD_DNS_FORWARDER: 1.1.1.1 8.8.8.8
|
||||
AD_ADMIN_PASSWORD: 'Lab!Only9Tester#2026'
|
||||
AD_SMB_SIGNING: required
|
||||
AD_SMB_ENCRYPT: desired
|
||||
AD_NTLM_AUTH: ntlmv2-only
|
||||
AD_LOG_LEVEL: '1'
|
||||
volumes:
|
||||
- adlab-conf:/etc/samba
|
||||
- adlab-lib:/var/lib/samba
|
||||
stop_grace_period: 60s
|
||||
restart: unless-stopped
|
||||
cap_drop: [ALL]
|
||||
# SYS_ADMIN is required: Samba persists NT ACLs in the security.NTACL xattr, which
|
||||
# the kernel refuses inside a container without it (SYSVOL/NETLOGON ACL writes).
|
||||
cap_add:
|
||||
- CHOWN
|
||||
- FOWNER
|
||||
- SETUID
|
||||
- SETGID
|
||||
- DAC_OVERRIDE
|
||||
- NET_BIND_SERVICE
|
||||
- SYS_NICE
|
||||
- SYS_RESOURCE
|
||||
- AUDIT_WRITE
|
||||
- SYS_ADMIN
|
||||
|
||||
networks:
|
||||
adlab:
|
||||
name: adlab
|
||||
driver: bridge
|
||||
internal: true
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.28.90.0/24
|
||||
|
||||
volumes:
|
||||
adlab-conf:
|
||||
adlab-lib:
|
||||
93
docker-compose.yml
Normal file
93
docker-compose.yml
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# =====================================================================================
|
||||
# Samba AD Domain Controller - production shape.
|
||||
#
|
||||
# cp .env.example .env && chmod 600 .env
|
||||
# make build && make up && make verify
|
||||
#
|
||||
# WHY host networking by default
|
||||
# ----------------------------
|
||||
# An AD DC writes *its own IP* into DNS A records and Windows clients then connect to
|
||||
# that IP on 88/389/445/3268 plus dynamically allocated DCE-RPC ports. NAT/port
|
||||
# publishing breaks that contract: the DC advertises an address the client cannot route
|
||||
# to, and group policy / Kerberos / secure channel fail in ways that look like auth bugs.
|
||||
# With network_mode: host, host IP == DC IP == the published record: self-consistent.
|
||||
#
|
||||
# Before `make up`:
|
||||
# * nothing may hold TCP/UDP 53 (systemd-resolved stub!), 88, 135, 139, 445, 3268
|
||||
# * get the host a DHCP reservation. A DHCP lease change silently rots the DNS A record.
|
||||
#
|
||||
# Other modes:
|
||||
# AD_NETWORK_MODE=adlan -> use the named bridge below (lab; needs published ports)
|
||||
# macvlan -> see docs/docker-compose.macvlan.yml.example
|
||||
# =====================================================================================
|
||||
|
||||
name: windowsad
|
||||
|
||||
x-ad-dc: &ad-dc
|
||||
image: local/samba-ad-dc:4.22-debian13
|
||||
build:
|
||||
context: .
|
||||
dockerfile: image/Dockerfile
|
||||
args:
|
||||
SAMBA_VERSION: "2:4.22.11+dfsg-0+deb13u1" # bump deliberately, see README "Upgrades"
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./conf:/etc/samba # smb.conf lives here so you can read it on the host
|
||||
- ./data/samba:/var/lib/samba # sam.ldb + SYSVOL + private keys -> BACK THIS UP
|
||||
- ./secrets:/run/ad-secrets:ro
|
||||
- /etc/localtime:/etc/localtime:ro # password expiry / logs follow host TZ
|
||||
stop_grace_period: 90s # let SMB sessions and the LDB/BDK close cleanly
|
||||
restart: unless-stopped
|
||||
# Least privilege this image actually runs with (validated by tests/smoke.sh).
|
||||
cap_drop: [ALL]
|
||||
cap_add:
|
||||
- CHOWN
|
||||
- FOWNER
|
||||
- SETUID
|
||||
- SETGID
|
||||
- DAC_OVERRIDE
|
||||
- NET_BIND_SERVICE
|
||||
- SYS_NICE
|
||||
- SYS_RESOURCE
|
||||
- AUDIT_WRITE
|
||||
# Required: Samba stores NT ACLs in the security.NTACL xattr, which the kernel
|
||||
# refuses without CAP_SYS_ADMIN inside a container (provisioning dies on SYSVOL ACLs).
|
||||
- SYS_ADMIN
|
||||
|
||||
services:
|
||||
# ---- only / primary domain controller ---------------------------------------------
|
||||
ad-dc:
|
||||
<<: *ad-dc
|
||||
container_name: ${AD_CONTAINER:-ad-dc}
|
||||
hostname: ${AD_HOSTNAME:-dc1}.${AD_DNS_DOMAIN:-ad.example.com}
|
||||
network_mode: ${AD_NETWORK_MODE:-host}
|
||||
|
||||
# ---- second domain controller (HA / replica) --------------------------------------
|
||||
# Because this uses host networking, run this profile on a SECOND MACHINE with the repo
|
||||
# synced to it (or switch to macvlan so each DC gets its own LAN address).
|
||||
# In .env set: AD_HOSTNAME2=dc2 AD_IP=<that machine's ip> AD_JOIN_SERVER=dc1.<domain>
|
||||
# AD_JOIN_USER=administrator AD_JOIN_PASSWORD=<or secrets/join.password>
|
||||
# Then: docker compose --profile replica up -d
|
||||
ad-dc2:
|
||||
<<: *ad-dc
|
||||
profiles: ["replica"]
|
||||
container_name: ${AD_CONTAINER2:-ad-dc2}
|
||||
hostname: ${AD_HOSTNAME2:-dc2}.${AD_DNS_DOMAIN:-ad.example.com}
|
||||
network_mode: ${AD_NETWORK_MODE:-host}
|
||||
volumes:
|
||||
- ./conf-dc2:/etc/samba
|
||||
- ./data/samba-dc2:/var/lib/samba
|
||||
- ./secrets:/run/ad-secrets:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
|
||||
networks:
|
||||
# Only used when AD_NETWORK_MODE=adlan. Kept here so `make up` can be switched to a
|
||||
# sandboxed bridge without editing YAML. Real Win11 joins from the LAN will NOT work in
|
||||
# this mode - see docs/ for macvlan if you need LAN-reachable without host networking.
|
||||
adlan:
|
||||
name: adlan
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.28.90.0/24
|
||||
46
docs/docker-compose.macvlan.yml.example
Normal file
46
docs/docker-compose.macvlan.yml.example
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# Standalone macvlan variant: the DC owns its own address on the LAN segment, so physical
|
||||
# Windows 11 clients can reach it without giving the container the host's network stack.
|
||||
#
|
||||
# Two things to know about macvlan:
|
||||
# * by default a macvlan container CANNOT talk to its own host. If you need that, add a
|
||||
# macvlan interface on the host too (`ip link add link <if> macvlan1 type macvlan
|
||||
# mode bridge ...`) or just use network_mode: host from the base compose file.
|
||||
# * pick an IP that your DHCP server will never hand out (or reserve it).
|
||||
name: windowsad-lan
|
||||
|
||||
services:
|
||||
ad-dc:
|
||||
image: local/samba-ad-dc:4.22-debian13
|
||||
container_name: ad-dc
|
||||
hostname: dc1.ad.example.com
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
# MUST match ipv4_address below - this is what lands in the DC's DNS A record.
|
||||
AD_IP: 192.168.5.50
|
||||
networks:
|
||||
lan:
|
||||
ipv4_address: 192.168.5.50
|
||||
volumes:
|
||||
- ./conf:/etc/samba
|
||||
- ./data/samba:/var/lib/samba
|
||||
- ./secrets:/run/ad-secrets:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
stop_grace_period: 90s
|
||||
restart: unless-stopped
|
||||
cap_drop: [ALL]
|
||||
cap_add: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE,
|
||||
SYS_NICE, SYS_RESOURCE, AUDIT_WRITE,
|
||||
SYS_ADMIN] # security.NTACL xattr writes (SYSVOL/NETLOGON ACLs)
|
||||
|
||||
networks:
|
||||
lan:
|
||||
name: adlan
|
||||
driver: macvlan
|
||||
driver_opts:
|
||||
parent: br0 # <- your LAN interface or bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 192.168.5.0/24
|
||||
gateway: 192.168.5.1
|
||||
ip_range: 192.168.5.48/28
|
||||
91
image/Dockerfile
Normal file
91
image/Dockerfile
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# syntax=docker/dockerfile:1.7
|
||||
# -------------------------------------------------------------------------------------
|
||||
# Samba 4.22 Active Directory Domain Controller on Debian 13 (trixie)
|
||||
#
|
||||
# One `samba` process serves the whole DC role:
|
||||
# DNS 53 LDAP 389 / LDAPS 636 Global Catalog 3268 / 3269
|
||||
# Kerberos 88 kpasswd 464 RPC epmapper 135 SMB 445 (SYSVOL/NETLOGON)
|
||||
# MS-SNTP 123 (Windows clients sync time from the DC after domain join)
|
||||
#
|
||||
# Build: docker build -t local/samba-ad-dc:4.22-debian13 -f image/Dockerfile .
|
||||
# -------------------------------------------------------------------------------------
|
||||
FROM debian:trixie-slim
|
||||
|
||||
# Pin every Samba-built package to the SAME version string (they carry exact-version
|
||||
# depends on each other, so pinning only `samba` produces unsatisfiable deps once the
|
||||
# archive carries two point releases). Empty => whatever trixie+security currently offer.
|
||||
ARG SAMBA_VERSION=2:4.22.11+dfsg-0+deb13u1
|
||||
|
||||
LABEL org.opencontainers.image.title="samba-ad-dc" \
|
||||
org.opencontainers.image.description="Samba AD Domain Controller (Windows 11 join compatible) on Debian 13" \
|
||||
org.opencontainers.image.source="https://github.com/local/samba-ad-dc" \
|
||||
org.opencontainers.image.licenses="GPL-3.0-or-later"
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# Package postinsts must never try to start services during an image build.
|
||||
RUN printf '#!/bin/sh\nexit 101\n' > /usr/sbin/policy-rc.d && chmod 0755 /usr/sbin/policy-rc.d
|
||||
|
||||
RUN set -eux; \
|
||||
pin=""; \
|
||||
if [ -n "${SAMBA_VERSION}" ]; then pin="=${SAMBA_VERSION}"; fi; \
|
||||
apt-get update; \
|
||||
apt-get install -y --no-install-recommends \
|
||||
"samba${pin}" \
|
||||
"samba-ad-dc${pin}" \
|
||||
"samba-common${pin}" \
|
||||
"samba-common-bin${pin}" \
|
||||
"samba-dsdb-modules${pin}" \
|
||||
"samba-ad-provision${pin}" \
|
||||
"python3-samba${pin}" \
|
||||
"winbind${pin}" \
|
||||
python3-setproctitle \
|
||||
krb5-user \
|
||||
libsasl2-modules-gssapi-mit \
|
||||
ldap-utils \
|
||||
smbclient \
|
||||
bind9-dnsutils \
|
||||
python3-dnspython \
|
||||
ca-certificates \
|
||||
openssl \
|
||||
iproute2 \
|
||||
procps \
|
||||
tzdata; \
|
||||
rm -f /usr/sbin/policy-rc.d; \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
# The stock smb.conf/krb5.conf shipped by the packages must NOT exist:
|
||||
# the entrypoint uses their absence to detect a virgin data volume, and named
|
||||
# volumes would otherwise be seeded with the stock (non-AD) config.
|
||||
rm -f /etc/samba/smb.conf /etc/krb5.conf; \
|
||||
mkdir -p /etc/samba /var/backups/ad-dc /run/ad-secrets; \
|
||||
# Leave the AD state dir EMPTY. Docker seeds named volumes from the image's content,
|
||||
# and `samba-tool domain backup restore` refuses a non-empty target dir. Provisioning
|
||||
# creates these directories itself.
|
||||
rm -rf /var/lib/samba; \
|
||||
mkdir -p /var/lib/samba; \
|
||||
# sanity: this image is useless without the AD DC bits
|
||||
test -x /usr/sbin/samba; \
|
||||
/usr/bin/samba-tool --help >/dev/null; \
|
||||
# the AD schema lives in a separate Debian package (samba-ad-provision) - fail the
|
||||
# build here rather than at first-run provisioning
|
||||
ls /usr/share/samba/setup/ad-schema/AD_DS_Attributes_Windows_Server_v1903.ldf >/dev/null; \
|
||||
# ldap-client GSSAPI and smbclient are used by the verification scripts
|
||||
ls /usr/lib/*/sasl2/libgssapiv2.so >/dev/null; \
|
||||
command -v smbclient >/dev/null; \
|
||||
command -v kvno >/dev/null; \
|
||||
samba -V
|
||||
|
||||
COPY image/ad-entrypoint.sh /usr/local/bin/ad-entrypoint
|
||||
COPY image/ad-healthcheck.sh /usr/local/bin/ad-healthcheck
|
||||
COPY image/ad-dnsupdate.sh /usr/local/bin/ad-dnsupdate
|
||||
RUN chmod 0755 /usr/local/bin/ad-entrypoint /usr/local/bin/ad-healthcheck /usr/local/bin/ad-dnsupdate
|
||||
|
||||
# Documentation only (real ports come from the network mode you choose).
|
||||
EXPOSE 53/tcp 53/udp 88/tcp 88/udp 123/udp 135/tcp 137/udp 138/udp 139/tcp \
|
||||
389/tcp 445/tcp 464/tcp 464/udp 636/tcp 3268/tcp 3269/tcp
|
||||
|
||||
# start_period is generous: first-run provisioning takes ~30-90s.
|
||||
HEALTHCHECK --interval=30s --timeout=20s --start-period=120s --retries=4 \
|
||||
CMD ["/usr/local/bin/ad-healthcheck"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/ad-entrypoint"]
|
||||
24
image/ad-dnsupdate.sh
Normal file
24
image/ad-dnsupdate.sh
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
#!/bin/sh
|
||||
# =====================================================================================
|
||||
# Samba dnsupdate wrapper: pins DNS registration to the address(es) this DC advertises.
|
||||
#
|
||||
# Why: a Docker host is multi-homed (docker0 + every user-defined bridge + whatever stale
|
||||
# DHCP addresses linger). Samba's updater registers ALL non-loopback interfaces, so the DC's
|
||||
# A record ends up containing 172.17.0.1 etc. Clients that pick one of those get connection
|
||||
# failures that look like Kerberos/SMB bugs. Wired in via smb.conf:
|
||||
# dns update command = /usr/local/bin/ad-dnsupdate
|
||||
# so it governs the daemon's internal updater as well as manual invocations.
|
||||
#
|
||||
# The address list is written by ad-entrypoint to /run/ad-dc-addresses (one per line).
|
||||
# =====================================================================================
|
||||
IPFILE=/run/ad-dc-addresses
|
||||
|
||||
if [ -f "$IPFILE" ]; then
|
||||
set -- --current-ip="$(head -n 1 "$IPFILE")" "$@"
|
||||
# remaining lines (e.g. IPv6) -> additional --current-ip arguments
|
||||
for line in $(tail -n +2 "$IPFILE"); do
|
||||
set -- "$@" --current-ip="$line"
|
||||
done
|
||||
fi
|
||||
|
||||
exec /usr/sbin/samba_dnsupdate "$@"
|
||||
460
image/ad-entrypoint.sh
Executable file
460
image/ad-entrypoint.sh
Executable file
|
|
@ -0,0 +1,460 @@
|
|||
#!/usr/bin/env bash
|
||||
# =====================================================================================
|
||||
# Samba AD DC container entrypoint.
|
||||
#
|
||||
# Idempotent. Safe to restart. Behaviour is driven entirely by environment variables
|
||||
# (see .env.example / README.md). Decision tree:
|
||||
#
|
||||
# /var/lib/samba/private/sam.ldb exists ?
|
||||
# yes -> start the existing domain (primary or replica, we don't care)
|
||||
# no -> AD_JOIN_SERVER set ?
|
||||
# yes -> join the existing forest as an additional DC
|
||||
# no -> provision a brand new forest
|
||||
#
|
||||
# Then: apply hardening params -> validate smb.conf with testparm (fail fast) ->
|
||||
# self-referencing resolv.conf -> keep DNS records fresh -> exec samba in foreground.
|
||||
# =====================================================================================
|
||||
set -Eeuo pipefail
|
||||
|
||||
ts() { date -u +%H:%M:%S; }
|
||||
log() { printf '\033[1;36m[ad-dc %s]\033[0m %s\n' "$(ts)" "$*"; }
|
||||
warn() { printf '\033[1;33m[ad-dc %s] WARN: %s\033[0m\n' "$(ts)" "$*" >&2; }
|
||||
die() { printf '\033[1;31m[ad-dc %s] FATAL: %s\033[0m\n' "$(ts)" "$*" >&2; exit 1; }
|
||||
on_err() { printf '\033[1;31m[ad-dc %s] FATAL: entrypoint failed at line %s (rc=%s)\033[0m\n' "$(ts)" "$2" "$1" >&2; exit "$1"; }
|
||||
trap 'on_err "$?" "$LINENO"' ERR
|
||||
|
||||
SAMDB=/var/lib/samba/private/sam.ldb
|
||||
SMBCONF=/etc/samba/smb.conf
|
||||
SECRETS_DIR="${AD_SECRETS_DIR:-/run/ad-secrets}"
|
||||
|
||||
# -------------------------------------------------------------------------------------
|
||||
# 1. Read + normalise configuration
|
||||
# -------------------------------------------------------------------------------------
|
||||
: "${AD_REALM:?AD_REALM is required, e.g. AD.EXAMPLE.COM (uppercase)}"
|
||||
AD_REALM="${AD_REALM^^}"
|
||||
AD_REALM="${AD_REALM//[[:space:]]/}"
|
||||
|
||||
AD_DNS_DOMAIN="${AD_DNS_DOMAIN:-${AD_REALM,,}}"
|
||||
AD_DNS_DOMAIN="${AD_DNS_DOMAIN,,}"
|
||||
AD_HOSTNAME="${AD_HOSTNAME:-dc1}"
|
||||
AD_HOSTNAME="${AD_HOSTNAME,,}"
|
||||
FQDN="${AD_HOSTNAME}.${AD_DNS_DOMAIN}"
|
||||
|
||||
NETBIOS_DOMAIN="${AD_NETBIOS_DOMAIN:-${AD_REALM%%.*}}"
|
||||
NETBIOS_DOMAIN="${NETBIOS_DOMAIN^^}"
|
||||
NETBIOS_HOST="${AD_HOSTNAME^^}"
|
||||
NETBIOS_HOST="${NETBIOS_HOST:0:15}"
|
||||
NETBIOS_DOMAIN="${NETBIOS_DOMAIN:0:15}"
|
||||
|
||||
AD_DNS_BACKEND="${AD_DNS_BACKEND:-SAMBA_INTERNAL}"
|
||||
AD_SITE="${AD_SITE:-Default-First-Site-Name}"
|
||||
AD_FUNCTION_LEVEL="${AD_FUNCTION_LEVEL:-2008_R2}"
|
||||
AD_BASE_SCHEMA="${AD_BASE_SCHEMA:-2019}"
|
||||
AD_USE_RFC2307="${AD_USE_RFC2307:-yes}"
|
||||
AD_DNS_FORWARDER="${AD_DNS_FORWARDER:-1.1.1.1 8.8.8.8}"
|
||||
AD_ADMIN_USER="${AD_ADMIN_USER:-administrator}"
|
||||
AD_MANAGE_RESOLV="${AD_MANAGE_RESOLV:-yes}"
|
||||
AD_APPLY_HARDENING="${AD_APPLY_HARDENING:-yes}"
|
||||
AD_LOG_LEVEL="${AD_LOG_LEVEL:-1}"
|
||||
AD_DNS_REFRESH_SECONDS="${AD_DNS_REFRESH_SECONDS:-300}"
|
||||
|
||||
# Hardening knobs -> smb.conf [global]
|
||||
AD_SMB_SIGNING="${AD_SMB_SIGNING:-required}" # required | yes | no
|
||||
AD_SMB_ENCRYPT="${AD_SMB_ENCRYPT:-desired}" # required | desired | off | on
|
||||
AD_NTLM_AUTH="${AD_NTLM_AUTH:-ntlmv2-only}" # disabled | ntlmv2-only | ms-capable
|
||||
AD_RESTRICT_ANONYMOUS="${AD_RESTRICT_ANONYMOUS:-2}" # 2 = reject anonymous (no guest fallback)
|
||||
AD_SMB_MIN_PROTOCOL="${AD_SMB_MIN_PROTOCOL:-SMB2_02}" # SMB1 stays off, Win11 removed it anyway
|
||||
|
||||
# -------------------------------------------------------------------------------------
|
||||
# 2. Validation
|
||||
# -------------------------------------------------------------------------------------
|
||||
[[ "$AD_REALM" =~ ^[A-Z0-9][A-Z0-9.-]*[A-Z0-9]\.[A-Z][A-Z]+$ ]] \
|
||||
|| die "AD_REALM must be an uppercase DNS realm without underscores (got '$AD_REALM')"
|
||||
[[ "$AD_DNS_DOMAIN" =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ ]] \
|
||||
|| die "AD_DNS_DOMAIN invalid (got '$AD_DNS_DOMAIN')"
|
||||
[[ "$AD_HOSTNAME" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]] \
|
||||
|| die "AD_HOSTNAME must be a short hostname, a-z0-9- (got '$AD_HOSTNAME')"
|
||||
[ "${#NETBIOS_DOMAIN}" -ge 1 ] || die "NETBIOS_DOMAIN derived to empty string"
|
||||
|
||||
case "$AD_DNS_BACKEND" in SAMBA_INTERNAL|BIND9_DLZ|NONE) ;; *) die "AD_DNS_BACKEND must be SAMBA_INTERNAL | BIND9_DLZ | NONE";; esac
|
||||
|
||||
# -------------------------------------------------------------------------------------
|
||||
# 3. Credentials: env var, or a 0600 file under the secrets mount.
|
||||
# -------------------------------------------------------------------------------------
|
||||
read_secret() { # $1=env value $2=file path
|
||||
if [ -n "${1:-}" ]; then printf '%s' "$1"; return 0; fi
|
||||
if [ -r "$2" ]; then tr -d '\r\n' < "$2"; return 0; fi
|
||||
return 1
|
||||
}
|
||||
|
||||
pw_quality() { # $1 = password on stdin (kept out of argv)
|
||||
printf '%s' "$1" | python3 -c 'import samba,sys; sys.exit(0 if samba.check_password_quality(sys.stdin.read()) else 1)' 2>/dev/null
|
||||
}
|
||||
|
||||
# Resolved lazily: a restore run must NOT need the admin password, so only the code paths
|
||||
# that actually create/join a directory call require_admin_pw.
|
||||
ADMIN_PW="$(read_secret "${AD_ADMIN_PASSWORD:-}" "$SECRETS_DIR/ad_admin.password" || true)"
|
||||
require_admin_pw() {
|
||||
[ -n "${ADMIN_PW:-}" ] || die "no administrator password. Set AD_ADMIN_PASSWORD or mount a file at ${SECRETS_DIR}/ad_admin.password (chmod 600)"
|
||||
pw_quality "$ADMIN_PW" || die "administrator password fails AD quality check (need >=12 chars and 3 of 4 classes: upper/lower/digit/symbol)"
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------------------------
|
||||
# 4. Own IP address (this is what lands in the DNS A/AAAA records -> must be reachable)
|
||||
# -------------------------------------------------------------------------------------
|
||||
detect_ip() {
|
||||
ip -4 route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([^ ]*\).*/\1/p' | head -1
|
||||
}
|
||||
if [ -z "${AD_IP:-}" ]; then
|
||||
AD_IP="$(detect_ip || true)"
|
||||
[ -n "$AD_IP" ] || die "cannot determine my IPv4 address - set AD_IP explicitly"
|
||||
n4=$(ip -4 -o addr show scope global 2>/dev/null | grep -c . || true)
|
||||
[ "${n4:-1}" -gt 1 ] && warn "multiple global IPv4 addresses found; auto-picked $AD_IP. Set AD_IP to be deterministic."
|
||||
fi
|
||||
if [ "${AD_IP6:-}" = "auto" ]; then
|
||||
AD_IP6="$(ip -6 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | grep -v '^[fd]' | head -1 || true)"
|
||||
fi
|
||||
log "identity: hostname=$AD_HOSTNAME fqdn=$FQDN realm=$AD_REALM netbios=$NETBIOS_DOMAIN ip=${AD_IP}${AD_IP6:+/${AD_IP6}}"
|
||||
|
||||
# AD_IP is what we publish in the A record and what every dynamic update is pinned to, so it
|
||||
# must actually be bound here. When it isn't, the DC cannot reach its own advertised address:
|
||||
# updates fail with "not reachable by the transport" and the DC comes up healthy while serving
|
||||
# NO SRV records - every client then fails to find a domain controller. Observed in a DR
|
||||
# restore where the container landed on .2 while AD_IP said .30. Fail loudly instead.
|
||||
addr_present() { ip -o addr show 2>/dev/null | grep -qF " $1/"; } # matches 'inet <addr>/<mask>'
|
||||
if ! addr_present "$AD_IP"; then
|
||||
have="$(ip -4 -o addr show scope global 2>/dev/null | awk '{printf "%s ", $4}')"
|
||||
if [ "${AD_IP_STRICT:-yes}" = "yes" ]; then
|
||||
die "AD_IP=${AD_IP} is not assigned to any interface here (global addresses: ${have:-none}).
|
||||
Clients follow AD_IP, so it has to be this machine's address.
|
||||
Correct AD_IP in .env, or set AD_IP_STRICT=no if something else assigns it later."
|
||||
else
|
||||
warn "AD_IP=${AD_IP} is not bound here (have: ${have:-none}); continuing because AD_IP_STRICT=no"
|
||||
fi
|
||||
fi
|
||||
if [ -n "${AD_IP6:-}" ] && ! addr_present "$AD_IP6"; then
|
||||
[ "${AD_IP_STRICT:-yes}" = "yes" ] \
|
||||
&& die "AD_IP6=${AD_IP6} is not assigned to any interface here (AD_IP_STRICT=no to allow)" \
|
||||
|| warn "AD_IP6=${AD_IP6} is not bound here; continuing because AD_IP_STRICT=no"
|
||||
fi
|
||||
|
||||
# /etc/hosts (append in place: the file may be a docker bind-mount, never unlink it)
|
||||
if ! grep -q "[[:space:]]${FQDN}\([[:space:]]\|$\)" /etc/hosts 2>/dev/null; then
|
||||
printf '%s\t%s %s\n' "$AD_IP" "$FQDN" "$AD_HOSTNAME" >> /etc/hosts
|
||||
fi
|
||||
if [ -n "${AD_EXTRA_HOSTS:-}" ]; then
|
||||
# e.g. AD_EXTRA_HOSTS="192.0.2.5 dc1.ad.example.com dc1"
|
||||
printf '%s\n' "${AD_EXTRA_HOSTS//;//}" >> /etc/hosts
|
||||
fi
|
||||
|
||||
# -------------------------------------------------------------------------------------
|
||||
# 5. /etc/krb5.conf (explicit KDCs; we do not trust DNS ordering during boot)
|
||||
# -------------------------------------------------------------------------------------
|
||||
KDC_HOSTS="$FQDN"
|
||||
if [ -n "${AD_JOIN_SERVER:-}" ]; then
|
||||
KDC_HOSTS="$AD_JOIN_SERVER $FQDN"
|
||||
fi
|
||||
{
|
||||
echo "# rendered by ad-entrypoint at $(date -u '+%FT%TZ') - do not edit inside the container"
|
||||
echo "[libdefaults]"
|
||||
echo " default_realm = ${AD_REALM}"
|
||||
echo " dns_lookup_realm = false"
|
||||
echo " dns_lookup_kdc = false"
|
||||
echo " rdns = false"
|
||||
echo " udp_preference_limit = 1465" # Win11 + many group SIDs => PAC > 1465 bytes
|
||||
echo " default_ccache_name = FILE:/tmp/krb5cc_%{uid}"
|
||||
echo " ticket_lifetime = 10h"
|
||||
echo " renew_lifetime = 7d"
|
||||
echo " forwardable = true"
|
||||
echo
|
||||
echo "[realms]"
|
||||
echo " ${AD_REALM} = {"
|
||||
for h in $KDC_HOSTS; do
|
||||
echo " kdc = ${h}"
|
||||
echo " admin_server = ${h}"
|
||||
echo " kpasswd_server = ${h}"
|
||||
done
|
||||
echo " default_domain = ${AD_DNS_DOMAIN}"
|
||||
echo " }"
|
||||
echo
|
||||
echo "[domain_realm]"
|
||||
echo " .${AD_DNS_DOMAIN} = ${AD_REALM}"
|
||||
echo " ${AD_DNS_DOMAIN} = ${AD_REALM}"
|
||||
} > /etc/krb5.conf
|
||||
|
||||
# -------------------------------------------------------------------------------------
|
||||
# 6. smb.conf helpers
|
||||
# -------------------------------------------------------------------------------------
|
||||
# Upsert "key = value" inside [global] (replaces in place, otherwise inserts).
|
||||
smb_set() { # $1=key $2=value
|
||||
local key="$1" val="$2" tmp
|
||||
tmp="$(mktemp)"
|
||||
awk -v k="$key" -v v="$val" '
|
||||
BEGIN { insec = 0; done = 0 }
|
||||
/^[[:space:]]*#/ { print; next }
|
||||
/^\[/ {
|
||||
if (insec && !done) { print " " k " = " v; done = 1 }
|
||||
insec = ($0 ~ /^\[global\]/)
|
||||
print; next
|
||||
}
|
||||
{
|
||||
if (insec && !done && $0 ~ "^[[:space:]]*" k "[[:space:]]*=") {
|
||||
print " " k " = " v; done = 1; next
|
||||
}
|
||||
print
|
||||
}
|
||||
END { if (insec && !done) print " " k " = " v }
|
||||
' "$SMBCONF" > "$tmp"
|
||||
cat "$tmp" > "$SMBCONF"; rm -f "$tmp"
|
||||
}
|
||||
|
||||
smb_get() { # $1=key -> value or empty
|
||||
awk -v k="$1" '
|
||||
/^[[:space:]]*#/ { next }
|
||||
/^\[/ { insec = ($0 ~ /^\[global\]/); next }
|
||||
insec && $0 ~ "^[[:space:]]*" k "[[:space:]]*=" {
|
||||
sub(/^[[:space:]]*[^=]*=[[:space:]]*/, ""); print; exit
|
||||
}' "$SMBCONF"
|
||||
}
|
||||
|
||||
smb_unset() { # $1=key (regex-escaped-ish)
|
||||
local key="$1" tmp
|
||||
tmp="$(mktemp)"
|
||||
awk -v k="$key" '
|
||||
!($0 ~ "^[[:space:]]*" k "[[:space:]]*=") { print }' "$SMBCONF" > "$tmp"
|
||||
cat "$tmp" > "$SMBCONF"; rm -f "$tmp"
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------------------------
|
||||
# 7. Optional: restore an online backup into a FRESH data volume, then stop.
|
||||
#
|
||||
# IMPORTANT semantics of `samba-tool domain backup restore`: it does a *join*, i.e. it
|
||||
# restores the database and then ADDS this host as a new domain controller. Restoring under
|
||||
# the same server name that the backup was taken from fails with "Entry CN=<NAME>,OU=Domain
|
||||
# Controllers... already exists" - so this must run with a NEW AD_HOSTNAME (and a free IP).
|
||||
# The archive carries its own smb.conf at ./etc/smb.conf; we promote it to /etc/samba.
|
||||
# -------------------------------------------------------------------------------------
|
||||
if [ -n "${AD_RESTORE_FROM:-}" ]; then
|
||||
[ "${AD_RESTORE_CONFIRM:-no}" = "yes" ] || die "AD_RESTORE_FROM set but AD_RESTORE_CONFIRM!=yes (this writes directory data)"
|
||||
[ -f "$AD_RESTORE_FROM" ] || die "backup file not found: $AD_RESTORE_FROM"
|
||||
[ ! -f "$SAMDB" ] || die "refusing to restore over an existing database at $SAMDB - use a fresh data volume"
|
||||
|
||||
# Read the archive manifest once, parse with bash regex - no `head` in a pipeline, which
|
||||
# under `set -o pipefail` turns a normal SIGPIPE into a fatal error.
|
||||
backup_txt="$(tar -xOf "$AD_RESTORE_FROM" ./backup.txt 2>/dev/null || true)"
|
||||
src_dc=""; src_netbios=""
|
||||
[[ "$backup_txt" =~ using\ DC\ \'([^\']+)\' ]] && src_dc="${BASH_REMATCH[1]}"
|
||||
[[ "$backup_txt" =~ Backup\ for\ domain\ ([A-Z0-9-]+)\ \(NetBIOS\) ]] && src_netbios="${BASH_REMATCH[1]}"
|
||||
log "archive origin: dc=${src_dc:-unknown} netbios=${src_netbios:-unknown}"
|
||||
|
||||
if [ -n "$src_dc" ] && [ "${src_dc%%.*}" = "$AD_HOSTNAME" ]; then
|
||||
die "restore under the same hostname as the backup source (${src_dc}) is not supported by Samba's restore tool. Set AD_HOSTNAME to a NEW name (and a free AD_IP) and retry."
|
||||
fi
|
||||
|
||||
log "restoring ${AD_RESTORE_FROM} as NEW domain controller ${FQDN} (${AD_IP})"
|
||||
samba-tool domain backup restore \
|
||||
--backup-file="$AD_RESTORE_FROM" \
|
||||
--targetdir=/var/lib/samba \
|
||||
--newservername="$NETBIOS_HOST" \
|
||||
--host-ip="$AD_IP" \
|
||||
${AD_IP6:+--host-ip6="$AD_IP6"} \
|
||||
--site="$AD_SITE"
|
||||
|
||||
if [ -f /var/lib/samba/etc/smb.conf ]; then
|
||||
mkdir -p /etc/samba
|
||||
cp /var/lib/samba/etc/smb.conf /etc/samba/smb.conf
|
||||
rm -rf /var/lib/samba/etc
|
||||
log "promoted the archive's smb.conf to /etc/samba/smb.conf"
|
||||
fi
|
||||
printf 'realm=%s\nfqdn=%s\nsource=%s\nrestored_from_dc=%s\nts=%s\n' \
|
||||
"$AD_REALM" "$FQDN" "$AD_RESTORE_FROM" "${src_dc:-unknown}" "$(date -u '+%FT%TZ')" > /var/lib/samba/.ad-provisioned
|
||||
log "restore complete. Unset AD_RESTORE_FROM and 'docker compose up -d' to serve it."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# A completed provision/join leaves a marker. A provision that dies half way (e.g. SYSVOL
|
||||
# NT-ACL write refused by the underlying filesystem/capability set) otherwise looks like a
|
||||
# healthy database to the next start, which then serves a broken domain. Fail loudly here.
|
||||
MARKER=/var/lib/samba/.ad-provisioned
|
||||
|
||||
if [ -f "$SAMDB" ] && [ -f "$MARKER" ]; then
|
||||
log "existing AD database found -> starting domain '${AD_REALM}'"
|
||||
elif [ -f "$SAMDB" ]; then
|
||||
if [ "${AD_FORCE_START:-no}" = "yes" ]; then
|
||||
warn "sam.ldb present but $(basename "$MARKER") missing; AD_FORCE_START=yes -> continuing"
|
||||
else
|
||||
die "sam.ldb exists but $(basename "$MARKER") is missing: a previous provision/join was interrupted. Inspect with 'samba-tool dbcheck', or remove $SAMDB and restart, or set AD_FORCE_START=yes"
|
||||
fi
|
||||
else
|
||||
if [ -n "${AD_JOIN_SERVER:-}" ]; then
|
||||
log "joining existing domain ${AD_DNS_DOMAIN} via ${AD_JOIN_SERVER} as ${AD_HOSTNAME}"
|
||||
JOIN_PW="$(read_secret "${AD_JOIN_PASSWORD:-}" "$SECRETS_DIR/join.password" || true)"
|
||||
[ -n "$JOIN_PW" ] || die "AD_JOIN_PASSWORD (or ${SECRETS_DIR}/join.password) is required to join"
|
||||
samba-tool domain join "${AD_DNS_DOMAIN}" DC \
|
||||
--realm="$AD_REALM" \
|
||||
--server-role=dc \
|
||||
--host-name="$NETBIOS_HOST" \
|
||||
--host-ip="$AD_IP" \
|
||||
${AD_IP6:+--host-ip6="$AD_IP6"} \
|
||||
--target-server="${AD_JOIN_SERVER}" \
|
||||
-U "${AD_REALM}\\${AD_JOIN_USER:-${AD_ADMIN_USER}}%${JOIN_PW}" \
|
||||
${AD_USE_RFC2307:+--use-rfc2307}
|
||||
else
|
||||
# IMPORTANT: never pre-create smb.conf before provisioning. `samba-tool domain
|
||||
# provision` refuses to configure a DC over a config that has no [netlogon]
|
||||
# share, so the realm/forwarder are passed via --option and Samba generates the
|
||||
# authoritative config (incl. the NETLOGON/SYSVOL shares) itself.
|
||||
if [ -f "$SMBCONF" ]; then
|
||||
backup="${SMBCONF}.discarded-$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
warn "moving pre-existing smb.conf out of the way -> $backup"
|
||||
mv "$SMBCONF" "$backup"
|
||||
fi
|
||||
require_admin_pw
|
||||
log "provisioning NEW forest ${AD_REALM} (this takes 30-90s, heavy CPU/IO)"
|
||||
samba-tool domain provision \
|
||||
--option="realm=${AD_REALM}" \
|
||||
--option="dns forwarder = ${AD_DNS_FORWARDER}" \
|
||||
--server-role=dc \
|
||||
--domain="$NETBIOS_DOMAIN" \
|
||||
--host-name="$NETBIOS_HOST" \
|
||||
--host-ip="$AD_IP" \
|
||||
${AD_IP6:+--host-ip6="$AD_IP6"} \
|
||||
--dns-backend="$AD_DNS_BACKEND" \
|
||||
--function-level="$AD_FUNCTION_LEVEL" \
|
||||
--base-schema="$AD_BASE_SCHEMA" \
|
||||
--site="$AD_SITE" \
|
||||
${AD_USE_RFC2307:+--use-rfc2307} \
|
||||
--adminpass="$ADMIN_PW"
|
||||
log "provisioning complete"
|
||||
fi
|
||||
[ -f "$SAMDB" ] || die "directory database still missing after provision/join: $SAMDB"
|
||||
printf 'realm=%s\nfqdn=%s\njoined_via=%s\nts=%s\n' \
|
||||
"$AD_REALM" "$FQDN" "${AD_JOIN_SERVER:-none}" "$(date -u '+%FT%TZ')" > "$MARKER"
|
||||
fi
|
||||
|
||||
# -------------------------------------------------------------------------------------
|
||||
# 8. Hardening (applied on every start, so .env changes take effect on restart)
|
||||
# -------------------------------------------------------------------------------------
|
||||
if [ "$AD_APPLY_HARDENING" = "yes" ]; then
|
||||
smb_set "dns forwarder" "$AD_DNS_FORWARDER"
|
||||
smb_set "server signing" "$AD_SMB_SIGNING"
|
||||
smb_set "smb encrypt" "$AD_SMB_ENCRYPT"
|
||||
smb_set "ntlm auth" "$AD_NTLM_AUTH"
|
||||
smb_set "restrict anonymous" "$AD_RESTRICT_ANONYMOUS"
|
||||
smb_set "map to guest" "never"
|
||||
smb_set "server min protocol" "$AD_SMB_MIN_PROTOCOL"
|
||||
smb_set "log level" "$AD_LOG_LEVEL"
|
||||
smb_set "max log size" "10000"
|
||||
log "hardening: signing=$AD_SMB_SIGNING encrypt=$AD_SMB_ENCRYPT ntlm=$AD_NTLM_AUTH anon=$AD_RESTRICT_ANONYMOUS min=$AD_SMB_MIN_PROTOCOL"
|
||||
else
|
||||
warn "AD_APPLY_HARDENING=no -> smb.conf is used exactly as you wrote it"
|
||||
fi
|
||||
|
||||
# LDAPS: only if certs are present. Samba's own auto-generated cert is untrusted by
|
||||
# Windows, so bring your own CA (scripts/tls.sh) for anything beyond smoke tests.
|
||||
#
|
||||
# Samba refuses to load a private key that is not root-owned / not group-other readable
|
||||
# (CVE-2013-4476). Bind-mounted host certs are owned by the host uid, so we copy them into
|
||||
# our own private dir as root and set the mode explicitly. The secrets mount stays read-only.
|
||||
if [ -n "${AD_TLS_CERT:-}" ] && [ -r "${AD_TLS_CERT:-/nonexistent}" ]; then
|
||||
[ -r "${AD_TLS_KEY:-/nonexistent}" ] || die "AD_TLS_CERT set but AD_TLS_KEY is unreadable"
|
||||
staged="/var/lib/samba/private/tls"
|
||||
mkdir -p "$staged"
|
||||
cp -f "$AD_TLS_CERT" "$staged/ad_server.pem"
|
||||
cp -f "$AD_TLS_KEY" "$staged/ad_server.key"
|
||||
chmod 0644 "$staged/ad_server.pem"
|
||||
chmod 0600 "$staged/ad_server.key"
|
||||
chown root:root "$staged/ad_server.pem" "$staged/ad_server.key" 2>/dev/null || true
|
||||
conf_cert="$staged/ad_server.pem"
|
||||
conf_key="$staged/ad_server.key"
|
||||
if [ -n "${AD_TLS_CA:-}" ] && [ -r "${AD_TLS_CA:-/nonexistent}" ]; then
|
||||
cp -f "$AD_TLS_CA" "$staged/ad_ca.pem"
|
||||
chmod 0644 "$staged/ad_ca.pem"
|
||||
smb_set "tls cafile" "$staged/ad_ca.pem"
|
||||
fi
|
||||
# sanity: key must match cert, otherwise samba fails late and cryptically
|
||||
c_mod="$(openssl x509 -noout -modulus -in "$conf_cert" 2>/dev/null | openssl md5 2>/dev/null)"
|
||||
k_mod="$(openssl rsa -noout -modulus -in "$conf_key" 2>/dev/null | openssl md5 2>/dev/null)"
|
||||
[ -n "$c_mod" ] && [ "$c_mod" = "$k_mod" ] || die "AD_TLS_CERT and AD_TLS_KEY do not match (modulus mismatch)"
|
||||
san="$(openssl x509 -noout -ext subjectAltName -in "$conf_cert" 2>/dev/null | tr -d ' \n')"
|
||||
printf '%s' "$san" | grep -qi "$FQDN" || warn "leaf cert SAN does not include $FQDN - Windows will reject LDAPS: ${san:-none}"
|
||||
smb_set "tls enabled" "yes"
|
||||
smb_set "tls certfile" "$conf_cert"
|
||||
smb_set "tls keyfile" "$conf_key"
|
||||
log "LDAPS enabled (staged $conf_cert / $conf_key, mode 0600, SAN: ${san:-none})"
|
||||
else
|
||||
log "LDAPS: no AD_TLS_CERT mounted (Samba will self-sign an untrusted cert on 636)"
|
||||
fi
|
||||
|
||||
# -------------------------------------------------------------------------------------
|
||||
# 9. Validate the rendered config. Fail loudly instead of half-working.
|
||||
# -------------------------------------------------------------------------------------
|
||||
tp_out="$(testparm -s "$SMBCONF" 2>&1 || true)"
|
||||
if printf '%s' "$tp_out" | grep -qiE 'unknown parameter encountered|cannot parse|invalid parameter'; then
|
||||
printf '%s\n' "$tp_out" | grep -iE 'unknown parameter encountered|cannot parse|invalid parameter' | sort -u >&2
|
||||
die "smb.conf contains rejected parameters (see above) - refusing to start"
|
||||
fi
|
||||
log "smb.conf validated by testparm"
|
||||
|
||||
# -------------------------------------------------------------------------------------
|
||||
# 10. Self-referencing resolver (classic AD DC requirement).
|
||||
# In host network mode this file lives in the container only; the host is untouched.
|
||||
# -------------------------------------------------------------------------------------
|
||||
if [ "$AD_MANAGE_RESOLV" = "yes" ]; then
|
||||
printf '# rendered by ad-entrypoint: the DC resolves its own zone from itself\nsearch %s\nnameserver 127.0.0.1\noptions timeout:2 attempts:2\n' \
|
||||
"$AD_DNS_DOMAIN" > /etc/resolv.conf
|
||||
log "/etc/resolv.conf -> nameserver 127.0.0.1 search ${AD_DNS_DOMAIN} (upstream via 'dns forwarder = ${AD_DNS_FORWARDER}')"
|
||||
fi
|
||||
|
||||
# Pin the daemon's OWN updater too (not just our loop above): every `dns update command`
|
||||
# invocation from the samba daemon goes through the wrapper, which reads the advertised
|
||||
# addresses from /run/ad-dc-addresses.
|
||||
printf '%s\n%s\n' "$AD_IP" "${AD_IP6:-}" | sed '/^$/d' > /run/ad-dc-addresses
|
||||
smb_set "dns update command" "/usr/local/bin/ad-dnsupdate"
|
||||
log "DNS registration pinned to: $(tr '\n' ' ' < /run/ad-dc-addresses)"
|
||||
|
||||
# -------------------------------------------------------------------------------------
|
||||
# 11. Keep DNS records aligned with the current IP (laptop/DHCP or after failover)
|
||||
#
|
||||
# We pin registration to AD_IP on purpose. A Docker host is multi-homed (docker0 and every
|
||||
# user-defined bridge, plus stale DHCP addresses), and Samba happily registers ALL of them
|
||||
# into the DC's A record - clients then sometimes connect to 172.17.0.1 and fail in ways
|
||||
# that look like Kerberos bugs. --current-ip keeps exactly one address published.
|
||||
# -------------------------------------------------------------------------------------
|
||||
(
|
||||
trap 'exit 0' TERM INT
|
||||
sleep 25
|
||||
while true; do
|
||||
if samba_dnsupdate --use-samba-tool \
|
||||
--current-ip="$AD_IP" \
|
||||
${AD_IP6:+--current-ip="$AD_IP6"} \
|
||||
--verbose >/tmp/dnsupdate.out 2>&1; then
|
||||
:
|
||||
else
|
||||
warn "samba_dnsupdate failed (see /tmp/dnsupdate.out)"
|
||||
fi
|
||||
|
||||
# Self-heal: stale A records left behind by an earlier (unpinned) updater are not
|
||||
# removed by dnsupdate, because they are no longer in its update cache. Delete any
|
||||
# address on our own FQDN that we are not advertising. Uses the DC's own machine
|
||||
# account (-P) so no admin password needs to exist inside the container.
|
||||
escaped="$(printf '%s' "$AD_IP" | sed 's/\./\\./g')"
|
||||
extra="$(dig +short @127.0.0.1 "$FQDN" A 2>/dev/null | grep -E '^[0-9.]+$' | grep -vx "$escaped" || true)"
|
||||
for bad in $extra; do
|
||||
log "removing stale A record ${FQDN} -> ${bad}"
|
||||
samba-tool dns delete 127.0.0.1 "$AD_DNS_DOMAIN" "$FQDN" A "$bad" -P \
|
||||
>/dev/null 2>&1 \
|
||||
|| warn "could not delete stale A record ${FQDN} -> ${bad}; run: samba-tool dns delete 127.0.0.1 ${AD_DNS_DOMAIN} ${FQDN} A ${bad} -P"
|
||||
done
|
||||
|
||||
sleep "$AD_DNS_REFRESH_SECONDS"
|
||||
done
|
||||
) &
|
||||
DNS_UPDATER_PID=$!
|
||||
trap 'kill "$DNS_UPDATER_PID" 2>/dev/null || true' EXIT
|
||||
|
||||
# -------------------------------------------------------------------------------------
|
||||
# 12. Foreground, exactly like the Debian systemd unit, so SIGTERM shuts down cleanly.
|
||||
# -------------------------------------------------------------------------------------
|
||||
log "starting ${FQDN} for realm ${AD_REALM}"
|
||||
exec samba --foreground --no-process-group --option="log level = ${AD_LOG_LEVEL}" -s "$SMBCONF"
|
||||
61
image/ad-healthcheck.sh
Executable file
61
image/ad-healthcheck.sh
Executable file
|
|
@ -0,0 +1,61 @@
|
|||
#!/usr/bin/env bash
|
||||
# =====================================================================================
|
||||
# Container healthcheck. Credential-free on purpose: it must never risk locking out an
|
||||
# account by testing authentication. Authenticated checks live in scripts/verify.sh.
|
||||
# - process alive
|
||||
# - LDAP rootDSE answers (anonymous base object is allowed by design)
|
||||
# - own DNS answers the AD SRV records
|
||||
# - Kerberos / SMB / Global Catalog ports accept a connection
|
||||
# - smb.conf still parses
|
||||
# =====================================================================================
|
||||
set -uo pipefail
|
||||
|
||||
problems=()
|
||||
|
||||
pgrep -x samba >/dev/null 2>&1 || problems+=("samba process not running")
|
||||
|
||||
# LDAP rootDSE
|
||||
if ! ldapsearch -x -H ldap://127.0.0.1:389 -s base -b '' defaultNamingContext 2>/dev/null \
|
||||
| grep -q '^defaultNamingContext:'; then
|
||||
problems+=("LDAP rootDSE did not answer")
|
||||
fi
|
||||
|
||||
# authoritative SRV lookup against ourselves
|
||||
zone="$(sed -n 's/^[[:space:]]*search[[:space:]]\{1,\}\([^[:space:]]\{1,\}\).*/\1/p' /etc/resolv.conf 2>/dev/null | head -1)"
|
||||
if [ -z "$zone" ]; then
|
||||
zone="$(hostname -d 2>/dev/null || true)"
|
||||
fi
|
||||
if [ -n "$zone" ]; then
|
||||
if ! dig +time=2 +tries=1 +short @127.0.0.1 "_ldap._tcp.dc._msdcs.${zone}" SRV 2>/dev/null | grep -q .; then
|
||||
problems+=("_ldap._tcp.dc._msdcs.${zone} SRV returned nothing")
|
||||
fi
|
||||
else
|
||||
problems+=("cannot determine DNS zone")
|
||||
fi
|
||||
|
||||
for port in 88 135 445 3268; do
|
||||
if ! timeout 3 bash -c "exec 3<>/dev/tcp/127.0.0.1/${port}" 2>/dev/null; then
|
||||
problems+=("tcp/${port} refused")
|
||||
fi
|
||||
done
|
||||
|
||||
# kpasswd (464) is UDP-only for most clients; check the KDC UDP path instead
|
||||
if ! timeout 3 bash -c 'exec 3<>/dev/udp/127.0.0.1/88' 2>/dev/null; then
|
||||
problems+=("udp/88 unusable")
|
||||
fi
|
||||
|
||||
testparm -s /etc/samba/smb.conf >/dev/null 2>&1 || problems+=("testparm failed")
|
||||
|
||||
if ((${#problems[@]} == 0)); then
|
||||
echo "healthy"
|
||||
exit 0
|
||||
fi
|
||||
printf 'UNHEALTHY:'
|
||||
printf ' %s;' "${problems[@]}"
|
||||
# Surface the daemon's own last words: samba writes the real reason to its log file and this
|
||||
# shows up in `docker inspect --format '{{json .State.Health}}'`.
|
||||
if [ -r /var/log/samba/log.samba ]; then
|
||||
printf '\n log.samba tail: %s\n' "$(tail -3 /var/log/samba/log.samba | tr '\n' '|')"
|
||||
fi
|
||||
echo
|
||||
exit 1
|
||||
51
scripts/add-replica.sh
Executable file
51
scripts/add-replica.sh
Executable file
|
|
@ -0,0 +1,51 @@
|
|||
#!/usr/bin/env bash
|
||||
# =====================================================================================
|
||||
# Add a second domain controller (replica) for availability.
|
||||
#
|
||||
# This runs the `replica` compose profile, which needs its own hostname/IP and the join
|
||||
# credentials of an existing DC. Because the profile uses host networking it is meant to
|
||||
# run on a SECOND MACHINE that has this repo synced to it (otherwise two containers fight
|
||||
# over UDP/TCP 53, 88, 445...). For one machine, use macvlan instead.
|
||||
#
|
||||
# Required in .env on the replica machine:
|
||||
# AD_DNS_DOMAIN / AD_REALM = the existing domain
|
||||
# AD_HOSTNAME2=dc2
|
||||
# AD_IP=<this machine's LAN ip>
|
||||
# AD_JOIN_SERVER=dc1.ad.example.com
|
||||
# AD_JOIN_USER=administrator
|
||||
# AD_JOIN_PASSWORD (or secrets/join.password)
|
||||
# =====================================================================================
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
. "$(dirname "$0")/envfile.sh"; ad_env_load .env # parsed, never executed (see envfile.sh)
|
||||
|
||||
[ -n "${AD_DNS_DOMAIN:-}" ] || { echo "set AD_DNS_DOMAIN to the existing domain in .env" >&2; exit 2; }
|
||||
[ -n "${AD_HOSTNAME2:-}" ] || { echo "set AD_HOSTNAME2 (e.g. dc2) in .env" >&2; exit 2; }
|
||||
[ -n "${AD_IP:-}" ] || { echo "set AD_IP to the reachable LAN address of this machine" >&2; exit 2; }
|
||||
[ -n "${AD_JOIN_SERVER:-}" ] || { echo "set AD_JOIN_SERVER (existing DC FQDN) in .env" >&2; exit 2; }
|
||||
|
||||
echo ">> replica: ${AD_HOSTNAME2}.${AD_DNS_DOMAIN} joining via ${AD_JOIN_SERVER}"
|
||||
|
||||
# the joining DC must be able to resolve the existing DC before its own DNS is trustworthy
|
||||
if ! getent hosts "$AD_JOIN_SERVER" >/dev/null 2>&1; then
|
||||
echo "!! ${AD_JOIN_SERVER} does not resolve from this host."
|
||||
echo " add AD_EXTRA_HOSTS=\"<ip> ${AD_JOIN_SERVER} ${AD_JOIN_SERVER%%.*}\" to .env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# sanity: realm must be reachable over Kerberos before we attempt the join
|
||||
if command -v kinit >/dev/null 2>&1; then
|
||||
echo " (tip: pre-auth once with 'kinit administrator@${AD_REALM:-}' to catch realm/ clock problems early)"
|
||||
fi
|
||||
|
||||
docker compose --profile replica up -d ad-dc2
|
||||
sleep 20
|
||||
AD_CONTAINER="${AD_CONTAINER2:-ad-dc2}" ./scripts/verify.sh || true
|
||||
|
||||
echo
|
||||
echo "Post-join checklist"
|
||||
echo " 1. DNS: point clients at BOTH DCs (DHCP option 006 = dc1,dc2)"
|
||||
echo " 2. docker exec ${AD_CONTAINER2:-ad-dc2} samba-tool drs showrepl -> no failing inbound/outbound"
|
||||
echo " 3. docker exec ${AD_CONTAINER2:-ad-dc2} samba-tool ntacl sysvolcheck"
|
||||
echo " 4. from a joined Windows host: nltest /dsgetdc:${AD_DNS_DOMAIN^^} (should list both DCs)"
|
||||
echo " 5. time sources: both DCs must be NTP-synced from outside; clients follow the PDC emulator"
|
||||
74
scripts/backup.sh
Executable file
74
scripts/backup.sh
Executable file
|
|
@ -0,0 +1,74 @@
|
|||
#!/usr/bin/env bash
|
||||
# =====================================================================================
|
||||
# Online full-domain backup (AD database + SYSVOL + secrets) via `samba-tool domain backup`.
|
||||
# Copied out of the container into ./backups/<timestamp>.tgz and checksummed.
|
||||
#
|
||||
# ./scripts/backup.sh # uses $AD_CONTAINER / .env
|
||||
# KEEP=14 ./scripts/backup.sh # prune to the newest 14 backups
|
||||
#
|
||||
# A backup you have not restored is a rumour. Restore drill: ./scripts/restore.sh
|
||||
# =====================================================================================
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
. "$(dirname "$0")/envfile.sh"; ad_env_load .env # parsed, never executed (see envfile.sh)
|
||||
|
||||
CID="${AD_CONTAINER:-ad-dc}"
|
||||
REALM="${AD_REALM:?}"
|
||||
HOST="${AD_HOSTNAME:-dc1}"
|
||||
ZONE="${AD_DNS_DOMAIN:?}"
|
||||
FQDN="${HOST}.${ZONE}"
|
||||
CC=/tmp/backup_ccache
|
||||
DEST="${AD_BACKUP_DIR:-backups}"
|
||||
KEEP="${KEEP:-14}"
|
||||
|
||||
PW="${AD_ADMIN_PASSWORD:-}"
|
||||
[ -z "$PW" ] && [ -r secrets/ad_admin.password ] && PW="$(tr -d '\r\n' < secrets/ad_admin.password)"
|
||||
[ -n "$PW" ] || { echo "no administrator password available" >&2; exit 2; }
|
||||
|
||||
docker inspect -f '{{.State.Status}}' "$CID" | grep -q running || { echo "$CID is not running" >&2; exit 1; }
|
||||
mkdir -p "$DEST"
|
||||
# Preflight: a backup that dies after 10 minutes of work is worse than one that never starts.
|
||||
# Docker creates bind-mount source dirs as root, and `docker cp` extracts to the host as the
|
||||
# *invoking* user - so a root-owned backups/ fails with a cryptic "openat: permission denied".
|
||||
if [ ! -w "$DEST" ]; then
|
||||
echo "!! backup dir '$DEST' is not writable by $(whoami) (owned $(stat -c '%U:%G %a' "$DEST"))" >&2
|
||||
echo " typically created root-owned by Docker for a bind mount. Fix with:" >&2
|
||||
echo " sudo chown $(whoami) \"$DEST\"" >&2
|
||||
exit 1
|
||||
fi
|
||||
ts="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
|
||||
echo ">> backing up ${FQDN} (online, consistent snapshot) -> ${DEST}/"
|
||||
printf '%s\n' "$PW" | docker exec -i -e KRB5CCNAME="FILE:${CC}" -e AD_FQDN="$FQDN" "$CID" \
|
||||
bash -euo pipefail -c '
|
||||
kinit "${AD_ADMIN_USER:-administrator}@${AD_REALM}"
|
||||
samba-tool domain backup online \
|
||||
--server="$AD_FQDN" \
|
||||
--targetdir=/var/backups/ad-dc \
|
||||
--use-krb5-ccache="$KRB5CCNAME"
|
||||
'
|
||||
|
||||
# Samba names the archive samba-backup-<domain>-<ts>.tar.bz2 (older builds: .tgz)
|
||||
newest="$(docker exec "$CID" bash -c 'ls -1t /var/backups/ad-dc/*.tar.bz2 /var/backups/ad-dc/*.tgz 2>/dev/null | head -1')"
|
||||
[ -n "$newest" ] || { echo "backup produced no archive" >&2; exit 1; }
|
||||
|
||||
docker cp "${CID}:${newest}" "${DEST}/${ts}-$(basename "$newest")"
|
||||
out="${DEST}/${ts}-$(basename "$newest")"
|
||||
( cd "$DEST" && sha256sum "$(basename "$out")" > "$(basename "$out").sha256" )
|
||||
|
||||
# Don't leave archives inside the container layer - they survive restarts and silently grow it.
|
||||
docker exec "$CID" rm -f "$newest" 2>/dev/null || true
|
||||
|
||||
size="$(du -h "$out" | cut -f1)"
|
||||
echo ">> wrote $out ($size)"
|
||||
echo " checksum: $(cat "$out.sha256")"
|
||||
|
||||
# rotation
|
||||
mapfile -t existing < <(ls -1t "$DEST"/*.tar.bz2 "$DEST"/*.tgz 2>/dev/null || true)
|
||||
if [ "${#existing[@]}" -gt "$KEEP" ]; then
|
||||
for old in "${existing[@]:$KEEP}"; do
|
||||
echo " pruning $old"
|
||||
rm -f "$old" "$old.sha256"
|
||||
done
|
||||
fi
|
||||
echo ">> done. Copy $out off this host (backup of a backup) and keep the AD_DB key safe."
|
||||
42
scripts/envfile.sh
Normal file
42
scripts/envfile.sh
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
#!/bin/sh
|
||||
# =====================================================================================
|
||||
# Safe .env reader for the host-side scripts.
|
||||
#
|
||||
# Do NOT `. ./.env`. A file that is perfectly valid for docker-compose is NOT valid shell:
|
||||
# AD_DNS_FORWARDER=1.1.1.1 8.8.8.8 # compose: fine. shell: runs "8.8.8.8"!
|
||||
# That exact thing silently broke a backup run. So we parse the file instead of executing it.
|
||||
#
|
||||
# Behaviour:
|
||||
# * KEY=VALUE, first '=' splits; the value keeps any later '=' (LDAP URIs, passwords)
|
||||
# * surrounding quotes are stripped; inline " # ..." is dropped on unquoted values
|
||||
# * full-line comments and blanks are ignored
|
||||
# * an existing environment variable WINS, so tests/CI can override:
|
||||
# AD_ADMIN_PASSWORD=x ./scripts/backup.sh
|
||||
#
|
||||
# Usage (bash/dash): . scripts/envfile.sh && ad_env_load .env
|
||||
# =====================================================================================
|
||||
|
||||
ad_env_load() {
|
||||
_f="${1:-.env}"
|
||||
[ -f "$_f" ] || return 0
|
||||
while IFS='=' read -r _k _v || [ -n "$_k" ]; do
|
||||
case "$_k" in ''|[\ \#]*) continue ;; esac
|
||||
# key: strip whitespace/CR
|
||||
_k="$(printf '%s' "$_k" | tr -d '\r' | sed 's/[[:space:]]*$//')"
|
||||
case "$_k" in ''|\#*) continue ;; esac
|
||||
# value: trim, strip one layer of quotes, or drop an inline comment
|
||||
_v="${_v%$'\r'}"
|
||||
_v="${_v#"${_v%%[![:space:]]*}"}"
|
||||
case "$_v" in
|
||||
\"*\") _v="${_v#\"}"; _v="${_v%\"}" ;;
|
||||
\'*\') _v="${_v#\'}"; _v="${_v%\'}" ;;
|
||||
*) _v="${_v%%[[:space:]]#*}" ;;
|
||||
esac
|
||||
_v="${_v%"${_v##*[![:space:]]}"}" # rtrim
|
||||
# don't clobber a value already provided in the environment
|
||||
if [ -z "$(printenv "$_k" 2>/dev/null)" ]; then
|
||||
export "$_k=$_v"
|
||||
fi
|
||||
done < "$_f"
|
||||
unset _f _k _v
|
||||
}
|
||||
48
scripts/mksecrets.sh
Executable file
48
scripts/mksecrets.sh
Executable file
|
|
@ -0,0 +1,48 @@
|
|||
#!/usr/bin/env bash
|
||||
# Create the secrets directory with a strong random administrator password (or keep your own).
|
||||
# ./scripts/mksecrets.sh -> generates secrets/ad_admin.password (0600)
|
||||
# ./scripts/mksecrets.sh 'mypw' -> stores the given password
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
umask 077
|
||||
mkdir -p secrets
|
||||
# Same trap as backup.sh: Docker may have created this dir root-owned for a bind mount.
|
||||
if [ ! -w secrets ]; then
|
||||
echo "!! 'secrets' is not writable by $(whoami) (owned $(stat -c '%U:%G %a' secrets)) - fix: sudo chown $(whoami) secrets" >&2
|
||||
exit 1
|
||||
fi
|
||||
F=secrets/ad_admin.password
|
||||
|
||||
if [ -e "$F" ] && [ -z "${1:-}" ]; then
|
||||
echo "refusing to overwrite existing $F" >&2
|
||||
echo "use 'make passwd' to rotate, or delete it first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "${1:-}" ]; then
|
||||
printf '%s' "$1" > "$F"
|
||||
else
|
||||
# 24 chars, upper+lower+digit+symbol -> comfortably clears AD complexity.
|
||||
# Deliberately no `tr ... | head -c N`: under `set -o pipefail` the SIGPIPE that kills
|
||||
# `tr` when head exits makes the whole pipeline fail (exit 141). One byte per dd instead.
|
||||
gen_class() { # $1 = char set, $2 = how many chars
|
||||
s=""
|
||||
while [ "${#s}" -lt "$2" ]; do
|
||||
c="$(dd if=/dev/urandom bs=1 count=1 2>/dev/null | tr -dc "$1")"
|
||||
[ -n "$c" ] && s="${s}${c}"
|
||||
done
|
||||
printf '%s' "$s"
|
||||
}
|
||||
pw="$(gen_class 'A-HJ-NP-Za-km-z2-9' 6)"
|
||||
pw+="$(gen_class 'A-Z' 3)"
|
||||
pw+="$(gen_class 'a-z' 8)"
|
||||
pw+="$(gen_class '#@%!$^&*' 3)"
|
||||
printf '%s' "$pw" > "$F"
|
||||
fi
|
||||
chmod 600 "$F"
|
||||
|
||||
echo "wrote $F (mode $(stat -c '%a' "$F"))"
|
||||
echo " password length: $(tr -d '\n' < "$F" | wc -c)"
|
||||
echo " store it in your password vault now; nothing else prints it again."
|
||||
echo " AD also needs an admin UPN; login as ${AD_REALM:-AD.EXAMPLE.COM}\\${AD_ADMIN_USER:-administrator}"
|
||||
114
scripts/restore.sh
Executable file
114
scripts/restore.sh
Executable file
|
|
@ -0,0 +1,114 @@
|
|||
#!/usr/bin/env bash
|
||||
# =====================================================================================
|
||||
# Disaster recovery: bring the domain back from an online backup archive.
|
||||
#
|
||||
# ./scripts/restore.sh backups/<archive>.tar.bz2 <new-dc-hostname> [new-ip]
|
||||
#
|
||||
# HOW SAMBA RESTORE ACTUALLY WORKS (this surprises people)
|
||||
# `samba-tool domain backup restore` performs a *join*: it unpacks the database into a
|
||||
# target dir and then ADDS the target host as a NEW domain controller. Restoring under the
|
||||
# same server name that the archive was taken from fails with
|
||||
# "Entry CN=<NAME>,OU=Domain Controllers,... already exists"
|
||||
# So: pick a NEW hostname (and an IP nothing else is using). The old DC name stays in DNS
|
||||
# until you clean it up; point clients at the new DC.
|
||||
#
|
||||
# Sequence: backup .env -> stop live DC -> move data dir aside (never deleted) ->
|
||||
# restore into fresh tree -> rewrite .env identity -> up -> verify
|
||||
# =====================================================================================
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
[ -f .env ] || { echo "no .env in $(pwd) - restore expects the production env file" >&2; exit 2; }
|
||||
. "$(dirname "$0")/envfile.sh"; ad_env_load .env # parsed, never executed (see envfile.sh)
|
||||
|
||||
BACKUP="${1:-}"
|
||||
NEW_HOST="${2:-${AD_RESTORE_AS:-}}"
|
||||
NEW_IP="${3:-${AD_RESTORE_IP:-$AD_IP}}"
|
||||
|
||||
[ -n "$BACKUP" ] && [ -r "$BACKUP" ] || { echo "usage: $0 <backup.tar.bz2> <new-dc-hostname> [new-ip]" >&2; exit 2; }
|
||||
[ -n "$NEW_HOST" ] || { echo "A Samba restore lands as a NEW domain controller. Give it a hostname, e.g. dc3" >&2; exit 2; }
|
||||
if [ "${NEW_HOST,,}" = "$(tar -f "$BACKUP" -O ./backup.txt 2>/dev/null | sed -n "s/.*using DC '\([^']*\)'.*/\1/p" | head -1 | cut -d. -f1 | tr 'A-Z' 'a-z')" ]; then
|
||||
echo "refusing: '$NEW_HOST' is the hostname the backup was taken from" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
src_dc="$(tar -f "$BACKUP" -O ./backup.txt 2>/dev/null | sed -n "s/.*using DC '\([^']*\)'.*/\1/p" | head -1)"
|
||||
echo "archive: $(basename "$BACKUP")"
|
||||
echo "from DC: ${src_dc:-unknown}"
|
||||
echo "restore as: ${NEW_HOST}.${AD_DNS_DOMAIN} @ ${NEW_IP}"
|
||||
read -r -p "Type RESTORE to proceed: " confirm
|
||||
[ "$confirm" = "RESTORE" ] || { echo "aborted"; exit 1; }
|
||||
|
||||
if [ -f "${BACKUP}.sha256" ] && ( cd "$(dirname "$BACKUP")" && sha256sum -c "$(basename "$BACKUP").sha256" ); then
|
||||
echo ">> archive checksum OK"
|
||||
else
|
||||
echo ">> no checksum (or mismatch) - continuing, you were warned"
|
||||
fi
|
||||
|
||||
ts="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
cid="${AD_CONTAINER:-ad-dc}"
|
||||
|
||||
echo ">> stopping live DC"
|
||||
docker compose stop ad-dc || true
|
||||
|
||||
echo ">> preserving current data/config so nothing is destroyed"
|
||||
[ -f .env ] && cp -a .env ".env.pre-restore-${ts}"
|
||||
if [ -d data/samba ] && [ -n "$(ls -A data/samba 2>/dev/null || true)" ]; then
|
||||
mv data/samba "data/samba.pre-restore-${ts}"
|
||||
echo " data/samba -> data/samba.pre-restore-${ts}"
|
||||
fi
|
||||
mkdir -p data/samba
|
||||
|
||||
echo ">> rewriting identity in .env (AD_HOSTNAME=${NEW_HOST}, AD_IP=${NEW_IP})"
|
||||
cp -a .env .env.tmp
|
||||
sed -i "s/^AD_HOSTNAME=.*/AD_HOSTNAME=${NEW_HOST}/; s/^AD_IP=.*/AD_IP=${NEW_IP}/" .env.tmp
|
||||
mv .env.tmp .env
|
||||
|
||||
echo ">> restoring into the fresh tree (one-shot container; does not start the DC)"
|
||||
docker run --rm \
|
||||
--name "ad-restore-${ts}" \
|
||||
--cap-drop ALL \
|
||||
--cap-add SYS_ADMIN --cap-add CHOWN --cap-add FOWNER --cap-add DAC_OVERRIDE \
|
||||
--cap-add SETUID --cap-add SETGID --cap-add NET_BIND_SERVICE \
|
||||
-v "$PWD/$(dirname "$BACKUP"):/restore:ro" \
|
||||
-v "$PWD/data/samba:/var/lib/samba" \
|
||||
-v "$PWD/conf:/etc/samba" \
|
||||
-v /etc/localtime:/etc/localtime:ro \
|
||||
--env-file .env \
|
||||
-e "AD_RESTORE_FROM=/restore/$(basename "$BACKUP")" \
|
||||
-e AD_RESTORE_CONFIRM=yes \
|
||||
"${AD_RESTORE_IMAGE:-local/samba-ad-dc:4.22-debian13}" || {
|
||||
echo "!! restore failed - roll back with: cp .env.pre-restore-${ts} .env" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo ">> starting the restored DC"
|
||||
docker compose up -d ad-dc
|
||||
|
||||
echo ">> waiting for health (max 200s)"
|
||||
h=none
|
||||
for _ in $(seq 1 50); do
|
||||
h="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$cid" 2>/dev/null || echo none)"
|
||||
[ "$h" = healthy ] && break
|
||||
sleep 4
|
||||
done
|
||||
echo " health: $h"
|
||||
|
||||
AD_CONTAINER="$cid" ./scripts/verify.sh || {
|
||||
echo "verification failed. Roll back:"
|
||||
echo " docker compose down"
|
||||
echo " cp .env.pre-restore-${ts} .env"
|
||||
echo " rm -rf data/samba && mv data/samba.pre-restore-${ts} data/samba"
|
||||
exit 1
|
||||
}
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Restored as ${NEW_HOST}.${AD_DNS_DOMAIN} (${NEW_IP}).
|
||||
Remaining manual steps:
|
||||
1. DHCP/DNS: hand clients the new DC address (${NEW_IP}) - option 006.
|
||||
2. If the old DC object (${src_dc:-unknown}) is still in the directory and will never
|
||||
come back, remove its stale computer/DNS objects so clients stop trying to reach it:
|
||||
docker exec -it $cid samba-tool dns serverdelete 127.0.0.1 <old-fqdn> -U administrator
|
||||
docker exec -it $cid ldbdel -H /var/lib/samba/private/sam.ldb "<old computer DN>" # careful
|
||||
3. Take a fresh backup immediately: make backup
|
||||
EOF
|
||||
130
scripts/tls.sh
Executable file
130
scripts/tls.sh
Executable file
|
|
@ -0,0 +1,130 @@
|
|||
#!/usr/bin/env bash
|
||||
# =====================================================================================
|
||||
# LDAPS certificates for the domain controller.
|
||||
#
|
||||
# Windows Schannel requires a SubjectAltName (CN-only certs are rejected), and it must be
|
||||
# DNS=<dc fqdn> matching what the client dials. Samba's built-in auto-generated cert is
|
||||
# self-signed and untrusted -> Windows shows trust errors and refusing it breaks LDAPS.
|
||||
#
|
||||
# This builds a throwaway-but-proper internal CA + a DC leaf cert:
|
||||
# secrets/tls/ca.crt import into Windows "Trusted Root Certification Authorities"
|
||||
# (best shipped via GPO so every joined host trusts it)
|
||||
# secrets/tls/dc.crt served by Samba on 636
|
||||
# secrets/tls/dc.key 0600, never leaves the host
|
||||
#
|
||||
# For anything long-lived/external prefer ACME DNS-01 against the DC's own DNS
|
||||
# (nsupdate -g) or your corporate PKI instead of this CA.
|
||||
# =====================================================================================
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
. "$(dirname "$0")/envfile.sh"; ad_env_load .env # parsed, never executed (see envfile.sh)
|
||||
|
||||
HOST="${AD_HOSTNAME:-dc1}"
|
||||
ZONE="${AD_DNS_DOMAIN:-ad.example.com}"
|
||||
FQDN="${HOST}.${ZONE}"
|
||||
D="${AD_TLS_DIR:-secrets/tls}"
|
||||
DAYS_CA="${AD_TLS_CA_DAYS:-3650}"
|
||||
DAYS_LEAF="${AD_TLS_LEAF_DAYS:-825}"
|
||||
|
||||
umask 077
|
||||
mkdir -p "$D"
|
||||
|
||||
if [ -f "$D/ca.key" ] && [ "${1:-}" != "--rotate-leaf-only" ]; then
|
||||
echo "CA already exists at $D/ca.key (leaf will be re-issued against it)."
|
||||
fi
|
||||
|
||||
# ---- root CA (only if missing) ------------------------------------------------------
|
||||
if [ ! -f "$D/ca.key" ]; then
|
||||
echo ">> generating internal root CA for ${FQDN}"
|
||||
openssl req -x509 -newkey rsa:4096 -sha256 -days "$DAYS_CA" -nodes \
|
||||
-keyout "$D/ca.key" -out "$D/ca.crt" \
|
||||
-subj "/C=US/O=${ZONE} /CN=${ZONE} Root CA" \
|
||||
-addext "basicConstraints=critical,CA:TRUE,pathlen:1" \
|
||||
-addext "keyUsage=critical,keyCertSign,cRLSign"
|
||||
chmod 600 "$D/ca.key"
|
||||
fi
|
||||
|
||||
# ---- leaf ------------------------------------------------------------------------
|
||||
echo ">> issuing DC leaf: ${FQDN} (+ ${HOST})"
|
||||
# SAN must cover every name/address a client may dial. Windows (Schannel) requires a SAN - a
|
||||
# CN-only cert is rejected outright - and tools that dial the DC by address need an IP SAN or
|
||||
# they fail hostname verification even though the chain is fine.
|
||||
SAN="DNS:${FQDN},DNS:${HOST}"
|
||||
[ -n "${AD_IP:-}" ] && SAN="${SAN},IP:${AD_IP}"
|
||||
[ -n "${AD_IP6:-}" ] && SAN="${SAN},IP:${AD_IP6}"
|
||||
[ -n "${AD_TLS_SAN_EXTRA:-}" ] && SAN="${SAN},${AD_TLS_SAN_EXTRA}"
|
||||
SAN="$(printf '%s' "$SAN" | tr ',' '\n' | awk '!seen[$0]++' | paste -sd, -)" # dedupe
|
||||
|
||||
openssl req -newkey rsa:2048 -sha256 -nodes \
|
||||
-keyout "$D/dc.key" -out "$D/dc.csr" \
|
||||
-subj "/C=US/O=${ZONE}/CN=${FQDN}"
|
||||
|
||||
openssl x509 -req -in "$D/dc.csr" -CA "$D/ca.crt" -CAkey "$D/ca.key" \
|
||||
-CAcreateserial -days "$DAYS_LEAF" -sha256 \
|
||||
-out "$D/dc.crt" \
|
||||
-extfile <(printf '%s\n' \
|
||||
"subjectAltName=${SAN}" \
|
||||
"extendedKeyUsage=serverAuth" \
|
||||
"keyUsage=critical,digitalSignature,keyEncipherment" \
|
||||
"basicConstraints=critical,CA:FALSE")
|
||||
|
||||
chmod 600 "$D/dc.key"
|
||||
rm -f "$D/dc.csr"
|
||||
|
||||
openssl verify -CAfile "$D/ca.crt" "$D/dc.crt"
|
||||
echo ">> SAN of issued cert:"
|
||||
openssl x509 -in "$D/dc.crt" -noout -ext subjectAltName | sed 's/^/ /'
|
||||
|
||||
# ---- publish the CA where joined machines can fetch it. NETLOGON is read-only and
|
||||
# world-readable by design, which is exactly why Windows ships its own CA there too.
|
||||
# Re-checked with sysvolcheck afterwards: if the extra file upsets the NT ACLs we undo it
|
||||
# rather than leave SYSVOL inconsistent.
|
||||
# AD_TLS_PUBLISH=no skips this entirely - MANDATORY for lab/test certs, otherwise a test run
|
||||
# would push a throwaway CA into the production DC's SYSVOL.
|
||||
CID="${AD_CONTAINER:-ad-dc}"
|
||||
if [ "${AD_TLS_PUBLISH:-yes}" != "yes" ]; then
|
||||
echo ">> CA publication skipped (AD_TLS_PUBLISH=no)"
|
||||
elif [ "$(docker inspect -f '{{.State.Running}}' "$CID" 2>/dev/null || echo false)" = "true" ]; then
|
||||
NETLOGON_DIR="/var/lib/samba/sysvol/${ZONE}/scripts"
|
||||
if docker exec "$CID" test -d "$NETLOGON_DIR" 2>/dev/null; then
|
||||
if docker exec "$CID" cp /run/ad-secrets/tls/ca.crt "$NETLOGON_DIR/ad-root-ca.crt" 2>/dev/null \
|
||||
|| docker cp "$D/ca.crt" "${CID}:${NETLOGON_DIR}/ad-root-ca.crt" 2>/dev/null; then
|
||||
docker exec "$CID" chmod 644 "$NETLOGON_DIR/ad-root-ca.crt" 2>/dev/null || true
|
||||
if docker exec "$CID" samba-tool ntacl sysvolcheck >/dev/null 2>&1; then
|
||||
echo ">> CA published for clients: \\\\${ZONE}\\NETLOGON\\ad-root-ca.crt"
|
||||
else
|
||||
docker exec "$CID" rm -f "$NETLOGON_DIR/ad-root-ca.crt" 2>/dev/null || true
|
||||
echo ">> CA publication rolled back (it broke the SYSVOL ACL check) - distribute ca.crt manually"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo ">> no NETLOGON scripts dir in ${CID}; distribute ca.crt manually"
|
||||
fi
|
||||
else
|
||||
echo ">> ${CID} is not running; skipping CA publication (run this again after 'make up')"
|
||||
fi
|
||||
echo ">> CA SHA-256 fingerprint (verify out-of-band before trusting):"
|
||||
openssl x509 -in "$D/ca.crt" -noout -fingerprint -sha256 | sed 's/^/ /'
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Next steps
|
||||
1) .env:
|
||||
AD_TLS_CERT=/run/ad-secrets/tls/dc.crt
|
||||
AD_TLS_KEY=/run/ad-secrets/tls/dc.key
|
||||
AD_TLS_CA=/run/ad-secrets/tls/ca.crt
|
||||
2) docker compose up -d --force-recreate ad-dc
|
||||
3) verify : openssl s_client -connect ${FQDN}:636 -CAfile $D/ca.crt </dev/null
|
||||
4) trust it on Windows 11 (admin PowerShell). This is the step that removes the popups -
|
||||
a joined machine that trusts the issuing CA sees a perfectly normal certificate:
|
||||
certutil -addstore -f ROOT "C:\\path\\to\\ca.crt"
|
||||
or straight from the DC (needs any domain credential):
|
||||
certutil -addstore -f ROOT "\\\\${ZONE}\\NETLOGON\\ad-root-ca.crt"
|
||||
IMPORTANT: install into the LOCAL COMPUTER store (certutil -addstore ROOT, elevated),
|
||||
not the current-user store. LDAP/Schannel binds run as SYSTEM/service accounts and will
|
||||
not see a per-user trust.
|
||||
For a fleet, publish ca.crt through GPO: Computer Configuration > Policies >
|
||||
Windows Settings > Security Settings > Public Key Policies > Trusted Root CA.
|
||||
5) BACK UP $D/ca.key OFFLINE. It is the trust anchor: lose it and every cert signed by it
|
||||
must be reissued and re-trusted; leak it and someone can mint certs your clients trust.
|
||||
EOF
|
||||
216
scripts/verify.sh
Executable file
216
scripts/verify.sh
Executable file
|
|
@ -0,0 +1,216 @@
|
|||
#!/usr/bin/env bash
|
||||
# =====================================================================================
|
||||
# Post-deployment verification battery for the AD DC container.
|
||||
# Runs everything through `docker exec` inside the running container so the checks hit
|
||||
# the same code paths a client would, without needing host-level AD tooling.
|
||||
#
|
||||
# ./scripts/verify.sh # standard checks
|
||||
# ./scripts/verify.sh --full # + ldb consistency check (slow, DB heavy)
|
||||
# =====================================================================================
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
. "$(dirname "$0")/envfile.sh"; ad_env_load .env # parsed, never executed (see envfile.sh)
|
||||
|
||||
FULL=0; [ "${1:-}" = "--full" ] && FULL=1
|
||||
|
||||
CID="${AD_CONTAINER:-ad-dc}"
|
||||
cenv(){ docker exec "$CID" printenv "$1" 2>/dev/null | head -n1; } # fall back to container env
|
||||
|
||||
REALM="${AD_REALM:-$(cenv AD_REALM)}"
|
||||
ZONE="${AD_DNS_DOMAIN:-${REALM,,}}"
|
||||
HOST="${AD_HOSTNAME:-$(cenv AD_HOSTNAME)}"
|
||||
[ -n "$REALM" ] || { echo "AD_REALM unknown (set it, or run against a container that has it)" >&2; exit 2; }
|
||||
[ -n "$ZONE" ] || { echo "AD_DNS_DOMAIN unknown" >&2; exit 2; }
|
||||
FQDN="${HOST}.${ZONE}"
|
||||
BASEDN="DC=$(printf '%s' "$ZONE" | sed 's/\./,DC=/g')"
|
||||
CC=/tmp/verify_ccache
|
||||
|
||||
PW="${AD_ADMIN_PASSWORD:-}"
|
||||
if [ -z "$PW" ]; then
|
||||
for f in secrets/ad_admin.password /run/ad-secrets/ad_admin.password; do
|
||||
[ -r "$f" ] && { PW="$(tr -d '\r\n' < "$f")"; break; }
|
||||
done
|
||||
fi
|
||||
[ -n "$PW" ] || PW="$(cenv AD_ADMIN_PASSWORD)"
|
||||
[ -n "$PW" ] || { echo "cannot find administrator password (AD_ADMIN_PASSWORD or secrets/ad_admin.password)" >&2; exit 2; }
|
||||
|
||||
pass=0; fail=0; skip=0
|
||||
ok() { printf ' \033[32m✔\033[0m %s\n' "$1"; pass=$((pass+1)); }
|
||||
bad() { printf ' \033[31m✘\033[0m %s\n' "$1"; [ -n "${2:-}" ] && printf ' %s\n' "$(printf '%s' "$2" | tail -4 | tr '\n' '|')"; fail=$((fail+1)); }
|
||||
skp() { printf ' \033[33m○\033[0m %s\n' "$1"; skip=$((skip+1)); }
|
||||
dexec(){ docker exec -i -e "KRB5CCNAME=FILE:${CC}" "$CID" "$@" 2>&1; }
|
||||
# expect a non-zero exit (used for "this must be denied" checks)
|
||||
dexec_expect_denied(){ ! docker exec -i "$CID" "$@" >/dev/null 2>&1; }
|
||||
|
||||
printf '\n\033[1mVerifying %s (realm %s, container %s)\033[0m\n' "$FQDN" "$REALM" "$CID"
|
||||
echo "----------------------------------------------------------------"
|
||||
|
||||
# --- 0. container / state ----------------------------------------------------------
|
||||
st="$(docker inspect -f '{{.State.Status}}' "$CID" 2>/dev/null || echo missing)"
|
||||
[ "$st" = running ] && ok "container running" || { bad "container not running (state: $st)"; echo; exit 1; }
|
||||
|
||||
health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$CID")"
|
||||
case "$health" in
|
||||
healthy) ok "docker health: healthy" ;;
|
||||
starting) skp "docker health: starting (re-run in ~60s)" ;;
|
||||
*) bad "docker health: $health" "$(docker inspect -f '{{range .State.Health.Log}}{{.Output}}{{end}}' "$CID" | tail -5)" ;;
|
||||
esac
|
||||
|
||||
dexec test -f /var/lib/samba/private/sam.ldb && ok "AD database present (sam.ldb)" \
|
||||
|| { bad "no AD database - provision did not complete"; echo; exit 1; }
|
||||
|
||||
# --- 1. DNS: SRV bootstrap records Windows depends on ------------------------------
|
||||
# SRV records a Windows 11 client actually needs for domain/DC location. Verified against
|
||||
# this Samba build. Samba does NOT register the NT4-era `_pdc._tcp` under the domain, the
|
||||
# `_gc._tcp.dc._msdcs` variant, or MIT-style `_kerberos-adm._tcp` - clients don't ask.
|
||||
for rec in _ldap._tcp.dc._msdcs _kerberos._tcp.dc._msdcs _ldap._tcp.gc._msdcs \
|
||||
_ldap._tcp.pdc._msdcs _gc._tcp _ldap._tcp _kerberos._udp _kerberos._tcp \
|
||||
_kpasswd._udp _kpasswd._tcp; do
|
||||
out="$(dexec dig +time=3 +tries=1 +short @127.0.0.1 "${rec}.${ZONE}" SRV)"
|
||||
if [ -n "$out" ]; then
|
||||
printf ' \033[32m✔\033[0m SRV %-34s -> %s\n' "$rec" "$(printf '%s' "$out" | head -1 | tr -s ' ')"
|
||||
pass=$((pass+1))
|
||||
else
|
||||
printf ' \033[31m✘\033[0m SRV %-34s -> (empty)\n' "$rec"
|
||||
fail=$((fail+1))
|
||||
fi
|
||||
done
|
||||
a_rec="$(dexec dig +time=3 +tries=1 +short @127.0.0.1 "$FQDN" A)"
|
||||
[ -n "$a_rec" ] && ok "A record ${FQDN} -> $(echo "$a_rec" | tr '\n' ' ')" || bad "A record for ${FQDN} missing"
|
||||
|
||||
# Regression guard: a multi-homed Docker host (docker0/bridges/stale DHCP) can leak
|
||||
# unreachable addresses into the DC's A record, and clients that pick one of those fail in
|
||||
# ways that look like Kerberos bugs. The recordset must be exactly what the DC advertises.
|
||||
# The container's own AD_IP is the source of truth - the host .env may describe a different
|
||||
# DC (lab container, replica), and AD_IP_EXPECT overrides explicitly.
|
||||
EXPECT_IP="${AD_IP_EXPECT:-$(cenv AD_IP)}"
|
||||
[ -n "$EXPECT_IP" ] || EXPECT_IP="${AD_IP:-}"
|
||||
if [ -z "$EXPECT_IP" ]; then
|
||||
skp "A recordset pin check skipped (no AD_IP known)"
|
||||
else
|
||||
extra_ips="$(printf '%s\n' $a_rec | grep -v "^${EXPECT_IP}$" | tr '\n' ' ')"
|
||||
if [ -n "${extra_ips// /}" ]; then
|
||||
bad "A recordset for ${FQDN} has non-advertised address(es): ${extra_ips}(only ${EXPECT_IP} may be published)"
|
||||
else
|
||||
ok "A recordset is exactly the advertised ${EXPECT_IP} (no Docker bridge / stale addresses)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 2. Kerberos -----------------------------------------------------------------
|
||||
out="$(printf '%s\n' "$PW" | dexec kinit "${AD_ADMIN_USER:-administrator}@${REALM}")" \
|
||||
&& ok "kinit ${AD_ADMIN_USER:-administrator}@${REALM} issued a TGT" \
|
||||
|| bad "kinit failed" "$out"
|
||||
|
||||
out="$(dexec kvno "cifs/${FQDN}")"
|
||||
if printf '%s' "$out" | grep -q 'kvno = '; then
|
||||
enc="$(dexec klist | grep -oiE 'aes[0-9]+[a-z0-9-]*' | sort -u | tr '\n' ' ')"
|
||||
ok "service ticket issued for cifs/${FQDN}${enc:+ (${enc})}"
|
||||
else
|
||||
bad "kvno cifs/${FQDN} failed" "$out"
|
||||
fi
|
||||
|
||||
# --- 3. LDAP / GC over GSSAPI ----------------------------------------------------
|
||||
out="$(dexec ldapsearch -H "ldap://${FQDN}" -Y GSSAPI -b "$BASEDN" '(objectClass=domain)' dn)"
|
||||
printf '%s' "$out" | grep -q "^dn: ${BASEDN}" && ok "LDAP + GSSAPI bind, domain object found" || bad "LDAP GSSAPI search failed" "$out"
|
||||
|
||||
out="$(dexec ldapsearch -H "ldap://${FQDN}:3268" -Y GSSAPI -b '' -s base defaultNamingContext)"
|
||||
printf '%s' "$out" | grep -q "^defaultNamingContext: ${BASEDN}" && ok "Global Catalog (3268) serving ${BASEDN}" || bad "GC on 3268 failed" "$out"
|
||||
|
||||
out="$(dexec ldapsearch -H "ldap://${FQDN}" -Y GSSAPI -b "$BASEDN" '(sAMAccountName=Domain Computers)' dn)"
|
||||
printf '%s' "$out" | grep -q 'dn: ' && ok "machine-account group readable (join will work)" || bad "cannot read Domain Computers group" "$out"
|
||||
|
||||
# --- 4. LDAPS -------------------------------------------------------------------
|
||||
# NB: the OpenLDAP client reads LDAPTLS_* environment variables (bare TLS_* is the
|
||||
# ldap.conf keyword, not the env var). Using the wrong name silently leaves TLS_REQCERT at
|
||||
# its default, and a verification failure then surfaces as the very misleading
|
||||
# "Can't contact LDAP server (-1)".
|
||||
# LDAPS status and the CA path are read from the CONTAINER we are verifying, not the host
|
||||
# .env - a host .env describing the production DC must not make us run LDAPS assertions
|
||||
# against a lab/replica container that has no TLS configured.
|
||||
LDAPS_ON="$(cenv AD_TLS_CERT)"
|
||||
LDAPS_CA="$(cenv AD_TLS_CA)"
|
||||
[ -n "$LDAPS_CA" ] || LDAPS_CA="/run/ad-secrets/tls/ca.crt"
|
||||
if [ -n "$LDAPS_ON" ]; then
|
||||
out="$(dexec openssl s_client -connect "${FQDN}:636" -CAfile "$LDAPS_CA" -verify_return_error </dev/null 2>&1)"
|
||||
printf '%s' "$out" | grep -q 'Verify return code: 0 (ok)' \
|
||||
&& ok "LDAPS 636 chain verifies against the CA" \
|
||||
|| bad "LDAPS verification failed" "$out"
|
||||
|
||||
# Real TLS-strict authenticated search: proves chain AND hostname match AND a bind.
|
||||
out="$(docker exec -e LDAPTLS_CACERT="$LDAPS_CA" -e LDAPTLS_REQCERT=demand "$CID" \
|
||||
ldapsearch -H "ldaps://${FQDN}" -D "CN=${AD_ADMIN_USER:-administrator},CN=Users,${BASEDN}" \
|
||||
-w "$PW" -b "$BASEDN" '(objectClass=domainDNS)' dn 2>&1)"
|
||||
printf '%s' "$out" | grep -q '^dn: ' \
|
||||
&& ok "LDAPS authenticated search with TLS_REQCERT=demand" \
|
||||
|| bad "LDAPS authenticated search failed" "$out"
|
||||
|
||||
# Negative controls: strict trust must actually be strict, or the whole exercise is theatre.
|
||||
out="$(docker exec -e LDAPTLS_REQCERT=demand -e LDAPTLS_CACERT=/nonexistent "$CID" \
|
||||
ldapsearch -H "ldaps://${FQDN}" -x -b "$BASEDN" '(objectClass=domainDNS)' dn 2>&1)"
|
||||
printf '%s' "$out" | grep -qi "Can't contact LDAP server" \
|
||||
&& ok "strict client WITHOUT the CA is rejected" \
|
||||
|| bad "untrusted LDAPS client was NOT rejected (trust is not being enforced)" "$out"
|
||||
|
||||
out="$(docker exec -e LDAPTLS_CACERT="$LDAPS_CA" -e LDAPTLS_REQCERT=demand "$CID" \
|
||||
ldapsearch -H "ldaps://127.0.0.1" -x -b "$BASEDN" '(objectClass=domainDNS)' dn 2>&1)"
|
||||
printf '%s' "$out" | grep -qi "Can't contact LDAP server" \
|
||||
&& ok "hostname/SAN mismatch is rejected (dialled 127.0.0.1)" \
|
||||
|| bad "SAN mismatch NOT rejected - cert accepted for a name it does not own" "$out"
|
||||
else
|
||||
skp "LDAPS not configured on ${CID} (set AD_TLS_* and run ./scripts/tls.sh)"
|
||||
fi
|
||||
|
||||
# --- 5. SYSVOL / NETLOGON --------------------------------------------------------
|
||||
out="$(dexec samba-tool ntacl sysvolcheck)"
|
||||
printf '%s' "$out" | grep -qi 'ERROR' \
|
||||
&& bad "SYSVOL ACL drift (fix: samba-tool ntacl sysvolreset)" "$out" \
|
||||
|| ok "SYSVOL ACLs consistent"
|
||||
|
||||
out="$(printf 'username=%s\npassword=%s\n' "${AD_ADMIN_USER:-administrator}" "$PW" \
|
||||
| dexec smbclient -L //127.0.0.1 -A /dev/stdin -m SMB3)"
|
||||
printf '%s' "$out" | grep -qi 'NETLOGON' && printf '%s' "$out" | grep -qi 'SYSVOL' \
|
||||
&& ok "SMB3 lists NETLOGON + SYSVOL (GPO/slogans path)" \
|
||||
|| bad "NETLOGON/SYSVOL not listed over SMB3" "$out"
|
||||
|
||||
# --- 6. Replication / DB ---------------------------------------------------------
|
||||
out="$(dexec samba-tool drs showrepl)"
|
||||
if printf '%s' "$out" | grep -q 'DSA object GUID:'; then
|
||||
failing="$(printf '%s' "$out" | grep -cE 'failing (inbound|outbound)' || true)"
|
||||
if [ "${failing:-0}" -eq 0 ]; then
|
||||
ok "DRS healthy (no failing replication requests)"
|
||||
else
|
||||
bad "DRS shows ${failing} failing replication request(s)" "$out"
|
||||
fi
|
||||
else
|
||||
bad "DRS did not report the DSA object" "$out"
|
||||
fi
|
||||
out="$(dexec samba-tool domain level show)"
|
||||
printf ' %s\n' "$(printf '%s' "$out" | tr -d '\r' | grep -iE 'function|level' | head -3 | tr '\n' ' ')"
|
||||
ok "functional levels readable"
|
||||
|
||||
if [ "$FULL" = 1 ]; then
|
||||
echo " .. running ldb consistency check (this can take minutes)"
|
||||
out="$(dexec samba-tool dbcheck --cross-ncs --yes)"
|
||||
printf '%s' "$out" | grep -qiE 'ERROR|bad referral' \
|
||||
&& bad "dbcheck reported errors" "$out" || ok "dbcheck clean"
|
||||
else
|
||||
skp "dbcheck skipped (use --full)"
|
||||
fi
|
||||
|
||||
# --- 7. Negative checks: what must NOT be reachable ------------------------------
|
||||
if dexec_expect_denied ldapsearch -x -H "ldap://127.0.0.1:389" -b "$BASEDN" '(objectClass=user)' dn; then
|
||||
ok "anonymous LDAP search of user objects denied"
|
||||
else
|
||||
bad "anonymous LDAP search of user objects SUCCEEDED (restrict anonymous?)"
|
||||
fi
|
||||
|
||||
if dexec_expect_denied smbclient -L //127.0.0.1 -g -m SMB1; then
|
||||
ok "SMB1 refused"
|
||||
else
|
||||
bad "SMB1 not refused"
|
||||
fi
|
||||
|
||||
echo "----------------------------------------------------------------"
|
||||
printf '\033[1m%d passed, %d failed, %d skipped\033[0m\n' "$pass" "$fail" "$skip"
|
||||
[ "$fail" -eq 0 ] || echo "AD NOT READY - fix failures above before joining Windows hosts"
|
||||
exit "$fail"
|
||||
84
scripts/win11-join.ps1
Normal file
84
scripts/win11-join.ps1
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# =====================================================================================
|
||||
# Windows 11 client side: DNS -> optional CA trust -> domain join -> verification.
|
||||
# Run in an elevated PowerShell on the Windows 11 machine.
|
||||
#
|
||||
# .\win11-join.ps1 -DnsServer 192.168.5.12 -Domain ad.example.com -CaCert C:\ca.crt
|
||||
# .\win11-join.ps1 -VerifyOnly
|
||||
# =====================================================================================
|
||||
#Requires -RunAsAdministrator
|
||||
param(
|
||||
[string]$DnsServer = "192.168.5.12",
|
||||
[string]$Domain = "ad.example.com",
|
||||
[string]$DcHost = "dc1.ad.example.com",
|
||||
[string]$OUPath = "", # e.g. "OU=Wks,DC=ad,DC=example,DC=com"
|
||||
[string]$CaCert = "", # ca.crt path - local, or straight off the DC:
|
||||
# \\premier.local\NETLOGON\ad-root-ca.crt
|
||||
[switch]$VerifyOnly,
|
||||
[switch]$NoRestart
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
function Step($m) { Write-Host "`n==> $m" -ForegroundColor Cyan }
|
||||
function Warn($m) { Write-Host " ! $m" -ForegroundColor Yellow }
|
||||
|
||||
if ($VerifyOnly) {
|
||||
Step "Secure channel"
|
||||
Test-ComputerSecureChannel -Verbose
|
||||
|
||||
Step "DC discovery for $Domain"
|
||||
& nltest /dsgetdc:$Domain
|
||||
|
||||
Step "Group policy"
|
||||
& gpupdate /force
|
||||
& gpresult /r
|
||||
|
||||
Step "Kerberos tickets (look for cifs/$DcHost and HTTP/$DcHost)"
|
||||
& klist
|
||||
|
||||
Step "Last 15 logon / netlogon errors"
|
||||
Get-WinEvent -FilterHashtable @{ LogName = 'System'; Level = 1,2,3; StartTime = (Get-Date).AddHours(-4) } `
|
||||
-MaxEvents 15 -ErrorAction SilentlyContinue |
|
||||
Format-Table TimeCreated, ProviderName, Id, @{n='Msg';e={$_.Message.Substring(0, [Math]::Min(120, $_.Message.Length))}} -AutoSize
|
||||
return
|
||||
}
|
||||
|
||||
Step "Point DNS at the domain controller ($DnsServer)"
|
||||
$adapters = Get-NetAdapter | Where-Object { $_.Status -eq 'Up' } | Sort-Object LinkSpeed -Descending
|
||||
$adapter = $adapters[0]
|
||||
Set-DnsClientServerAddress -InterfaceIndex $adapter.ifIndex -ServerAddresses ($DnsServer, '8.8.8.8')
|
||||
Write-Host " adapter: $($adapter.Name) -> $($DnsServer), 8.8.8.8"
|
||||
ipconfig /flushdns | Out-Null
|
||||
|
||||
Step "SRV bootstrap records"
|
||||
try {
|
||||
Resolve-DnsName -Name "_ldap._tcp.dc._msdcs.$Domain" -Type SRV -ErrorAction Stop |
|
||||
Select-Object Name, Strings | Format-Table -AutoSize
|
||||
} catch {
|
||||
Warn "no SRV answer for _ldap._tcp.dc._msdcs.$Domain - DNS is not pointing at the DC yet"
|
||||
throw
|
||||
}
|
||||
|
||||
Step "Connectivity matrix"
|
||||
foreach ($p in 53, 88, 389, 445, 636, 3268) {
|
||||
$r = Test-NetConnection -ComputerName $DcHost -Port $p -WarningAction SilentlyContinue
|
||||
Write-Host (" {0,-5} {1}" -f $p, $(if ($r.TcpTestSucceeded) { 'open' } else { 'CLOSED' }))
|
||||
}
|
||||
|
||||
if ($CaCert -and (Test-Path $CaCert)) {
|
||||
Step "Importing LDAPS root CA into the machine trust store"
|
||||
Import-Certificate -FilePath $CaCert -CertStoreLocation 'Cert:\LocalMachine\Root' | Out-Null
|
||||
Write-Host " imported $CaCert"
|
||||
} else {
|
||||
Warn "no -CaCert supplied: LDAPS will present an untrusted certificate"
|
||||
}
|
||||
|
||||
Step "Joining $Domain as $(hostname)"
|
||||
$cred = Get-Credential -Message "Domain join credentials (use ${Domain.ToUpper()}\administrator)"
|
||||
$joinArgs = @{ DomainName = $Domain; Credential = $cred }
|
||||
if ($OUPath) { $joinArgs['OUPath'] = $OUPath }
|
||||
if ($NoRestart) { $joinArgs['NoRestart'] = $true }
|
||||
Add-Computer @joinArgs
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Joined. Reboot now (or run: Restart-Computer -Force) and log in with a domain account." -ForegroundColor Green
|
||||
Write-Host "Then re-run this script with -VerifyOnly to confirm secure channel + GPO." -ForegroundColor Green
|
||||
0
secrets/.gitkeep
Normal file
0
secrets/.gitkeep
Normal file
144
tests/dr-drill.sh
Executable file
144
tests/dr-drill.sh
Executable file
|
|
@ -0,0 +1,144 @@
|
|||
#!/usr/bin/env bash
|
||||
# =====================================================================================
|
||||
# Disaster-recovery drill - self-contained and paranoid.
|
||||
#
|
||||
# Proves that a restore actually recovers THE data, not just a working-looking directory:
|
||||
# lab DC -> marker OU -> online backup -> restore as a NEW DC -> marker + SID must match
|
||||
#
|
||||
# This exists because `samba-tool domain backup restore` can silently leave you with a
|
||||
# *fresh forest* if the restore step is skipped and the container then self-provisions.
|
||||
# A restore that passes health checks but lost your data is the worst failure mode there is.
|
||||
#
|
||||
# ./tests/dr-drill.sh # tear down afterwards
|
||||
# KEEP=1 ./tests/dr-drill.sh # leave lab + restored DC running
|
||||
# =====================================================================================
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
IMG=local/samba-ad-dc:4.22-debian13
|
||||
PW='Lab!Only9Tester#2026'
|
||||
REALM=TESTLAB.LOCAL
|
||||
ZONE=testlab.local
|
||||
BASEDN="DC=testlab,DC=local"
|
||||
LAB=ad-lab
|
||||
DRILL=ad-drill
|
||||
DRILL_IP=172.28.90.30
|
||||
STAMP="$(date -u +%H%M%S)"
|
||||
MARKER="DR-MARKER-${STAMP}"
|
||||
FAIL=0
|
||||
|
||||
note() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
|
||||
die() { printf '\033[31mDRILL FAILED: %s\033[0m\n' "$*" >&2; FAIL=1; }
|
||||
sid_of() { # $1 container -> base64 domain objectSid (authenticates inside the container)
|
||||
docker exec "$1" bash -c "printf '%s\n' '$PW' | kinit administrator@${REALM} >/dev/null 2>&1; \
|
||||
ldapsearch -H ldap://127.0.0.1 -Y GSSAPI -s base -b '${BASEDN}' '(objectClass=domainDNS)' objectSid 2>/dev/null \
|
||||
| sed -n 's/^[Oo]bjectSid:: //p' | tr -d '\r'"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if [ "${KEEP:-0}" != 1 ]; then
|
||||
docker rm -f "$DRILL" >/dev/null 2>&1 || true
|
||||
docker volume rm drill-lib drill-conf >/dev/null 2>&1 || true
|
||||
docker compose -f docker-compose.lab.yml down -v >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
caps=(--cap-drop ALL)
|
||||
for c in CHOWN FOWNER SETUID SETGID DAC_OVERRIDE NET_BIND_SERVICE SYS_NICE SYS_RESOURCE AUDIT_WRITE SYS_ADMIN; do
|
||||
caps+=(--cap-add "$c")
|
||||
done
|
||||
|
||||
note "1/7 fresh lab DC (this is the 'production' we are protecting)"
|
||||
docker compose -f docker-compose.lab.yml up -d >/dev/null 2>&1 || die "could not start the lab"
|
||||
h=none
|
||||
for _ in $(seq 1 60); do
|
||||
h="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$LAB" 2>/dev/null)"
|
||||
[ "$h" = healthy ] && break
|
||||
sleep 4
|
||||
done
|
||||
[ "$h" = healthy ] || die "lab not healthy ($h)"
|
||||
src_sid="$(sid_of "$LAB")"
|
||||
[ -n "$src_sid" ] || die "could not read the source domain SID"
|
||||
printf ' %s healthy, domain SID %s\n' "$LAB" "$src_sid"
|
||||
|
||||
note "2/7 plant a marker OU so we can prove data survived"
|
||||
docker exec "$LAB" bash -c "printf '%s\n' '$PW' | kinit administrator@${REALM} 2>/dev/null && samba-tool ou create 'OU=${MARKER}'" >/dev/null 2>&1 \
|
||||
|| die "could not create the marker OU"
|
||||
printf ' planted OU=%s\n' "$MARKER"
|
||||
|
||||
note "3/7 online backup"
|
||||
AD_CONTAINER="$LAB" AD_REALM="$REALM" AD_DNS_DOMAIN="$ZONE" AD_HOSTNAME=dc1 AD_ADMIN_PASSWORD="$PW" \
|
||||
./scripts/backup.sh >/tmp/dr-backup.log 2>&1 || { tail -20 /tmp/dr-backup.log; die "backup.sh failed"; }
|
||||
BK="$(ls -1t backups/*.tar.bz2 backups/*.tgz 2>/dev/null | head -1)"
|
||||
[ -n "$BK" ] || die "no archive produced"
|
||||
printf ' %s (%s)\n' "$(basename "$BK")" "$(du -h "$BK" | cut -f1)"
|
||||
|
||||
note "4/7 restore into a fresh volume AS A NEW DC (dc3) - samba's restore does a join"
|
||||
# Remove the drill container FIRST: a still-mounted volume cannot be removed, and reusing a
|
||||
# stale volume would silently test the wrong tree.
|
||||
docker rm -f "$DRILL" >/dev/null 2>&1 || true
|
||||
docker volume rm drill-lib drill-conf >/dev/null 2>&1 || true
|
||||
if docker volume inspect drill-lib >/dev/null 2>&1; then
|
||||
die "could not remove the drill volume (something still holds it) - refusing to test a stale tree"
|
||||
fi
|
||||
docker run --rm --network adlab --ip "$DRILL_IP" \
|
||||
--cap-drop ALL --cap-add SYS_ADMIN --cap-add CHOWN --cap-add FOWNER \
|
||||
--cap-add DAC_OVERRIDE --cap-add SETUID --cap-add SETGID \
|
||||
-v "$PWD/backups:/restore:ro" -v drill-lib:/var/lib/samba -v drill-conf:/etc/samba \
|
||||
-e AD_REALM="$REALM" -e AD_DNS_DOMAIN="$ZONE" -e AD_HOSTNAME=dc3 -e AD_IP="$DRILL_IP" \
|
||||
-e AD_RESTORE_FROM="/restore/$(basename "$BK")" -e AD_RESTORE_CONFIRM=yes \
|
||||
"$IMG" >/tmp/dr-restore.log 2>&1
|
||||
rc=$?
|
||||
tail -4 /tmp/dr-restore.log | sed 's/^/ /'
|
||||
[ "$rc" -eq 0 ] || die "restore exited $rc"
|
||||
grep -q 'restore complete' /tmp/dr-restore.log || die "restore did not report completion"
|
||||
|
||||
note "5/7 the fresh volume must already hold sam.ldb BEFORE anything starts serving"
|
||||
has=$(docker run --rm -v drill-lib:/var/lib/samba --entrypoint bash "$IMG" \
|
||||
-c '[ -f /var/lib/samba/private/sam.ldb ] && echo yes || echo no' | tail -1)
|
||||
[ "$has" = yes ] || die "restored volume has no sam.ldb"
|
||||
printf ' sam.ldb present; marker: %s\n' "$(docker run --rm -v drill-lib:/var/lib/samba --entrypoint bash "$IMG" -c 'head -1 /var/lib/samba/.ad-provisioned')"
|
||||
|
||||
note "6/7 serve the restored DC"
|
||||
docker rm -f "$DRILL" >/dev/null 2>&1 || true
|
||||
docker run -d --name "$DRILL" --hostname "dc3.${ZONE}" "${caps[@]}" --network adlab --ip "$DRILL_IP" \
|
||||
-v drill-lib:/var/lib/samba -v drill-conf:/etc/samba \
|
||||
-e AD_REALM="$REALM" -e AD_DNS_DOMAIN="$ZONE" -e AD_HOSTNAME=dc3 -e AD_IP="$DRILL_IP" \
|
||||
-e AD_ADMIN_PASSWORD="$PW" -e AD_DNS_FORWARDER='1.1.1.1' "$IMG" >/dev/null || die "could not start restored DC"
|
||||
h=none
|
||||
for _ in $(seq 1 60); do
|
||||
h="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$DRILL" 2>/dev/null)"
|
||||
[ "$h" = healthy ] && break
|
||||
sleep 4
|
||||
done
|
||||
[ "$h" = healthy ] || { docker logs --tail 40 "$DRILL"; die "restored DC not healthy"; }
|
||||
printf ' %s healthy on %s\n' "$DRILL" "$DRILL_IP"
|
||||
|
||||
note "7/7 integrity assertions"
|
||||
if docker logs "$DRILL" 2>&1 | grep -q 'provisioning NEW forest'; then
|
||||
die "restored DC provisioned a NEW forest instead of serving the restore"
|
||||
fi
|
||||
new_sid="$(sid_of "$DRILL")"
|
||||
if [ -z "$new_sid" ]; then
|
||||
die "could not read the restored domain SID (empty) - refusing to call this a match"
|
||||
elif [ "$new_sid" = "$src_sid" ]; then
|
||||
printf ' SID matches the source domain ✔ (%s)\n' "$new_sid"
|
||||
else
|
||||
die "domain SID mismatch (src=$src_sid restored=$new_sid)"
|
||||
fi
|
||||
found=$(docker exec "$DRILL" bash -c "printf '%s\n' '$PW' | kinit administrator@${REALM} >/dev/null 2>&1; ldapsearch -H ldap://127.0.0.1 -Y GSSAPI -s base -b 'OU=${MARKER},${BASEDN}' dn 2>/dev/null | grep -c '^dn:'" || true)
|
||||
if [ "${found:-0}" -ge 1 ]; then
|
||||
printf ' marker OU survived the restore ✔\n'
|
||||
else
|
||||
die "marker OU '${MARKER}' is missing after restore - data loss"
|
||||
fi
|
||||
AD_SKIP_DOTENV=1 AD_CONTAINER="$DRILL" AD_REALM="$REALM" AD_DNS_DOMAIN="$ZONE" \
|
||||
AD_HOSTNAME=dc3 AD_ADMIN_PASSWORD="$PW" ./scripts/verify.sh || die "verify failed against restored DC"
|
||||
|
||||
if [ "$FAIL" -eq 0 ]; then
|
||||
printf '\n\033[32mDR DRILL PASSED\033[0m archive=%s -> %s (dc3)\n' "$(basename "$BK")" "$DRILL"
|
||||
else
|
||||
printf '\n\033[31mDR DRILL FAILED\033[0m see messages above\n'
|
||||
fi
|
||||
exit "$FAIL"
|
||||
77
tests/ldaps-test.sh
Executable file
77
tests/ldaps-test.sh
Executable file
|
|
@ -0,0 +1,77 @@
|
|||
#!/usr/bin/env bash
|
||||
# =====================================================================================
|
||||
# LDAPS test. Proves three separate things:
|
||||
# * the DC serves 636 and the chain verifies against our CA
|
||||
# * the SAN actually matches the DC FQDN (Windows requires SAN, not CN)
|
||||
# * a client WITHOUT our CA is rejected (i.e. we are not accidentally self-trusted)
|
||||
# Run ./scripts/tls.sh first to create secrets/tls/.
|
||||
# ./tests/ldaps-test.sh # tears down afterwards
|
||||
# =====================================================================================
|
||||
# LDAPS end-to-end check against a freshly provisioned DC with our generated certs.
|
||||
set -uo pipefail
|
||||
cd /dockers/WindowsAD
|
||||
PW='Lab!Only9Tester#2026'
|
||||
LABTLS="$PWD/secrets/tls-lab"
|
||||
trap '[ "${KEEP:-0}" = 1 ] || { docker rm -f ad-tlslab >/dev/null 2>&1; docker volume rm tlslab-lib >/dev/null 2>&1; rm -rf "$LABTLS"; }' EXIT
|
||||
|
||||
# certs for THIS lab only. Never reuse secrets/tls: that is the production chain,
|
||||
# regenerating it here would silently destroy the trust anchor every client has installed.
|
||||
echo "=== generating throwaway testlab CA + leaf (isolated dir, no SYSVOL publish) ==="
|
||||
mkdir -p "$LABTLS"
|
||||
AD_TLS_DIR="$LABTLS" AD_TLS_PUBLISH=no AD_REALM=TESTLAB.LOCAL AD_DNS_DOMAIN=testlab.local \
|
||||
AD_HOSTNAME=dc1 AD_IP=172.28.90.10 AD_CONTAINER=ad-tlslab-absent ./scripts/tls.sh >/tmp/ldaps-tls.log 2>&1 \
|
||||
|| { echo "cert generation failed:"; tail -15 /tmp/ldaps-tls.log; exit 1; }
|
||||
# Guard: refuse to run against a cert that is not ours, otherwise this test silently tests the
|
||||
# production chain and can "pass" for the wrong reason (or fail confusingly).
|
||||
openssl x509 -in "$LABTLS/dc.crt" -noout -subject | grep -q 'testlab.local' \
|
||||
|| { echo "!! $LABTLS/dc.crt is not a testlab cert - refusing to run this test"; exit 1; }
|
||||
openssl x509 -in "$LABTLS/dc.crt" -noout -ext subjectAltName | sed 's/^/ /'
|
||||
|
||||
docker rm -f ad-tlslab >/dev/null 2>&1 || true
|
||||
docker volume rm tlslab-lib >/dev/null 2>&1 || true
|
||||
docker network rm adlab >/dev/null 2>&1 || true
|
||||
docker network create --subnet 172.28.90.0/24 --internal adlab >/dev/null
|
||||
|
||||
docker run -d --name ad-tlslab --hostname dc1.testlab.local --network adlab --ip 172.28.90.10 \
|
||||
--cap-drop ALL --cap-add SYS_ADMIN --cap-add CHOWN --cap-add FOWNER --cap-add DAC_OVERRIDE \
|
||||
--cap-add SETUID --cap-add SETGID --cap-add NET_BIND_SERVICE \
|
||||
-v "$PWD/secrets:/run/ad-secrets:ro" -v "$LABTLS:/run/ad-secrets/tls:ro" -v tlslab-lib:/var/lib/samba \
|
||||
-e AD_REALM=TESTLAB.LOCAL -e AD_DNS_DOMAIN=testlab.local -e AD_HOSTNAME=dc1 \
|
||||
-e AD_IP=172.28.90.10 -e AD_ADMIN_PASSWORD="$PW" \
|
||||
-e AD_TLS_CERT=/run/ad-secrets/tls/dc.crt -e AD_TLS_KEY=/run/ad-secrets/tls/dc.key \
|
||||
-e AD_TLS_CA=/run/ad-secrets/tls/ca.crt \
|
||||
local/samba-ad-dc:4.22-debian13 >/dev/null || { echo "start failed"; exit 1; }
|
||||
|
||||
h=none
|
||||
for _ in $(seq 1 60); do
|
||||
h="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' ad-tlslab 2>/dev/null || echo gone)"
|
||||
[ "$h" = healthy ] && break
|
||||
sleep 4
|
||||
done
|
||||
echo "health: $h"
|
||||
st="$(docker inspect -f '{{.State.Status}} exit={{.State.ExitCode}}' ad-tlslab 2>/dev/null)"
|
||||
echo "state: $st"
|
||||
[ "$h" = healthy ] || { echo "--- log ---"; docker logs ad-tlslab 2>&1 | grep -vE 'Applied |Skip ' | tail -20; exit 1; }
|
||||
|
||||
echo "=== 636 listening? ==="
|
||||
docker exec ad-tlslab ss -ltn | grep ':636' || { echo "636 not listening"; exit 1; }
|
||||
|
||||
echo "=== handshake, trusting our CA (must verify) ==="
|
||||
docker exec ad-tlslab openssl s_client -connect 127.0.0.1:636 \
|
||||
-CAfile /run/ad-secrets/tls/ca.crt -verify_return_error </dev/null 2>&1 \
|
||||
| grep -E 'Verify return code|subject=|CN' | head -4
|
||||
|
||||
echo "=== handshake with default trust store (must FAIL, proves certs are not self-trusted) ==="
|
||||
if docker exec ad-tlslab openssl s_client -connect 127.0.0.1:636 -verify_return_error </dev/null >/dev/null 2>&1; then
|
||||
echo "UNEXPECTED: untrusted handshake succeeded"; RC=1
|
||||
else
|
||||
echo "rejected as expected ✔"
|
||||
fi
|
||||
|
||||
echo "=== full battery with LDAPS enabled ==="
|
||||
AD_SKIP_DOTENV=1 AD_CONTAINER=ad-tlslab AD_REALM=TESTLAB.LOCAL AD_DNS_DOMAIN=testlab.local \
|
||||
AD_HOSTNAME=dc1 AD_ADMIN_PASSWORD="$PW" \
|
||||
AD_TLS_CERT=/run/ad-secrets/tls/dc.crt AD_TLS_CA=/run/ad-secrets/tls/ca.crt \
|
||||
./scripts/verify.sh | grep -E 'LDAPS|passed,'
|
||||
RC=${RC:-0}
|
||||
exit "$RC"
|
||||
109
tests/smoke.sh
Executable file
109
tests/smoke.sh
Executable file
|
|
@ -0,0 +1,109 @@
|
|||
#!/usr/bin/env bash
|
||||
# =====================================================================================
|
||||
# End-to-end smoke test. Fully isolated: realm TESTLAB.LOCAL on an internal-only docker
|
||||
# network, named volumes, no host ports, throwaway credentials.
|
||||
#
|
||||
# Proves, in order:
|
||||
# 1. the image builds
|
||||
# 2. a fresh volume provisions a working AD DC (DNS/Kerberos/LDAP/GC/SYSVOL)
|
||||
# 3. hardening params actually land in smb.conf
|
||||
# 4. a restart REUSES the database instead of re-provisioning (idempotency)
|
||||
# 5. the full verify battery passes both before and after the restart
|
||||
#
|
||||
# ./tests/smoke.sh # build + test + destroy
|
||||
# KEEP=1 ./tests/smoke.sh # leave the lab container running for poking at
|
||||
# =====================================================================================
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
LAB_FILE=docker-compose.lab.yml
|
||||
CID=ad-lab
|
||||
LAB_PW='Lab!Only9Tester#2026'
|
||||
IMAGE=local/samba-ad-dc:4.22-debian13
|
||||
fail_hard() {
|
||||
echo
|
||||
echo "!! ${1}" >&2
|
||||
echo "---- recent log (schema-apply noise filtered) ----" >&2
|
||||
docker compose -f "$LAB_FILE" logs --tail 400 2>&1 \
|
||||
| grep -vE '(Applied|Skip) (Domain|Forest) Update' | tail -40 | sed 's/^/ /' >&2
|
||||
[ "${KEEP:-0}" = 1 ] || docker compose -f "$LAB_FILE" down -v >/dev/null 2>&1 || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "==> 1/6 build image"
|
||||
if ! docker build -t "$IMAGE" -f image/Dockerfile . ; then
|
||||
echo "!! image build failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> 2/6 start lab DC"
|
||||
docker compose -f "$LAB_FILE" down -v >/dev/null 2>&1 || true # always start from a clean slate
|
||||
docker compose -f "$LAB_FILE" up -d >/dev/null 2>&1 || fail_hard "compose up failed"
|
||||
|
||||
wait_healthy() { # $1 = seconds
|
||||
local deadline=$(( SECONDS + ${1:-240} )) st=""
|
||||
while [ "$SECONDS" -lt "$deadline" ]; do
|
||||
st="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$CID" 2>/dev/null || echo missing)"
|
||||
[ "$st" = healthy ] && return 0
|
||||
[ "$st" = unhealthy ] && return 2
|
||||
printf '.'
|
||||
sleep 4
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
printf ' waiting for healthy '
|
||||
wait_healthy 300; rc=$?
|
||||
echo " -> $rc"
|
||||
[ "$rc" = 0 ] || fail_hard "lab DC never became healthy (rc=$rc)"
|
||||
|
||||
if ! docker compose -f "$LAB_FILE" logs 2>&1 | grep -q 'provisioning complete'; then
|
||||
fail_hard "expected the entrypoint to report a fresh provisioning run"
|
||||
fi
|
||||
echo " fresh provisioning reported"
|
||||
|
||||
verify() {
|
||||
AD_SKIP_DOTENV=1 AD_CONTAINER="$CID" \
|
||||
AD_REALM=TESTLAB.LOCAL AD_DNS_DOMAIN=testlab.local AD_HOSTNAME=dc1 \
|
||||
AD_ADMIN_PASSWORD="$LAB_PW" ./scripts/verify.sh
|
||||
}
|
||||
|
||||
echo "==> 3/6 verification battery (fresh forest)"
|
||||
verify || fail_hard "verification failed on a freshly provisioned forest"
|
||||
|
||||
echo "==> 4/6 hardening landed in smb.conf"
|
||||
for kv in 'server signing = required' 'smb encrypt = desired' 'ntlm auth = ntlmv2-only' 'restrict anonymous = 2'; do
|
||||
if docker exec "$CID" grep -q "^[[:space:]]*${kv}[[:space:]]*$" /etc/samba/smb.conf; then
|
||||
echo " ok: $kv"
|
||||
else
|
||||
fail_hard "missing '$kv' in rendered smb.conf"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "==> 5/6 restart must NOT re-provision"
|
||||
# Count log lines first and diff afterwards: `docker logs --since` is interpreted in the
|
||||
# daemon's local timezone, which silently mis-filters when the host is not UTC.
|
||||
log_before="$(docker compose -f "$LAB_FILE" logs 2>/dev/null | wc -l)"
|
||||
docker restart "$CID" >/dev/null 2>&1 || fail_hard "restart failed"
|
||||
printf ' waiting for healthy after restart '
|
||||
wait_healthy 180; rc=$?
|
||||
echo " -> $rc"
|
||||
[ "$rc" = 0 ] || fail_hard "not healthy after restart"
|
||||
log="$(docker compose -f "$LAB_FILE" logs 2>/dev/null | tail -n +$((log_before + 1)))"
|
||||
if printf '%s' "$log" | grep -q 'provisioning NEW forest'; then
|
||||
fail_hard "restart tried to re-provision the domain (idempotency broken)"
|
||||
fi
|
||||
printf '%s' "$log" | grep -q 'existing AD database found' || fail_hard "restart did not detect the existing database"
|
||||
echo " entrypoint reused the existing database"
|
||||
|
||||
echo "==> 6/6 verification battery (after restart)"
|
||||
verify || fail_hard "verification failed after restart"
|
||||
|
||||
echo
|
||||
echo -e "\033[32mSMOKE TEST PASSED\033[0m (image: $IMAGE)"
|
||||
if [ "${KEEP:-0}" = 1 ]; then
|
||||
echo "lab left running: docker compose -f $LAB_FILE logs -f | docker exec -it $CID bash"
|
||||
else
|
||||
docker compose -f "$LAB_FILE" down -v >/dev/null 2>&1 || true
|
||||
echo "lab destroyed"
|
||||
fi
|
||||
Loading…
Add table
Add a link
Reference in a new issue