# Host-Übernahme — Plattformseite, Umsetzungsplan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** CluPilot nimmt einen fertig installierten Proxmox-Host in Betrieb, ohne ihn je zu installieren und ohne dauerhaften Shell-Zugang — der Host meldet sich selbst, holt seine Routen selbst und wird nur noch geprüft.

**Architecture:** Beim Anlegen eines Hosts erzeugt die Konsole ein WireGuard-Schlüsselpaar und einen Einmal-Code und zeigt beides in einer kopierbaren Befehlszeile. Das Bootstrap-Skript (eigener Plan) tritt damit dem Tunnel bei und meldet sich über drei Endpunkte zurück, die **ausschließlich** aus dem WireGuard-Subnetz erreichbar sind. Traefik auf dem Host holt seine Routentabelle über denselben Weg ab, statt sie per SSH geschrieben zu bekommen. Die Host-Kette schrumpft von vierzehn auf sechs Schritte, die nur noch nachsehen.

**Tech Stack:** PHP 8.3, Laravel 13, Livewire 3, Pest 4, MariaDB, Tailwind v3, Docker Compose.

## Global Constraints

- **Vollfassung des Entwurfs:** `docs/superpowers/specs/2026-07-30-host-uebernahme-statt-installation-design.md`. Bei Widerspruch gewinnt die Spec.
- **Die härteste Bedingung, aus §8a:** Es entsteht **kein einziger neuer Weg von außen** — weder auf den Host noch zu CluPilot. `register`, `progress` und `routes` liegen alle drei hinter der Tunnel-Beschränkung. Ein Task, der einen davon öffentlich erreichbar macht, ist gescheitert, auch wenn seine Tests grün sind.
- **Commit-Disziplin, nicht verhandelbar.** Weitere Sitzungen arbeiten im selben Repository. **Immer** `git add -- <pfade>` und `git commit -F - -- <pfade>`. **Nie** `git add -A`, `git add .`, `git commit -a` oder ein nacktes `git commit`.
- `pint` nur auf eigene Pfade, **nie** `--dirty`, keine Kosmetik an fremden Dateien.
- **R18–R24 aus `CLAUDE.md`** gelten und werden per Test erzwungen. R22 besonders: eine Prüfrunde, eine Fix-Runde, dann parken.
- **Pest-Fallen, beide heute erlebt:** `toThrow(EinInterface::class)` beweist nichts — immer eine konkrete Klasse. Und `toContain($a, $b)` ist **variadisch**, der zweite Parameter ist kein Meldungstext; dasselbe gilt für `toHaveKey()`.
- **Testisolation:** die Suite läuft mit `CACHE_STORE=array`, der zwischen Testdateien nicht geleert wird. Ein Test, der von `Settings` abhängt, setzt den Wert selbst.
- **Betreiber-Rollen** heißen exakt `Owner`, `Admin`, `Support`, `Billing`, `Read-only`, `Developer`. Fabrik: `Operator::factory()->role('Owner')->create()`. Testpasswort `passwort-fuer-tests`. Guard-Name `'operator'` — bei `Livewire::actingAs($op, 'operator')` **nicht vergessen**.
- **Tests laufen nur im Container**, `docker compose` aus `/home/nexxo/clupilot`:
  `docker compose exec -T -w /var/www/html/.worktrees/host-bootstrap app php artisan test`
- **Immer die volle Suite**, nie nur einen Filter. Eine Aufgabe hat heute mit einem Filter gearbeitet und einen Bruch übersehen, der erst zwei Aufgaben später auffiel.
- **Kein `migrate` gegen die Entwicklungsdatenbank.** Die Container bedienen ein anderes Arbeitsverzeichnis; eine Migration von hier aus beschädigt die laufende Installation. Beweise führen die Tests auf SQLite im Speicher.

---

## Was schon da ist

