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,73 @@
|
||||
---
|
||||
# Authenticate PVE users against Samba AD over verified LDAPS.
|
||||
#
|
||||
# WHY the AD realm and not OIDC: PVE's `ad`/`ldap` realms support a real user +
|
||||
# group SYNC (pveum realm sync), so permissions can be granted to a group BEFORE
|
||||
# anyone logs in, and username+password works for the API/CLI (Terraform,
|
||||
# pvesh). PVE's OIDC realm only creates users on first browser login and cannot
|
||||
# do non-interactive auth. Authelia OIDC can be added later as an EXTRA realm
|
||||
# for convenient SSO; it is not a replacement.
|
||||
|
||||
pve_auth_realm: ad # users appear as <user>@ad
|
||||
pve_auth_domain: ad.ddupan.top
|
||||
|
||||
# MUST be the hostname, NEVER 192.168.10.5: the DC's LDAPS cert is issued by
|
||||
# OpenBao ACME with a DNS SAN only (no IP SAN), so verification fails by IP with
|
||||
# "IP address mismatch". Verified 2026-07-25.
|
||||
pve_auth_server1: dc1.ad.ddupan.top
|
||||
pve_auth_port: 636
|
||||
pve_auth_mode: ldaps # `--secure` is DEPRECATED in favour of this
|
||||
|
||||
# The whole point of trusting the internal CA on these nodes (role pve_ca_trust):
|
||||
# PVE defaults --verify to 0, i.e. it does NOT check the DC's certificate, which
|
||||
# makes the directory bind trivially MITM-able on a flat LAN. capath's default
|
||||
# (/etc/ssl/certs) already contains the hashed internal CA.
|
||||
pve_auth_verify: 1
|
||||
pve_auth_capath: /etc/ssl/certs
|
||||
|
||||
pve_auth_base_dn: "DC=ad,DC=ddupan,DC=top"
|
||||
pve_auth_bind_dn: "CN=svc-pve,CN=Users,DC=ad,DC=ddupan,DC=top"
|
||||
# Read-only bind account created by samba-ad (samba_ad_service_accounts).
|
||||
pve_auth_bind_password: "{{ vault_pve_bind_password | default('') }}"
|
||||
|
||||
# AD logon name. Without this PVE would try the default LDAP `uid`, which AD
|
||||
# does not populate.
|
||||
pve_auth_user_attr: sAMAccountName
|
||||
# AD stores users AND groups under CN=Users by default (not an OU).
|
||||
pve_auth_group_dn: "CN=Users,DC=ad,DC=ddupan,DC=top"
|
||||
pve_auth_group_classes: group
|
||||
pve_auth_user_classes: user
|
||||
|
||||
# Only sync real, ENABLED people — not service or system accounts.
|
||||
# The userAccountControl bit-AND rule (1.2.840.113556.1.4.803 := 2) excludes
|
||||
# DISABLED accounts, which is what keeps AD's built-in `Guest` and `krbtgt` out.
|
||||
# Filtering on name alone let both through on the first sync.
|
||||
pve_auth_filter: >-
|
||||
(&(objectCategory=person)(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2))(!(sAMAccountName=svc-*)))
|
||||
|
||||
# Only groups this cluster actually uses for RBAC. `(objectClass=group)` pulls in
|
||||
# every builtin AD group: PVE rejects most outright ("group name 'Domain Users-ad'
|
||||
# contains invalid characters" — spaces are illegal in PVE group IDs) and imports
|
||||
# the rest as clutter (DnsAdmins-ad, DnsUpdateProxy-ad). Convention: name any
|
||||
# group PVE should see `pve-*`.
|
||||
pve_auth_group_filter: "(&(objectClass=group)(cn=pve-*))"
|
||||
pve_auth_sync_attributes: "email=mail,firstname=givenName,lastname=sn"
|
||||
|
||||
# AD logon names are case-insensitive; PVE defaults to case-sensitive, which
|
||||
# means Panxiao81 and panxiao81 would become two different PVE users.
|
||||
pve_auth_case_sensitive: 0
|
||||
|
||||
# remove-vanished: 'none' on purpose. Anything stronger lets a transient LDAP
|
||||
# hiccup delete users, their properties, or their ACLs from a live cluster.
|
||||
pve_auth_sync_defaults: "scope=both,enable-new=1,remove-vanished=none"
|
||||
|
||||
# --- scheduled sync ---
|
||||
pve_auth_sync_job: pve-ad-sync
|
||||
pve_auth_sync_schedule: "*-*-* 04:11:00"
|
||||
|
||||
# --- RBAC ---
|
||||
# PVE RENAMES synced groups to "<name>-<realm>", so AD's pve-admins becomes
|
||||
# pve-admins-ad. Granting the ACL to "pve-admins" would silently match nothing.
|
||||
pve_auth_admin_group: "pve-admins-{{ pve_auth_realm }}"
|
||||
pve_auth_admin_role: Administrator
|
||||
pve_auth_admin_path: /
|
||||
@@ -0,0 +1,201 @@
|
||||
---
|
||||
# Realm config lives in /etc/pve/domains.cfg, which is REPLICATED cluster-wide,
|
||||
# so every task here runs once against a single node. Doing it per-host would
|
||||
# just have three nodes racing to write the same file.
|
||||
|
||||
- name: Require the bind password
|
||||
ansible.builtin.assert:
|
||||
that: pve_auth_bind_password | length > 0
|
||||
fail_msg: >-
|
||||
pve_auth_bind_password is empty. It comes from vault_pve_bind_password in
|
||||
samba-ad/ansible/group_vars/all/vault.yml — pass it via -e or a vars file.
|
||||
quiet: true
|
||||
run_once: true
|
||||
|
||||
- name: Check whether the realm already exists
|
||||
ansible.builtin.command:
|
||||
cmd: "pveum realm list --output-format json"
|
||||
register: _realms
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
run_once: true
|
||||
|
||||
- name: Decide create vs update
|
||||
ansible.builtin.set_fact:
|
||||
_realm_exists: "{{ pve_auth_realm in (_realms.stdout | from_json | map(attribute='realm') | list) }}"
|
||||
run_once: true
|
||||
|
||||
# --- shared option set, so create and update cannot drift apart -------------
|
||||
# `--type` is deliberately NOT in here: a realm's type is immutable, and
|
||||
# `pveum realm modify --type ad` fails with "Unknown option: type". It is passed
|
||||
# only on the create path below.
|
||||
- name: Build the realm option string
|
||||
ansible.builtin.set_fact:
|
||||
_realm_opts: >-
|
||||
--domain {{ pve_auth_domain }}
|
||||
--server1 {{ pve_auth_server1 }}
|
||||
--port {{ pve_auth_port }}
|
||||
--mode {{ pve_auth_mode }}
|
||||
--verify {{ pve_auth_verify }}
|
||||
--capath {{ pve_auth_capath }}
|
||||
--base_dn '{{ pve_auth_base_dn }}'
|
||||
--bind_dn '{{ pve_auth_bind_dn }}'
|
||||
--user_attr {{ pve_auth_user_attr }}
|
||||
--user_classes '{{ pve_auth_user_classes }}'
|
||||
--group_dn '{{ pve_auth_group_dn }}'
|
||||
--group_classes '{{ pve_auth_group_classes }}'
|
||||
--filter '{{ pve_auth_filter }}'
|
||||
--group_filter '{{ pve_auth_group_filter }}'
|
||||
--sync_attributes '{{ pve_auth_sync_attributes }}'
|
||||
--sync-defaults-options '{{ pve_auth_sync_defaults }}'
|
||||
--case-sensitive {{ pve_auth_case_sensitive }}
|
||||
--comment 'Samba AD (dc1) over verified LDAPS'
|
||||
run_once: true
|
||||
|
||||
- name: Create the realm
|
||||
# --check-connection makes PVE actually bind before saving, so a wrong DN,
|
||||
# password, or an untrusted certificate fails HERE instead of silently
|
||||
# producing a realm nobody can log in to.
|
||||
ansible.builtin.shell:
|
||||
cmd: >-
|
||||
pveum realm add {{ pve_auth_realm }} --type ad {{ _realm_opts }}
|
||||
--password '{{ pve_auth_bind_password }}'
|
||||
--check-connection 1
|
||||
when: not _realm_exists
|
||||
run_once: true
|
||||
# no_log hides the bind password, but it also hides WHY a failure happened.
|
||||
# Keep it on (the password is on the command line) and rely on
|
||||
# --check-connection plus the manual `pveum realm modify` path for diagnosis.
|
||||
no_log: true
|
||||
|
||||
- name: Read the current realm config
|
||||
ansible.builtin.command:
|
||||
cmd: "pvesh get /access/domains/{{ pve_auth_realm }} --output-format json"
|
||||
register: _realm_cur
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
when: _realm_exists
|
||||
run_once: true
|
||||
|
||||
- name: Detect realm drift
|
||||
# pveum has no diff mode, so compare the fields we manage. Without this the
|
||||
# role reported "changed" on every single run, which makes real drift invisible.
|
||||
ansible.builtin.set_fact:
|
||||
_realm_drift: "{{ _realm_exists and (
|
||||
(_realm_cur.stdout | from_json).get('server1') != pve_auth_server1 or
|
||||
(_realm_cur.stdout | from_json).get('base_dn') != pve_auth_base_dn or
|
||||
(_realm_cur.stdout | from_json).get('bind_dn') != pve_auth_bind_dn or
|
||||
(_realm_cur.stdout | from_json).get('mode') != pve_auth_mode or
|
||||
(_realm_cur.stdout | from_json).get('verify') | default(0) | int != pve_auth_verify | int or
|
||||
(_realm_cur.stdout | from_json).get('filter') != pve_auth_filter | trim or
|
||||
(_realm_cur.stdout | from_json).get('group_filter') != pve_auth_group_filter or
|
||||
(_realm_cur.stdout | from_json).get('user_attr') != pve_auth_user_attr or
|
||||
(_realm_cur.stdout | from_json).get('sync_attributes') != pve_auth_sync_attributes
|
||||
) }}"
|
||||
run_once: true
|
||||
|
||||
- name: Update the realm
|
||||
ansible.builtin.shell:
|
||||
cmd: >-
|
||||
pveum realm modify {{ pve_auth_realm }} {{ _realm_opts }}
|
||||
--password '{{ pve_auth_bind_password }}'
|
||||
--check-connection 1
|
||||
when: _realm_drift | default(false)
|
||||
run_once: true
|
||||
no_log: true
|
||||
|
||||
- name: Show the resulting realm
|
||||
ansible.builtin.command:
|
||||
cmd: "pveum realm list --output-format json"
|
||||
register: _realm_after
|
||||
changed_when: false
|
||||
run_once: true
|
||||
|
||||
- name: Report it
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ _realm_after.stdout | from_json | selectattr('realm', 'equalto', pve_auth_realm) | list }}"
|
||||
run_once: true
|
||||
|
||||
# --- initial sync ----------------------------------------------------------
|
||||
- name: Dry-run the sync first
|
||||
# Proves the bind and filters work, and shows what WOULD be imported, without
|
||||
# writing to user.cfg.
|
||||
ansible.builtin.command:
|
||||
cmd: "pveum realm sync {{ pve_auth_realm }} --scope both --dry-run 1"
|
||||
register: _sync_dry
|
||||
changed_when: false
|
||||
run_once: true
|
||||
|
||||
- name: Show what the sync would import
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ _sync_dry.stderr_lines | default([]) + _sync_dry.stdout_lines | default([]) }}"
|
||||
run_once: true
|
||||
|
||||
- name: Sync users and groups
|
||||
# Always reports "changed": this is a reconcile ACTION against a live
|
||||
# directory, not a declared state, and pveum gives no no-op signal to key off.
|
||||
# The realm config and ACL above ARE drift-detected, so a re-run showing
|
||||
# changed=1 means "sync ran", not "something was wrong".
|
||||
ansible.builtin.command:
|
||||
cmd: "pveum realm sync {{ pve_auth_realm }} --scope both --enable-new 1 --remove-vanished none"
|
||||
register: _sync
|
||||
changed_when: true
|
||||
run_once: true
|
||||
|
||||
# --- scheduled sync --------------------------------------------------------
|
||||
- name: List existing realm-sync jobs
|
||||
ansible.builtin.command:
|
||||
cmd: "pvesh get /cluster/jobs/realm-sync --output-format json"
|
||||
register: _jobs
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
run_once: true
|
||||
|
||||
- name: Create the scheduled sync job
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
pvesh create /cluster/jobs/realm-sync/{{ pve_auth_sync_job }}
|
||||
--realm {{ pve_auth_realm }}
|
||||
--schedule '{{ pve_auth_sync_schedule }}'
|
||||
--scope both --enable-new 1 --remove-vanished none --enabled 1
|
||||
--comment 'Nightly AD user/group sync'
|
||||
when: pve_auth_sync_job not in (_jobs.stdout | from_json | map(attribute='id') | list)
|
||||
run_once: true
|
||||
|
||||
# --- RBAC ------------------------------------------------------------------
|
||||
- name: Read current ACLs
|
||||
ansible.builtin.command:
|
||||
cmd: "pveum acl list --output-format json"
|
||||
register: _acl_cur
|
||||
changed_when: false
|
||||
check_mode: false
|
||||
run_once: true
|
||||
|
||||
- name: Grant the admin group its role
|
||||
# NOTE the group name: PVE appends "-<realm>" to every synced group, so AD's
|
||||
# `pve-admins` is `pve-admins-ad` here. Granting to the AD name matches nothing.
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
pveum acl modify {{ pve_auth_admin_path }}
|
||||
--group {{ pve_auth_admin_group }} --role {{ pve_auth_admin_role }}
|
||||
when: >-
|
||||
(_acl_cur.stdout | from_json
|
||||
| selectattr('path', 'equalto', pve_auth_admin_path)
|
||||
| selectattr('ugid', 'equalto', pve_auth_admin_group)
|
||||
| selectattr('roleid', 'equalto', pve_auth_admin_role) | list | length) == 0
|
||||
run_once: true
|
||||
|
||||
- name: Report users, groups and ACLs
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
echo "--- users ---"; pveum user list --output-format json | python3 -c "import json,sys;[print(' ',u['userid']) for u in json.load(sys.stdin)]"
|
||||
echo "--- groups ---"; pveum group list --output-format json | python3 -c "import json,sys;[print(' ',g['groupid'], g.get('users','')) for g in json.load(sys.stdin)]"
|
||||
echo "--- acls ---"; pveum acl list --output-format json | python3 -c "import json,sys;[print(' ',a['path'],a.get('ugid'),a.get('roleid')) for a in json.load(sys.stdin)]"
|
||||
register: _final
|
||||
changed_when: false
|
||||
run_once: true
|
||||
|
||||
- name: Show it
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ _final.stdout_lines }}"
|
||||
run_once: true
|
||||
Reference in New Issue
Block a user