CluPilotCloud/deploy/bootstrap/lib/register.sh

212 lines
8.6 KiB
Bash

# shellcheck shell=sh
#
# Die Übergabe: Proxmox-Rolle und Token erzeugen, bei CluPilot registrieren, den
# WireGuard-Schlüssel tauschen.
#
# ---------------------------------------------------------------------------
# Die Rolle, und warum `|| true` hier NICHT steht
# ---------------------------------------------------------------------------
#
# `pveum role add … || true` wendet die Rechteliste nur bei dem Lauf an, der die
# Rolle zum ersten Mal anlegt. Ein Recht, das später dazukam, erreichte damit
# neue Hosts und keinen einzigen bestehenden. Genau so fehlte `Sys.Modify`
# überall — mit einem Symptom, das viel später und ganz woanders auftrat: ein
# 403 auf `POST /cluster/backup`, und die Bereitstellung eines zahlenden Kunden
# scheiterte bei `register_backup`.
#
# Deshalb: `role add`, und wenn das scheitert (die Rolle gibt es schon, der
# gewöhnliche Fall), `role modify` — zwei getrennte Befehle, damit ein
# Fehlschlag zuzuordnen ist. `modify` ohne `-append` ERSETZT die Liste, der Host
# passt sich also der Konfiguration an, statt anzusammeln, was irgendeine
# frühere Fassung einmal gewährt hat.
#
# Für Benutzer und ACL galt `|| true` bisher als geduldet, weil „gibt es schon"
# dort der Normalfall ist. Auch das entfällt: statt den Fehler zu verwerfen,
# wird nachgesehen, ob der gewünschte Zustand steht. Ein `|| true` verdeckt
# nicht nur „gibt es schon", sondern auch „ging schief".
CLUPILOT_PVE_ROLE='CluPilotAutomation'
CLUPILOT_PVE_USER='automation@pve'
CLUPILOT_PVE_TOKEN_NAME='clupilot'
# Wörtlich aus `config/provisioning.php`. Sys.Modify ist nicht optional: es steht
# in `PVE/API2/Backup.pm` als `check => ['perm', '/', ['Sys.Modify']]` — an der
# Quelle geprüft, nicht aus einem Forenbeitrag geschlossen — und deckt zugleich
# `PUT /cluster/firewall/options` ab. Gewährt auf `/`, weil beide Endpunkte
# gegen `/` prüfen und nichts Engeres sie zufriedenstellt.
CLUPILOT_PVE_PRIVS='VM.Allocate,VM.Clone,VM.Config.Disk,VM.Config.CPU,VM.Config.Memory,VM.Config.Network,VM.Config.Options,VM.Config.Cloudinit,VM.PowerMgmt,VM.Monitor,VM.Audit,VM.Backup,VM.GuestAgent.Audit,VM.GuestAgent.Unrestricted,Datastore.AllocateSpace,Datastore.Audit,Sys.Audit,Sys.Modify'
# Ein Feld aus flachem JSON. Nimmt `jq`, wenn es da ist, und sonst einen
# eng gefassten Ausdruck.
json_field() {
_key="$1"
_json="$2"
if command -v jq >/dev/null 2>&1; then
printf '%s' "$_json" | jq -r --arg k "$_key" '.[$k] // empty' 2>/dev/null
return 0
fi
printf '%s' "$_json" \
| sed -n "s/.*\"${_key}\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p" \
| head -1
}
converge_proxmox_role() {
if pveum role add "$CLUPILOT_PVE_ROLE" -privs "$CLUPILOT_PVE_PRIVS" >/dev/null 2>&1; then
log "Proxmox-Rolle ${CLUPILOT_PVE_ROLE} angelegt"
return 0
fi
if pveum role modify "$CLUPILOT_PVE_ROLE" -privs "$CLUPILOT_PVE_PRIVS" >/dev/null 2>&1; then
log "Proxmox-Rolle ${CLUPILOT_PVE_ROLE} auf die aktuelle Rechteliste gebracht"
return 0
fi
log 'Die Rechteliste der Proxmox-Rolle ließ sich nicht angleichen'
return 1
}
# Benutzer und ACL — ohne `|| true`, aber mit Nachsehen.
ensure_proxmox_user() {
if ! pveum user list --output-format json 2>/dev/null | grep -q "\"${CLUPILOT_PVE_USER}\""; then
if ! pveum user add "$CLUPILOT_PVE_USER" >/dev/null 2>&1; then
# Nicht blind schlucken: nachsehen, ob er jetzt da ist. Wenn ja, war
# es ein Wettlauf und kein Fehler.
if ! pveum user list --output-format json 2>/dev/null | grep -q "\"${CLUPILOT_PVE_USER}\""; then
log "Proxmox-Benutzer ${CLUPILOT_PVE_USER} ließ sich nicht anlegen"
return 1
fi
fi
fi
if ! pveum acl modify / -user "$CLUPILOT_PVE_USER" -role "$CLUPILOT_PVE_ROLE" >/dev/null 2>&1; then
# Auch hier: gilt die Zuweisung inzwischen, war es keiner.
if ! pveum acl list --output-format json 2>/dev/null | grep -q "$CLUPILOT_PVE_ROLE"; then
log 'Die Rollenzuweisung auf / ließ sich nicht setzen'
return 1
fi
fi
return 0
}
# Erzeugt den API-Token und gibt "<tokenid>=<secret>" aus.
#
# Das Geheimnis wird genau einmal gezeigt. Ein halb angelegter Token aus einem
# abgebrochenen früheren Lauf wird vorher entfernt, damit ein zweiter Lauf
# gleich ausgeht.
mint_proxmox_token() {
pveum user token remove "$CLUPILOT_PVE_USER" "$CLUPILOT_PVE_TOKEN_NAME" >/dev/null 2>&1 || true
_out="$(pveum user token add "$CLUPILOT_PVE_USER" "$CLUPILOT_PVE_TOKEN_NAME" -privsep 0 --output-format json 2>/dev/null)"
[ -n "$_out" ] || return 1
_secret="$(json_field value "$_out")"
[ -n "$_secret" ] || return 1
_tokenid="$(json_field 'full-tokenid' "$_out")"
[ -n "$_tokenid" ] || _tokenid="${CLUPILOT_PVE_USER}!${CLUPILOT_PVE_TOKEN_NAME}"
printf '%s=%s' "$_tokenid" "$_secret"
}
# Der Fingerabdruck des SSH-Wirtsschlüssels. Nur zur Anzeige — die Plattform
# meldet sich nicht mehr per SSH an (Spec §6).
ssh_host_fingerprint() {
for _key in /etc/ssh/ssh_host_ed25519_key.pub /etc/ssh/ssh_host_rsa_key.pub; do
if [ -f "$_key" ]; then
ssh-keygen -lf "$_key" 2>/dev/null | awk '{ print $2; exit }'
return 0
fi
done
printf ''
}
# Die Eckdaten dieses Knotens, so wie `RegisterCapacity` sie liest.
#
# Der größte Speicher, der Platten aufnimmt — NICHT die Summe. Proxmox führt
# überlappende Pools (local und local-lvm auf derselben Platte), und zu
# summieren hieße, denselben Platz zweimal zu verkaufen.
node_total_gb() {
pvesm status --content images 2>/dev/null \
| awk 'NR > 1 { if ($4 + 0 > max) max = $4 + 0 } END { printf "%d", max / 1048576 }'
}
# Baut den Rumpf für POST /host/register.
render_register_payload() {
_api_token="$1"
_wg_pubkey="$2"
printf '{"code":"%s","api_token":"%s","wg_pubkey":"%s","node":"%s","pve_version":"%s","total_gb":%s,"total_ram_mb":%s,"cpu_cores":%s,"ssh_host_key":"%s"}' \
"$(json_escape "$CLUPILOT_CODE")" \
"$(json_escape "$_api_token")" \
"$(json_escape "$_wg_pubkey")" \
"$(json_escape "$(hostname)")" \
"$(json_escape "$(pveversion 2>/dev/null | head -1)")" \
"$(node_total_gb)" \
"$(awk '/^MemTotal:/ { printf "%d", $2 / 1024; exit }' /proc/meminfo)" \
"$(nproc 2>/dev/null || echo 1)" \
"$(json_escape "$(ssh_host_fingerprint)")"
}
# POST /host/register — gibt die Antwort aus, oder 1.
post_registration() {
_payload="$1"
if command -v curl >/dev/null 2>&1; then
curl -sS -f --connect-timeout 10 --max-time 60 \
-H 'Content-Type: application/json' -H 'Accept: application/json' \
-X POST --data "$_payload" \
"${CLUPILOT_API}/host/register" 2>/dev/null
return $?
fi
wget -q -O - --timeout=60 \
--header='Content-Type: application/json' --header='Accept: application/json' \
--post-data="$_payload" \
"${CLUPILOT_API}/host/register" 2>/dev/null
}
# ---------------------------------------------------------------------------
# Der Schlüsseltausch
# ---------------------------------------------------------------------------
#
# Task 6 lässt den Tunnel erst mit bewiesenem Handshake gelten. Hier wird genau
# dieser Tunnel im laufenden Betrieb auf einen neuen Schlüssel umgestellt — also
# gilt dieselbe Regel, und die Reihenfolge allein genügt nicht: sie sagt, WANN
# verworfen wird, nicht OB das Neue trägt.
#
# 1. neues Paar erzeugen, registrieren, Antwort haben
# 2. wg0 auf den neuen privaten Schlüssel umstellen
# 3. erneuten Handshake BEWEISEN
# 4. erst dann den alten verwerfen
#
# Kommt der Handshake nicht, wird zurückgestellt und laut gescheitert. Ein Host,
# der sich im letzten Schritt selbst aussperrt, ist der eine Fall, den niemand
# aus der Ferne repariert — und er passiert am Ende eines Laufs, der bis dahin
# alles richtig gemacht hat.
swap_wireguard_key() {
_new_private="$1"
_old_private="$CLUPILOT_WG_PRIVATE"
CLUPILOT_WG_PRIVATE="$_new_private"
printf '%s\n' "$(render_wireguard_config)" > /etc/wireguard/wg0.conf
chmod 600 /etc/wireguard/wg0.conf
systemctl restart wg-quick@wg0 >/dev/null 2>&1 || true
if wireguard_handshake_proven 10; then
log 'Handshake mit dem neuen Schlüssel bewiesen'
return 0
fi
log 'Kein Handshake mit dem neuen Schlüssel — es wird auf den alten zurückgestellt'
CLUPILOT_WG_PRIVATE="$_old_private"
printf '%s\n' "$(render_wireguard_config)" > /etc/wireguard/wg0.conf
chmod 600 /etc/wireguard/wg0.conf
systemctl restart wg-quick@wg0 >/dev/null 2>&1 || true
return 1
}