| | |
|---|---|
| `App\Services\Wireguard\WireguardHub` | `allocateIp()`, `addPeer($pubkey, $ip)`, `removePeer($pubkey)`, `endpoint()`, `publicKey()`, `peers()` |
| `config('admin_access.trusted_ranges')` | aus `TRUSTED_RANGES`, Vorgabe `10.66.0.0/24,127.0.0.1` |
| `App\Http\Middleware\RestrictConsoleNetwork` | **das Vorbild** für die Tunnel-Beschränkung — nicht neu erfinden |
| `App\Services\Secrets\SecretVault` | Tresor mit Modus-Plätzen; `stripe.secret` ist strikt |
| `App\Support\Readiness` | fünf Prüfgruppen, Spread-Array, Wächter-Test über die Registry |
| `hosts` | `uuid, name, dns_name, datacenter, public_ip, wg_ip, wg_pubkey, ssh_host_key, api_token_ref, total_gb, total_ram_mb, cpu_cores, pve_version, node, status, last_seen_at` |
| `SshTraefikWriter::write($trafficHost, $subdomain, array $hostnames, $backend)` | die abzulösende Stelle |
| `ConfigureDnsAndTls` | bildet `$hostnames = array_filter([$fqdn, $customDomain])` — beide Namen |

---

### Task 1: Einmal-Code und Schlüsselpaar beim Anlegen

**Files:**
- Create: `database/migrations/2026_08_02_090000_give_a_host_its_enrolment.php`
- Create: `app/Support/HostEnrolment.php`
- Test: `tests/Feature/Host/HostEnrolmentTest.php`

**Interfaces:**
- Consumes: `WireguardHub::allocateIp()`, `publicKey()`, `endpoint()`, `addPeer()`.
- Produces: `HostEnrolment::issue(Host $host): string` — legt Code, Schlüsselpaar und Peer an und gibt den **Klartext-Code** genau einmal zurück. `HostEnrolment::claim(string $code): ?Host` — löst einen gültigen, unverbrauchten Code auf und markiert ihn als verbraucht. Spalten auf `hosts`: `enrolment_code_hash`, `enrolment_expires_at`, `enrolment_used_at`.

- [ ] **Step 1: Den fehlschlagenden Test schreiben**

```php
<?php

use App\Models\Host;
use App\Support\HostEnrolment;
use Illuminate\Support\Facades\DB;

/**
 * Der Code ist ein Ausweis für den Rückweg, kein Schlüssel zu einer Auskunft:
 * er öffnet nichts nach außen (§5 der Spec), sondern verhindert nur, dass ein
 * Gerät im Tunnel für einen fremden Host spricht.
 */
it('hands out the code exactly once and stores only its hash', function () {
    $host = Host::factory()->create();

    $code = HostEnrolment::issue($host);

    expect($code)->toHaveLength(32);

    $stored = DB::table('hosts')->where('id', $host->id)->value('enrolment_code_hash');
    expect($stored)->not->toBe($code)
        ->and($stored)->not->toContain($code);
});

it('resolves a valid code to its host and consumes it', function () {
    $host = Host::factory()->create();
    $code = HostEnrolment::issue($host);

    expect(HostEnrolment::claim($code)?->id)->toBe($host->id);

    // Verbraucht. Ein zweiter Lauf braucht einen neuen Code aus der Konsole —
    // eine halb installierte Maschine wird neu aufgesetzt, nicht nachgebessert.
    expect(HostEnrolment::claim($code))->toBeNull();
});

it('refuses a code past its expiry', function () {
    $host = Host::factory()->create();
    $code = HostEnrolment::issue($host);

    $this->travel(25)->hours();

    expect(HostEnrolment::claim($code))->toBeNull();
});

it('refuses a code that was never issued', function () {
    expect(HostEnrolment::claim(str_repeat('a', 32)))->toBeNull();
});

/**
 * Der Hub muss den Peer kennen, BEVOR der Host im Tunnel ist — sonst käme er
 * nie hinein und müsste seinen Schlüssel über einen öffentlichen Weg melden.
 * Genau das vermeidet dieser Entwurf.
 */
it('allocates a tunnel address and admits the peer up front', function () {
    $host = Host::factory()->create(['wg_ip' => null, 'wg_pubkey' => null]);

    HostEnrolment::issue($host);

    $host->refresh();
    expect($host->wg_ip)->not->toBeNull()
        ->and($host->wg_pubkey)->not->toBeNull();

    expect(app(\App\Services\Wireguard\WireguardHub::class)->peers())
        ->toContain($host->wg_pubkey);
});
```

- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen**

Run: `cd /home/nexxo/clupilot && docker compose exec -T -w /var/www/html/.worktrees/host-bootstrap app php artisan test --filter=HostEnrolmentTest`
Expected: FAIL mit `Class "App\Support\HostEnrolment" not found`

