diff --git a/platform/proxmox/ansible/playbooks/cluster.yml b/platform/proxmox/ansible/playbooks/cluster.yml new file mode 100644 index 0000000..d349597 --- /dev/null +++ b/platform/proxmox/ansible/playbooks/cluster.yml @@ -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] diff --git a/platform/proxmox/ansible/roles/pve-cluster/defaults/main.yml b/platform/proxmox/ansible/roles/pve-cluster/defaults/main.yml new file mode 100644 index 0000000..6201456 --- /dev/null +++ b/platform/proxmox/ansible/roles/pve-cluster/defaults/main.yml @@ -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 diff --git a/platform/proxmox/ansible/roles/pve-cluster/tasks/main.yml b/platform/proxmox/ansible/roles/pve-cluster/tasks/main.yml new file mode 100644 index 0000000..dd9af76 --- /dev/null +++ b/platform/proxmox/ansible/roles/pve-cluster/tasks/main.yml @@ -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 diff --git a/platform/proxmox/ansible/roles/pve-cluster/templates/cluster.fw.j2 b/platform/proxmox/ansible/roles/pve-cluster/templates/cluster.fw.j2 new file mode 100644 index 0000000..d0a4845 --- /dev/null +++ b/platform/proxmox/ansible/roles/pve-cluster/templates/cluster.fw.j2 @@ -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 diff --git a/platform/proxmox/scripts/pve-admin-user.sh b/platform/proxmox/scripts/pve-admin-user.sh new file mode 100644 index 0000000..2fb5647 --- /dev/null +++ b/platform/proxmox/scripts/pve-admin-user.sh @@ -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): +# PVE_ADMIN__PASS wird ergänzt, falls noch nicht vorhanden +# (Standard: C:/Users/r.rapp/it/proxmox/.env) +# /tfa-.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'." diff --git a/sites/knaster/proxmox.yaml b/sites/knaster/proxmox.yaml index 08ea961..6918a7a 100644 --- a/sites/knaster/proxmox.yaml +++ b/sites/knaster/proxmox.yaml @@ -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]} 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 @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