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,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>
|
||||
Reference in New Issue
Block a user