- [ ] **Step 3: Migration und Klasse schreiben**

Die Migration legt drei Spalten an `hosts` an — Hash, Ablauf, Verbrauch. Der
private Schlüssel wird **nicht** gespeichert: er wandert einmal in die
Befehlszeile und ist nach dem Tausch (Task 3) wertlos.

```php
public function issue(Host $host): string
{
    $hub = app(WireguardHub::class);

    // Das Paar entsteht HIER und nicht auf dem Host. Erzeugte der Host es
    // selbst, müsste er seinen öffentlichen Schlüssel melden, bevor der Tunnel
    // steht — und dafür bräuchte es einen öffentlichen Endpunkt. Der Preis ist
    // ein Schlüssel, der Minuten lebt (Task 3 tauscht ihn), der Gewinn ist eine
    // Netzgrenze, die unverändert bleibt.
    [$privateKey, $publicKey] = self::keypair();
    $ip = $hub->allocateIp();
    $hub->addPeer($publicKey, $ip);

    $code = Str::random(32);

    $host->update([
        'wg_ip' => $ip,
        'wg_pubkey' => $publicKey,
        'enrolment_code_hash' => hash('sha256', $code),
        'enrolment_expires_at' => now()->addDay(),
        'enrolment_used_at' => null,
    ]);

    // Der private Schlüssel wird bewusst nicht abgelegt. Er geht in die
    // Befehlszeile und sonst nirgendwohin.
    self::$issuedPrivateKey = $privateKey;

    return $code;
}
```

`claim()` sucht über den Hash, prüft `enrolment_expires_at > now()` und
`enrolment_used_at === null`, setzt `enrolment_used_at` und gibt den Host zurück.

**Hash statt `Hash::make`:** ein `sha256` über einen 32-Zeichen-Zufallswert reicht
hier und ist suchbar. Ein bcrypt-Hash wäre nicht in einer `where`-Bedingung
auflösbar, ohne alle Zeilen durchzuprobieren.

- [ ] **Step 4: Test laufen lassen, Erfolg bestätigen**

Run: wie Step 2
Expected: PASS, 5 Tests

- [ ] **Step 5: Volle Suite**

Run: `cd /home/nexxo/clupilot && docker compose exec -T -w /var/www/html/.worktrees/host-bootstrap app php artisan test`
Expected: PASS

- [ ] **Step 6: Committen**

```bash
git add -- database/migrations/2026_08_02_090000_give_a_host_its_enrolment.php app/Support/HostEnrolment.php tests/Feature/Host/HostEnrolmentTest.php
git commit -F - -- database/migrations/2026_08_02_090000_give_a_host_its_enrolment.php app/Support/HostEnrolment.php tests/Feature/Host/HostEnrolmentTest.php <<'EOF'
Let the console admit a host to the tunnel before it exists

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EOF
```

---

### Task 2: Die Tunnel-Beschränkung — der wichtigste Test dieses Plans

**Files:**
- Create: `app/Http/Middleware/RestrictTunnelOnly.php`
- Modify: `bootstrap/app.php` (Alias registrieren)
- Test: `tests/Feature/Host/TunnelOnlyTest.php`

**Interfaces:**
- Consumes: `config('admin_access.trusted_ranges')`, das Muster von `RestrictConsoleNetwork`.
- Produces: Middleware-Alias `tunnel-only`.

**Lies zuerst `app/Http/Middleware/RestrictConsoleNetwork.php`.** Es löst dieselbe
Frage bereits — CIDR-Vergleich gegen `trusted_ranges` — und dieser Task soll ihm
folgen, nicht eine zweite Formulierung danebenstellen. Wenn sich die Logik
herausziehen lässt, statt sie zu verdoppeln, tu das.

- [ ] **Step 1: Den fehlschlagenden Test schreiben**

