pve-cluster: Phase 4 als Rolle – Admin-Gruppe/-Konten, Healer-Token, Benachrichtigungen, Firewall mit IPSET management (Tasks 4.1–4.4)
ci / test (push) Failing after 21s

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Robert Rapp
2026-10-13 17:05:30 +02:00
co-authored by Claude Fable 5.1
parent 5c790cc8d4
commit 81602373fc
6 changed files with 309 additions and 0 deletions
@@ -0,0 +1,12 @@
# Clusterweite Grundkonfiguration (Phase 4): Zugang, Benachrichtigungen, Firewall.
# Läuft auf einem Knoten, die Einstellungen liegen in /etc/pve und gelten für alle.
pve_site_file: "{{ playbook_dir }}/../../../../sites/{{ knaster_site }}/proxmox.yaml"
# Healer (SPEC-healer): darf GPU-VMs nur ein- und ausschalten und ihren Zustand lesen.
pve_healer_role: KnasterHealer
pve_healer_privs: "VM.PowerMgmt VM.Audit"
pve_healer_user: healer@pve
pve_healer_token: knaster
pve_healer_token_file: /root/.config/knaster/healer.env
# Totmannschalter: schaltet die Firewall nach dieser Zeit wieder ab, wenn die Prüfung am Ende
# des Laufs nicht erreicht wird (Lauf abgebrochen, ops ausgesperrt).
pve_firewall_deadman_seconds: 600
@@ -0,0 +1,188 @@
- name: Site-Datei laden
ansible.builtin.set_fact:
knaster_pve: "{{ lookup('ansible.builtin.file', pve_site_file) | from_yaml }}"
delegate_to: localhost
# ---------- 4.1 Benutzer, Gruppen, Rollen, Token ----------
- name: Rollen, Gruppen, Benutzer und ACLs lesen
ansible.builtin.shell: |
set -euo pipefail
python3 - <<'PY'
import json, subprocess
def get(p): return json.loads(subprocess.check_output(["pvesh", "get", p, "--output-format", "json"]))
print(json.dumps({
"roles": [r["roleid"] for r in get("/access/roles")],
"groups": [g["groupid"] for g in get("/access/groups")],
"users": [u["userid"] for u in get("/access/users")],
"acl": [[a["path"], a["ugid"], a["roleid"]] for a in get("/access/acl")],
}))
PY
args: {executable: /bin/bash}
register: access_raw
check_mode: false
changed_when: false
- name: Zugangsdaten als Fakt
ansible.builtin.set_fact:
pve_access: "{{ access_raw.stdout | from_json }}"
- name: Rolle für den Healer (nur Ein/Aus und Lesen der GPU-VMs)
ansible.builtin.command: pveum role add {{ pve_healer_role }} --privs "{{ pve_healer_privs }}"
when: pve_healer_role not in pve_access.roles
changed_when: true
- name: Admin-Gruppe
ansible.builtin.command: pveum group add {{ knaster_pve.access.admin_group }} --comment "Administratoren (knaster), root@pam nur Notfall"
when: knaster_pve.access.admin_group not in pve_access.groups
changed_when: true
- name: Admin-Gruppe darf alles (Administrator auf /)
ansible.builtin.command: pveum acl modify / --groups {{ knaster_pve.access.admin_group }} --roles Administrator
when: ['/', '@' ~ knaster_pve.access.admin_group, 'Administrator'] not in pve_access.acl
changed_when: true
# Ohne Passwort: das setzt scripts/pve-admin-user.sh vom Laptop (zusammen mit TOTP und Recovery-Keys)
- name: Persönliche Admin-Konten
ansible.builtin.command: >
pveum user add {{ item }}@pve --groups {{ knaster_pve.access.admin_group }}
--comment "persönlicher Admin, Passwort/TOTP per pve-admin-user.sh"
loop: "{{ knaster_pve.access.admin_users }}"
when: (item ~ '@pve') not in pve_access.users
changed_when: true
- name: Healer-Benutzer
ansible.builtin.command: pveum user add {{ pve_healer_user }} --comment "knaster Healer (SPEC-healer), nur Token"
when: pve_healer_user not in pve_access.users
changed_when: true
- name: Healer darf GPU-VMs im Pool gpu schalten
ansible.builtin.command: pveum acl modify /pool/gpu --users {{ pve_healer_user }} --roles {{ pve_healer_role }}
when: ['/pool/gpu', pve_healer_user, pve_healer_role] not in pve_access.acl
changed_when: true
- name: Healer-Token lesen
ansible.builtin.command: pveum user token list {{ pve_healer_user }} --output-format json
register: healer_tokens
check_mode: false
changed_when: false
- name: Healer-Token anlegen (Geheimnis nur einmal sichtbar)
ansible.builtin.command: >
pveum user token add {{ pve_healer_user }} {{ pve_healer_token }} --privsep 0
--comment "knaster Healer auf infra" --output-format json
register: healer_token_new
when: healer_tokens.stdout | from_json | selectattr('tokenid', 'equalto', pve_healer_token) | list | length == 0
no_log: true
changed_when: true
- name: Healer-Token auf ops ablegen (nur beim Anlegen)
ansible.builtin.copy:
dest: "{{ pve_healer_token_file }}"
content: |
PVE_USER={{ pve_healer_user }}
PVE_TOKEN_NAME={{ pve_healer_token }}
PVE_TOKEN_VALUE={{ (healer_token_new.stdout | from_json).value }}
mode: "0600"
delegate_to: localhost
when: healer_token_new is not skipped
no_log: true
- name: Healer-Token-Datei auf ops prüfen
ansible.builtin.stat:
path: "{{ pve_healer_token_file }}"
delegate_to: localhost
register: healer_file
- name: Hinweis, wenn der Token existiert, aber die Datei fehlt
ansible.builtin.debug:
msg: "Token {{ pve_healer_user }}!{{ pve_healer_token }} existiert, {{ pve_healer_token_file }} fehlt – Token entfernen (pveum user token remove) und Lauf wiederholen."
when: not healer_file.stat.exists
# ---------- 4.3 Benachrichtigungen ----------
- name: Benachrichtigungsziele und Matcher lesen
ansible.builtin.shell: |
set -euo pipefail
python3 - <<'PY'
import json, subprocess
def get(p): return json.loads(subprocess.check_output(["pvesh", "get", p, "--output-format", "json"]))
print(json.dumps({
"sendmail": {e["name"]: e for e in get("/cluster/notifications/endpoints/sendmail")},
"matchers": {m["name"]: m for m in get("/cluster/notifications/matchers")},
}))
PY
args: {executable: /bin/bash}
register: notify_raw
check_mode: false
changed_when: false
- name: Benachrichtigungen als Fakt
ansible.builtin.set_fact:
pve_notify: "{{ notify_raw.stdout | from_json }}"
# Zustellung direkt an die MX von mygodel.de (Postfix auf dem Knoten, 13.10. getestet: 250 queued)
- name: Mail-Ziel knaster-mail
ansible.builtin.command: >
pvesh create /cluster/notifications/endpoints/sendmail --name knaster-mail
--mailto {{ knaster_pve.access.mail_to }} --comment "Proxmox-Ereignisse an das KI-Postfach"
when: "'knaster-mail' not in pve_notify.sendmail"
register: notify_target
changed_when: true
- name: Matcher knaster-alles (jedes Ereignis an knaster-mail)
ansible.builtin.command: >
pvesh create /cluster/notifications/matchers --name knaster-alles --target knaster-mail
--comment "Backup, Fencing, Updates, Replikation"
when: "'knaster-alles' not in pve_notify.matchers"
changed_when: true
- name: Standard-Matcher abschalten (sonst kommt jede Mail doppelt)
ansible.builtin.command: pvesh set /cluster/notifications/matchers/default-matcher --disable 1
when: not (pve_notify.matchers['default-matcher'].disable | default(false))
changed_when: true
- name: Testnachricht über knaster-mail
ansible.builtin.command: pvesh create /cluster/notifications/targets/knaster-mail/test
when: notify_target is changed
changed_when: false
# ---------- 4.4 Firewall ----------
- name: Firewall-Regelwerk des Clusters schreiben
ansible.builtin.template:
src: cluster.fw.j2
dest: /etc/pve/firewall/cluster.fw
mode: "0640"
unsafe_writes: true
register: cluster_fw
- name: Regelwerk übersetzen (Syntaxprüfung, ändert nichts)
ansible.builtin.command: pve-firewall compile
check_mode: false
changed_when: false
- name: Totmannschalter stellen (Firewall nach {{ pve_firewall_deadman_seconds }} s wieder aus)
ansible.builtin.shell: |
systemctl stop knaster-fw-totmann.timer 2>/dev/null || true
systemd-run --on-active={{ pve_firewall_deadman_seconds }} --unit knaster-fw-totmann --description "knaster: Firewall-Rollback" sed -i 's/^enable: 1$/enable: 0/' /etc/pve/firewall/cluster.fw
when: cluster_fw is changed and knaster_pve.firewall.enable
changed_when: true
- name: Warten, bis alle Knoten das Regelwerk geladen haben
ansible.builtin.pause:
seconds: 15
when: cluster_fw is changed
- name: Prüfung von ops aus (SSH und 8006 zu jedem Knoten)
ansible.builtin.shell: |
set -euo pipefail
ssh -o BatchMode=yes -o ConnectTimeout=5 root@{{ hostvars[item].ansible_host }} 'pve-firewall status'
curl -sk -m 5 -o /dev/null -w '%{http_code}\n' https://{{ hostvars[item].ansible_host }}:8006/ | grep -q 200
args: {executable: /bin/bash}
loop: "{{ groups['pve_all'] | sort }}"
delegate_to: localhost
changed_when: false
when: cluster_fw is changed and knaster_pve.firewall.enable
- name: Totmannschalter aufheben (Prüfung bestanden)
ansible.builtin.command: systemctl stop knaster-fw-totmann.timer
when: cluster_fw is changed and knaster_pve.firewall.enable
changed_when: true
@@ -0,0 +1,20 @@
# Verwaltet von Ansible (Rolle pve-cluster, Vorlage cluster.fw.j2). Änderungen im Repo, nicht hier.
[OPTIONS]
enable: {{ 1 if knaster_pve.firewall.enable else 0 }}
policy_in: DROP
policy_out: ACCEPT
log_ratelimit: enable=1,rate=10/second,burst=5
[IPSET management] # Zugang zu 8006, 22, 5900-5999, 3128, 60000-60050 (Proxmox-Standardregeln)
{% for host in groups['pve_all'] | sort %}
{{ hostvars[host].ansible_host }} # Knoten {{ host }}
{% endfor %}
{% for m in knaster_pve.firewall.management %}
{{ m.ip }} # {{ m.comment }}
{% endfor %}
[RULES]
IN ACCEPT -source {{ knaster_pve.firewall.ping_from }} -p icmp -log nolog # Ping aus dem LAN