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,6 @@
# Clusterweite Grundkonfiguration (Phase 4): Admin-Gruppe und -Konten, Healer-Rolle/-Token,
# Benachrichtigungen, Firewall mit IPSET management. Läuft auf einem Knoten (Einstellungen in /etc/pve).
# Vorher: Laptop-, ops- und infra-Adresse in sites/knaster/proxmox.yaml (firewall.management) prüfen.
- name: Cluster-Grundkonfiguration
hosts: "{{ pve_cluster_node | default('pve44') }}"
roles: [pve-cluster]
@@ -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
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Persönliches Admin-Konto fertigstellen: Passwort, TOTP (Authenticator-App) und Recovery-Keys.
# Läuft auf dem Admin-Laptop (Git-Bash). Das Konto selbst legt playbooks/cluster.yml an.
#
# platform/proxmox/scripts/pve-admin-user.sh r.rapp [pve44]
#
# Ergebnis (nie ins Repo):
# <ENV> PVE_ADMIN_<NAME>_PASS wird ergänzt, falls noch nicht vorhanden
# (Standard: C:/Users/r.rapp/it/proxmox/.env)
# <ENVDIR>/tfa-<name>.txt otpauth-URI zum Eintippen/Scannen und die Recovery-Keys (0600)
# Idempotent: Passwort nur beim ersten Lauf, TOTP/Recovery nur, wenn noch keine 2FA existiert.
set -euo pipefail
user=${1:?Benutzername ohne @pve}
node=${2:-pve44}
env_file=${PVE_ENV:-C:/Users/r.rapp/it/proxmox/.env}
out_dir=$(dirname "$env_file")
userid="${user}@pve"
var="PVE_ADMIN_$(echo "$user" | tr 'a-z.-' 'A-Z__')_PASS"
tfa_file="$out_dir/tfa-${user}.txt"
run() { ssh -T -o BatchMode=yes -o ConnectTimeout=10 "$node" "$@"; }
run "pveum user list --output-format json" | grep -q "\"userid\":\"$userid\"" \
|| { echo "FEHLER: $userid gibt es nicht – erst playbooks/cluster.yml laufen lassen" >&2; exit 1; }
# 1. Passwort (einmalig erzeugen, in .env ablegen, nie ausgeben)
if ! grep -qE "^${var}=" "$env_file"; then
pw=$(openssl rand -base64 24 | tr -d '/+=' | cut -c1-24)
printf '%s=%s\n' "$var" "$pw" >> "$env_file"
printf '%s' "$pw" | run "p=\$(cat); pvesh set /access/password --userid $userid --password \"\$p\""
echo "Passwort gesetzt und als $var in $env_file abgelegt"
else
echo "Passwort: $var ist schon in $env_file, unverändert"
fi
# 2. TOTP und Recovery-Keys, nur wenn für das Konto noch keine 2FA existiert
if run "pvesh get /access/tfa/$userid --output-format json" | grep -q '"type"'; then
echo "2FA: für $userid existiert schon ein Eintrag, nichts geändert (siehe $tfa_file)"
exit 0
fi
secret=$(openssl rand 20 | base32 | tr -d '=')
uri="otpauth://totp/knaster:${userid}?secret=${secret}&issuer=knaster&digits=6&period=30&algorithm=SHA1"
# aktuellen Code auf dem Knoten berechnen (Zeit des Knotens, kein Zusatzpaket)
code=$(run "python3 -c \"
import base64,hmac,struct,time,hashlib
s='$secret'
k=base64.b32decode(s+'='*(-len(s)%8))
h=hmac.new(k,struct.pack('>Q',int(time.time())//30),hashlib.sha1).digest()
o=h[-1]&15
print('%06d'%((struct.unpack('>I',h[o:o+4])[0]&0x7fffffff)%1000000))\"")
run "pvesh create /access/tfa/$userid --type totp --description 'Authenticator-App' --totp '$uri' --value $code" >/dev/null
keys=$(run "pvesh create /access/tfa/$userid --type recovery --description 'Recovery-Keys (offline)' --output-format json" \
| python3 -c 'import json,sys; print("\n".join(json.load(sys.stdin)["recovery"]))')
umask 077
{
echo "Proxmox 2FA für $userid (angelegt $(date +%F))"
echo
echo "TOTP in die Authenticator-App eintragen (QR aus der URI erzeugen oder Secret manuell eingeben):"
echo "$uri"
echo "Secret: $secret"
echo
echo "Recovery-Keys (je einmal gültig, offline aufbewahren):"
echo "$keys"
} > "$tfa_file"
echo "TOTP und Recovery-Keys angelegt: $tfa_file (0600). Anmeldung: Weboberfläche, Realm 'Proxmox VE authentication server'."
+18
View File
@@ -31,3 +31,21 @@ containers: # Infrastruktur als LXC (ADR-0001), HA + ZFS-Repli
infra: {node: pve44, vmid: 101, cores: 4, memory_mb: 8192, disk_gb: 60, storage: tank, ip: dhcp, ha: true, replicate_to: [pve52, pve48]} infra: {node: pve44, vmid: 101, cores: 4, memory_mb: 8192, disk_gb: 60, storage: tank, ip: dhcp, ha: true, replicate_to: [pve52, pve48]}
replication_schedule: "*/15" replication_schedule: "*/15"
# Cluster-Grundkonfiguration (Phase 4, Rolle pve-cluster, Playbook playbooks/cluster.yml)
access:
admin_group: admins # Gruppe mit Administrator auf /; root@pam bleibt Notfallzugang
admin_users: [r.rapp] # persönliche Konten <name>@pve; Passwort und TOTP per scripts/pve-admin-user.sh
mail_to: ki@mygodel.de # Ziel der Proxmox-Benachrichtigungen (Backup, Fencing, Updates, Replikation)
firewall:
enable: true
# IPSET management: nur diese Quellen (plus alle Knoten) erreichen 8006, 22, 5900-5999, 3128 und
# 60000-60050 der Knoten. Corosync zwischen den Knoten erlaubt Proxmox von selbst.
# Achtung: Laptop, ops und infra laufen noch per DHCP (D5). Wechselt eine Adresse, sperrt die
# Firewall diese Quelle aus. Rückweg: ssh über eine noch zugelassene Adresse (ops) oder Konsole.
management:
- {ip: 192.168.100.151, comment: Admin-Laptop (WLAN, DHCP)}
- {ip: 192.168.100.29, comment: appsrv08 (LiteLLM mit PSE, n8n-Bruecke)}
- {ip: 192.168.100.173, comment: ops CT 100 (Ansible, tofu; DHCP)}
- {ip: 192.168.100.188, comment: infra CT 101 (pve-exporter; DHCP)}
ping_from: 192.168.100.0/24