```php
<?php

use App\Models\Host;

/**
 * Die härteste Bedingung der Spec (§8a): es entsteht kein einziger neuer Weg von
 * außen. Dieser Test ist der Grund, warum der Entwurf so aussieht, wie er
 * aussieht — er hält fest, dass alle drei Host-Endpunkte unsichtbar bleiben.
 */
beforeEach(function () {
    config()->set('admin_access.trusted_ranges', ['10.66.0.0/24']);
});

dataset('host endpoints', [
    'register' => ['post', '/host/register'],
    'progress' => ['post', '/host/progress'],
    'routes' => ['get', '/host/routes'],
]);

it('answers 404 from outside the tunnel', function (string $method, string $path) {
    // 404, nicht 403: eine Adresse, die es für Fremde nicht gibt, verrät auch
    // nicht, dass es sie gibt. Dasselbe Muster wie RestrictAdminHost.
    $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.9'])
        ->call($method, $path)
        ->assertNotFound();
})->with('host endpoints');

it('is reachable from inside the tunnel', function (string $method, string $path) {
    // Erreichbar heißt nicht erlaubt — ohne gültigen Ausweis folgt weiter unten
    // eine Abweisung. Hier zählt nur, dass die Beschränkung nicht schon greift.
    $this->withServerVariables(['REMOTE_ADDR' => '10.66.0.11'])
        ->call($method, $path)
        ->assertStatus(fn (int $status) => $status !== 404);
})->with('host endpoints');
```

- [ ] **Step 2: Test laufen lassen, Fehlschlag bestätigen**

Expected: FAIL — die Routen gibt es noch nicht

- [ ] **Step 3: Middleware und Routen-Gerüst schreiben**

`RestrictTunnelOnly` prüft `$request->ip()` gegen `admin_access.trusted_ranges`
und wirft sonst `abort(404)`. Die drei Routen entstehen als Gerüst, das noch
nichts tut außer zu antworten — gefüllt werden sie in Task 3 bis 5.

- [ ] **Step 4: Test laufen lassen, Erfolg bestätigen**

Expected: PASS, 6 Tests

- [ ] **Step 5: Volle Suite, dann committen**

Nachricht: `Make the host endpoints invisible from outside the tunnel`

---

### Task 3: `POST /host/register` — Token in den Tresor, Schlüssel tauschen

**Files:**
- Create: `app/Http/Controllers/HostRegistrationController.php`
- Modify: `app/Services/Secrets/SecretVault.php` (Registry-Eintrag je Host)
- Test: `tests/Feature/Host/HostRegistrationTest.php`

**Interfaces:**
- Consumes: `HostEnrolment::claim()` (Task 1), Tunnel-Middleware (Task 2), `WireguardHub::addPeer/removePeer`.
- Produces: `hosts.api_token_ref` gefüllt, `hosts.wg_pubkey` auf den frischen Schlüssel, Antwort enthält das **dauerhafte Host-Token** für den Routen-Abruf.

- [ ] **Step 1: Den fehlschlagenden Test schreiben**

```php
<?php

use App\Models\Host;
use App\Services\Wireguard\WireguardHub;
use App\Support\HostEnrolment;

beforeEach(function () {
    config()->set('admin_access.trusted_ranges', ['10.66.0.0/24']);
    $this->host = Host::factory()->create(['status' => 'onboarding']);
    $this->code = HostEnrolment::issue($this->host);
});

function registerAs(string $code, array $payload = []): \Illuminate\Testing\TestResponse
{
    return test()->withServerVariables(['REMOTE_ADDR' => '10.66.0.11'])
        ->postJson('/host/register', array_merge([
            'code' => $code,
            'api_token' => 'root@pam!clupilot=abc-123',
            'wg_pubkey' => 'FRESHKEYFRESHKEYFRESHKEYFRESHKEYFRESHKEY01=',
            'node' => 'pve-fsn-1',
            'pve_version' => '9.0-1',
            'total_gb' => 1000,
            'total_ram_mb' => 65536,
            'cpu_cores' => 16,
            'ssh_host_key' => 'SHA256:abc',
        ], $payload));
}

it('puts the proxmox token in the vault and never in the response', function () {
    $response = registerAs($this->code);

    $response->assertOk();
    expect($response->json())->not->toContain('root@pam!clupilot=abc-123');
    expect($this->host->fresh()->api_token_ref)->not->toBeNull();
});

/**
 * Die Reihenfolge ist die ganze Aussage: erst aufnehmen, dann entfernen.
 * Andersherum schneidet sich der Host im selben Aufruf den Ast ab, auf dem er
 * sitzt, und die Antwort erreicht ihn nie.
 */
it('admits the new key before dropping the old one', function () {
    $old = $this->host->fresh()->wg_pubkey;

    registerAs($this->code)->assertOk();

    $peers = app(WireguardHub::class)->peers();
    expect($peers)->toContain('FRESHKEYFRESHKEYFRESHKEYFRESHKEYFRESHKEY01=')
        ->and($peers)->not->toContain($old);
    expect($this->host->fresh()->wg_pubkey)->toBe('FRESHKEYFRESHKEYFRESHKEYFRESHKEYFRESHKEY01=');
});

it('refuses a code that belongs to another host', function () {
    $other = Host::factory()->create();
    $otherCode = HostEnrolment::issue($other);

    registerAs($otherCode)->assertOk();

    // Der fremde Host wurde eingerichtet, DIESER nicht.
    expect($this->host->fresh()->api_token_ref)->toBeNull();
});

it('refuses a spent code', function () {
    registerAs($this->code)->assertOk();
    registerAs($this->code)->assertStatus(422);
});

it('hands back a durable host token for the route pull', function () {
    expect(registerAs($this->code)->json('host_token'))->toBeString()->not->toBeEmpty();
});
```

