Establish clean homelab infrastructure baseline
Reorganize the brownfield repository, remove retired and generated artifacts, harden ignore rules, and record the GitOps/IaC redesign.
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
# ansible/group_vars/all/vault.yml is now ANSIBLE-VAULT ENCRYPTED and IS committed.
|
||||
# The password lives in ../.vault_pass (gitignored at the repo root) and a copy is
|
||||
# in OpenBao at kv/infra/ansible-vault.
|
||||
# ⚠ If you ever `ansible-vault decrypt` it, DO NOT commit until re-encrypted.
|
||||
ansible/inventory/hosts.local.yml
|
||||
ansible/*.retry
|
||||
.vault_pass
|
||||
@@ -0,0 +1,420 @@
|
||||
# Samba Active Directory Domain Controller
|
||||
|
||||
Samba AD DC is the **identity source of truth** for the whole environment: Kerberos,
|
||||
LDAP, DNS, SMB and Group Policy — a real Active Directory domain that Windows VMs can
|
||||
domain-join and be GPO-managed, **without a licensed Windows Server as the DC**.
|
||||
|
||||
Consumers layered on top (later docs):
|
||||
- **Samba file server** — domain member serving SMB shares.
|
||||
- **Linux clients** — `realmd`/`sssd` domain join for central login.
|
||||
- **Authelia** — LDAP source against this DC → OIDC / forward-auth web SSO.
|
||||
|
||||
This document covers **Phase 1 (the DC)** and **Phase 2 (the Windows admin box)** only.
|
||||
|
||||
**Everything here is managed as code with Ansible** — see `ansible/`. The shell
|
||||
commands in §1–§2 are documentation of *what the roles automate*, not steps to run by
|
||||
hand. Run the playbooks.
|
||||
|
||||
---
|
||||
|
||||
## Ansible quickstart
|
||||
|
||||
```bash
|
||||
cd ansible
|
||||
|
||||
# 1. one-time: ansible + collections + WinRM lib
|
||||
# (uv-installed ansible: uv tool install ansible-core --with ansible --with pywinrm --with requests-ntlm)
|
||||
ansible-galaxy collection install -r requirements.yml
|
||||
|
||||
# 2. set your values (already filled in for ad.ddupan.top / DDUPAN / 192.168.10.5)
|
||||
$EDITOR group_vars/all/vars.yml # realm, netbios, DC IP, forwarder, KMS host
|
||||
$EDITOR inventory/hosts.yml # DC + Windows box addresses
|
||||
cp group_vars/all/vault.example.yml group_vars/all/vault.yml
|
||||
$EDITOR group_vars/all/vault.yml # admin passwords
|
||||
ansible-vault encrypt group_vars/all/vault.yml # optional; plaintext also works (gitignored)
|
||||
|
||||
# 3. create the VM locally (libvirt + Ubuntu cloud image + cloud-init)
|
||||
ssh-keygen -t ed25519 # if you don't have ~/.ssh/id_ed25519.pub yet
|
||||
ansible-playbook create-dc-vm.yml # boots dc1 on br0 at 192.168.10.5
|
||||
|
||||
# 4. provision the domain, then verify
|
||||
ansible-playbook provision-dc.yml --ask-vault-pass
|
||||
ansible-playbook provision-dc.yml --ask-vault-pass --tags verify
|
||||
|
||||
# 5. join + configure the Windows admin box (needs WinRM reachable)
|
||||
ansible-playbook join-windows.yml --ask-vault-pass
|
||||
```
|
||||
|
||||
Re-running is safe: domain provisioning is guarded by the `sam.ldb` database, so a
|
||||
second run is a no-op there and only reconciles config drift.
|
||||
|
||||
**Layout**
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `roles/dc_vm/` | creates the DC VM locally via libvirt + cloud-init |
|
||||
| `roles/samba_ad_dc/` | provisions the DC (§1) — idempotent |
|
||||
| `roles/samba_ad_dc/tasks/verify.yml` | smoke tests (`--tags verify`) |
|
||||
| `roles/samba_ad_dc/tasks/legacy.yml` | opt-in retro-client protocols (§ Retro clients) |
|
||||
| `roles/windows_vm/` | unattended-installs the Windows Server 2025 admin box (§2.0) |
|
||||
| `roles/win_domain_join/` | joins Windows box, installs RSAT, KMS activation (§2) |
|
||||
| `group_vars/all/vars.yml` | all non-secret settings — **edit this** |
|
||||
| `group_vars/all/vault.yml` | admin passwords (gitignored; encrypt with ansible-vault) |
|
||||
| `inventory/hosts.yml` | DC + Windows hosts |
|
||||
|
||||
---
|
||||
|
||||
## 0. Decisions — fill these in before touching anything
|
||||
|
||||
These map 1:1 onto `ansible/group_vars/all.yml`.
|
||||
|
||||
|
||||
| Item | Value | Notes |
|
||||
|---|---|---|
|
||||
| DNS realm | `AD.DDUPAN.TOP` | Delegated subdomain of `ddupan.top`. Never `.local`. |
|
||||
| NetBIOS / short name | `DDUPAN` | ≤15 chars, uppercase, no dots. Windows-visible short domain. |
|
||||
| DC hostname (FQDN) | `dc1.ad.ddupan.top` | |
|
||||
| DC static IP | `192.168.10.5/24` | On `br0`; free in the `.2–.9` range. **Must stay static.** |
|
||||
| Gateway | `192.168.10.1` | |
|
||||
| Domain admin | `Administrator` | password from the vault |
|
||||
| Forwarder DNS | `192.168.10.1` | where the DC forwards non-AD lookups |
|
||||
| Windows admin box | `192.168.10.6` | RSAT/GPMC station |
|
||||
|
||||
> The example shell in §1–§2 still shows `example.com`-style placeholders for
|
||||
> readability; the **live values above** are what `group_vars/all.yml` actually sets.
|
||||
|
||||
---
|
||||
|
||||
## 1. Provision the DC VM
|
||||
|
||||
> Automated by `roles/samba_ad_dc`. The commands below are what each task does.
|
||||
|
||||
**Base:** Debian 12 (or Ubuntu 24.04 LTS) VM — a **full VM, not LXC**. The DC is a
|
||||
stateful pet; a VM avoids LXC keyring/namespace quirks with Kerberos. (The VM must
|
||||
already exist and be reachable over SSH; this role configures the OS, it does not
|
||||
create the VM — see "VM lifecycle" below.)
|
||||
|
||||
### 1.1 Host prerequisites
|
||||
|
||||
```bash
|
||||
# Static IP, correct hostname, and time sync are non-negotiable.
|
||||
hostnamectl set-hostname dc1.ad.example.com
|
||||
timedatectl set-ntp true # or install chrony; Kerberos dies on >5min skew
|
||||
apt update && apt install -y chrony
|
||||
```
|
||||
|
||||
`/etc/hosts` — the DC must resolve its own FQDN to its **real** IP (not 127.0.1.1):
|
||||
|
||||
```
|
||||
10.10.10.10 dc1.ad.example.com dc1
|
||||
```
|
||||
|
||||
### 1.2 Free up port 53
|
||||
|
||||
Samba's internal DNS must bind `:53`. Disable the `systemd-resolved` stub listener:
|
||||
|
||||
```bash
|
||||
mkdir -p /etc/systemd/resolved.conf.d
|
||||
printf '[Resolve]\nDNSStubListener=no\n' > /etc/systemd/resolved.conf.d/no-stub.conf
|
||||
systemctl restart systemd-resolved
|
||||
# Point the DC at itself for DNS:
|
||||
ln -sf /run/systemd/resolve/resolv.conf /dev/null 2>/dev/null || true
|
||||
printf 'nameserver 10.10.10.10\nsearch ad.example.com\n' > /etc/resolv.conf
|
||||
chattr +i /etc/resolv.conf # stop NM/cloud-init from clobbering it
|
||||
```
|
||||
|
||||
### 1.3 Install and provision
|
||||
|
||||
```bash
|
||||
apt install -y samba krb5-config winbind smbclient ldb-tools
|
||||
|
||||
# Stop distro auto-started daemons; the AD DC runs the unified `samba` service only.
|
||||
systemctl disable --now smbd nmbd winbind 2>/dev/null || true
|
||||
systemctl unmask samba-ad-dc
|
||||
|
||||
# Clean any stock config so provision writes fresh
|
||||
mv /etc/samba/smb.conf /etc/samba/smb.conf.orig 2>/dev/null || true
|
||||
|
||||
samba-tool domain provision \
|
||||
--use-rfc2307 \
|
||||
--realm=AD.EXAMPLE.COM \
|
||||
--domain=EXAMPLE \
|
||||
--server-role=dc \
|
||||
--dns-backend=SAMBA_INTERNAL \
|
||||
--adminpass='CHANGE-ME-Strong.Passw0rd' \
|
||||
--option="dns forwarder = 10.10.10.1"
|
||||
|
||||
systemctl enable --now samba-ad-dc
|
||||
```
|
||||
|
||||
> **`--use-rfc2307` is mandatory and cannot be cleanly added later.** It stores POSIX
|
||||
> uid/gid in the directory so Linux clients get stable IDs.
|
||||
|
||||
### 1.4 Wire Kerberos
|
||||
|
||||
```bash
|
||||
cp /var/lib/samba/private/krb5.conf /etc/krb5.conf
|
||||
```
|
||||
|
||||
### 1.5 Smoke tests
|
||||
|
||||
```bash
|
||||
# LDAP / domain sanity
|
||||
samba-tool domain level show
|
||||
|
||||
# Kerberos ticket for the admin
|
||||
kinit [email protected] && klist
|
||||
|
||||
# DNS: the DC must resolve its own SRV records
|
||||
host -t SRV _ldap._tcp.ad.example.com.
|
||||
host -t SRV _kerberos._udp.ad.example.com.
|
||||
host -t A dc1.ad.example.com.
|
||||
|
||||
# SMB: default shares present
|
||||
smbclient -L localhost -U administrator
|
||||
```
|
||||
|
||||
All four must succeed before moving on. **If anything AD-related "doesn't work"
|
||||
later, it is almost always DNS** — every member must use the DC as its resolver, and
|
||||
the DC must resolve itself.
|
||||
|
||||
---
|
||||
|
||||
## 2. Windows admin box (your one long-lived Windows VM)
|
||||
|
||||
You need exactly **one** Windows VM as the GPO authoring / AD management station.
|
||||
Samba stores GPOs but you edit them with the Windows GPMC; `samba-tool gpo` only
|
||||
covers basic operations.
|
||||
|
||||
### 2.0 Build it as code — `create-windows-vm.yml` (role `windows_vm`)
|
||||
|
||||
Fully unattended install of **Windows Server 2025 Standard (Desktop Experience)** from
|
||||
your ISO, the Windows analogue of the DC's cloud-init:
|
||||
|
||||
```bash
|
||||
ansible-playbook create-windows-vm.yml # ~30-45 min: install + reboots + WinRM setup
|
||||
```
|
||||
|
||||
What it does:
|
||||
- Creates zvol `data/vm/winadmin`, **UEFI/GPT**, **virtio disk + virtio NIC**.
|
||||
- Renders `autounattend.xml` (edition index 2, locale zh-CN, GVLK, admin pw from vault),
|
||||
packs it on a seed ISO. **Injects virtio drivers** (`viostor`/`NetKVM`, 2k25) during
|
||||
Setup via `DriverPaths` off the `virtio-win` ISO so Setup sees the virtio disk.
|
||||
- `FirstLogonCommands`: static IP `192.168.10.6` → DC DNS, **WinRM-over-HTTPS** listener
|
||||
(self-signed), open 5986, **enable RDP**, and run **virtio-win guest tools** (QEMU
|
||||
guest agent + balloon).
|
||||
- Gets past "Press any key to boot from CD" with `virsh send-key`, waits for WinRM,
|
||||
then ejects install media and deletes the password-bearing seed ISO.
|
||||
|
||||
Then hand off to §2.1–2.4 below, automated by `join-windows.yml` (role `win_domain_join`)
|
||||
— domain join → RSAT → KMS activate. Needs WinRM reachable; the commands below are what
|
||||
those tasks do.
|
||||
|
||||
> The seed ISO holds the local admin password in cleartext while Setup runs; the play
|
||||
> removes it once WinRM is up. Edition index / GVLK / locale are role vars — verify the
|
||||
> index against your ISO with `wiminfo …\sources\install.wim`.
|
||||
|
||||
### 2.1 Point it at the DC for DNS
|
||||
|
||||
On the Windows VM's NIC, set **DNS server = `10.10.10.10`** (the DC). This is the
|
||||
single most common failure point — a Windows box using any other resolver cannot
|
||||
find the domain.
|
||||
|
||||
### 2.2 Join the domain
|
||||
|
||||
`System → Rename this PC (advanced) → Domain: ad.example.com` →
|
||||
authenticate as `EXAMPLE\Administrator` → reboot.
|
||||
|
||||
### 2.3 Install RSAT (management tools)
|
||||
|
||||
On Windows 10/11:
|
||||
|
||||
```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
|
||||
Add-WindowsCapability -Online -Name Rsat.Dns.Tools~~~~0.0.1.0
|
||||
```
|
||||
|
||||
This gives you **AD Users & Computers**, **Group Policy Management (GPMC)**, and the
|
||||
DNS console — the graphical admin surface for the domain.
|
||||
|
||||
### 2.4 Activate against existing KMS
|
||||
|
||||
This repo already runs `vlmcsd` (KMS emulator). Activate the Windows VM against it so
|
||||
the admin box stays licensed:
|
||||
|
||||
```powershell
|
||||
slmgr /skms kms.example.com:1688 # your vlmcsd host
|
||||
slmgr /ipk <GVLK-for-this-edition> # public KMS client setup key for the edition
|
||||
slmgr /ato
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## VM lifecycle (creating the box as code)
|
||||
|
||||
The VM itself is created by `roles/dc_vm` (`create-dc-vm.yml`), which runs against
|
||||
the **local libvirt host**:
|
||||
|
||||
1. Downloads the **latest Ubuntu 24.04 LTS cloud image** (`.../noble/current/…`) once
|
||||
into `/var/lib/libvirt/images/base/`.
|
||||
2. Creates a **ZFS zvol** `data/vm/dc1` (`dc_vm_disk_gb`, default 25 GB, 16K
|
||||
volblocksize — matching `data/vm/win2k25`) and writes the image into it raw with
|
||||
`qemu-img convert`. cloud-init `growpart` expands the rootfs to fill it on first boot.
|
||||
3. Renders `user-data` / `meta-data` / `network-config` from templates and packs a
|
||||
**NoCloud seed ISO** (`genisoimage`, volid `cidata`). The seed sets a static IP
|
||||
(`192.168.10.5/24`, gw `.1`), the hostname, and injects your SSH public key.
|
||||
4. `virt-install --import` attaches the zvol (`/dev/zvol/data/vm/dc1`, virtio) and the
|
||||
seed ISO, boots the domain on bridge **`br0`**, then waits for SSH.
|
||||
|
||||
It's idempotent — if the `dc1` domain exists the block is skipped; the zvol write is
|
||||
separately guarded so a re-run never clobbers an existing disk.
|
||||
|
||||
**Before running:** you need an SSH keypair; the play injects
|
||||
`dc_vm_ssh_pubkey_file` (default `~/.ssh/id_ed25519.pub`). Generate one with
|
||||
`ssh-keygen -t ed25519` if absent — the play fails fast otherwise.
|
||||
|
||||
Tunables live in `roles/dc_vm/defaults/main.yml` (vCPU, RAM, disk, image URL,
|
||||
bridge, gateway, `dc_vm_zvol_parent`). Alternatives (PXE via your existing `netboot/`, or a Debian base)
|
||||
are still viable, but the contract is the same: hand `samba_ad_dc` a booted VM with a
|
||||
static IP matching `samba_ad_dc_ip`, SSH reachable as the `ansible` user.
|
||||
|
||||
---
|
||||
|
||||
## 3. Operations
|
||||
|
||||
### Add users / groups (either UI or CLI)
|
||||
|
||||
```bash
|
||||
samba-tool user create alice
|
||||
samba-tool group add engineering
|
||||
samba-tool group addmembers engineering alice
|
||||
```
|
||||
|
||||
…or do it graphically from the Windows admin box via **AD Users & Computers**.
|
||||
|
||||
### Backups — do this before the domain becomes load-bearing
|
||||
|
||||
```bash
|
||||
samba-tool domain backup offline --targetdir=/var/backups/samba
|
||||
```
|
||||
|
||||
Schedule it (cron/systemd timer) and ship the tarball off-box.
|
||||
|
||||
### Second DC (resilience) — easy now, painful to retrofit after an outage
|
||||
|
||||
On a second freshly-provisioned member (DNS pointed at `dc1`):
|
||||
|
||||
```bash
|
||||
samba-tool domain join ad.example.com DC \
|
||||
-U"EXAMPLE\administrator" --dns-backend=SAMBA_INTERNAL
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Gotchas cheat-sheet
|
||||
|
||||
- **DNS is the whole ballgame.** ~90% of "AD is broken" is a member not using the DC
|
||||
as its resolver, or the DC not resolving itself. Domain members point at
|
||||
`192.168.10.5` directly; non-domain LAN devices reach AD names via the router's
|
||||
conditional forward — see `router-dns-nec-ix.md`.
|
||||
- **Time skew > 5 min → Kerberos silently fails.** Keep `chrony` healthy on the DC and
|
||||
all members.
|
||||
- **Don't containerize the DC in K8s** — it fights K8s's DNS/networking model. VM only.
|
||||
- **`--use-rfc2307` at provision time** or you lose stable Linux uid/gid mapping.
|
||||
- **One share off the DC is fine for testing, but real shares belong on a member
|
||||
file server** (next doc), not the DC.
|
||||
- **GPO editing needs the Windows RSAT box**; keep that one VM around.
|
||||
|
||||
---
|
||||
|
||||
## Retro clients (Win9x / NT4 / Windows 2000 / XP)
|
||||
|
||||
Modern Samba disables the old protocols these machines need (SMB1, NTLMv1, LANMAN,
|
||||
DES Kerberos). Support is **opt-in** via `samba_ad_legacy_clients: true` in
|
||||
`group_vars/all.yml`, which runs `roles/samba_ad_dc/tasks/legacy.yml` to inject the
|
||||
insecure knobs and restart the DC.
|
||||
|
||||
> ⚠️ This materially weakens the **entire domain** — LM/NTLMv1 hashes are trivially
|
||||
> crackable and SMB1 is wormable. Only enable it on an **isolated VLAN** for the retro
|
||||
> machines. Ideally, don't weaken the primary DC at all: stand up a **dedicated legacy
|
||||
> Samba member server** for file access and keep the DC strict. For domain *logon* of
|
||||
> 9x/NT4 (below) the DC itself must speak old crypto, so segment the network instead.
|
||||
|
||||
What each era can actually do:
|
||||
|
||||
| Client | Domain **join** | Domain **logon** | SMB **share access** | What it needs |
|
||||
|---|---|---|---|---|
|
||||
| **Windows XP / 2003** (NT 5.1/5.2) | ✅ real AD join | ✅ Kerberos/NTLMv2 | ✅ | SMB1 (`server min protocol = NT1`). Mostly works with the toggle. |
|
||||
| **Windows 2000** (NT 5.0) | ✅ real AD join | ✅ | ✅ | SMB1 **+** weak Kerberos crypto (DES) — `allow_weak_crypto`. Fiddly. |
|
||||
| **Windows NT4** | ❌ (no Kerberos) | ✅ NT4-style (NTLM) | ✅ | NTLM + `allow nt4 crypto`. Joins as an NT4 domain, not AD. |
|
||||
| **Windows 9x / ME** | ❌ (not a domain member) | ⚠️ NT-domain network logon only | ✅ | LANMAN auth + WINS. No Kerberos, ever. LM hashes only. |
|
||||
|
||||
The toggle turns on (see `legacy.yml`):
|
||||
|
||||
```
|
||||
server min protocol = NT1 # SMB1 for XP/2000/NT4/9x
|
||||
ntlm auth = ntlmv1-permitted # NTLMv1
|
||||
lanman auth = yes # LANMAN — required by 9x, insecure
|
||||
client lanman auth = yes
|
||||
allow nt4 crypto = yes # NT4 member logon
|
||||
wins support = yes # NetBIOS name resolution for 9x/NT4
|
||||
```
|
||||
plus `allow_weak_crypto = true` in `/etc/krb5.conf` for Windows 2000's DES Kerberos.
|
||||
|
||||
Practical notes for the oldest clients:
|
||||
- **Win9x needs WINS**, not DNS — point the clients' WINS server at the DC's IP (or a
|
||||
legacy member running `wins support = yes`). Browsing relies on NetBIOS.
|
||||
- **9x can't join** — it does an NT-domain *network logon* to a logon server and then
|
||||
accesses `\\server\share` with domain credentials. Set the client's "Logon to
|
||||
Windows NT domain" to `DDUPAN`.
|
||||
- **Passwords:** after enabling weak crypto, accounts used by Win2000 may need a
|
||||
password reset so the DES/RC4 keys get regenerated with the new enctypes allowed.
|
||||
- Keep these clients on `192.168.10.x` with the DC as WINS + gateway, firewalled off
|
||||
from anything you care about.
|
||||
|
||||
---
|
||||
|
||||
## 5. Linux Samba member fileserver (`join-member.yml`)
|
||||
|
||||
Joins a Linux host to the domain as a **Samba member fileserver** (`security = ADS`
|
||||
+ winbind), so AD users/groups can authenticate to its SMB shares. Codified as the
|
||||
`samba_member` role; runs against the `samba_members` inventory group.
|
||||
|
||||
```bash
|
||||
# Inventory already has `laptop` (the KVM host, 192.168.10.127) under samba_members.
|
||||
ansible-playbook join-member.yml --check --diff # preview (safe; no real join)
|
||||
ansible-playbook join-member.yml # join for real → creates LAPTOP$ in AD
|
||||
ansible-playbook join-member.yml --tags verify # re-run smoke tests only
|
||||
```
|
||||
|
||||
What the role does, in order: install `winbind` + `libnss-winbind` + `krb5-user`
|
||||
(**no `libpam-winbind`** — this is a fileserver join, not OS login) → write
|
||||
`/etc/krb5.conf` for the realm → add a split-DNS drop-in → rewrite `smb.conf`
|
||||
(`[global]` becomes an ADS member; existing shares preserved, original saved to
|
||||
`smb.conf.pre-ads`) → add `winbind` to NSS `passwd`/`group` → `net ads join`
|
||||
(guarded by `net ads testjoin`, so re-runs are no-ops) → start winbind → smoke tests.
|
||||
|
||||
Design choices:
|
||||
- **RID idmap** (`idmap config DDUPAN : backend = rid`, range `10000-999999`) —
|
||||
algorithmic, deterministic, needs no RFC2307 attributes. The DC *is* `--use-rfc2307`,
|
||||
so switching to `backend = ad` for centrally-managed `uidNumber`/`gidNumber` is a
|
||||
later option (must be identical on every member). Administrator → uid `10500`.
|
||||
- **Split-DNS is mandatory here.** The LAN router (`192.168.10.1`) does **not** answer
|
||||
realm SRV lookups, so the role drops `/etc/systemd/resolved.conf.d/ad-realm.conf`
|
||||
routing `ad.ddupan.top` → the DC (`192.168.10.5`), which is authoritative and fast.
|
||||
- **`apt update` is OFF by default** (`samba_member_apt_update_cache: false`). That
|
||||
host's upstream DNS forwarder is flaky and a full refresh touches every repo in
|
||||
`sources.list.d`; the member packages are already in the local cache. Pass
|
||||
`-e samba_member_apt_update_cache=true` to force a refresh when DNS is healthy.
|
||||
- Shares are data (`samba_member_shares` in the role defaults) — the guest `[win]`
|
||||
share and the Cockpit ZFS `include` are carried over verbatim.
|
||||
|
||||
Verify by hand: `sudo net ads testjoin` → *Join is OK*; `wbinfo --online-status`;
|
||||
`getent group 'domain admins'`; `smbclient -L localhost -N`.
|
||||
|
||||
## Next docs
|
||||
|
||||
- ~~`samba-fileserver` — domain-member SMB shares.~~ **Done** — see §5 above.
|
||||
- `linux-domain-join` — `realmd` + `sssd` (OS login, distinct from this fileserver join).
|
||||
- `../../apps/authelia/` — LDAP source → OIDC / forward-auth web SSO.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
# LDAPS certificate for the DC, issued and auto-renewed from OpenBao's ACME.
|
||||
# ansible-playbook acme-dc.yml
|
||||
- name: Samba AD DC LDAPS certificate (OpenBao ACME)
|
||||
hosts: samba_dc
|
||||
become: true
|
||||
roles:
|
||||
- samba_ad_acme
|
||||
@@ -0,0 +1,13 @@
|
||||
[defaults]
|
||||
inventory = inventory/hosts.yml
|
||||
roles_path = roles
|
||||
host_key_checking = False
|
||||
callback_result_format = yaml
|
||||
nocows = True
|
||||
|
||||
# NOTE: no global become — the inventory is mixed Linux (sudo) + Windows (can't sudo).
|
||||
# Linux plays declare `become: true` themselves; Windows uses runas per-task where needed.
|
||||
|
||||
# Encrypted group_vars/all/vault.yml are COMMITTED; the password is not.
|
||||
# Relative to this file, so it resolves for any checkout location.
|
||||
vault_password_file = ../../../.vault_pass
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
# Create the DC's Ubuntu VM locally via libvirt + cloud-init.
|
||||
# Runs on the libvirt host itself (localhost / qemu:///system).
|
||||
# ansible-playbook create-dc-vm.yml
|
||||
# Then provision the domain:
|
||||
# ansible-playbook provision-dc.yml --ask-vault-pass
|
||||
- name: Create the Samba AD DC VM
|
||||
hosts: localhost
|
||||
connection: local
|
||||
become: true
|
||||
gather_facts: false
|
||||
roles:
|
||||
- role: dc_vm
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
# Create + unattended-install the Windows Server 2025 admin box on local libvirt.
|
||||
# Runs on the libvirt host (localhost / qemu:///system).
|
||||
# ansible-playbook create-windows-vm.yml --ask-vault-pass
|
||||
# Then join it to the domain + install RSAT + activate:
|
||||
# ansible-playbook join-windows.yml --ask-vault-pass
|
||||
- name: Create the Windows Server 2025 admin VM
|
||||
hosts: localhost
|
||||
connection: local
|
||||
become: true
|
||||
gather_facts: false
|
||||
roles:
|
||||
- role: windows_vm
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
# Non-secret variables shared by all plays. EDIT THESE to your environment.
|
||||
# Secrets live in group_vars/vault.yml (encrypted) — see vault.example.yml.
|
||||
|
||||
# --- Domain identity (used by both the DC and the Windows join) ---
|
||||
samba_ad_realm: "AD.DDUPAN.TOP"
|
||||
samba_ad_domain: "DDUPAN"
|
||||
samba_ad_dc_hostname: "dc1"
|
||||
samba_ad_dc_ip: "192.168.10.5"
|
||||
samba_ad_dns_forwarder: "192.168.10.1"
|
||||
samba_ad_reverse_zone: "10.168.192.in-addr.arpa" # reverse of 192.168.10.0/24
|
||||
|
||||
# Extra A records for non-domain hosts published in the AD DNS zone.
|
||||
samba_ad_extra_a_records:
|
||||
- { name: "bao", ip: "192.168.10.8" } # OpenBao (../openbao), not domain-joined
|
||||
# Proxmox cluster nodes (../proxmox). Not domain-joined; they authenticate
|
||||
# USERS against this DC rather than being members themselves.
|
||||
- { name: "pve1", ip: "192.168.10.4" }
|
||||
- { name: "pve2", ip: "192.168.10.7" }
|
||||
- { name: "pve3", ip: "192.168.10.9" }
|
||||
# Lab VMs on the SDN VNets (routed via the VyOS router, see ../../proxmox).
|
||||
# These are NOT on 192.168.10.0/24, so they have no PTR in the existing
|
||||
# reverse zone — forward resolution only unless a 0.60.10.in-addr.arpa zone
|
||||
# is added later.
|
||||
- { name: "retrolab", ip: "10.60.0.10" }
|
||||
# k3s services exposed on the LAN through the Envoy gateway (../../../platform/envoy-gateway;
|
||||
# Contour was retired 2026-07-25). They all point at the k3s node, which is where
|
||||
# Envoy's LoadBalancer lands; the gateway routes by Host header and serves the
|
||||
# *.ad.ddupan.top wildcard cert.
|
||||
# Adding another such service = one more line here + an HTTPRoute, nothing else.
|
||||
- { name: "netbox", ip: "192.168.10.127" } # NetBox (../../../apps/netbox)
|
||||
# SeaweedFS S3. Exists so Terraform state does NOT ride the Cloudflare tunnel:
|
||||
# obj.ddupan.top works, but it hairpins through the WAN, and on 2026-07-28 that
|
||||
# path was blackholed for hours by a dead VPN tunnel. State must stay on the LAN.
|
||||
- { name: "s3", ip: "192.168.10.127" } # SeaweedFS S3 (../../../apps/seaweedfs)
|
||||
|
||||
# Support legacy clients (Win9x/NT4/2000/XP)? INSECURE — see README "Retro clients".
|
||||
samba_ad_legacy_clients: false
|
||||
|
||||
# --- KMS auto-activation via DNS ---
|
||||
# Adds a _vlmcs._tcp SRV record so any domain-joined Windows self-activates against
|
||||
# vlmcsd. Set the IP where vlmcsd listens (:1688); empty string disables.
|
||||
samba_ad_kms_host_ip: "192.168.10.127" # vlmcsd runs here (docker, 0.0.0.0:1688)
|
||||
samba_ad_kms_hostname: "kms" # A record kms.ad.ddupan.top -> that IP
|
||||
samba_ad_kms_port: 1688
|
||||
|
||||
# --- Windows admin box ---
|
||||
win_dc_ip: "{{ samba_ad_dc_ip }}"
|
||||
win_domain_dns_name: "{{ samba_ad_realm | lower }}"
|
||||
win_domain_admin_user: "{{ samba_ad_domain }}\\Administrator"
|
||||
win_kms_host: "" # e.g. "kms.example.com:1688" (existing vlmcsd)
|
||||
win_kms_client_key: "" # public GVLK for the Windows edition
|
||||
|
||||
# --- Secret indirection: real values come from the encrypted vault ---
|
||||
samba_ad_admin_password: "{{ vault_samba_ad_admin_password }}"
|
||||
win_domain_admin_password: "{{ vault_samba_ad_admin_password }}"
|
||||
|
||||
# --- Service accounts for apps that BIND to LDAP (passwords in vault.yml) ---
|
||||
# Previously created by hand (svc-authelia); codified so a DC rebuild restores them.
|
||||
samba_ad_service_accounts:
|
||||
- name: svc-pve
|
||||
password: "{{ vault_pve_bind_password }}"
|
||||
description: "Proxmox VE realm bind + user/group sync (read-only)"
|
||||
|
||||
# --- Groups used for RBAC by downstream apps ---
|
||||
# NOTE: PVE renames synced groups to "<name>-<realm>", so pve-admins becomes
|
||||
# "pve-admins-ad" inside Proxmox. Grant ACLs to THAT name, not this one.
|
||||
samba_ad_groups:
|
||||
- name: pve-admins
|
||||
members: [panxiao81]
|
||||
|
||||
# NetBox: Authelia forward-auth restricts the site to this group, and NetBox maps it
|
||||
# to is_superuser + is_staff via REMOTE_AUTH_SUPERUSER_GROUPS / STAFF_GROUPS
|
||||
# (../../../apps/netbox). Membership is re-evaluated on every request, so removing someone
|
||||
# here revokes their NetBox admin immediately.
|
||||
- name: netbox-admins
|
||||
members: [panxiao81]
|
||||
|
||||
# Who may log in to AD-joined workstations (SSSD simple_allow_groups).
|
||||
# NESTING DIRECTION MATTERS: pve-admins is a MEMBER OF this group, so admins
|
||||
# get lab access. The reverse (this group inside pve-admins) would hand every
|
||||
# lab user Proxmox Administrator on / — see the ACL in proxmox/ansible.
|
||||
- name: retrolab-users
|
||||
members: [pve-admins]
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
# Copy to vault.yml and encrypt: ansible-vault encrypt group_vars/vault.yml
|
||||
# NEVER commit the decrypted vault.yml.
|
||||
vault_samba_ad_admin_password: "CHANGE-ME-Strong.Passw0rd"
|
||||
vault_win_local_admin_password: "CHANGE-ME-Local.Passw0rd"
|
||||
|
||||
# Bind account for the Proxmox VE 'ad' realm (read-only user/group sync).
|
||||
vault_pve_bind_password: 'CHANGEME-strong-random'
|
||||
@@ -0,0 +1,26 @@
|
||||
$ANSIBLE_VAULT;1.1;AES256
|
||||
65313862663465383663666633613563346532633332313236633666373861373263393366363061
|
||||
3637336438393766643564363033666564316630626533370a316136323131376666363761333465
|
||||
36386466653037303161346435316632396534393331643939303336613961366632353664656436
|
||||
3636363837356131310a306565396334396363633563316366316131623065393135656239613131
|
||||
62386134383234366465306435653230326362386131616431313030656637303662353066643434
|
||||
62326237303664623530323531333063343032343231363830656235616134623864653761303231
|
||||
37366361653032303839383866373038363765633162636437633639343933383631383533346366
|
||||
39386438386237326632313437626535636638386437323131353438666464623435653233313237
|
||||
35326466313533653661373938626239666465646637396566653230396164363233303238336638
|
||||
63313937363631373663303661313935313738313734663634353161663365363236353162373432
|
||||
33326464303535353131363562323831653262366161643031353238343265653462643130326539
|
||||
39643635326136353934363438336231393866663338613864363835303564303034663430376363
|
||||
63323765643133383536636133376537336534393534626462636335373661353535313961383934
|
||||
38323934383462613533343665313432333938323938613736383430393661643562346235386438
|
||||
38323332366132653664366135383662623263356534613234323238303963643537636631363832
|
||||
65646164616331336131636464363461366664386433313633623662333936623637656330343463
|
||||
66306331303733336563653134323837306535336136636531663130316435353366343664656333
|
||||
33613735663431343437333636663735326136643464363963346133323238303239336431316230
|
||||
37636132363735383735393533333630646165393966656235633037623931326362373230636139
|
||||
64313034336236363434346133386537323033326163316432323430333766376461643738323030
|
||||
35306438346633643631316461303635633966666536636531386238393339643437326535363034
|
||||
37326464623133623564626465373936336432323034333161363363333637386532323136383664
|
||||
31303835356431343737656635396131613062633162366562633333636337613131643062373031
|
||||
62303233356466633761356339343532633836333262396132613461343635326262656434376665
|
||||
6430
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
# Inventory. Copy to hosts.local.yml and edit, or edit in place.
|
||||
all:
|
||||
children:
|
||||
samba_dc:
|
||||
hosts:
|
||||
dc1:
|
||||
ansible_host: 192.168.10.5
|
||||
ansible_user: ansible # cloud-init user created by create-dc-vm.yml
|
||||
# Provisions to dc1.ad.ddupan.top — matches samba_ad_* vars in group_vars.
|
||||
|
||||
samba_members:
|
||||
hosts:
|
||||
laptop:
|
||||
# The KVM/hypervisor host itself (192.168.10.127 on br0), also the KMS host.
|
||||
# Joined as an AD member fileserver by join-member.yml — runs locally.
|
||||
ansible_connection: local
|
||||
ansible_host: 127.0.0.1
|
||||
|
||||
windows_admin:
|
||||
hosts:
|
||||
winadmin1:
|
||||
ansible_host: 192.168.10.6
|
||||
vars:
|
||||
# WinRM connection for ansible.windows modules.
|
||||
ansible_connection: winrm
|
||||
ansible_user: Administrator
|
||||
ansible_password: "{{ vault_win_local_admin_password }}"
|
||||
ansible_port: 5986
|
||||
ansible_winrm_transport: ntlm
|
||||
ansible_winrm_server_cert_validation: ignore
|
||||
|
||||
# Hosts that need INTERACTIVE domain login (PAM/SSSD), not SMB serving.
|
||||
ad_workstations:
|
||||
hosts:
|
||||
retrolab:
|
||||
ansible_host: 10.60.0.10
|
||||
ansible_user: panxiao81
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
# Join a Linux host to the domain as a Samba MEMBER fileserver (winbind).
|
||||
# ansible-playbook join-member.yml --ask-vault-pass
|
||||
# ansible-playbook join-member.yml --tags verify # smoke tests only
|
||||
- name: Samba AD member fileserver
|
||||
hosts: samba_members
|
||||
become: true
|
||||
gather_facts: true
|
||||
roles:
|
||||
- role: samba_member
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
# AD join for interactive login (workstations), via realmd + SSSD.
|
||||
# Distinct from join-member.yml, which makes a host an AD member FILESERVER
|
||||
# (samba/winbind, no PAM login).
|
||||
#
|
||||
# ansible-playbook join-sssd.yml
|
||||
- name: AD workstation join (SSSD)
|
||||
hosts: ad_workstations
|
||||
become: true
|
||||
roles:
|
||||
- ad_sssd_join
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
# Join the Windows admin box (RSAT/GPMC station) to the domain.
|
||||
# ansible-playbook join-windows.yml --ask-vault-pass
|
||||
- name: Windows domain admin box
|
||||
hosts: windows_admin
|
||||
gather_facts: false
|
||||
roles:
|
||||
- role: win_domain_join
|
||||
# Trust the internal CA. Tagged so it can be re-run on its own after a CA
|
||||
# rotation without going near the domain-join and KMS-activation tasks:
|
||||
# ansible-playbook join-windows.yml --tags ca
|
||||
- role: win_ca_trust
|
||||
tags: [ca]
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
# Provision the Samba AD Domain Controller.
|
||||
# ansible-playbook provision-dc.yml --ask-vault-pass
|
||||
# ansible-playbook provision-dc.yml --tags verify # smoke tests only
|
||||
- name: Samba AD Domain Controller
|
||||
hosts: samba_dc
|
||||
become: true
|
||||
gather_facts: true
|
||||
roles:
|
||||
- role: samba_ad_dc
|
||||
post_tasks:
|
||||
- name: Smoke tests
|
||||
ansible.builtin.import_role:
|
||||
name: samba_ad_dc
|
||||
tasks_from: verify.yml
|
||||
tags: [verify, never]
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
# Install with: ansible-galaxy collection install -r requirements.yml
|
||||
collections:
|
||||
- name: community.general # general modules
|
||||
- name: community.crypto # x509_certificate/openssl_* for the DC LDAPS cert
|
||||
- name: ansible.windows # win_dns_client, win_feature, win_command, win_reboot
|
||||
- name: community.windows # extra Windows modules
|
||||
- name: microsoft.ad # membership (AD domain join) — replaces win_domain_membership
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
# Join a Linux host to AD for INTERACTIVE LOGIN, via realmd + SSSD.
|
||||
#
|
||||
# WHY NOT `samba_member`: that role makes a host an AD member FILE SERVER —
|
||||
# smb.conf, smbd/nmbd, winbind in NSS only, and its own comment says
|
||||
# "no PAM/login change". It deliberately cannot log a domain user in.
|
||||
# This role is the other half: PAM/SSSD so domain users can actually sign in
|
||||
# (RDP, console, ssh), with no SMB serving at all.
|
||||
#
|
||||
# Use samba_member for a fileserver, this for a workstation. A host could run
|
||||
# both, but neither implies the other.
|
||||
|
||||
ad_sssd_packages:
|
||||
- sssd-ad
|
||||
- sssd-tools
|
||||
- realmd
|
||||
- adcli
|
||||
- krb5-user
|
||||
- oddjob
|
||||
- oddjob-mkhomedir
|
||||
- libnss-sss
|
||||
- libpam-sss
|
||||
|
||||
# Domain users log in as `user` rather than `user@realm`.
|
||||
ad_sssd_use_fqn: false
|
||||
ad_sssd_fallback_homedir: "/home/%u"
|
||||
ad_sssd_shell: "/bin/bash"
|
||||
|
||||
# Restrict who may log in. EMPTY = every domain user can, which on a lab box
|
||||
# reachable from the LAN is broader than it looks. Prefer naming a group.
|
||||
# pve-admins is nested INSIDE retrolab-users, so admins get lab login without
|
||||
# lab users gaining Proxmox rights. SSSD resolves nested AD groups.
|
||||
ad_sssd_allow_groups: [retrolab-users]
|
||||
|
||||
# Credentials for the join itself (creates a computer account in AD).
|
||||
ad_sssd_join_user: Administrator
|
||||
ad_sssd_join_password: "{{ vault_samba_ad_admin_password }}"
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
- name: Restart sssd
|
||||
ansible.builtin.systemd_service:
|
||||
name: sssd
|
||||
state: restarted
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
# realmd + SSSD join, for INTERACTIVE LOGIN. See defaults for why this is
|
||||
# separate from samba_member.
|
||||
#
|
||||
# SSSD is Ubuntu's default AD backend (ADSys uses it unless winbind is
|
||||
# explicitly selected); winbind is for file/printer sharing and GPO.
|
||||
|
||||
- name: Assert required variables
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- samba_ad_realm | length > 0
|
||||
- ad_sssd_join_password | length > 0
|
||||
fail_msg: "samba_ad_realm and ad_sssd_join_password (vault_samba_ad_admin_password) are required."
|
||||
quiet: true
|
||||
|
||||
- name: Install realmd + SSSD packages
|
||||
ansible.builtin.apt:
|
||||
name: "{{ ad_sssd_packages }}"
|
||||
state: present
|
||||
update_cache: true
|
||||
cache_valid_time: 3600
|
||||
register: _sssd_pkgs
|
||||
retries: 3
|
||||
delay: 15
|
||||
until: _sssd_pkgs is succeeded
|
||||
|
||||
# Kerberos rejects a skew over 5 minutes, and the resulting error names the
|
||||
# clock nowhere near clearly enough. Fail here with a useful message instead.
|
||||
- name: Check the clock is NTP-synchronised
|
||||
ansible.builtin.command: timedatectl show -p NTPSynchronized --value
|
||||
register: _ntp
|
||||
changed_when: false
|
||||
|
||||
- name: Assert time is synchronised
|
||||
ansible.builtin.assert:
|
||||
that: "_ntp.stdout | trim == 'yes'"
|
||||
fail_msg: "Clock is not NTP-synchronised; the Kerberos join will fail on skew."
|
||||
quiet: true
|
||||
|
||||
- name: Check whether already joined
|
||||
ansible.builtin.command: "realm list {{ samba_ad_realm | lower }}"
|
||||
register: _realm
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false
|
||||
|
||||
- name: Join the domain
|
||||
# adcli creates the computer account. no_log: the admin password is on argv.
|
||||
ansible.builtin.shell:
|
||||
cmd: >-
|
||||
echo '{{ ad_sssd_join_password }}' |
|
||||
realm join --user={{ ad_sssd_join_user }} {{ samba_ad_realm | lower }}
|
||||
when: samba_ad_realm | lower not in (_realm.stdout | default(''))
|
||||
no_log: true
|
||||
notify: Restart sssd
|
||||
|
||||
- name: Deploy sssd.conf
|
||||
ansible.builtin.template:
|
||||
src: sssd.conf.j2
|
||||
dest: /etc/sssd/sssd.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0600" # sssd refuses to start if this is group/world readable
|
||||
notify: Restart sssd
|
||||
|
||||
- name: Create home directories on first login
|
||||
# Without this a domain user logs in with no home and lands in /, which breaks
|
||||
# anything expecting a desktop session.
|
||||
ansible.builtin.command:
|
||||
cmd: pam-auth-update --enable mkhomedir
|
||||
register: _mkhome
|
||||
changed_when: false
|
||||
|
||||
- name: Enable and start sssd
|
||||
ansible.builtin.systemd_service:
|
||||
name: sssd
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
- name: Flush handlers before verifying
|
||||
ansible.builtin.meta: flush_handlers
|
||||
|
||||
# --- verification: prove the join actually resolves a domain user ------------
|
||||
- name: Verify a domain user resolves through NSS
|
||||
ansible.builtin.command: "id {{ ad_sssd_verify_user | default('Administrator') }}"
|
||||
register: _id
|
||||
changed_when: false
|
||||
retries: 6
|
||||
delay: 5
|
||||
until: _id.rc == 0
|
||||
|
||||
- name: Report
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ _id.stdout }}"
|
||||
@@ -0,0 +1,23 @@
|
||||
# {{ ansible_managed }}
|
||||
# SSSD in AD mode: identity + authentication for interactive login.
|
||||
[sssd]
|
||||
domains = {{ samba_ad_realm | lower }}
|
||||
config_file_version = 2
|
||||
services = nss, pam
|
||||
|
||||
[domain/{{ samba_ad_realm | lower }}]
|
||||
id_provider = ad
|
||||
access_provider = {{ 'simple' if ad_sssd_allow_groups else 'ad' }}
|
||||
{% if ad_sssd_allow_groups %}
|
||||
simple_allow_groups = {{ ad_sssd_allow_groups | join(', ') }}
|
||||
{% endif %}
|
||||
ad_domain = {{ samba_ad_realm | lower }}
|
||||
krb5_realm = {{ samba_ad_realm | upper }}
|
||||
realmd_tags = manages-system joined-with-adcli
|
||||
cache_credentials = true
|
||||
krb5_store_password_if_offline = true
|
||||
# Log in as `alice`, not `[email protected]`.
|
||||
use_fully_qualified_names = {{ 'true' if ad_sssd_use_fqn else 'false' }}
|
||||
fallback_homedir = {{ ad_sssd_fallback_homedir }}
|
||||
default_shell = {{ ad_sssd_shell }}
|
||||
ldap_id_mapping = true
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
# dc_vm role — create the DC's Ubuntu VM locally via libvirt + cloud-init (NoCloud).
|
||||
# Runs on the libvirt host (localhost). Configures nothing inside the OS beyond the
|
||||
# cloud-init seed; the samba_ad_dc role does the AD provisioning afterward.
|
||||
|
||||
dc_vm_name: "{{ samba_ad_dc_hostname | default('dc1') }}"
|
||||
dc_vm_vcpus: 2
|
||||
dc_vm_memory_mb: 2048
|
||||
dc_vm_disk_gb: 25
|
||||
|
||||
# Latest Ubuntu LTS cloud image (24.04 Noble). "current" always points at the newest build.
|
||||
dc_vm_image_url: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img"
|
||||
dc_vm_osinfo: "ubuntu24.04"
|
||||
|
||||
# libvirt placement.
|
||||
# The VM root disk is a ZFS zvol (matches data/vm/win2k25 convention).
|
||||
# dc_vm_images_dir only holds the small base image + the cloud-init seed ISO (files).
|
||||
dc_vm_images_dir: "/var/lib/libvirt/images"
|
||||
dc_vm_bridge: "br0" # LAN bridge → puts the DC on 192.168.10.0/24
|
||||
|
||||
# ZFS zvol for the root disk
|
||||
dc_vm_zvol_parent: "data/vm" # parent dataset, one zvol per VM
|
||||
dc_vm_zvol: "{{ dc_vm_zvol_parent }}/{{ dc_vm_name }}"
|
||||
dc_vm_zvol_dev: "/dev/zvol/{{ dc_vm_zvol }}"
|
||||
dc_vm_zvol_volblocksize: "16K" # matches existing VMs
|
||||
dc_vm_zvol_sparse: false # thick-provisioned like data/vm/win2k25
|
||||
|
||||
# Networking for the guest (static — AD requires it). Pulls from group_vars/all.yml.
|
||||
dc_vm_ip: "{{ samba_ad_dc_ip }}"
|
||||
dc_vm_prefix: 24
|
||||
dc_vm_gateway: "192.168.10.1"
|
||||
dc_vm_boot_dns: "{{ samba_ad_dns_forwarder }}" # first-boot resolver (before it is its own DNS)
|
||||
|
||||
# Cloud-init login user + the public key Ansible will connect with.
|
||||
dc_vm_user: "ansible"
|
||||
dc_vm_ssh_pubkey_file: "~/.ssh/id_ed25519.pub" # generate with: ssh-keygen -t ed25519
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
# Create the DC VM on the local libvirt host. Idempotent: if the domain already
|
||||
# exists it does nothing. Run on localhost with qemu:///system (become: true).
|
||||
|
||||
- name: Resolve the SSH public key to inject
|
||||
ansible.builtin.set_fact:
|
||||
dc_vm_ssh_pubkey: "{{ lookup('file', dc_vm_ssh_pubkey_file | expanduser) }}"
|
||||
|
||||
- name: Fail early if no usable public key
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- dc_vm_ssh_pubkey is search('^ssh-')
|
||||
fail_msg: >-
|
||||
No SSH public key at {{ dc_vm_ssh_pubkey_file }}. Generate one
|
||||
(ssh-keygen -t ed25519) or set dc_vm_ssh_pubkey_file.
|
||||
|
||||
- name: Check whether the libvirt domain already exists
|
||||
ansible.builtin.command: "virsh dominfo {{ dc_vm_name }}"
|
||||
register: dc_vm_dominfo
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Create the VM
|
||||
when: dc_vm_dominfo.rc != 0
|
||||
block:
|
||||
- name: Ensure image directories exist
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: directory
|
||||
mode: "0711"
|
||||
loop:
|
||||
- "{{ dc_vm_images_dir }}"
|
||||
- "{{ dc_vm_images_dir }}/base"
|
||||
|
||||
- name: Download the Ubuntu cloud image (once)
|
||||
ansible.builtin.get_url:
|
||||
url: "{{ dc_vm_image_url }}"
|
||||
dest: "{{ dc_vm_images_dir }}/base/{{ dc_vm_image_url | basename }}"
|
||||
mode: "0644"
|
||||
|
||||
- name: Check whether the root-disk zvol already exists
|
||||
ansible.builtin.command: "zfs list -H -o name {{ dc_vm_zvol }}"
|
||||
register: dc_vm_zvol_check
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Create the root-disk zvol
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
zfs create {{ '-s ' if dc_vm_zvol_sparse else '' }}-V {{ dc_vm_disk_gb }}G
|
||||
-o volblocksize={{ dc_vm_zvol_volblocksize }}
|
||||
{{ dc_vm_zvol }}
|
||||
when: dc_vm_zvol_check.rc != 0
|
||||
|
||||
- name: Wait for the zvol device node to appear
|
||||
ansible.builtin.wait_for:
|
||||
path: "{{ dc_vm_zvol_dev }}"
|
||||
timeout: 30
|
||||
when: dc_vm_zvol_check.rc != 0
|
||||
|
||||
- name: Write the cloud image into the zvol (raw)
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
qemu-img convert -O raw
|
||||
{{ dc_vm_images_dir }}/base/{{ dc_vm_image_url | basename }}
|
||||
{{ dc_vm_zvol_dev }}
|
||||
when: dc_vm_zvol_check.rc != 0
|
||||
# cloud-init growpart expands the rootfs to fill the zvol on first boot.
|
||||
|
||||
- name: Render the cloud-init seed files
|
||||
ansible.builtin.template:
|
||||
src: "{{ item }}.j2"
|
||||
dest: "{{ dc_vm_images_dir }}/{{ dc_vm_name }}-seed-{{ item }}"
|
||||
mode: "0644"
|
||||
loop:
|
||||
- user-data
|
||||
- meta-data
|
||||
- network-config
|
||||
|
||||
- name: Build the NoCloud seed ISO
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
genisoimage -output {{ dc_vm_images_dir }}/{{ dc_vm_name }}-seed.iso
|
||||
-volid cidata -joliet -rock
|
||||
-graft-points
|
||||
user-data={{ dc_vm_images_dir }}/{{ dc_vm_name }}-seed-user-data
|
||||
meta-data={{ dc_vm_images_dir }}/{{ dc_vm_name }}-seed-meta-data
|
||||
network-config={{ dc_vm_images_dir }}/{{ dc_vm_name }}-seed-network-config
|
||||
args:
|
||||
creates: "{{ dc_vm_images_dir }}/{{ dc_vm_name }}-seed.iso"
|
||||
|
||||
- name: Define and start the domain (cloud-init imports the disk)
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
virt-install
|
||||
--name {{ dc_vm_name }}
|
||||
--memory {{ dc_vm_memory_mb }}
|
||||
--vcpus {{ dc_vm_vcpus }}
|
||||
--osinfo require=off,name={{ dc_vm_osinfo }}
|
||||
--disk path={{ dc_vm_zvol_dev }},format=raw,bus=virtio
|
||||
--disk path={{ dc_vm_images_dir }}/{{ dc_vm_name }}-seed.iso,device=cdrom
|
||||
--network bridge={{ dc_vm_bridge }},model=virtio
|
||||
--graphics none --noautoconsole --import
|
||||
register: virt_install
|
||||
changed_when: true
|
||||
|
||||
- name: Wait for SSH on the new DC
|
||||
ansible.builtin.wait_for:
|
||||
host: "{{ dc_vm_ip }}"
|
||||
port: 22
|
||||
delay: 10
|
||||
timeout: 300
|
||||
when: dc_vm_dominfo.rc != 0
|
||||
@@ -0,0 +1,2 @@
|
||||
instance-id: {{ dc_vm_name }}-001
|
||||
local-hostname: {{ dc_vm_name }}
|
||||
@@ -0,0 +1,20 @@
|
||||
version: 2
|
||||
ethernets:
|
||||
primary:
|
||||
# Match the (single) ethernet NIC by kernel name and configure it in place.
|
||||
# NOTE: do NOT add set-name here — netplan only supports set-name when matching
|
||||
# on mac/driver, not on name, and a name-match + rename leaves the NIC unconfigured.
|
||||
match:
|
||||
name: "en*"
|
||||
dhcp4: false
|
||||
dhcp6: false
|
||||
addresses:
|
||||
- {{ dc_vm_ip }}/{{ dc_vm_prefix }}
|
||||
routes:
|
||||
- to: default
|
||||
via: {{ dc_vm_gateway }}
|
||||
nameservers:
|
||||
addresses:
|
||||
- {{ dc_vm_boot_dns }}
|
||||
search:
|
||||
- {{ samba_ad_realm | lower }}
|
||||
@@ -0,0 +1,20 @@
|
||||
#cloud-config
|
||||
# NoCloud user-data for the Samba AD DC base VM.
|
||||
hostname: {{ dc_vm_name }}
|
||||
fqdn: {{ dc_vm_name }}.{{ samba_ad_realm | lower }}
|
||||
# /etc/hosts is owned by the samba_ad_dc role (pins FQDN to the real IP), not cloud-init.
|
||||
manage_etc_hosts: false
|
||||
preserve_hostname: false
|
||||
|
||||
users:
|
||||
- name: {{ dc_vm_user }}
|
||||
groups: [sudo]
|
||||
shell: /bin/bash
|
||||
sudo: "ALL=(ALL) NOPASSWD:ALL"
|
||||
lock_passwd: true
|
||||
ssh_authorized_keys:
|
||||
- {{ dc_vm_ssh_pubkey }}
|
||||
|
||||
ssh_pwauth: false
|
||||
package_update: true
|
||||
package_upgrade: false
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
# LDAPS certificate for the DC, auto-renewed from OpenBao's internal ACME.
|
||||
#
|
||||
# WHY: the first bao-issued LDAPS cert (2026-07-25) was placed by hand and expires
|
||||
# 2027-07-25 with nothing to renew it. If it lapses, Authelia loses its LDAPS
|
||||
# backend and every SSO consumer (Gitea, Grafana, OpenBao's own OIDC login) fails
|
||||
# at once — a year later, with no memory of how it got there.
|
||||
|
||||
# --- lego (ACME client). Same pinned release as openbao_acme, deliberately. ---
|
||||
samba_ad_acme_version: "5.3.1"
|
||||
samba_ad_acme_checksum: "sha256:b3c71b122ee1947eacfe0b809b955647f6377239fe4bfc49f73b1a091ae1252a"
|
||||
samba_ad_acme_url: "https://github.com/go-acme/lego/releases/download/v{{ samba_ad_acme_version }}/lego_v{{ samba_ad_acme_version }}_linux_amd64.tar.gz"
|
||||
samba_ad_acme_bin: "/usr/local/bin/lego"
|
||||
|
||||
# --- Paths ---
|
||||
samba_ad_acme_dir: "/etc/samba/acme" # lego state (account + certs)
|
||||
samba_ad_acme_tls_dir: "/var/lib/samba/private/tls" # where Samba reads cert/key/ca
|
||||
|
||||
# --- Identity ---
|
||||
samba_ad_acme_domain: "dc1.ad.ddupan.top"
|
||||
samba_ad_acme_email: "[email protected]"
|
||||
|
||||
# OpenBao's ACME directory, pinned to the ROLE-scoped path so issuance is capped by
|
||||
# the bao-server role (allowed_domains=ad.ddupan.top) rather than sign-verbatim.
|
||||
samba_ad_acme_server: "https://bao.ad.ddupan.top:8200/v1/pki/roles/bao-server/acme/directory"
|
||||
|
||||
# http-01: lego binds this address only while validating, then releases it. Verified nothing
|
||||
# else listens on :80 on the DC, and bao (192.168.10.8) can reach it.
|
||||
# NOTE: this yields a cert with a DNS SAN ONLY — no IP SAN, unlike the hand-issued
|
||||
# one it replaces. Clients MUST connect as dc1.ad.ddupan.top, not 192.168.10.5.
|
||||
# NOTE: lego v5 calls this --http.address (NOT --http.port, which is a v4-ism
|
||||
# and fails with "flag provided but not defined"). Mirrors --tls.address.
|
||||
samba_ad_acme_http_address: ":80"
|
||||
|
||||
# OpenBao's ACME caps certificate lifetime (issued cert is ~32 days, NOT the
|
||||
# role's 1y max_ttl — ACME deliberately issues short-lived certs). A 30-day
|
||||
# threshold against a 32-day cert would try to renew on almost every run, so keep
|
||||
# the window well inside the lifetime: renew with ~10 days of headroom.
|
||||
samba_ad_acme_renew_days: 10
|
||||
samba_ad_acme_renew_oncalendar: "*-*-* 03:42:00"
|
||||
|
||||
# lego defaults to an EC (P-256) key, but the bao-server PKI role pins
|
||||
# key_type=rsa / key_bits=2048, so an EC CSR is rejected at finalize with
|
||||
# "badCSR :: refusing to sign CSR: role requires keys of type rsa".
|
||||
# Match the role rather than loosening it — the role is what caps ACME issuance.
|
||||
samba_ad_acme_key_type: "rsa2048"
|
||||
@@ -0,0 +1,2 @@
|
||||
---
|
||||
dependencies: []
|
||||
@@ -0,0 +1,128 @@
|
||||
---
|
||||
# Install lego, obtain the DC's LDAPS cert from OpenBao's ACME, deploy it into
|
||||
# Samba's TLS dir, and enable a renewal timer. Idempotent.
|
||||
|
||||
- name: Check installed lego version
|
||||
ansible.builtin.command: "{{ samba_ad_acme_bin }} --version"
|
||||
register: lego_installed
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Install lego when missing or version mismatch
|
||||
when: samba_ad_acme_version not in (lego_installed.stdout | default(''))
|
||||
block:
|
||||
- name: Download lego release tarball (checksum-verified)
|
||||
ansible.builtin.get_url:
|
||||
url: "{{ samba_ad_acme_url }}"
|
||||
dest: "/tmp/lego_{{ samba_ad_acme_version }}.tar.gz"
|
||||
checksum: "{{ samba_ad_acme_checksum }}"
|
||||
mode: "0644"
|
||||
retries: 3
|
||||
delay: 10
|
||||
|
||||
- name: Create lego staging dir
|
||||
ansible.builtin.file:
|
||||
path: "/tmp/lego_{{ samba_ad_acme_version }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Extract lego
|
||||
ansible.builtin.unarchive:
|
||||
src: "/tmp/lego_{{ samba_ad_acme_version }}.tar.gz"
|
||||
dest: "/tmp/lego_{{ samba_ad_acme_version }}"
|
||||
remote_src: true
|
||||
|
||||
- name: Install lego binary
|
||||
ansible.builtin.copy:
|
||||
src: "/tmp/lego_{{ samba_ad_acme_version }}/lego"
|
||||
dest: "{{ samba_ad_acme_bin }}"
|
||||
remote_src: true
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
|
||||
- name: Create ACME state directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ samba_ad_acme_dir }}"
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0700"
|
||||
|
||||
- name: Install the obtain/renew wrapper and deploy hook
|
||||
ansible.builtin.template:
|
||||
src: "{{ item.src }}"
|
||||
dest: "{{ item.dest }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
loop:
|
||||
- { src: samba-acme.sh.j2, dest: /usr/local/bin/samba-acme.sh }
|
||||
- { src: samba-acme-deploy.sh.j2, dest: /usr/local/bin/samba-acme-deploy.sh }
|
||||
|
||||
- name: Install the renewal systemd service + timer
|
||||
ansible.builtin.template:
|
||||
src: "{{ item.src }}"
|
||||
dest: "{{ item.dest }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
loop:
|
||||
- { src: samba-acme.service.j2, dest: /etc/systemd/system/samba-acme.service }
|
||||
- { src: samba-acme.timer.j2, dest: /etc/systemd/system/samba-acme.timer }
|
||||
register: acme_units
|
||||
|
||||
- name: Reload systemd
|
||||
ansible.builtin.systemd_service:
|
||||
daemon_reload: true
|
||||
when: acme_units is changed
|
||||
|
||||
# --- Preflight: the two things that actually make http-01 fail here ------------
|
||||
- name: Confirm nothing else is bound to port 80
|
||||
# lego binds :80 for the duration of validation. Anything already holding it
|
||||
# makes issuance fail with a bind error rather than anything ACME-shaped.
|
||||
ansible.builtin.shell:
|
||||
cmd: "ss -ltn '( sport = :80 )' | tail -n +2 | wc -l"
|
||||
register: port80
|
||||
changed_when: false
|
||||
|
||||
- name: Fail if port 80 is occupied
|
||||
ansible.builtin.fail:
|
||||
msg: "Port 80 is in use on {{ inventory_hostname }}; lego's http-01 cannot bind it."
|
||||
when: port80.stdout | trim | int > 0
|
||||
|
||||
- name: Confirm the OpenBao ACME directory is reachable
|
||||
ansible.builtin.uri:
|
||||
url: "{{ samba_ad_acme_server }}"
|
||||
return_content: false
|
||||
validate_certs: true
|
||||
register: acme_dir
|
||||
retries: 3
|
||||
delay: 10
|
||||
until: acme_dir is succeeded
|
||||
changed_when: false
|
||||
|
||||
- name: Obtain/renew the certificate now
|
||||
# Safe to run every time: lego only acts when the cert is missing or within
|
||||
# --renew-days of expiry, and only then fires the deploy hook.
|
||||
ansible.builtin.command: /usr/local/bin/samba-acme.sh
|
||||
register: lego_run
|
||||
changed_when: "'Server responded with a certificate' in (lego_run.stdout | default('') + lego_run.stderr | default(''))"
|
||||
|
||||
- name: Enable and start the renewal timer
|
||||
ansible.builtin.systemd_service:
|
||||
name: samba-acme.timer
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
- name: Report the live LDAPS certificate
|
||||
ansible.builtin.shell:
|
||||
cmd: >-
|
||||
echo | timeout 10 openssl s_client -connect 127.0.0.1:636 2>/dev/null
|
||||
| openssl x509 -noout -subject -issuer -dates
|
||||
register: live_cert
|
||||
changed_when: false
|
||||
|
||||
- name: Show it
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ live_cert.stdout_lines }}"
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
# {{ ansible_managed }}
|
||||
# Install a freshly issued/renewed cert into Samba's TLS dir.
|
||||
# lego passes the paths in LEGO_CERT_PATH / LEGO_CERT_KEY_PATH.
|
||||
set -euo pipefail
|
||||
|
||||
TLS="{{ samba_ad_acme_tls_dir }}"
|
||||
CRT="${LEGO_CERT_PATH:-{{ samba_ad_acme_dir }}/certificates/{{ samba_ad_acme_domain }}.crt}"
|
||||
KEY="${LEGO_CERT_KEY_PATH:-{{ samba_ad_acme_dir }}/certificates/{{ samba_ad_acme_domain }}.key}"
|
||||
ISS="${CRT%.crt}.issuer.crt"
|
||||
|
||||
install -o root -g root -m 0644 "${CRT}" "${TLS}/cert.pem"
|
||||
install -o root -g root -m 0600 "${KEY}" "${TLS}/key.pem"
|
||||
[ -s "${ISS}" ] && install -o root -g root -m 0644 "${ISS}" "${TLS}/ca.pem"
|
||||
|
||||
# Samba re-reads its TLS credentials PER CONNECTION, so a renewal normally goes
|
||||
# live with no restart and no LDAPS downtime (observed 2026-07-25). Do not assume
|
||||
# it though: if the served cert does not match what we just installed, the old one
|
||||
# is still being handed out and would eventually expire in place. Verify, and only
|
||||
# restart if we must — that keeps the common path at zero downtime while making
|
||||
# the failure mode loud instead of silent.
|
||||
new="$(openssl x509 -noout -fingerprint -sha256 -in "${TLS}/cert.pem" | cut -d= -f2)"
|
||||
served="$(echo | timeout 10 openssl s_client -connect 127.0.0.1:636 2>/dev/null \
|
||||
| openssl x509 -noout -fingerprint -sha256 2>/dev/null | cut -d= -f2 || true)"
|
||||
|
||||
if [ "${new}" != "${served}" ]; then
|
||||
echo "served cert != installed cert; restarting samba-ad-dc to load it"
|
||||
systemctl restart samba-ad-dc
|
||||
else
|
||||
echo "samba already serving the new cert; no restart needed"
|
||||
fi
|
||||
@@ -0,0 +1,9 @@
|
||||
# {{ ansible_managed }}
|
||||
[Unit]
|
||||
Description=Samba AD DC LDAPS certificate (lego, OpenBao ACME http-01)
|
||||
After=network-online.target samba-ad-dc.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/samba-acme.sh
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
# {{ ansible_managed }}
|
||||
# Obtain or renew the DC's LDAPS cert from OpenBao's internal ACME (http-01).
|
||||
# lego's `run` does BOTH: it renews only when due (--renew-days) and fires
|
||||
# --deploy-hook on any actual create/renew. There is no separate `renew` command
|
||||
# in lego v5, and every flag must come AFTER `run`.
|
||||
set -euo pipefail
|
||||
|
||||
exec {{ samba_ad_acme_bin }} run \
|
||||
--accept-tos \
|
||||
--email "{{ samba_ad_acme_email }}" \
|
||||
--server "{{ samba_ad_acme_server }}" \
|
||||
--http \
|
||||
--http.address "{{ samba_ad_acme_http_address }}" \
|
||||
--domains "{{ samba_ad_acme_domain }}" \
|
||||
--key-type "{{ samba_ad_acme_key_type }}" \
|
||||
--path "{{ samba_ad_acme_dir }}" \
|
||||
--renew-days {{ samba_ad_acme_renew_days }} \
|
||||
--deploy-hook /usr/local/bin/samba-acme-deploy.sh
|
||||
@@ -0,0 +1,11 @@
|
||||
# {{ ansible_managed }}
|
||||
[Unit]
|
||||
Description=Samba AD DC LDAPS certificate renewal timer
|
||||
|
||||
[Timer]
|
||||
OnCalendar={{ samba_ad_acme_renew_oncalendar }}
|
||||
RandomizedDelaySec=3600
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
# samba_ad_dc role defaults — override in group_vars/host_vars.
|
||||
# Secrets (samba_ad_admin_password) MUST come from an Ansible Vault file, not here.
|
||||
|
||||
samba_ad_realm: "AD.EXAMPLE.COM" # DNS realm, uppercase. Delegated subdomain you own.
|
||||
samba_ad_domain: "EXAMPLE" # NetBIOS / short name, <=15 chars, uppercase, no dots.
|
||||
samba_ad_dc_hostname: "dc1" # short hostname of this DC
|
||||
samba_ad_dc_ip: "10.10.10.10" # this DC's static IP (used for /etc/hosts + resolv.conf)
|
||||
samba_ad_dns_forwarder: "10.10.10.1" # where the DC forwards non-AD lookups
|
||||
|
||||
# Reverse DNS zone to create after provision (optional; empty string skips it).
|
||||
# Give the in-addr.arpa name directly, e.g. "10.10.10.in-addr.arpa" for 10.10.10.0/24.
|
||||
samba_ad_reverse_zone: ""
|
||||
|
||||
# Extra A records to publish in the AD DNS zone for non-domain hosts (e.g. OpenBao).
|
||||
# List of {name, ip}. Added with the DC machine account (-P) — no admin password.
|
||||
samba_ad_extra_a_records: []
|
||||
|
||||
# Re-enable legacy protocols (SMB1/NTLMv1/LANMAN) for Win9x/NT4/2000/XP. INSECURE.
|
||||
samba_ad_legacy_clients: false
|
||||
|
||||
# Package set (Debian/Ubuntu). RHEL-family names differ — adjust if you switch base.
|
||||
samba_ad_packages:
|
||||
- samba
|
||||
- krb5-config
|
||||
- krb5-user # kinit/klist for admin + smoke tests
|
||||
- winbind
|
||||
- smbclient
|
||||
- ldb-tools
|
||||
- chrony
|
||||
|
||||
# Distro daemons that MUST be stopped on an AD DC (the unified `samba` service owns these roles).
|
||||
samba_ad_conflicting_services:
|
||||
- smbd
|
||||
- nmbd
|
||||
- winbind
|
||||
- systemd-resolved # stub listener freed separately; service left running but stub disabled
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
- name: restart systemd-resolved
|
||||
ansible.builtin.service:
|
||||
name: systemd-resolved
|
||||
state: restarted
|
||||
|
||||
- name: restart samba-ad-dc
|
||||
ansible.builtin.systemd:
|
||||
name: samba-ad-dc
|
||||
state: restarted
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
galaxy_info:
|
||||
role_name: samba_ad_dc
|
||||
description: Provision a Samba Active Directory Domain Controller (Kerberos + LDAP + DNS + SMB).
|
||||
min_ansible_version: "2.15"
|
||||
platforms:
|
||||
- name: Debian
|
||||
versions: [bookworm]
|
||||
- name: Ubuntu
|
||||
versions: [jammy, noble]
|
||||
dependencies: []
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
# Service accounts + RBAC groups that downstream apps depend on.
|
||||
# Codified because these were originally created by hand (svc-authelia), which
|
||||
# means a DC rebuild would silently lose every app's ability to bind.
|
||||
|
||||
- name: Create service accounts
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- samba-tool
|
||||
- user
|
||||
- create
|
||||
- "{{ item.name }}"
|
||||
- "{{ item.password }}"
|
||||
- "--description={{ item.description | default('') }}"
|
||||
loop: "{{ samba_ad_service_accounts | default([]) }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
register: _svc_create
|
||||
changed_when: "'created successfully' in (_svc_create.stdout | default(''))"
|
||||
failed_when:
|
||||
- _svc_create.rc != 0
|
||||
- "'already exists' not in (_svc_create.stdout | default('') + _svc_create.stderr | default(''))"
|
||||
no_log: true # passwords are on the argv
|
||||
|
||||
- name: Make service-account passwords non-expiring
|
||||
# A bind account whose password silently expires takes the dependent app down
|
||||
# with it, with no obvious cause. These are long random secrets in vault.yml.
|
||||
ansible.builtin.command:
|
||||
cmd: "samba-tool user setexpiry {{ item.name }} --noexpiry"
|
||||
loop: "{{ samba_ad_service_accounts | default([]) }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
register: _svc_expiry
|
||||
changed_when: _svc_expiry.rc == 0
|
||||
|
||||
- name: Create RBAC groups
|
||||
ansible.builtin.command:
|
||||
cmd: "samba-tool group add {{ item.name }}"
|
||||
loop: "{{ samba_ad_groups | default([]) }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
register: _grp_create
|
||||
changed_when: "'added successfully' in (_grp_create.stdout | default(''))"
|
||||
failed_when:
|
||||
- _grp_create.rc != 0
|
||||
- "'already exists' not in (_grp_create.stdout | default('') + _grp_create.stderr | default(''))"
|
||||
|
||||
- name: Add group members
|
||||
ansible.builtin.command:
|
||||
cmd: "samba-tool group addmembers {{ item.name }} {{ item.members | join(',') }}"
|
||||
loop: "{{ samba_ad_groups | default([]) | selectattr('members', 'defined') | list }}"
|
||||
loop_control:
|
||||
label: "{{ item.name }}"
|
||||
register: _grp_members
|
||||
changed_when: "'Added members' in (_grp_members.stdout | default(''))"
|
||||
failed_when:
|
||||
- _grp_members.rc != 0
|
||||
# samba-tool wording varies: "already a member" for users, but
|
||||
# "Attribute member already exists" when the member is a nested GROUP.
|
||||
- "'already a member' not in (_grp_members.stdout | default('') + _grp_members.stderr | default('')) | lower"
|
||||
- "'already exists' not in (_grp_members.stdout | default('') + _grp_members.stderr | default('')) | lower"
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
# Legacy client support — Win9x / NT4 / Win2000 / XP.
|
||||
# INSECURE: re-enables SMB1, NTLMv1 and LANMAN auth. Only run on an isolated network.
|
||||
# Gated by samba_ad_legacy_clients (default false).
|
||||
|
||||
- name: Inject legacy protocol settings into smb.conf [global]
|
||||
ansible.builtin.blockinfile:
|
||||
path: /etc/samba/smb.conf
|
||||
marker: "\t# {mark} ANSIBLE MANAGED — legacy clients (INSECURE)"
|
||||
insertafter: '^\[global\]'
|
||||
block: |2
|
||||
server min protocol = NT1
|
||||
ntlm auth = ntlmv1-permitted
|
||||
lanman auth = yes
|
||||
client lanman auth = yes
|
||||
allow nt4 crypto = yes
|
||||
wins support = yes
|
||||
notify: restart samba-ad-dc
|
||||
|
||||
- name: Allow weak Kerberos crypto for Windows 2000 (DES enctypes)
|
||||
ansible.builtin.lineinfile:
|
||||
path: /etc/krb5.conf
|
||||
insertafter: '^\[libdefaults\]'
|
||||
line: " allow_weak_crypto = true"
|
||||
state: present
|
||||
notify: restart samba-ad-dc
|
||||
|
||||
- name: Legacy warning
|
||||
ansible.builtin.debug:
|
||||
msg: >-
|
||||
Legacy client support ENABLED (SMB1/NTLMv1/LANMAN). This materially weakens the
|
||||
domain — keep retro machines on an isolated VLAN. See README "Retro clients".
|
||||
@@ -0,0 +1,217 @@
|
||||
---
|
||||
# Provision a Samba Active Directory Domain Controller.
|
||||
# Idempotent: the provision step is guarded by the existence of the sam.ldb database,
|
||||
# so re-running the playbook against an already-provisioned DC is a no-op there.
|
||||
#
|
||||
# DNS ordering is deliberate: the box keeps using its normal upstream resolver for
|
||||
# apt + provisioning, and is only repointed at its OWN Samba DNS *after* samba-ad-dc
|
||||
# is up and serving :53. That way an interruption can never strand the DC on dead DNS.
|
||||
|
||||
- name: Assert required variables are set
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- samba_ad_realm | length > 0
|
||||
- samba_ad_domain | length > 0
|
||||
- samba_ad_dc_ip | length > 0
|
||||
- samba_ad_admin_password is defined
|
||||
- samba_ad_admin_password | length >= 8
|
||||
fail_msg: >-
|
||||
Set samba_ad_realm/domain/dc_ip and provide samba_ad_admin_password from vault.
|
||||
|
||||
- name: Set hostname to the DC FQDN
|
||||
ansible.builtin.hostname:
|
||||
name: "{{ samba_ad_dc_hostname }}.{{ samba_ad_realm | lower }}"
|
||||
|
||||
- name: Pin DC FQDN to its real IP in /etc/hosts (never 127.0.1.1)
|
||||
ansible.builtin.lineinfile:
|
||||
path: /etc/hosts
|
||||
regexp: '\s{{ samba_ad_dc_hostname }}\.{{ samba_ad_realm | lower | regex_escape }}\b'
|
||||
line: "{{ samba_ad_dc_ip }} {{ samba_ad_dc_hostname }}.{{ samba_ad_realm | lower }} {{ samba_ad_dc_hostname }}"
|
||||
state: present
|
||||
|
||||
- name: Install Samba AD DC packages
|
||||
ansible.builtin.apt:
|
||||
name: "{{ samba_ad_packages }}"
|
||||
state: present
|
||||
update_cache: true
|
||||
|
||||
- name: Ensure time sync is active (Kerberos dies on >5min skew)
|
||||
ansible.builtin.service:
|
||||
name: chrony
|
||||
state: started
|
||||
enabled: true
|
||||
|
||||
# --- Stop conflicting daemons --------------------------------------------------
|
||||
- name: Disable distro smbd/nmbd/winbind (AD DC uses the unified samba service)
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ item }}"
|
||||
state: stopped
|
||||
enabled: false
|
||||
masked: false
|
||||
loop:
|
||||
- smbd
|
||||
- nmbd
|
||||
- winbind
|
||||
failed_when: false
|
||||
|
||||
- name: Unmask samba-ad-dc service
|
||||
ansible.builtin.systemd:
|
||||
name: samba-ad-dc
|
||||
masked: false
|
||||
|
||||
# --- Provision the domain (guarded) --------------------------------------------
|
||||
- name: Check whether the domain is already provisioned
|
||||
ansible.builtin.stat:
|
||||
path: /var/lib/samba/private/sam.ldb
|
||||
register: samba_sam_db
|
||||
|
||||
- name: Move stock smb.conf aside before first provision
|
||||
ansible.builtin.command:
|
||||
cmd: mv /etc/samba/smb.conf /etc/samba/smb.conf.orig
|
||||
removes: /etc/samba/smb.conf
|
||||
when: not samba_sam_db.stat.exists
|
||||
|
||||
- name: Provision the Active Directory domain
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- samba-tool
|
||||
- domain
|
||||
- provision
|
||||
- "--use-rfc2307"
|
||||
- "--realm={{ samba_ad_realm }}"
|
||||
- "--domain={{ samba_ad_domain }}"
|
||||
- "--server-role=dc"
|
||||
- "--dns-backend=SAMBA_INTERNAL"
|
||||
- "--adminpass={{ samba_ad_admin_password }}"
|
||||
- "--option=dns forwarder = {{ samba_ad_dns_forwarder }}"
|
||||
creates: /var/lib/samba/private/sam.ldb
|
||||
no_log: true # keep the admin password out of logs
|
||||
|
||||
- name: Install the generated krb5.conf system-wide
|
||||
ansible.builtin.copy:
|
||||
src: /var/lib/samba/private/krb5.conf
|
||||
dest: /etc/krb5.conf
|
||||
remote_src: true
|
||||
mode: "0644"
|
||||
|
||||
- name: Replace default LDAPS cert (positive serial + SANs) for modern Go clients
|
||||
ansible.builtin.import_tasks: tls.yml
|
||||
|
||||
# --- Free port 53, then bring Samba's internal DNS online ----------------------
|
||||
# Do this only now: up to here the box still resolves via its upstream (DHCP/cloud-init)
|
||||
# resolver, so apt + provision above always had working DNS.
|
||||
- name: Ensure resolved.conf.d drop-in directory exists
|
||||
ansible.builtin.file:
|
||||
path: /etc/systemd/resolved.conf.d
|
||||
state: directory
|
||||
mode: "0755"
|
||||
|
||||
- name: Disable systemd-resolved stub listener (frees :53 for Samba)
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/systemd/resolved.conf.d/no-stub.conf
|
||||
content: |
|
||||
[Resolve]
|
||||
DNSStubListener=no
|
||||
mode: "0644"
|
||||
register: stub_dropin
|
||||
|
||||
- name: Restart systemd-resolved to release :53 before Samba binds it
|
||||
ansible.builtin.systemd:
|
||||
name: systemd-resolved
|
||||
state: restarted
|
||||
when: stub_dropin is changed
|
||||
|
||||
- name: Enable and start samba-ad-dc
|
||||
ansible.builtin.systemd:
|
||||
name: samba-ad-dc
|
||||
state: started
|
||||
enabled: true
|
||||
|
||||
- name: Point the DC at its own Samba DNS (now that it is serving :53)
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/resolv.conf
|
||||
content: |
|
||||
nameserver {{ samba_ad_dc_ip }}
|
||||
search {{ samba_ad_realm | lower }}
|
||||
follow: false
|
||||
force: true
|
||||
mode: "0644"
|
||||
|
||||
- name: Legacy client support (Win9x/NT4/2000/XP)
|
||||
ansible.builtin.import_tasks: legacy.yml
|
||||
when: samba_ad_legacy_clients | default(false) | bool
|
||||
|
||||
# --- Post-provision: reverse DNS zone (optional) -------------------------------
|
||||
- name: Create reverse DNS zone
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
samba-tool dns zonecreate {{ samba_ad_dc_ip }} {{ samba_ad_reverse_zone }}
|
||||
-U administrator%{{ samba_ad_admin_password }}
|
||||
when: samba_ad_reverse_zone | length > 0
|
||||
register: revzone
|
||||
changed_when: "'already exists' not in (revzone.stderr | default(''))"
|
||||
failed_when:
|
||||
- revzone.rc != 0
|
||||
- "'already exists' not in (revzone.stderr | default(''))"
|
||||
no_log: true
|
||||
|
||||
- name: Register the DC's own PTR record in the reverse zone
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
samba-tool dns add {{ samba_ad_dc_ip }} {{ samba_ad_reverse_zone }}
|
||||
{{ samba_ad_dc_ip.split('.')[3] }} PTR {{ samba_ad_dc_hostname }}.{{ samba_ad_realm | lower }}.
|
||||
-U administrator%{{ samba_ad_admin_password }}
|
||||
when: samba_ad_reverse_zone | length > 0
|
||||
register: ptr_add
|
||||
changed_when: "'Record added successfully' in (ptr_add.stdout | default(''))"
|
||||
failed_when:
|
||||
- ptr_add.rc != 0
|
||||
- "'already exists' not in (ptr_add.stderr | default('')) + (ptr_add.stdout | default(''))"
|
||||
no_log: true
|
||||
|
||||
# --- KMS auto-activation records (_vlmcs SRV → vlmcsd) --------------------------
|
||||
- name: Register the KMS host A record (SRV target)
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
samba-tool dns add {{ samba_ad_dc_ip }} {{ samba_ad_realm | lower }}
|
||||
{{ samba_ad_kms_hostname }} A {{ samba_ad_kms_host_ip }}
|
||||
-U administrator%{{ samba_ad_admin_password }}
|
||||
when: samba_ad_kms_host_ip | length > 0
|
||||
register: kms_a
|
||||
changed_when: "'Record added successfully' in (kms_a.stdout | default(''))"
|
||||
failed_when:
|
||||
- kms_a.rc != 0
|
||||
- "'already exists' not in (kms_a.stderr | default('')) + (kms_a.stdout | default(''))"
|
||||
no_log: true
|
||||
|
||||
- name: Register the _vlmcs._tcp SRV record for KMS auto-discovery
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
samba-tool dns add {{ samba_ad_dc_ip }} {{ samba_ad_realm | lower }}
|
||||
_vlmcs._tcp SRV "{{ samba_ad_kms_hostname }}.{{ samba_ad_realm | lower }} {{ samba_ad_kms_port }} 0 100"
|
||||
-U administrator%{{ samba_ad_admin_password }}
|
||||
when: samba_ad_kms_host_ip | length > 0
|
||||
register: kms_srv
|
||||
changed_when: "'Record added successfully' in (kms_srv.stdout | default(''))"
|
||||
failed_when:
|
||||
- kms_srv.rc != 0
|
||||
- "'already exists' not in (kms_srv.stderr | default('')) + (kms_srv.stdout | default(''))"
|
||||
no_log: true
|
||||
|
||||
# --- Extra A records for non-domain hosts (e.g. OpenBao) -----------------------
|
||||
- name: Service accounts and RBAC groups
|
||||
ansible.builtin.import_tasks: directory_objects.yml
|
||||
tags: [directory, accounts]
|
||||
|
||||
- name: Register extra A records in the AD DNS zone
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
samba-tool dns add {{ samba_ad_dc_ip }} {{ samba_ad_realm | lower }}
|
||||
{{ item.name }} A {{ item.ip }} -P
|
||||
loop: "{{ samba_ad_extra_a_records }}"
|
||||
register: extra_a
|
||||
changed_when: "'Record added successfully' in (extra_a.stdout | default(''))"
|
||||
failed_when:
|
||||
- extra_a.rc != 0
|
||||
- "'already exists' not in (extra_a.stderr | default('')) + (extra_a.stdout | default(''))"
|
||||
tags: [dns]
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
# Replace Samba's default self-signed LDAPS certificate with one that has a POSITIVE
|
||||
# serial number and proper SANs. Samba's auto-generated cert uses a negative serial,
|
||||
# which Go 1.23+ LDAP clients (Authelia 4.39, etc.) reject at PARSE time with
|
||||
# "x509: negative serial number" — so tls.skip_verify on the client can't help.
|
||||
# Idempotent via community.crypto (only regenerates when inputs change).
|
||||
|
||||
- name: Ensure python cryptography is present (for community.crypto)
|
||||
ansible.builtin.apt:
|
||||
name: python3-cryptography
|
||||
state: present
|
||||
|
||||
# --- Internal CA ---
|
||||
- name: CA private key
|
||||
community.crypto.openssl_privatekey:
|
||||
path: /var/lib/samba/private/tls/ca-key.pem
|
||||
size: 4096
|
||||
mode: "0600"
|
||||
|
||||
- name: CA CSR (carries subject + CA basic constraints)
|
||||
community.crypto.openssl_csr:
|
||||
path: /var/lib/samba/private/tls/ca.csr
|
||||
privatekey_path: /var/lib/samba/private/tls/ca-key.pem
|
||||
common_name: "ddupan-ad-ca"
|
||||
basic_constraints:
|
||||
- "CA:TRUE"
|
||||
basic_constraints_critical: true
|
||||
use_common_name_for_san: false
|
||||
|
||||
- name: CA certificate (self-signed, positive serial)
|
||||
community.crypto.x509_certificate:
|
||||
path: /var/lib/samba/private/tls/ca.pem
|
||||
csr_path: /var/lib/samba/private/tls/ca.csr
|
||||
privatekey_path: /var/lib/samba/private/tls/ca-key.pem
|
||||
provider: selfsigned
|
||||
selfsigned_not_after: "+3650d"
|
||||
mode: "0644"
|
||||
notify: restart samba-ad-dc
|
||||
|
||||
# --- Server (LDAPS) cert signed by our CA ---
|
||||
- name: Server private key
|
||||
community.crypto.openssl_privatekey:
|
||||
path: /var/lib/samba/private/tls/key.pem
|
||||
size: 4096
|
||||
mode: "0600"
|
||||
|
||||
- name: Server CSR (FQDN + SANs)
|
||||
community.crypto.openssl_csr:
|
||||
path: /var/lib/samba/private/tls/server.csr
|
||||
privatekey_path: /var/lib/samba/private/tls/key.pem
|
||||
common_name: "{{ samba_ad_dc_hostname }}.{{ samba_ad_realm | lower }}"
|
||||
subject_alt_name:
|
||||
- "DNS:{{ samba_ad_dc_hostname }}.{{ samba_ad_realm | lower }}"
|
||||
- "DNS:{{ samba_ad_realm | lower }}"
|
||||
- "IP:{{ samba_ad_dc_ip }}"
|
||||
|
||||
- name: Server certificate signed by our CA (positive serial)
|
||||
community.crypto.x509_certificate:
|
||||
path: /var/lib/samba/private/tls/cert.pem
|
||||
csr_path: /var/lib/samba/private/tls/server.csr
|
||||
ownca_path: /var/lib/samba/private/tls/ca.pem
|
||||
ownca_privatekey_path: /var/lib/samba/private/tls/ca-key.pem
|
||||
provider: ownca
|
||||
ownca_not_after: "+3650d"
|
||||
mode: "0644"
|
||||
notify: restart samba-ad-dc
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
# Smoke tests — run via the `verify` tag: ansible-playbook provision-dc.yml --tags verify
|
||||
# Fails the play if the DC is not answering LDAP/Kerberos/DNS/SMB correctly.
|
||||
|
||||
- name: LDAP / domain level responds
|
||||
ansible.builtin.command: samba-tool domain level show
|
||||
changed_when: false
|
||||
|
||||
- name: DNS SRV record for LDAP resolves
|
||||
ansible.builtin.command: "host -t SRV _ldap._tcp.{{ samba_ad_realm | lower }}."
|
||||
register: srv_ldap
|
||||
changed_when: false
|
||||
failed_when: "'has SRV record' not in srv_ldap.stdout"
|
||||
|
||||
- name: DNS SRV record for Kerberos resolves
|
||||
ansible.builtin.command: "host -t SRV _kerberos._udp.{{ samba_ad_realm | lower }}."
|
||||
register: srv_krb
|
||||
changed_when: false
|
||||
failed_when: "'has SRV record' not in srv_krb.stdout"
|
||||
|
||||
- name: DC A record resolves to its own IP
|
||||
ansible.builtin.command: "host -t A {{ samba_ad_dc_hostname }}.{{ samba_ad_realm | lower }}."
|
||||
register: dc_a
|
||||
changed_when: false
|
||||
failed_when: samba_ad_dc_ip not in dc_a.stdout
|
||||
|
||||
- name: SMB default shares are listable
|
||||
ansible.builtin.command: "smbclient -L localhost -U administrator%{{ samba_ad_admin_password }}"
|
||||
register: smb_list
|
||||
changed_when: false
|
||||
no_log: true
|
||||
failed_when: "'sysvol' not in (smb_list.stdout | lower)"
|
||||
|
||||
- name: Report
|
||||
ansible.builtin.debug:
|
||||
msg: "Samba AD DC smoke tests passed: LDAP + Kerberos SRV + DC A record + SMB shares OK."
|
||||
@@ -0,0 +1,80 @@
|
||||
---
|
||||
# samba_member role defaults — Samba as an Active Directory DOMAIN MEMBER (fileserver).
|
||||
# Joins the box to the domain provisioned by the samba_ad_dc role and lets AD users
|
||||
# authenticate to its SMB shares via winbind. The existing standalone shares are
|
||||
# preserved (see samba_member_shares below) and the machine's own login stack is
|
||||
# left untouched (NSS gains winbind, but PAM is NOT modified — no AD OS login).
|
||||
#
|
||||
# Domain identity (samba_ad_realm / samba_ad_domain / samba_ad_dc_ip) is inherited
|
||||
# from group_vars/all/vars.yml — the same values the DC was provisioned with.
|
||||
|
||||
# NetBIOS name of THIS member (<=15 chars, uppercase). Defaults to the short hostname.
|
||||
samba_member_netbios_name: "{{ ansible_facts['hostname'] | upper }}"
|
||||
|
||||
# --- ID mapping ---------------------------------------------------------------
|
||||
# Algorithmic RID backend: deterministic UIDs/GIDs from the AD RID, no RFC2307
|
||||
# attributes required. (The DC was provisioned --use-rfc2307, so switching to the
|
||||
# `ad` backend for centrally-managed uidNumber/gidNumber is possible later — see README.)
|
||||
samba_member_idmap_default_range: "3000-7999" # idmap config * (local/builtin)
|
||||
samba_member_idmap_domain_range: "10000-999999" # idmap config DOMAIN (rid)
|
||||
|
||||
# Strip the DOMAIN\ prefix so AD users appear as bare names (e.g. `alice`, not
|
||||
# `DDUPAN\alice`). Only shadows a local account if an AD user shares its name.
|
||||
samba_member_use_default_domain: true
|
||||
|
||||
# Shell/home template applied to AD users by winbind.
|
||||
samba_member_template_shell: "/bin/bash"
|
||||
samba_member_template_homedir: "/home/%U"
|
||||
|
||||
# --- Split-DNS ----------------------------------------------------------------
|
||||
# The domain member MUST resolve _ldap._tcp / _kerberos._udp SRV records for the
|
||||
# realm to discover the DC. The LAN router does NOT forward ad.ddupan.top here, so
|
||||
# route just the realm to the DC via a systemd-resolved drop-in. Set false if your
|
||||
# resolver already answers realm SRV lookups.
|
||||
samba_member_configure_split_dns: true
|
||||
|
||||
# --- Packages -----------------------------------------------------------------
|
||||
# Deliberately NO libpam-winbind: this is a fileserver join, not an OS-login join.
|
||||
# libnss-winbind IS included so `getent passwd` / `ls -l` resolve AD owners.
|
||||
#
|
||||
# Cache refresh is OFF by default: `apt update` refreshes EVERY repo in
|
||||
# sources.list.d, and this host's upstream DNS forwarder is unreliable, so a full
|
||||
# refresh routinely fails. The member packages are standard Ubuntu main and are
|
||||
# already in the local cache. Set true (with `-e samba_member_apt_update_cache=true`)
|
||||
# to force a refresh when DNS is healthy and you need newer versions.
|
||||
samba_member_apt_update_cache: false
|
||||
|
||||
samba_member_packages:
|
||||
- samba
|
||||
- winbind
|
||||
- libnss-winbind
|
||||
- krb5-user # kinit/klist for the join + smoke tests
|
||||
- smbclient
|
||||
- ldb-tools
|
||||
|
||||
# --- Preserved shares ---------------------------------------------------------
|
||||
# Custom shares carried over verbatim from the pre-join standalone smb.conf.
|
||||
# [printers]/[print$] and the Cockpit `include` are emitted by the template itself.
|
||||
samba_member_shares:
|
||||
# Guests (e.g. WinPE) get read-only access; the authenticated AD user panxiao81
|
||||
# can write (write list). `guest only` is intentionally NOT set — that would force
|
||||
# EVERY session to guest and defeat the write list. force user keeps all files
|
||||
# owned by the local panxiao81 (uid 1000) that owns /mnt/pool/win.
|
||||
- name: win
|
||||
options:
|
||||
comment: Windows install media (guest RO; panxiao81 RW)
|
||||
path: /mnt/pool/win
|
||||
browseable: "yes"
|
||||
read only: "yes"
|
||||
guest ok: "yes"
|
||||
# Domain-qualified: a LOCAL panxiao81 (uid 1000) also exists, and a bare name
|
||||
# resolves to the local SID, which won't match the connected AD user's SID.
|
||||
write list: 'DDUPAN\panxiao81'
|
||||
force user: panxiao81
|
||||
hosts allow: 192.168.10.0/24 127.0.0.1
|
||||
create mask: "0644"
|
||||
directory mask: "0755"
|
||||
# Files are written 0644 (no Unix +x). Since Samba 4.0, "open for execution"
|
||||
# is denied without the execute bit, which blocks running setup.exe/dism off the
|
||||
# share from WinPE. Allow execution regardless of the mode bit.
|
||||
acl allow execute always: "yes"
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
- name: restart winbind
|
||||
ansible.builtin.systemd:
|
||||
name: winbind
|
||||
state: restarted
|
||||
when: not ansible_check_mode # unit only exists after the package install (skipped under --check)
|
||||
|
||||
- name: restart smbd
|
||||
ansible.builtin.systemd:
|
||||
name: smbd
|
||||
state: restarted
|
||||
|
||||
- name: restart nmbd
|
||||
ansible.builtin.systemd:
|
||||
name: nmbd
|
||||
state: restarted
|
||||
failed_when: false # nmbd may be masked/absent on some setups
|
||||
|
||||
- name: restart systemd-resolved
|
||||
ansible.builtin.systemd:
|
||||
name: systemd-resolved
|
||||
state: restarted
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
galaxy_info:
|
||||
role_name: samba_member
|
||||
description: >-
|
||||
Join a host to the Samba AD domain as a member fileserver (security = ADS)
|
||||
with winbind identity mapping, preserving its existing SMB shares.
|
||||
license: MIT
|
||||
min_ansible_version: "2.15"
|
||||
platforms:
|
||||
- name: Ubuntu
|
||||
versions:
|
||||
- noble
|
||||
dependencies: []
|
||||
@@ -0,0 +1,171 @@
|
||||
---
|
||||
# Join this host to the AD domain as a Samba member (fileserver).
|
||||
# Idempotent: the actual `net ads join` runs only when `net ads testjoin` fails,
|
||||
# so re-running the play against an already-joined member is a no-op there.
|
||||
#
|
||||
# Ordering matters: packages → krb5.conf → split-DNS (so realm SRV resolves) →
|
||||
# smb.conf + nsswitch → join → start winbind. The join needs working realm DNS,
|
||||
# a valid krb5.conf, and <5 min clock skew (verified separately) to reach the KDC.
|
||||
|
||||
- name: Assert required variables are set
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- samba_ad_realm | length > 0
|
||||
- samba_ad_domain | length > 0
|
||||
- samba_ad_dc_ip | length > 0
|
||||
- samba_member_netbios_name | length > 0
|
||||
- samba_member_netbios_name | length <= 15
|
||||
- samba_ad_admin_password is defined
|
||||
- samba_ad_admin_password | length >= 8
|
||||
fail_msg: >-
|
||||
Need samba_ad_realm/domain/dc_ip, a <=15 char samba_member_netbios_name,
|
||||
and samba_ad_admin_password from the vault.
|
||||
|
||||
- name: Install Samba member + winbind packages
|
||||
ansible.builtin.apt:
|
||||
name: "{{ samba_member_packages }}"
|
||||
state: present
|
||||
update_cache: "{{ samba_member_apt_update_cache | bool }}"
|
||||
register: apt_install
|
||||
until: apt_install is succeeded
|
||||
retries: 3
|
||||
delay: 5
|
||||
|
||||
- name: Warn if the clock is not NTP-synchronised (Kerberos fails on >5min skew)
|
||||
ansible.builtin.command: timedatectl show -p NTPSynchronized --value
|
||||
register: ntp_synced
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false # read-only probe; must run even under --check
|
||||
|
||||
- name: Assert time is synchronised before attempting the Kerberos join
|
||||
ansible.builtin.assert:
|
||||
that: ntp_synced.stdout | trim == 'yes'
|
||||
fail_msg: >-
|
||||
Clock is not NTP-synchronised — the Kerberos join will fail on skew.
|
||||
Fix time sync (systemd-timesyncd/chrony) first.
|
||||
success_msg: "Clock is NTP-synchronised."
|
||||
|
||||
- name: Install the Kerberos client config for the realm
|
||||
ansible.builtin.template:
|
||||
src: krb5.conf.j2
|
||||
dest: /etc/krb5.conf
|
||||
mode: "0644"
|
||||
backup: true
|
||||
|
||||
# --- Split-DNS: route the realm to the DC so SRV discovery works ----------------
|
||||
- name: Ensure resolved.conf.d drop-in directory exists
|
||||
ansible.builtin.file:
|
||||
path: /etc/systemd/resolved.conf.d
|
||||
state: directory
|
||||
mode: "0755"
|
||||
when: samba_member_configure_split_dns | bool
|
||||
|
||||
- name: Route the AD realm to the DC via systemd-resolved (split-DNS)
|
||||
ansible.builtin.copy:
|
||||
dest: /etc/systemd/resolved.conf.d/ad-realm.conf
|
||||
content: |
|
||||
# MANAGED BY ANSIBLE (role: samba_member)
|
||||
# Send {{ samba_ad_realm | lower }} lookups to the DC; everything else stays
|
||||
# on the per-link resolver. Needed because the LAN router does not forward the realm.
|
||||
[Resolve]
|
||||
DNS={{ samba_ad_dc_ip }}
|
||||
Domains=~{{ samba_ad_realm | lower }}
|
||||
mode: "0644"
|
||||
when: samba_member_configure_split_dns | bool
|
||||
register: dns_dropin
|
||||
|
||||
- name: Restart systemd-resolved so realm DNS is live before the join
|
||||
ansible.builtin.systemd:
|
||||
name: systemd-resolved
|
||||
state: restarted
|
||||
when: dns_dropin is changed
|
||||
|
||||
- name: Verify the realm's LDAP SRV record now resolves
|
||||
ansible.builtin.command: "host -t SRV _ldap._tcp.{{ samba_ad_realm | lower }}."
|
||||
register: srv_check
|
||||
changed_when: false
|
||||
retries: 5
|
||||
delay: 2
|
||||
until: "'has SRV record' in srv_check.stdout"
|
||||
failed_when: "'has SRV record' not in srv_check.stdout"
|
||||
when: not ansible_check_mode # drop-in isn't really written under --check, so skip the probe
|
||||
|
||||
# --- Samba config -------------------------------------------------------------
|
||||
- name: Back up the existing (standalone) smb.conf once
|
||||
ansible.builtin.copy:
|
||||
src: /etc/samba/smb.conf
|
||||
dest: /etc/samba/smb.conf.pre-ads
|
||||
remote_src: true
|
||||
force: false # never clobber the original backup on re-runs
|
||||
mode: "0644"
|
||||
failed_when: false # tolerate a missing original
|
||||
|
||||
- name: Deploy the AD-member smb.conf
|
||||
ansible.builtin.template:
|
||||
src: smb.conf.j2
|
||||
dest: /etc/samba/smb.conf
|
||||
mode: "0644"
|
||||
validate: "testparm -s %s"
|
||||
notify:
|
||||
- restart smbd
|
||||
- restart nmbd
|
||||
- restart winbind
|
||||
|
||||
- name: Add winbind to NSS passwd/group (resolve AD owners; no PAM/login change)
|
||||
ansible.builtin.lineinfile:
|
||||
path: /etc/nsswitch.conf
|
||||
regexp: '^{{ item }}:(?!.*winbind)(.*)$'
|
||||
line: '{{ item }}:\1 winbind'
|
||||
backrefs: true
|
||||
loop:
|
||||
- passwd
|
||||
- group
|
||||
|
||||
# --- Join ---------------------------------------------------------------------
|
||||
- name: Check whether the host is already joined
|
||||
ansible.builtin.command: net ads testjoin
|
||||
register: testjoin
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
check_mode: false # read-only; keep testjoin.rc defined under --check
|
||||
|
||||
- name: Join the Active Directory domain as a member
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- net
|
||||
- ads
|
||||
- join
|
||||
- "-U"
|
||||
- "administrator%{{ samba_ad_admin_password }}"
|
||||
when: testjoin.rc != 0
|
||||
no_log: true
|
||||
register: ads_join
|
||||
changed_when: "'Joined' in (ads_join.stdout | default(''))"
|
||||
|
||||
- name: Enable and (re)start winbind now that we are joined
|
||||
ansible.builtin.systemd:
|
||||
name: winbind
|
||||
state: started
|
||||
enabled: true
|
||||
when: not ansible_check_mode # unit only exists once the package is really installed
|
||||
|
||||
- name: Ensure smbd/nmbd are enabled and running
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ item }}"
|
||||
state: started
|
||||
enabled: true
|
||||
loop:
|
||||
- smbd
|
||||
- nmbd
|
||||
failed_when: false # nmbd optional
|
||||
when: not ansible_check_mode
|
||||
|
||||
# Apply pending smbd/nmbd/winbind restarts from the smb.conf change.
|
||||
- name: Flush handlers
|
||||
ansible.builtin.meta: flush_handlers
|
||||
|
||||
- name: Smoke tests
|
||||
ansible.builtin.import_tasks: verify.yml
|
||||
when: not ansible_check_mode # nothing is really joined under --check
|
||||
tags: [verify]
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
# Post-join smoke tests. Run standalone with:
|
||||
# ansible-playbook join-member.yml --tags verify
|
||||
|
||||
- name: Secure channel to the DC is healthy (net ads testjoin)
|
||||
ansible.builtin.command: net ads testjoin
|
||||
register: v_testjoin
|
||||
changed_when: false
|
||||
failed_when: "'Join is OK' not in v_testjoin.stdout"
|
||||
|
||||
- name: winbind can reach the domain (wbinfo -p / --online-status)
|
||||
ansible.builtin.command: wbinfo -P
|
||||
register: v_wbping
|
||||
changed_when: false
|
||||
failed_when: v_wbping.rc != 0
|
||||
|
||||
- name: Domain users are enumerable via winbind
|
||||
ansible.builtin.command: wbinfo -u
|
||||
register: v_wbusers
|
||||
changed_when: false
|
||||
failed_when: v_wbusers.rc != 0
|
||||
|
||||
- name: AD Administrator resolves through NSS (winbind idmap works)
|
||||
ansible.builtin.command: >-
|
||||
getent passwd {{ 'administrator' if samba_member_use_default_domain
|
||||
else samba_ad_domain ~ '\\administrator' }}
|
||||
register: v_getent
|
||||
changed_when: false
|
||||
failed_when: v_getent.rc != 0
|
||||
|
||||
- name: smbd is serving our shares (guest listing includes [win])
|
||||
ansible.builtin.command: smbclient -L localhost -N
|
||||
register: v_shares
|
||||
changed_when: false
|
||||
failed_when: "'win' not in (v_shares.stdout | lower)"
|
||||
|
||||
- name: Report
|
||||
ansible.builtin.debug:
|
||||
msg: >-
|
||||
Member join OK: secure channel up, winbind online, AD users resolve via NSS,
|
||||
and smbd is serving shares. Resolved Administrator ->
|
||||
{{ v_getent.stdout | default('n/a') }}
|
||||
@@ -0,0 +1,5 @@
|
||||
# MANAGED BY ANSIBLE (role: samba_member) — do not edit.
|
||||
[libdefaults]
|
||||
default_realm = {{ samba_ad_realm }}
|
||||
dns_lookup_realm = false
|
||||
dns_lookup_kdc = true
|
||||
@@ -0,0 +1,72 @@
|
||||
#
|
||||
# smb.conf — Samba as an Active Directory DOMAIN MEMBER (fileserver).
|
||||
# MANAGED BY ANSIBLE (role: samba_member). Manual edits WILL be overwritten.
|
||||
# Converted from the previous standalone config, backed up to smb.conf.pre-ads.
|
||||
#
|
||||
[global]
|
||||
# --- AD domain membership ---
|
||||
workgroup = {{ samba_ad_domain }}
|
||||
realm = {{ samba_ad_realm }}
|
||||
security = ADS
|
||||
netbios name = {{ samba_member_netbios_name }}
|
||||
server string = %h server (Samba, Ubuntu)
|
||||
|
||||
# Authenticate against the DC; keep the machine-account keytab in sync.
|
||||
kerberos method = secrets and keytab
|
||||
winbind refresh tickets = yes
|
||||
winbind use default domain = {{ 'yes' if samba_member_use_default_domain else 'no' }}
|
||||
winbind offline logon = yes
|
||||
winbind enum users = no
|
||||
winbind enum groups = no
|
||||
|
||||
# --- ID mapping (algorithmic RID — no RFC2307 attrs required) ---
|
||||
idmap config * : backend = tdb
|
||||
idmap config * : range = {{ samba_member_idmap_default_range }}
|
||||
idmap config {{ samba_ad_domain }} : backend = rid
|
||||
idmap config {{ samba_ad_domain }} : range = {{ samba_member_idmap_domain_range }}
|
||||
|
||||
template shell = {{ samba_member_template_shell }}
|
||||
template homedir = {{ samba_member_template_homedir }}
|
||||
|
||||
# --- logging ---
|
||||
log file = /var/log/samba/log.%m
|
||||
max log size = 1000
|
||||
logging = file
|
||||
panic action = /usr/share/samba/panic-action %d
|
||||
|
||||
# --- guest handling (kept for the guest [win] share) ---
|
||||
map to guest = bad user
|
||||
usershare allow guests = yes
|
||||
|
||||
# Cockpit-managed ZFS shares (file created on demand by Cockpit; skipped if absent).
|
||||
include = /etc/cockpit/zfs/shares.conf
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Printer sharing (stock Ubuntu defaults, preserved)
|
||||
# ---------------------------------------------------------------------------
|
||||
[printers]
|
||||
comment = All Printers
|
||||
browseable = no
|
||||
path = /var/tmp
|
||||
printable = yes
|
||||
guest ok = no
|
||||
read only = yes
|
||||
create mask = 0700
|
||||
|
||||
[print$]
|
||||
comment = Printer Drivers
|
||||
path = /var/lib/samba/printers
|
||||
browseable = yes
|
||||
read only = yes
|
||||
guest ok = no
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Custom shares (preserved from the pre-join standalone config)
|
||||
# ---------------------------------------------------------------------------
|
||||
{% for s in samba_member_shares %}
|
||||
[{{ s.name }}]
|
||||
{% for k, v in s.options.items() %}
|
||||
{{ k }} = {{ v }}
|
||||
{% endfor %}
|
||||
|
||||
{% endfor %}
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
# win_ca_trust role defaults.
|
||||
#
|
||||
# Windows counterpart to services/proxmox/ansible/roles/pve_ca_trust: installs the
|
||||
# homelab's internal CA into the machine-wide Trusted Root store so browsers and
|
||||
# .NET/PowerShell on this box validate certificates issued by OpenBao's pki/ mount.
|
||||
#
|
||||
# WHY it is needed now: the Proxmox web UI (:8006) is moving from its self-signed
|
||||
# cluster cert to one issued by the internal CA (services/proxmox, role pve_acme).
|
||||
# The internal CA is deliberately NOT publicly trusted, so every machine that
|
||||
# administers the lab has to be told about it once. Same applies to bao's own PKI
|
||||
# consumers and anything else issued off ad.ddupan.top.
|
||||
|
||||
# Unauthenticated by design -- the CA is public information, so no token is needed
|
||||
# and a CA rotation is picked up simply by re-running this role.
|
||||
win_ca_trust_url: https://bao.ad.ddupan.top:8200/v1/pki/ca/pem
|
||||
|
||||
# Where the PEM is staged on disk. Kept on disk (rather than imported and deleted)
|
||||
# so the installed anchor is auditable and the next run can diff against it.
|
||||
win_ca_trust_dir: C:\ProgramData\ddupan
|
||||
win_ca_trust_file: C:\ProgramData\ddupan\ddupan-internal-ca.crt
|
||||
|
||||
# LocalMachine\Root = trusted by every user on the box, including services.
|
||||
# CurrentUser would only cover the account Ansible happens to connect as.
|
||||
win_ca_trust_store_location: LocalMachine
|
||||
win_ca_trust_store_name: Root
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
# Install the ddupan.top internal CA into LocalMachine\Root on the Windows box.
|
||||
# Requires collection: ansible.windows.
|
||||
|
||||
# Fetched on the CONTROL NODE, not the target. Two reasons: the content can be
|
||||
# sanity-checked before anything is written to the trust store, and it keeps the
|
||||
# fetch off a host whose proxy/TLS settings we do not manage.
|
||||
- name: Fetch the internal CA from OpenBao
|
||||
ansible.builtin.uri:
|
||||
url: "{{ win_ca_trust_url }}"
|
||||
return_content: true
|
||||
# bao serves a real Let's Encrypt cert (openbao_acme role), so normal
|
||||
# verification works here -- do NOT relax this.
|
||||
validate_certs: true
|
||||
register: _bao_ca
|
||||
delegate_to: localhost
|
||||
changed_when: false
|
||||
# uri does not support check mode and would otherwise skip, leaving every later
|
||||
# task with an empty register. Fetching is read-only, so running it is safe.
|
||||
check_mode: false
|
||||
# The WAN is unreliable and bao is a VM that may still be unsealing.
|
||||
retries: 3
|
||||
delay: 10
|
||||
until: _bao_ca is succeeded
|
||||
|
||||
- name: Sanity-check that we actually got a CA certificate
|
||||
# Without this, a captive-portal HTML page or an error body would be installed
|
||||
# as a trust anchor -- silently, and in the store that matters most.
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "'BEGIN CERTIFICATE' in _bao_ca.content"
|
||||
fail_msg: "OpenBao did not return a PEM certificate -- refusing to install it as a trust anchor."
|
||||
quiet: true
|
||||
|
||||
- name: Ensure the staging directory exists
|
||||
ansible.windows.win_file:
|
||||
path: "{{ win_ca_trust_dir }}"
|
||||
state: directory
|
||||
|
||||
- name: Stage the CA certificate on the target
|
||||
ansible.windows.win_copy:
|
||||
content: "{{ _bao_ca.content }}"
|
||||
dest: "{{ win_ca_trust_file }}"
|
||||
|
||||
- name: Install the CA into the machine-wide Trusted Root store
|
||||
# Idempotent: the module matches on thumbprint, so a re-run is a no-op.
|
||||
#
|
||||
# NOTE ON ROTATION: this ADDS a trust anchor, it does not replace one. If the
|
||||
# internal CA is ever re-keyed, the superseded certificate stays in the store
|
||||
# and must be removed explicitly (state: absent with its thumbprint). That is
|
||||
# deliberate -- silently dropping the old anchor mid-rotation would break every
|
||||
# certificate still chaining to it.
|
||||
ansible.windows.win_certificate_store:
|
||||
path: "{{ win_ca_trust_file }}"
|
||||
store_location: "{{ win_ca_trust_store_location }}"
|
||||
store_name: "{{ win_ca_trust_store_name }}"
|
||||
state: present
|
||||
register: _ca_store
|
||||
|
||||
- name: Report the installed thumbprint
|
||||
ansible.builtin.debug:
|
||||
msg: "Trusted root installed: {{ _ca_store.thumbprints | default([]) | join(', ') }}"
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
# win_domain_join role defaults.
|
||||
# Target host must be reachable over WinRM (see inventory/hosts.yml windows group).
|
||||
|
||||
win_dc_ip: "10.10.10.10" # DC IP — becomes the box's primary DNS
|
||||
win_domain_dns_name: "ad.example.com" # lowercase DNS domain to join
|
||||
win_domain_admin_user: "EXAMPLE\\Administrator"
|
||||
# win_domain_admin_password comes from vault.
|
||||
|
||||
# RSAT for the GPO/AD management station. On Windows SERVER these are Features
|
||||
# (Install-WindowsFeature), NOT the client "Rsat.*~~~~" capabilities.
|
||||
win_rsat_features:
|
||||
- GPMC # Group Policy Management Console
|
||||
- RSAT-AD-Tools # ADUC / ADAC / AD PowerShell
|
||||
- RSAT-DNS-Server # DNS console
|
||||
|
||||
# KMS activation. Default relies on the _vlmcs._tcp SRV record in AD DNS for
|
||||
# auto-discovery (see samba_ad_dc role) — the GVLK is already baked in by autounattend.
|
||||
win_activate: true
|
||||
win_kms_host: "" # optional explicit "host:1688" override; empty = SRV auto-discovery
|
||||
win_kms_client_key: "" # optional GVLK /ipk; empty = already installed at build time
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
# Join the Windows admin box to the domain, install RSAT, activate against KMS.
|
||||
# Requires collections: ansible.windows, community.windows.
|
||||
|
||||
- name: Point primary DNS at the DC (mandatory before join)
|
||||
ansible.windows.win_dns_client:
|
||||
adapter_names: "*"
|
||||
dns_servers:
|
||||
- "{{ win_dc_ip }}"
|
||||
|
||||
- name: Join the Active Directory domain (reboots automatically)
|
||||
microsoft.ad.membership:
|
||||
dns_domain_name: "{{ win_domain_dns_name }}"
|
||||
domain_admin_user: "{{ win_domain_admin_user }}"
|
||||
domain_admin_password: "{{ win_domain_admin_password }}"
|
||||
state: domain
|
||||
reboot: true
|
||||
no_log: true
|
||||
|
||||
- name: Install RSAT management features (GPMC, ADUC, DNS console) — Server SKU
|
||||
ansible.windows.win_feature:
|
||||
name: "{{ win_rsat_features }}"
|
||||
state: present
|
||||
include_management_tools: true
|
||||
register: rsat_feature
|
||||
|
||||
- name: Reboot if an RSAT feature asked for it
|
||||
ansible.windows.win_reboot:
|
||||
when: rsat_feature.reboot_required | default(false)
|
||||
|
||||
- name: KMS activation (SRV auto-discovery unless a host is pinned)
|
||||
when: win_activate | bool
|
||||
block:
|
||||
- name: Install GVLK (only if explicitly provided; normally already baked in)
|
||||
ansible.windows.win_command: "cscript //nologo C:\\Windows\\System32\\slmgr.vbs /ipk {{ win_kms_client_key }}"
|
||||
when: win_kms_client_key | length > 0
|
||||
changed_when: true
|
||||
|
||||
- name: Pin KMS host (only if overriding the _vlmcs SRV auto-discovery)
|
||||
ansible.windows.win_command: "cscript //nologo C:\\Windows\\System32\\slmgr.vbs /skms {{ win_kms_host }}"
|
||||
when: win_kms_host | length > 0
|
||||
changed_when: true
|
||||
|
||||
- name: Activate (discovers KMS via _vlmcs._tcp SRV in AD DNS)
|
||||
ansible.windows.win_command: "cscript //nologo C:\\Windows\\System32\\slmgr.vbs /ato"
|
||||
register: slmgr_ato
|
||||
changed_when: true
|
||||
failed_when: false # first /ato can race the domain DNS; not fatal
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
# windows_vm role — create a fresh Windows Server 2025 admin box from the ISO,
|
||||
# fully unattended via autounattend.xml, WinRM-ready for join-windows.yml to finish.
|
||||
#
|
||||
# Uses virtio disk (viostor) + virtio NIC (NetKVM): the drivers are injected during
|
||||
# Setup via autounattend DriverPaths (from the virtio-win ISO), and the full
|
||||
# virtio-win guest tools (QEMU guest agent, balloon, …) are installed at first logon.
|
||||
|
||||
win_vm_name: "winadmin"
|
||||
win_vm_vcpus: 4
|
||||
win_vm_memory_mb: 6144
|
||||
win_vm_disk_gb: 60
|
||||
|
||||
# libvirt placement — zvol root disk, presented as SATA (AHCI, native Windows driver).
|
||||
win_vm_zvol_parent: "data/vm"
|
||||
win_vm_zvol: "{{ win_vm_zvol_parent }}/{{ win_vm_name }}"
|
||||
win_vm_zvol_dev: "/dev/zvol/{{ win_vm_zvol }}"
|
||||
win_vm_zvol_volblocksize: "16K"
|
||||
win_vm_bridge: "br0"
|
||||
|
||||
# Install media (already in your iso pool)
|
||||
win_vm_iso: "/mnt/pool/proxmox/template/iso/zh-cn_windows_server_2025_updated_aug_2025_x64_dvd_9236d79b.iso"
|
||||
# virtio-win drivers + guest tools — injected during Setup, guest tools run at first logon
|
||||
win_vm_virtio_iso: "/mnt/pool/proxmox/template/iso/virtio-win-1.9.45.iso"
|
||||
win_vm_virtio_ver: "2k25" # driver subfolder for Server 2025
|
||||
|
||||
# Edition to install from install.wim. VERIFY with:
|
||||
# dism /Get-WimInfo /WimFile:<mounted>\sources\install.wim
|
||||
# Typical Server 2025 indexes: 1=Std Core, 2=Std Desktop, 3=DC Core, 4=DC Desktop.
|
||||
win_vm_image_index: 2 # Standard (Desktop Experience) — GPMC needs Desktop
|
||||
|
||||
# GVLK (public KMS client key) matching the edition above; activation happens later
|
||||
# in join-windows.yml against vlmcsd. Std=below; DC=D764K-2NDRG-47T6Q-P8T8W-YP6DF
|
||||
win_vm_gvlk: "TVRH6-WHNXV-R9WG3-9XRFY-MY832"
|
||||
|
||||
# Locale (ISO is zh-CN)
|
||||
win_vm_ui_language: "zh-CN"
|
||||
win_vm_system_locale: "zh-CN"
|
||||
win_vm_user_locale: "zh-CN"
|
||||
win_vm_input_locale: "0409:00000409;0804:00000804" # en-US + zh-CN
|
||||
win_vm_timezone: "China Standard Time"
|
||||
|
||||
# Static IP applied by autounattend (so it's reachable at a known address for WinRM).
|
||||
win_vm_ip: "192.168.10.6"
|
||||
win_vm_prefix: 24
|
||||
win_vm_gateway: "192.168.10.1"
|
||||
win_vm_dns: "{{ samba_ad_dc_ip }}" # point Windows at the DC directly (AD requirement)
|
||||
|
||||
# Local Administrator password comes from vault (vault_win_local_admin_password),
|
||||
# surfaced as win_domain_admin_password / win_local_admin_password in group_vars.
|
||||
win_vm_admin_password: "{{ vault_win_local_admin_password }}"
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
# Create + unattended-install a Windows Server 2025 admin box on local libvirt.
|
||||
# Runs on localhost (qemu:///system, become: true). Idempotent on the domain existing.
|
||||
|
||||
- name: Assert the Windows install ISO exists
|
||||
ansible.builtin.stat:
|
||||
path: "{{ win_vm_iso }}"
|
||||
register: win_iso_stat
|
||||
failed_when: not win_iso_stat.stat.exists
|
||||
|
||||
- name: Check whether the libvirt domain already exists
|
||||
ansible.builtin.command: "virsh dominfo {{ win_vm_name }}"
|
||||
register: win_dominfo
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Create the Windows VM
|
||||
when: win_dominfo.rc != 0
|
||||
block:
|
||||
- name: Check whether the root-disk zvol already exists
|
||||
ansible.builtin.command: "zfs list -H -o name {{ win_vm_zvol }}"
|
||||
register: win_zvol_check
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: Create the root-disk zvol
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
zfs create -V {{ win_vm_disk_gb }}G
|
||||
-o volblocksize={{ win_vm_zvol_volblocksize }}
|
||||
{{ win_vm_zvol }}
|
||||
when: win_zvol_check.rc != 0
|
||||
|
||||
- name: Wait for the zvol device node
|
||||
ansible.builtin.wait_for:
|
||||
path: "{{ win_vm_zvol_dev }}"
|
||||
timeout: 30
|
||||
|
||||
- name: Render autounattend.xml
|
||||
ansible.builtin.template:
|
||||
src: autounattend.xml.j2
|
||||
dest: "/var/lib/libvirt/images/{{ win_vm_name }}-autounattend.xml"
|
||||
mode: "0600"
|
||||
no_log: true # contains the local admin password
|
||||
|
||||
- name: Build the autounattend seed ISO (label AUTOUNATTEND)
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
genisoimage -output /var/lib/libvirt/images/{{ win_vm_name }}-unattend.iso
|
||||
-volid AUTOUNATTEND -joliet -rock -graft-points
|
||||
autounattend.xml=/var/lib/libvirt/images/{{ win_vm_name }}-autounattend.xml
|
||||
args:
|
||||
creates: "/var/lib/libvirt/images/{{ win_vm_name }}-unattend.iso"
|
||||
|
||||
- name: Define and start the VM (UEFI, virtio disk + NIC, virtio-win attached)
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
virt-install
|
||||
--name {{ win_vm_name }}
|
||||
--memory {{ win_vm_memory_mb }}
|
||||
--vcpus {{ win_vm_vcpus }}
|
||||
--machine q35
|
||||
--boot loader=/usr/share/OVMF/OVMF_CODE_4M.fd,loader.readonly=yes,loader.type=pflash,nvram.template=/usr/share/OVMF/OVMF_VARS_4M.fd
|
||||
--osinfo require=off,name=win2k25
|
||||
--disk path={{ win_vm_zvol_dev }},format=raw,bus=virtio,boot.order=2
|
||||
--disk path={{ win_vm_iso }},device=cdrom,bus=sata,boot.order=1
|
||||
--disk path=/var/lib/libvirt/images/{{ win_vm_name }}-unattend.iso,device=cdrom,bus=sata
|
||||
--disk path={{ win_vm_virtio_iso }},device=cdrom,bus=sata
|
||||
--network bridge={{ win_vm_bridge }},model=virtio
|
||||
--video vga --graphics vnc,listen=0.0.0.0
|
||||
--noautoconsole --import
|
||||
register: win_virt_install
|
||||
|
||||
- name: Press a key past "Press any key to boot from CD" (first boot only)
|
||||
ansible.builtin.shell: >-
|
||||
for i in $(seq 1 20); do
|
||||
virsh send-key {{ win_vm_name }} --codeset linux KEY_ENTER >/dev/null 2>&1 || true;
|
||||
sleep 2;
|
||||
done
|
||||
changed_when: false
|
||||
|
||||
- name: Wait for WinRM (Windows Setup runs unattended, then configures WinRM)
|
||||
ansible.builtin.wait_for:
|
||||
host: "{{ win_vm_ip }}"
|
||||
port: 5986
|
||||
delay: 60
|
||||
timeout: 2700 # up to 45 min: install + reboots + first-logon commands
|
||||
when: win_dominfo.rc != 0
|
||||
|
||||
- name: Eject install media and remove the (password-bearing) seed ISO
|
||||
when: win_dominfo.rc != 0
|
||||
block:
|
||||
- name: Eject install media (win ISO=sda, unattend=sdb, virtio=sdc)
|
||||
ansible.builtin.command: "virsh change-media {{ win_vm_name }} {{ item }} --eject --config"
|
||||
loop: [sda, sdb, sdc]
|
||||
failed_when: false
|
||||
changed_when: true
|
||||
|
||||
- name: Delete the autounattend seed ISO + rendered answer file
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: absent
|
||||
loop:
|
||||
- "/var/lib/libvirt/images/{{ win_vm_name }}-unattend.iso"
|
||||
- "/var/lib/libvirt/images/{{ win_vm_name }}-autounattend.xml"
|
||||
@@ -0,0 +1,141 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Unattended install for Windows Server 2025 (Desktop Experience), UEFI/GPT.
|
||||
Rendered by the windows_vm role. Contains the local Administrator password in
|
||||
cleartext — it lives only on a local, ephemeral seed ISO that the role removes
|
||||
after install. -->
|
||||
<unattend xmlns="urn:schemas-microsoft-com:unattend">
|
||||
|
||||
<!-- ============ windowsPE: language, disk, image, product key ============ -->
|
||||
<settings pass="windowsPE">
|
||||
<!-- Inject virtio drivers so Setup sees the virtio disk (viostor) and NIC (NetKVM).
|
||||
The virtio CD's drive letter in WinPE is unpredictable, so list candidates. -->
|
||||
<component name="Microsoft-Windows-PnpCustomizationsWinPE" processorArchitecture="amd64"
|
||||
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||
<DriverPaths>
|
||||
{% for L in ['D','E','F','G'] %}
|
||||
<PathAndCredentials wcm:action="add" wcm:keyValue="{{ loop.index }}1" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"><Path>{{ L }}:\viostor\{{ win_vm_virtio_ver }}\amd64</Path></PathAndCredentials>
|
||||
<PathAndCredentials wcm:action="add" wcm:keyValue="{{ loop.index }}2" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"><Path>{{ L }}:\NetKVM\{{ win_vm_virtio_ver }}\amd64</Path></PathAndCredentials>
|
||||
{% endfor %}
|
||||
</DriverPaths>
|
||||
</component>
|
||||
|
||||
<component name="Microsoft-Windows-International-Core-WinPE" processorArchitecture="amd64"
|
||||
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||
<SetupUILanguage><UILanguage>{{ win_vm_ui_language }}</UILanguage></SetupUILanguage>
|
||||
<InputLocale>{{ win_vm_input_locale }}</InputLocale>
|
||||
<SystemLocale>{{ win_vm_system_locale }}</SystemLocale>
|
||||
<UILanguage>{{ win_vm_ui_language }}</UILanguage>
|
||||
<UserLocale>{{ win_vm_user_locale }}</UserLocale>
|
||||
</component>
|
||||
|
||||
<component name="Microsoft-Windows-Setup" processorArchitecture="amd64"
|
||||
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||
<DiskConfiguration>
|
||||
<WillShowUI>OnError</WillShowUI>
|
||||
<Disk wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
|
||||
<DiskID>0</DiskID>
|
||||
<WillWipeDisk>true</WillWipeDisk>
|
||||
<CreatePartitions>
|
||||
<CreatePartition wcm:action="add"><Order>1</Order><Type>EFI</Type><Size>260</Size></CreatePartition>
|
||||
<CreatePartition wcm:action="add"><Order>2</Order><Type>MSR</Type><Size>16</Size></CreatePartition>
|
||||
<CreatePartition wcm:action="add"><Order>3</Order><Type>Primary</Type><Extend>true</Extend></CreatePartition>
|
||||
</CreatePartitions>
|
||||
<ModifyPartitions>
|
||||
<ModifyPartition wcm:action="add"><Order>1</Order><PartitionID>1</PartitionID><Format>FAT32</Format><Label>System</Label></ModifyPartition>
|
||||
<ModifyPartition wcm:action="add"><Order>2</Order><PartitionID>2</PartitionID></ModifyPartition>
|
||||
<ModifyPartition wcm:action="add"><Order>3</Order><PartitionID>3</PartitionID><Format>NTFS</Format><Label>Windows</Label><Letter>C</Letter></ModifyPartition>
|
||||
</ModifyPartitions>
|
||||
</Disk>
|
||||
</DiskConfiguration>
|
||||
<ImageInstall>
|
||||
<OSImage>
|
||||
<InstallTo><DiskID>0</DiskID><PartitionID>3</PartitionID></InstallTo>
|
||||
<InstallFrom>
|
||||
<MetaData wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
|
||||
<Key>/IMAGE/INDEX</Key><Value>{{ win_vm_image_index }}</Value>
|
||||
</MetaData>
|
||||
</InstallFrom>
|
||||
</OSImage>
|
||||
</ImageInstall>
|
||||
<UserData>
|
||||
<ProductKey><Key>{{ win_vm_gvlk }}</Key><WillShowUI>OnError</WillShowUI></ProductKey>
|
||||
<AcceptEula>true</AcceptEula>
|
||||
<FullName>Administrator</FullName>
|
||||
<Organization>ddupan</Organization>
|
||||
</UserData>
|
||||
</component>
|
||||
</settings>
|
||||
|
||||
<!-- ==================== specialize: computer name ==================== -->
|
||||
<settings pass="specialize">
|
||||
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64"
|
||||
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||
<ComputerName>{{ win_vm_name }}</ComputerName>
|
||||
</component>
|
||||
</settings>
|
||||
|
||||
<!-- ============ oobeSystem: admin pw, autologon, first-logon ============ -->
|
||||
<settings pass="oobeSystem">
|
||||
<component name="Microsoft-Windows-International-Core" processorArchitecture="amd64"
|
||||
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||
<InputLocale>{{ win_vm_input_locale }}</InputLocale>
|
||||
<SystemLocale>{{ win_vm_system_locale }}</SystemLocale>
|
||||
<UILanguage>{{ win_vm_ui_language }}</UILanguage>
|
||||
<UserLocale>{{ win_vm_user_locale }}</UserLocale>
|
||||
</component>
|
||||
|
||||
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64"
|
||||
publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||
<OOBE>
|
||||
<HideEULAPage>true</HideEULAPage>
|
||||
<HideLocalAccountScreen>true</HideLocalAccountScreen>
|
||||
<HideOnlineAccountScreens>true</HideOnlineAccountScreens>
|
||||
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
|
||||
<NetworkLocation>Work</NetworkLocation>
|
||||
<ProtectYourPC>3</ProtectYourPC>
|
||||
<SkipMachineOOBE>true</SkipMachineOOBE>
|
||||
<SkipUserOOBE>true</SkipUserOOBE>
|
||||
</OOBE>
|
||||
<TimeZone>{{ win_vm_timezone }}</TimeZone>
|
||||
<UserAccounts>
|
||||
<AdministratorPassword>
|
||||
<Value>{{ win_vm_admin_password }}</Value>
|
||||
<PlainText>true</PlainText>
|
||||
</AdministratorPassword>
|
||||
</UserAccounts>
|
||||
<AutoLogon>
|
||||
<Enabled>true</Enabled>
|
||||
<LogonCount>1</LogonCount>
|
||||
<Username>Administrator</Username>
|
||||
<Password><Value>{{ win_vm_admin_password }}</Value><PlainText>true</PlainText></Password>
|
||||
</AutoLogon>
|
||||
<FirstLogonCommands>
|
||||
<SynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
|
||||
<Order>1</Order>
|
||||
<Description>Static IP + DNS</Description>
|
||||
<CommandLine>powershell -NoProfile -ExecutionPolicy Bypass -Command "$i=(Get-NetAdapter | Where-Object Status -eq 'Up' | Select-Object -First 1).ifIndex; New-NetIPAddress -InterfaceIndex $i -IPAddress {{ win_vm_ip }} -PrefixLength {{ win_vm_prefix }} -DefaultGateway {{ win_vm_gateway }}; Set-DnsClientServerAddress -InterfaceIndex $i -ServerAddresses {{ win_vm_dns }}"</CommandLine>
|
||||
</SynchronousCommand>
|
||||
<SynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
|
||||
<Order>2</Order>
|
||||
<Description>Enable WinRM + HTTPS listener (self-signed)</Description>
|
||||
<CommandLine>powershell -NoProfile -ExecutionPolicy Bypass -Command "Enable-PSRemoting -Force; $c=New-SelfSignedCertificate -DnsName $env:COMPUTERNAME -CertStoreLocation Cert:\LocalMachine\My; New-Item -Path WSMan:\localhost\Listener -Address * -Transport HTTPS -CertificateThumbPrint $c.Thumbprint -Force"</CommandLine>
|
||||
</SynchronousCommand>
|
||||
<SynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
|
||||
<Order>3</Order>
|
||||
<Description>Firewall: allow WinRM HTTPS 5986</Description>
|
||||
<CommandLine>powershell -NoProfile -ExecutionPolicy Bypass -Command "New-NetFirewallRule -DisplayName 'WinRM HTTPS' -Direction Inbound -Protocol TCP -LocalPort 5986 -Action Allow"</CommandLine>
|
||||
</SynchronousCommand>
|
||||
<SynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
|
||||
<Order>4</Order>
|
||||
<Description>Enable Remote Desktop + firewall group</Description>
|
||||
<CommandLine>powershell -NoProfile -ExecutionPolicy Bypass -Command "Set-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -Name fDenyTSConnections -Value 0; Set-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' -Name UserAuthentication -Value 1; Enable-NetFirewallRule -Group '@FirewallAPI.dll,-28752'"</CommandLine>
|
||||
</SynchronousCommand>
|
||||
<SynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
|
||||
<Order>5</Order>
|
||||
<Description>Install virtio-win guest tools (drivers + QEMU guest agent + balloon)</Description>
|
||||
<CommandLine>powershell -NoProfile -ExecutionPolicy Bypass -Command "$e=(Get-PSDrive -PSProvider FileSystem | ForEach-Object { Join-Path $_.Root 'virtio-win-guest-tools.exe' } | Where-Object { Test-Path $_ } | Select-Object -First 1); if ($e) { Start-Process $e -ArgumentList '/install','/quiet','/norestart' -Wait }"</CommandLine>
|
||||
</SynchronousCommand>
|
||||
</FirstLogonCommands>
|
||||
</component>
|
||||
</settings>
|
||||
</unattend>
|
||||
@@ -0,0 +1,74 @@
|
||||
# NEC IX — conditional DNS forwarding for `ad.ddupan.top`
|
||||
|
||||
Makes the LAN router (NEC IX, `192.168.10.1`) forward queries for the AD zone to the
|
||||
Samba DC (`192.168.10.5`) while sending everything else to its normal upstream.
|
||||
|
||||
> NEC IX is IOS-*like* but not IOS. Domain-based forwarding is **not** a `domain`
|
||||
> command — you define a `url-list` that matches the domain and bind it to a
|
||||
> `proxy-dns server`. Config mode is `configure` (no `terminal`); enable is
|
||||
> `proxy-dns ip enable`.
|
||||
|
||||
## Scope
|
||||
|
||||
Only for non-domain LAN devices that resolve via the router. Domain-joined machines
|
||||
and the DC itself must use `192.168.10.5` directly as primary DNS — pointing an AD
|
||||
member at the router breaks Kerberos/SRV autodiscovery.
|
||||
|
||||
No loop: the router forwards `ad.ddupan.top` → DC, and the DC's `smb.conf`
|
||||
`dns forwarder = 192.168.10.1` sends non-AD queries back to the router. Different
|
||||
zones, so they don't ping-pong.
|
||||
|
||||
## Config
|
||||
|
||||
```
|
||||
enable
|
||||
configure
|
||||
!
|
||||
! 1. match the AD zone (and its subdomains)
|
||||
url-list AD-ZONE permit domain ad.ddupan.top
|
||||
url-list AD-ZONE permit domain *.ad.ddupan.top
|
||||
!
|
||||
! (optional) reverse zone for 192.168.10.0/24
|
||||
url-list AD-ZONE permit domain *.10.168.192.in-addr.arpa
|
||||
!
|
||||
! 2. enable proxy-dns (skip if already enabled)
|
||||
proxy-dns ip enable
|
||||
!
|
||||
! 3. send AD-zone queries to the DC; higher priority = preferred/first
|
||||
proxy-dns server 192.168.10.5 url-list AD-ZONE priority 200
|
||||
!
|
||||
! keep your existing upstream server(s) as the catch-all at lower priority, e.g.
|
||||
! proxy-dns server 192.168.10.1 priority 100 <- (your current default; leave as-is)
|
||||
!
|
||||
exit
|
||||
write memory
|
||||
```
|
||||
|
||||
How it resolves: a query is offered to the `proxy-dns server`s in priority order;
|
||||
the DC (200) is bound to `AD-ZONE`, so it only takes queries matching the url-list.
|
||||
Anything not matching falls through to your existing catch-all server(s) at lower
|
||||
priority — so existing internet DNS keeps working unchanged.
|
||||
|
||||
Verify grammar on your unit — classic IX (IX2215-era IXOS) and newer IX-R/IX-V share
|
||||
this shape, but use `?` to confirm. Notably:
|
||||
- The `proxy-dns server` line accepts `[INTERFACE [NEXTHOP|dhcp]]` before `url-list`;
|
||||
the DC is on the local LAN so no interface/nexthop is needed.
|
||||
- `url-list ... permit domain` patterns: `ad.ddupan.top` matches the apex,
|
||||
`*.ad.ddupan.top` matches hosts under it. Add both.
|
||||
- Persist with `write memory`.
|
||||
|
||||
## Verify
|
||||
|
||||
From a non-domain LAN client using the router as resolver:
|
||||
|
||||
```bash
|
||||
nslookup dc1.ad.ddupan.top 192.168.10.1 # → 192.168.10.5
|
||||
nslookup -type=srv _ldap._tcp.ad.ddupan.top 192.168.10.1 # → dc1 ...
|
||||
nslookup example.com 192.168.10.1 # still resolves normally
|
||||
```
|
||||
|
||||
Reverse (only if the in-addr.arpa url-list entry was added):
|
||||
|
||||
```bash
|
||||
nslookup 192.168.10.5 192.168.10.1 # → dc1.ad.ddupan.top
|
||||
```
|
||||
Reference in New Issue
Block a user