Drop the public endpoint: the command line already carries what the host needs

The operator was right to challenge POST /enrol. The three values a script needs
before the tunnel exists — hub key, hub address, assigned tunnel IP — are all
known to the console when the host is created, so they travel in the copied
command instead of being fetched.

The one thing that forced an endpoint was the hub needing the host's public key
before the tunnel is up. So the console generates the pair and hands it over,
and the host swaps in a freshly generated key through the tunnel afterwards.
The key that passed through a clipboard lives for minutes.

Net effect: the network boundary is unchanged. No new way in from outside.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/host-bootstrap
nexxo 2026-07-30 18:25:02 +02:00
parent 0f1f397823
commit c0cd367a0e
1 changed files with 76 additions and 42 deletions

View File

@ -91,37 +91,48 @@ Zwei Stellen, an denen der Betreiber etwas tut. Kein Wert wird abgetippt.
---
## 5. Der Einmal-Code und der eine öffentliche Endpunkt
## 5. Kein öffentlicher Endpunkt — die Befehlszeile trägt alles
### Das Henne-Ei-Problem
Alles soll im Tunnel passieren — aber um dem Tunnel beizutreten, braucht das Skript
die WireGuard-Angaben. Etwas muss einmal von außen kommen.
Alles soll im Tunnel passieren, aber um dem Tunnel beizutreten, braucht das Skript
die WireGuard-Angaben. Ein erster Entwurf löste das mit einem öffentlichen
`POST /enrol`. **Das war unnötig**, und der Betreiber hat es zu Recht bestritten.
### Die Auflösung
Die drei Werte, die das Skript vor dem Tunnel braucht — Hub-Schlüssel,
Hub-Adresse, zugeteilte Tunnel-IP — kennt die Konsole beim Anlegen des Hosts alle.
Sie kann sie **in die kopierte Befehlszeile schreiben**. Es gibt nichts abzurufen.
Genau **ein** öffentlicher Endpunkt, `POST /enrol`, der ohne gültigen Code nichts
preisgibt.
### Der eine Haken, und wie er aufgelöst wird
- Beim Anlegen des Hosts entsteht ein Code: 32 Zeichen aus `random_bytes`, in der
Datenbank **nur als Hash** (wie ein Passwort), mit `expires_at` **60 Minuten** und
`used_at`.
- Das Skript ruft `POST /enrol` mit dem Code. Antwort bei Erfolg: WireGuard-Subnetz,
Hub-Pubkey, Hub-Endpunkt, die dem Host zugeteilte Tunnel-IP, und ein
**Fortschritts-Token** für die Rückmeldungen aus §7.
- Der Code ist danach verbraucht (`used_at`), auch bei Abbruch. Ein zweiter Versuch
braucht einen neuen Code aus der Konsole. Ein Skriptlauf, der stirbt, wird nicht
„nochmal probiert" — er wird neu angestoßen, weil die halb installierte Maschine
ohnehin neu aufgesetzt gehört.
- **Ratenbegrenzung** und immer dieselbe Antwortzeit bei falschem Code: der
Endpunkt darf nicht verraten, ob ein Code existiert.
- Antwortet **nicht**, wenn der Host in der Konsole schon `active` ist.
Damit der Hub den Host hereinlässt, muss er dessen **öffentlichen** Schlüssel
kennen. Erzeugte der Host ihn selbst — wie `ConfigureWireguard` es heute tut —,
müsste er ihn melden, **bevor** der Tunnel steht. Genau dafür wäre ein öffentlicher
Endpunkt nötig gewesen.
### Was der Endpunkt NICHT tut
Also erzeugt **CluPilot** das Schlüsselpaar beim Anlegen des Hosts und gibt es mit.
Der Hub kennt den Peer damit von Anfang an, und es gibt keinen Rückkanal vor dem
Tunnel.
Er nimmt keinen Proxmox-Token entgegen und schreibt keine Zugangsdaten. Alles, was
ein Geheimnis ist, geht den Rückweg durch den Tunnel (§6). Der öffentliche Endpunkt
gibt nur her, was ein Angreifer mit gültigem Code ohnehin bekäme: wo der Hub steht.
### Und danach wird getauscht
Sobald der Tunnel steht, erzeugt der Host mit `wg genkey` einen **frischen**
Schlüssel, meldet den öffentlichen Teil **durch den Tunnel** (§6), und CluPilot
tauscht den Peer aus. Der Schlüssel, der je in einer Befehlszeile und einer
Zwischenablage stand, ist danach wertlos.
Das ist der Grund, warum diese Lösung der öffentlichen Tür vorzuziehen ist: der
Preis ist ein Schlüssel mit einer Lebensdauer von Minuten, der Gewinn ist, dass die
Netzgrenze **unverändert** bleibt. Es kommt kein einziger neuer Weg von außen
hinzu.
### Der Einmal-Code
Bleibt trotzdem, aber er ist jetzt nur noch ein **Ausweis für den Rückweg**, nicht
der Schlüssel zu einer Auskunft: 32 Zeichen aus `random_bytes`, in der Datenbank
**nur als Hash**, mit `expires_at` (**24 Stunden**, großzügig — er öffnet nichts
nach außen) und `used_at`. Das Skript legt ihn den Aufrufen aus §6 und §7 bei,
damit ein Gerät im Tunnel nicht für einen fremden Host sprechen kann.
---
@ -131,13 +142,19 @@ Nachdem das Skript dem Tunnel beigetreten ist, meldet es sich über die
**Tunnel-Adresse** von CluPilot — nicht über die öffentliche.
`POST /host/register` (nur aus dem WireGuard-Subnetz erreichbar, `TRUSTED_RANGES`)
mit dem Fortschritts-Token und:
mit dem Einmal-Code als Ausweis und:
- dem Proxmox-API-Token, den das Skript auf der Maschine erzeugt hat
- dem **frisch erzeugten WireGuard-Pubkey** (§5): CluPilot tauscht den Peer aus und
verwirft den, der in der Befehlszeile stand
- dem SSH-Host-Schlüssel-Fingerabdruck (nur zur Anzeige; die Plattform verbindet
sich nicht mehr)
- Knotenname, `pve_version`, Plattengröße, RAM, Kerne
**Reihenfolge beim Schlüsseltausch:** erst den neuen Peer aufnehmen, dann den alten
entfernen. Andersherum schneidet sich der Host im selben Aufruf den Ast ab, auf dem
er sitzt — und die Antwort erreicht ihn nie.
CluPilot legt den Token in den Tresor (`SecretVault`, verschlüsselt mit
`SECRETS_KEY`) und schreibt `hosts.api_token_ref`. **Der Betreiber sieht den Token
nie.**
@ -150,8 +167,14 @@ Zwischenspeichern. Der Tunnel ist ohnehin da, wenn dieser Aufruf passiert.
## 7. Fortschritt: der Host erzählt, die Konsole zeigt
`POST /host/progress` (ebenfalls nur aus dem Tunnel, mit dem Fortschritts-Token)
meldet je Abschnitt einen Schlüssel und einen Zustand.
`POST /host/progress` (ebenfalls nur aus dem Tunnel, mit dem Einmal-Code als
Ausweis) meldet je Abschnitt einen Schlüssel und einen Zustand.
Die Abschnitte **vor** `wireguard_joined` kann der Host naturgemäß noch nicht
melden — es gibt bis dahin keinen Weg zu CluPilot. Sie werden lokal mitgeschrieben
und beim ersten erreichbaren Aufruf **nachgereicht**, mit ihren Zeitstempeln. Die
Konsole zeigt bis dahin „wartet auf den Tunnel" und, nach einer Frist, dass sie
lange wartet.
Die Abschnitte des Skripts, in dieser Reihenfolge:
@ -264,17 +287,24 @@ Die Beweislast teilt sich:
**Was die Suite prüfen kann und muss:**
1. `POST /enrol` gibt ohne gültigen Code nichts preis, und die Antwortzeit
unterscheidet nicht zwischen „Code falsch" und „Code existiert nicht".
2. Ein Code gilt **einmal** und verfällt nach 60 Minuten.
3. Der Code liegt **nur als Hash** in der Datenbank.
4. `POST /host/register` und `/host/progress` sind **von außerhalb des Tunnels
nicht erreichbar** — der wichtigste Test dieser Spec.
5. Der Proxmox-Token landet im Tresor und **nie** in einer Antwort, einem Protokoll
1. **Es entsteht kein öffentlich erreichbarer Weg.** Ein Test hält fest, dass
`register`, `progress` und `routes` alle drei hinter der Tunnel-Beschränkung
liegen — der wichtigste Test dieser Spec, und der Grund, warum der Entwurf so
aussieht.
2. Ein Einmal-Code gilt **einmal**, verfällt nach 24 Stunden und liegt **nur als
Hash** in der Datenbank.
3. Ein Aufruf mit dem Code eines **fremden** Hosts wird abgewiesen — sonst spricht
ein Gerät im Tunnel für eine Maschine, die ihm nicht gehört.
4. Der Proxmox-Token landet im Tresor und **nie** in einer Antwort, einem Protokoll
oder einer Ansicht.
6. `GET /host/routes` liefert genau die Routen dieses Hosts und keine fremden.
7. Die geschrumpfte Kette hat die sechs Schritte in der festgelegten Reihenfolge.
8. Ein Fortschrittsbericht für einen fremden Host wird abgewiesen.
5. **Der Schlüsseltausch nimmt erst auf, dann weg.** Ein Test, der die Reihenfolge
umdreht, muss zeigen, dass der Host dabei aus dem Tunnel fällt — sonst ist die
Reihenfolge nur ein Kommentar.
6. Der Schlüssel aus der Befehlszeile ist nach dem Tausch **nicht mehr gültig**.
7. `GET /host/routes` liefert genau die Routen dieses Hosts und keine fremden.
8. Die geschrumpfte Kette hat die sechs Schritte in der festgelegten Reihenfolge.
9. Nachgereichte Fortschrittsmeldungen behalten **ihre** Zeitstempel, nicht den des
Eingangs — sonst sieht eine zwanzigminütige Installation aus wie eine Sekunde.
**Was nur auf echter Hardware zu beweisen ist:** alles im Skript. Der Handoff sagt
dazu das Richtige — *„Jeder Schritt, der scheitert, ist ein Fund — und der Server
@ -286,8 +316,10 @@ auf einer frisch aufgesetzten Maschine immer gleich ausgeht.
## 12. Reihenfolge
1. Einmal-Code, `POST /enrol`, Ablauf und Verbrauch — mit den Sicherheitstests.
2. Tunnel-Endpunkte `register` und `progress`, Token in den Tresor.
1. Einmal-Code (Hash, Ablauf, Verbrauch) und die Schlüsselpaar-Erzeugung beim
Anlegen des Hosts — mit den Sicherheitstests aus §11.
2. Tunnel-Endpunkte `register` und `progress`, Token in den Tresor, Schlüsseltausch
in der richtigen Reihenfolge.
3. Adminbereich: Host anlegen zeigt die Befehlszeile, Fortschritt läuft mit.
4. Routen-Endpunkt und Umstellung von `SshTraefikWriter` auf den Abruf.
5. Die Kette schrumpfen, Bereitschaftsprüfungen nachziehen.
@ -301,10 +333,12 @@ ist die Abnahme, und sie ist die einzige, die zählt.
## 13. Risiken
- **Ein öffentlicher Endpunkt mehr.** Heute ist alles außer Portal und Website
zugesperrt. `POST /enrol` ist die erste Ausnahme. Er gibt ohne gültigen Code
nichts preis und ist ratenbegrenzt — aber er ist neu, und neue Türen sind
Türen.
- **Schlüsselmaterial in der Zwischenablage.** Der WireGuard-Schlüssel, mit dem der
Host dem Tunnel beitritt, steht in der kopierten Befehlszeile und damit kurz in
der Zwischenablage und im Verlauf des Rettungssystems. Entschärft dadurch, dass
er Minuten lebt und nach dem Tausch (§5) wertlos ist — und dass das
Rettungssystem beim ersten Neustart ohnehin verworfen wird. Wer den Tausch
weglässt, hat diesen Punkt dauerhaft; deshalb ist er nicht optional.
- **Der Fortschritt hängt am Host.** Ein Skript, das stirbt, bevor es meldet,
hinterlässt eine Konsole, die auf etwas wartet, das nie kommt. Deshalb je
Abschnitt eine Frist und eine sichtbare Aussage, welcher offen ist.