- [ ] **Step 2 bis 6:** Fehlschlag bestätigen, schreiben, bestätigen, volle Suite, committen.

**Beim Schreiben beachten:** Der Proxmox-Token gehört in den Tresor (verschlüsselt
mit `SECRETS_KEY`), nicht in eine Spalte. `hosts.api_token_ref` hält nur den
Verweis — so war es schon vor diesem Plan, und der Handoff nennt die Verknüpfung
mit `SECRETS_KEY` ausdrücklich als das teuerste Einzelstück.

Nachricht: `Take the host's token through the tunnel and swap its key`

---

### Task 4: `POST /host/progress` — mit Nachreichen

**Files:**
- Create: `app/Http/Controllers/HostProgressController.php`
- Create: `database/migrations/2026_08_02_100000_record_what_a_host_reported.php`
- Test: `tests/Feature/Host/HostProgressTest.php`

**Interfaces:**
- Produces: Tabelle `host_progress_events` (`host_id`, `section`, `state`, `message`, `occurred_at`), die Abschnittsliste aus §7 der Spec.

Die Abschnitte, in dieser Reihenfolge und mit genau diesen Schlüsseln — sie sind
die **einzige Absprache** zwischen diesem Plan und dem Skript-Plan:

`rescue_checked`, `debian_installed`, `rebooted`, `proxmox_installed`,
`network_bridged`, `wireguard_joined`, `traefik_running`, `template_built`,
`registered`

- [ ] **Step 1: Den fehlschlagenden Test schreiben**

Der wichtigste Fall ist das **Nachreichen**: alles vor `wireguard_joined` kann der
Host nicht melden, weil es bis dahin keinen Weg zu CluPilot gibt. Er schreibt es
lokal mit und reicht es beim ersten erreichbaren Aufruf nach.

```php
it('keeps the reported timestamps, not the arrival time', function () {
    // Sonst sieht eine zwanzigminütige Installation in der Konsole aus wie eine
    // Sekunde, und man kann nicht erkennen, welcher Abschnitt lange gedauert hat.
    $reported = now()->subMinutes(18);

    postProgress($this->code, [
        ['section' => 'rescue_checked', 'state' => 'done', 'occurred_at' => $reported->toIso8601String()],
        ['section' => 'debian_installed', 'state' => 'done', 'occurred_at' => $reported->addMinutes(6)->toIso8601String()],
    ])->assertOk();

    expect($this->host->progressEvents()->first()->occurred_at->toIso8601String())
        ->toBe($reported->copy()->subMinutes(6)->toIso8601String());
});

it('refuses a section it does not know', function () {
    // Ein Abschnittsname aus dem Netz. Ohne diese Prüfung füllt jemand die
    // Fortschrittsanzeige mit erfundenen Zeilen.
    postProgress($this->code, [['section' => 'erfunden', 'state' => 'done']])
        ->assertStatus(422);
});
```

- [ ] **Steps 2 bis 6** wie gehabt. Nachricht: `Let the host tell the console how far it got`

---

### Task 5: `GET /host/routes` — Traefik holt sich seine Tabelle

**Files:**
- Create: `app/Http/Controllers/HostRoutesController.php`
- Create: `app/Services/Traefik/RouteTable.php`
- Test: `tests/Feature/Host/HostRoutesTest.php`

