Establish clean homelab infrastructure baseline
lint / yaml (push) Has been cancelled
lint / ansible (push) Has been cancelled
lint / terraform (push) Has been cancelled

Reorganize the brownfield repository, remove retired and generated artifacts, harden ignore rules, and record the GitOps/IaC redesign.
This commit is contained in:
2026-09-09 16:47:20 +00:00
commit 88a02ababa
418 changed files with 50579 additions and 0 deletions
@@ -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 &quot;$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 }}&quot;</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 &quot;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&quot;</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 &quot;New-NetFirewallRule -DisplayName 'WinRM HTTPS' -Direction Inbound -Protocol TCP -LocalPort 5986 -Action Allow&quot;</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 &quot;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'&quot;</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 &quot;$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 }&quot;</CommandLine>
</SynchronousCommand>
</FirstLogonCommands>
</component>
</settings>
</unattend>