**Interfaces:**
- Consumes: das dauerhafte Host-Token aus Task 3, `Instance` mit `subdomain`, `custom_domain`, `routed_hostnames`.
- Produces: `RouteTable::forHost(Host $host): array` — Traefiks dynamische Konfiguration für genau diesen Host.

- [ ] **Step 1: Den fehlschlagenden Test schreiben**

```php
it('serves only the instances of this host', function () {
    // Ein entwendetes Token zeigt die Routen genau eines Hosts, nicht die aller.
    $mine = Instance::factory()->create(['host_id' => $this->host->id, 'subdomain' => 'meine']);
    $theirs = Instance::factory()->create(['subdomain' => 'fremde']);

    $body = json_encode(fetchRoutes($this->hostToken)->json());

    expect($body)->toContain('meine')->not->toContain('fremde');
});

it('serves both the subdomain and a proven custom domain', function () {
    // Dazunehmen, nicht ersetzen — dieselbe Regel wie ConfigureDnsAndTls.
    Instance::factory()->create([
        'host_id' => $this->host->id,
        'subdomain' => 'mueller',
        'custom_domain' => 'cloud.mueller-gmbh.de',
        'domain_verified_at' => now(),
    ]);

    $body = json_encode(fetchRoutes($this->hostToken)->json());

    expect($body)->toContain('mueller.')->and($body)->toContain('cloud.mueller-gmbh.de');
});

it('leaves out a custom domain that was never proven', function () {
    Instance::factory()->create([
        'host_id' => $this->host->id,
        'custom_domain' => 'nicht-bewiesen.de',
        'domain_verified_at' => null,
    ]);

    expect(json_encode(fetchRoutes($this->hostToken)->json()))
        ->not->toContain('nicht-bewiesen.de');
});
```

**Beim Schreiben:** Die Entrypoint- und certResolver-Namen müssen zu dem passen,
was `SshTraefikWriter::render()` heute ausgibt. Der Handoff warnt in Block C
ausdrücklich: *„hier zuerst nachlesen, nicht raten."* Das gilt unverändert.

- [ ] **Steps 2 bis 6.** Nachricht: `Serve a host the routes it is supposed to answer for`

---

### Task 6: `SshTraefikWriter` ablösen

**Files:**
- Modify: `app/Services/Traefik/` (neuer `TraefikWriter`, der in die Datenbank schreibt)
- Delete: `app/Services/Traefik/SshTraefikWriter.php`
- Test: `tests/Feature/Host/NoShellForRoutesTest.php`

**Interfaces:**
- Die Aufrufer (`ConfigureDnsAndTls`, `RunAcceptanceChecks`, `EndInstanceService`) ändern sich **nicht**. Sie rufen weiter `TraefikWriter::write(...)`; nur was dahinter passiert, ändert sich.

- [ ] **Step 1: Den fehlschlagenden Test schreiben**

```php
/**
 * Der Beweis, dass die Plattform keinen Shell-Zugang mehr auf Kundenhosts
 * braucht. Solange irgendwo eine RemoteShell für Routen aufgemacht wird, ist der
 * Entwurf nicht eingelöst — egal wie grün alles andere ist.
 */
it('opens no shell when an address is configured', function () {
    $s = fakeServices();
    $instance = Instance::factory()->create(['host_id' => $this->host->id]);

    app(ConfigureDnsAndTls::class)->execute(runFor($instance));

    expect($s['shell']->connectionsWith('key'))->toBeEmpty();
});

it('has no SshTraefikWriter left in the repository', function () {
    expect(file_exists(base_path('app/Services/Traefik/SshTraefikWriter.php')))->toBeFalse();
});
```

- [ ] **Steps 2 bis 6.** Nachricht: `Stop reaching into customer hosts to write a route`

---

### Task 7: Adminbereich — Befehlszeile und Fortschritt

**Files:**
- Modify: `app/Livewire/Admin/HostCreate.php`, `HostDetail.php` und deren Blades
- Test: `tests/Feature/Admin/HostEnrolmentPageTest.php`

- [ ] **Step 1: Den fehlschlagenden Test schreiben**

```php
it('shows the command exactly once, right after creating the host', function () {
    // Der Code steht nur als Hash in der Datenbank. Wer die Seite neu lädt,
    // bekommt ihn nicht wieder — er legt einen neuen an.
    $page = Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')
        ->test(HostCreate::class)
        ->set('name', 'pve-fsn-2')
        ->set('datacenter', 'fsn')
        ->set('publicIp', '198.51.45.9')
        ->call('create');

    $page->assertSee('curl');

    Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')
        ->test(HostDetail::class, ['uuid' => Host::latest('id')->first()->uuid])
        ->assertDontSee('curl');
});

it('shows which section the host is stuck in', function () {
    // „Irgendwas ging schief" ist keine Auskunft. Welcher Abschnitt offen ist,
    // ist eine.
    $this->host->progressEvents()->create([
        'section' => 'proxmox_installed', 'state' => 'done', 'occurred_at' => now()->subMinutes(30),
    ]);

    Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')
        ->test(HostDetail::class, ['uuid' => $this->host->uuid])
        ->assertSee(__('hosts.section.network_bridged'));
});
```

**R18, R19, R23, R24 gelten** — Blades werden angefasst. Die vier Wächter-Tests am
Ende ausdrücklich mitlaufen lassen. Zeiten der Fortschrittsmeldungen gehen durch
`->local()`.

- [ ] **Steps 2 bis 6.** Nachricht: `Give the operator one line to copy and a progress to watch`

---

### Task 8: Die Subdomain gehört dem Kunden

**Files:**
- Modify: `app/Livewire/Order.php` bzw. die Kasse, `app/Provisioning/Steps/Customer/ReserveResources.php`
- Create: `app/Rules/AvailableSubdomain.php`
- Test: `tests/Feature/SubdomainChoiceTest.php`

**Interfaces:**
- Heute: `ReserveResources::uniqueSubdomain()` bildet `Str::slug($customer->name).'-'.Str::random(5)`. Künftig kommt die Subdomain **aus dem Auftrag**; die Ableitung bleibt nur als Rückfall für einen leeren Wunsch.

- [ ] **Step 1: Den fehlschlagenden Test schreiben**

```php
it('refuses a name the platform needs itself', function (string $name) {
    expect(validator(['subdomain' => $name], ['subdomain' => new AvailableSubdomain])->passes())
        ->toBeFalse();
})->with(['www', 'mail', 'admin', 'api', 'app', 'ws', 'status', 'vpn']);

it('refuses a name another customer already has, whatever the case', function () {
    Instance::factory()->create(['subdomain' => 'mueller']);

    expect(validator(['subdomain' => 'MUELLER'], ['subdomain' => new AvailableSubdomain])->passes())
        ->toBeFalse();
});

it('falls back to a neutral name when the field is left empty', function () {
    // Die Vorgabe darf NICHT der Nachname sein: jede Subdomain landet über das
    // Zertifikat dauerhaft in den öffentlichen Certificate-Transparency-Logs.
    $order = orderWithoutSubdomainWish(customerNamed('Anna Müller'));

    $subdomain = app(ReserveResources::class)->execute(runFor($order))->context('subdomain');

    expect($subdomain)->not->toContain('mueller');
});

it('keeps the name the customer chose', function () {
    $order = orderWishing('meine-wolke');

    expect(app(ReserveResources::class)->execute(runFor($order))->context('subdomain'))
        ->toBe('meine-wolke');
});
```

- [ ] **Steps 2 bis 6.** Der Hinweis neben dem Feld muss sagen, dass die Adresse
  öffentlich und dauerhaft ist. Nachricht: `Let the customer pick the address they will keep`

---

### Task 9: Die Kette schrumpfen

**Files:**
- Modify: `config/provisioning.php`
- Delete: `app/Provisioning/Steps/Host/{EstablishSshTrust,PrepareBaseSystem,ConfigureWireguard,InstallProxmoxVe,RebootIntoPveKernel,ConfigureProxmox,CreateAutomationToken}.php` und ihre Tests
- Modify: `app/Provisioning/Steps/Host/SecureHostFirewall.php` — vom Schreiben zum Prüfen
- Test: `tests/Feature/Provisioning/HostPipelineShapeTest.php`

> **HALT — die einzige echte Kopplung zwischen den zwei Plänen.**
>
> Diese sieben Dateien sind die **Quelle** für den Skript-Plan. Sie enthalten
> Wissen, das aus echten Ausfällen entstanden ist: die Codename-Tabelle für
> Debian 13/PVE 9, die vollständige `vmbr0`-Diagnose, die nftables-Regeln samt
> der ICMP-Korrektur, die systemd-Aktivierung von `wg0`, und die Proxmox-Rolle
> **inklusive `Sys.Modify`**, ohne das jede Kundenbereitstellung am
> Backup-Schritt stirbt.
>
> **Führe diesen Task erst aus, wenn Task 4 bis 9 des Skript-Plans geschrieben
> sind** — oder vergewissere dich, dass der Skript-Strang sie gelesen hat. Aus
> `git log` sind sie zwar wiederherstellbar, aber niemand liest die Historie,
> wenn er nicht weiß, dass dort etwas fehlt. Genau so gehen Erkenntnisse
> verloren, die einmal einen Ausfall gekostet haben.

- [ ] **Step 1: Den fehlschlagenden Test schreiben**

```php
it('has six steps that only look, never change', function () {
    expect(config('provisioning.pipelines.host'))->toBe([
        Host\ValidateHostInput::class,
        Host\VerifyProxmoxApi::class,
        Host\VerifyVmTemplate::class,
        Host\RegisterHostDns::class,
        Host\RegisterCapacity::class,
        Host\CompleteHostOnboarding::class,
    ]);
});

it('opens no shell anywhere in the host pipeline', function () {
    // Der Beweis für §1 der Spec: der einzige dauerhafte Zugang ist der
    // Proxmox-Token.
    $s = fakeServices();

    foreach (config('provisioning.pipelines.host') as $step) {
        app($step)->execute($this->run);
    }

    expect($s['shell']->connectionsWith('key'))->toBeEmpty()
        ->and($s['shell']->connectionsWith('password'))->toBeEmpty();
});
```

**Achtung:** `SecureHostFirewall` bleibt, wird aber zur Prüfung. Ob es dafür noch
in der Kette steht oder in die Bereitschaftsprüfungen wandert, entscheidet der
Implementierer — begründet im Bericht.

- [ ] **Steps 2 bis 6.** Nachricht: `Shrink the host pipeline to six steps that only look`

---

### Task 10: Bereitschaftsprüfungen nachziehen

**Files:**
- Modify: `app/Support/Readiness/OnboardingChecks.php`, `ProvisioningChecks.php`
- Modify: `lang/de/readiness.php`, `lang/en/readiness.php`
- Test: `tests/Feature/Readiness/OnboardingChecksTest.php`, `ProvisioningChecksTest.php`

Zu ändern, aus §10 der Spec:

- `onboarding.ssh_private_key` — **nicht mehr blockierend.** Die Plattform braucht
  keinen SSH-Schlüssel mehr. Der Tresor-Eintrag bleibt (Notfallzugang), sein
  Fehlen hält nichts auf.
- `provisioning.traefik_path` — **entfällt.** Es gibt kein Verzeichnis mehr, in das
  CluPilot schreibt.
- **Neu:** mindestens ein Host holt seine Routen tatsächlich ab (letzter Abruf
  jünger als das Intervall). Ein Host, der das nicht mehr tut, serviert
  stillschweigend einen veralteten Stand.

**Der Wächter-Test aus dem Betriebsmodus-Vorhaben erzwingt, dass jeder
Tresor-Eintrag auf der Bereitschaftsseite auftaucht.** Wer hier einen Eintrag
entfernt oder umbenennt, zieht ihn nach.

- [ ] **Steps 1 bis 6.** Nachricht: `Teach the readiness page that nobody writes to a host any more`

---

## Abschluss

- [ ] Volle Suite grün.
- [ ] `VERSION` **nicht** anheben, **kein** Tag. Das passiert erst, wenn auch der Skript-Plan durch ist und beide auf echter Hardware zusammen liefen.
- [ ] Branch pushen:

```bash
git push "https://x-access-token:$TOKEN@git.bave.dev/boban/CluPilotCloud.git" feature/host-bootstrap
```

## Was danach offen bleibt

- **Der Skript-Plan** (`2026-07-30-host-uebernahme-bootstrap-skript.md`) — läuft parallel, trifft sich mit diesem nur an den Abschnittsschlüsseln aus Task 4.
- **Die Abnahme auf echter Hardware.** Sie ist die einzige, die zählt.
- Die zwei Seed-Hosts aus `DatabaseSeeder` entfernen.
- Nextcloud zurücksetzen und die Störungsmeldung — beschlossen, eigene Entwürfe, §14 der Spec.
