diff --git a/VERSION b/VERSION index 281fd4d..9f7fc3e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.84 +1.3.85 diff --git a/app/Provisioning/Steps/Host/ConfigureWireguard.php b/app/Provisioning/Steps/Host/ConfigureWireguard.php index b725a21..24dd75b 100644 --- a/app/Provisioning/Steps/Host/ConfigureWireguard.php +++ b/app/Provisioning/Steps/Host/ConfigureWireguard.php @@ -22,6 +22,16 @@ class ConfigureWireguard extends HostStep { private const CONFIG_PATH = '/etc/wireguard/wg0.conf'; + /** + * Wie frisch ein Handshake sein muss, damit der Tunnel als stehend gilt. + * + * WireGuard handshaked etwa alle zwei Minuten neu, und die Konfiguration, + * die dieser Schritt schreibt, hat `PersistentKeepalive = 25`. Drei Minuten + * ist damit der erste Wert, der bei einem ruhenden, aber verbundenen Peer + * nicht flackert — dieselbe Überlegung wie VpnPeer::ONLINE_AFTER_MINUTES. + */ + private const HANDSHAKE_FRESH_SECONDS = 180; + private const UNIT = 'wg-quick@wg0'; public function __construct(private RemoteShell $shell, private WireguardHub $hub) {} @@ -41,7 +51,7 @@ class ConfigureWireguard extends HostStep // wg_ip + the run's own breadcrumb keeps ONE definition of "the tunnel // works" in the pipeline, the same one keyLogin() dials on. if ($this->tunnelProven($host)) { - return $this->verifyHandshake(); + return $this->verifyHandshake(trim($this->hub->publicKey())); } // The hub identity, checked BEFORE anything is installed, allocated or @@ -73,7 +83,7 @@ class ConfigureWireguard extends HostStep // Allocate + reserve the management IP atomically so concurrent onboarding // runs can never receive the same address. $wgIp = Cache::lock('wireguard:allocate', 30)->block(10, function () use ($host) { - $ip = $host->wg_ip ?: $this->hub->allocateIp(); + $ip = $host->wg_ip ?: $this->hub->allocateIp((int) config('provisioning.wireguard.host_offset', 100)); if (blank($host->wg_ip)) { $host->update(['wg_ip' => $ip]); } @@ -111,7 +121,7 @@ class ConfigureWireguard extends HostStep // and keyLogin() must not dial an unproven tunnel. $host->update(['wg_pubkey' => $publicKey]); - if ($this->verifyHandshake()->type !== StepResult::ADVANCE) { + if ($this->verifyHandshake($hubPublicKey)->type !== StepResult::ADVANCE) { return StepResult::retry(15, 'WireGuard handshake not up yet'); } @@ -176,13 +186,58 @@ class ConfigureWireguard extends HostStep return null; } - private function verifyHandshake(): StepResult + /** + * WireGuard selbst fragen, nicht auf einen Ping schließen. + * + * Hier stand `ping -c1 -W2 `. Das prüfte drei Dinge auf einmal und + * nannte nur eines davon: dass der Handshake steht, dass ICMP durchkommt — + * und dass es `ping` auf der Maschine überhaupt GIBT. Auf Hetzners + * `Debian-trixie-…-base` gibt es das nicht: `iputils-ping` ist nicht dabei, + * und PrepareBaseSystem installiert es auch nicht. Der Schritt lief damit + * fünfmal in die Wiederholung und meldete „WireGuard handshake not up yet" + * über einen Tunnel, der stehen konnte. + * + * `latest-handshakes` gibt je Peer eine Unix-Zeit aus, `0` heißt „noch + * nie". Das ist genau die Frage, die hier gestellt wird, und die Antwort + * kommt von der Stelle, die es weiß. + */ + private function verifyHandshake(string $hubPublicKey): StepResult { - $hubIp = (string) config('provisioning.wireguard.hub_ip'); + // Die Uhr des HOSTS kommt mit, in derselben Antwort. `latest-handshakes` + // gibt eine absolute Zeit aus; sie gegen unsere Uhr zu halten hiesse, + // eine Zeitverschiebung zwischen zwei Maschinen als Tunnelzustand zu + // lesen. `date` ist in coreutils und überall da — anders als `ping`. + $result = $this->shell->run('date +%s; wg show wg0 latest-handshakes'); - return $this->shell->run('ping -c1 -W2 '.escapeshellarg($hubIp))->ok() - ? StepResult::advance() - : StepResult::retry(15, 'WireGuard handshake not up yet'); + if (! $result->ok()) { + return StepResult::retry(15, 'WireGuard handshake not up yet'); + } + + $lines = preg_split('/\R/', trim($result->stdout)) ?: []; + $now = (int) trim((string) array_shift($lines)); + + foreach ($lines as $line) { + // "\t". Nur die Zeile des konfigurierten Hubs + // zählt: ein fremder Peer auf wg0 ist kein Beweis dafür, dass WIR + // erreichbar sind. + $parts = preg_split('/\s+/', trim($line)); + + if (count($parts) < 2 || $parts[0] !== $hubPublicKey) { + continue; + } + + $seen = (int) $parts[1]; + + // `> 0` allein hiesse „hat jemals" (Codex P1). WireGuard behält den + // Zeitstempel unbegrenzt, also meldete ein Wiederholungslauf über + // einem längst toten Tunnel „steht" — und die Schritte danach + // wählten die Tunneladresse für SSH, über die niemand mehr kommt. + if ($seen > 0 && ($now - $seen) <= self::HANDSHAKE_FRESH_SECONDS) { + return StepResult::advance(); + } + } + + return StepResult::retry(15, 'WireGuard handshake not up yet'); } /** Names the setting that is missing, so the console shows what to fill in. */ diff --git a/app/Services/Wireguard/FakeWireguardHub.php b/app/Services/Wireguard/FakeWireguardHub.php index 7e6422a..6462447 100644 --- a/app/Services/Wireguard/FakeWireguardHub.php +++ b/app/Services/Wireguard/FakeWireguardHub.php @@ -16,8 +16,13 @@ class FakeWireguardHub implements WireguardHub /** Snapshots the tests can shape; defaults to "configured, never handshaked". */ public array $snapshots = []; - public function allocateIp(): string + public function allocateIp(int $from = 1): string { + // Der Zähler springt nach vorn, wenn ab einer höheren Stelle gesucht + // wird, und läuft danach von dort weiter — sonst gäbe die Attrappe + // zweimal dieselbe Adresse aus, sobald jemand einmal mit Versatz fragt. + $this->next = max($this->next, $from); + return '10.66.0.'.$this->next++; } diff --git a/app/Services/Wireguard/LocalWireguardHub.php b/app/Services/Wireguard/LocalWireguardHub.php index 568821c..5a8288b 100644 --- a/app/Services/Wireguard/LocalWireguardHub.php +++ b/app/Services/Wireguard/LocalWireguardHub.php @@ -16,7 +16,7 @@ use RuntimeException; */ class LocalWireguardHub implements WireguardHub { - public function allocateIp(): string + public function allocateIp(int $from = 1): string { // Both tables hand out addresses from the same subnet — an operator VPN // access and a host peer would otherwise be given the same tunnel IP. @@ -34,10 +34,22 @@ class LocalWireguardHub implements WireguardHub $base = ip2long($network) & (0xFFFFFFFF << $hostBits) & 0xFFFFFFFF; // Skip the network address (offset 0) and broadcast (offset size-1). - for ($offset = 1; $offset < $size - 1; $offset++) { - $ip = long2ip($base + $offset); - if ($ip !== $hubIp && ! isset($used[$ip])) { - return $ip; + // + // Zwei Durchgänge, weil `$from` eine BEVORZUGUNG ist und keine + // Bedingung: erst ab dort, dann von vorn. Das konfigurierte Subnetz darf + // kleiner sein als der Versatz — in einem /26 liegt 100 ausserhalb, und + // eine Vergabe, die „Subnetz erschöpft" meldet, während unten alles frei + // ist, wäre die schlechtere Antwort als eine Adresse aus dem + // Personenbereich (Codex P1). + // + // Der Versatz ist damit auch keine Obergrenze: läuft der Hostbereich + // voll, zählt der erste Durchgang einfach weiter. + foreach ([max(1, $from), 1] as $start) { + for ($offset = $start; $offset < $size - 1; $offset++) { + $ip = long2ip($base + $offset); + if ($ip !== $hubIp && ! isset($used[$ip])) { + return $ip; + } } } diff --git a/app/Services/Wireguard/WireguardHub.php b/app/Services/Wireguard/WireguardHub.php index 4edc194..65f9cb9 100644 --- a/app/Services/Wireguard/WireguardHub.php +++ b/app/Services/Wireguard/WireguardHub.php @@ -9,7 +9,15 @@ namespace App\Services\Wireguard; interface WireguardHub { /** Allocate a free management IP from the hub subnet. */ - public function allocateIp(): string; + /** + * Die nächste freie Adresse ab `$from` (Zähler im Subnetz, nicht Oktett). + * + * Personen und Hosts kommen aus demselben Subnetz, sollen aber nicht + * ineinander wachsen: fortlaufend vergeben landete der erste Host zwischen + * zwei Notebooks, und wer eine Adresse in einem Protokoll sieht, konnte + * nicht sagen, ob dahinter ein Mensch oder eine Maschine steht. + */ + public function allocateIp(int $from = 1): string; public function addPeer(string $publicKey, string $ip): void; diff --git a/app/Support/HostEnrolment.php b/app/Support/HostEnrolment.php index df950e1..9c2edc6 100644 --- a/app/Support/HostEnrolment.php +++ b/app/Support/HostEnrolment.php @@ -72,7 +72,7 @@ final class HostEnrolment // Übernahmeversuch soll denselben Host an derselben Adresse ergeben — // die Spalte ist eindeutig indiziert, und eine neue Adresse hieße, die // alte für immer belegt zu lassen. - $ip = $host->wg_ip ?: $hub->allocateIp(); + $ip = $host->wg_ip ?: $hub->allocateIp((int) config('provisioning.wireguard.host_offset', 100)); $previous = $host->wg_pubkey; diff --git a/config/provisioning.php b/config/provisioning.php index 77d450c..61cc57a 100644 --- a/config/provisioning.php +++ b/config/provisioning.php @@ -410,6 +410,15 @@ return [ 'endpoint' => env('CLUPILOT_WG_ENDPOINT', ''), // host:port reachable by peers 'hub_public_key' => env('CLUPILOT_WG_HUB_PUBKEY', ''), 'config_path' => env('CLUPILOT_WG_CONFIG_PATH', '/etc/wireguard/wg0.conf'), + + // Ab welcher Stelle im Subnetz HOSTS ihre Tunneladresse bekommen. + // + // Personen zählen von unten (10.66.0.2, .3, …), Maschinen ab .100. + // Fortlaufend vergeben landete der erste Host zwischen zwei Notebooks, + // und wer eine Adresse in einem Protokoll sah, konnte nicht sagen, ob + // dahinter ein Mensch oder ein Server steckt. Es ist eine Untergrenze, + // keine Obergrenze: läuft der Bereich voll, zählt die Vergabe weiter. + 'host_offset' => (int) env('CLUPILOT_WG_HOST_OFFSET', 100), ], // SSH identity CluPilot deploys to each host after first password login. diff --git a/tests/Feature/Admin/VpnTest.php b/tests/Feature/Admin/VpnTest.php index c482b16..feaf6a8 100644 --- a/tests/Feature/Admin/VpnTest.php +++ b/tests/Feature/Admin/VpnTest.php @@ -419,7 +419,7 @@ it('reports a lost creation race as a validation error, not a 500', function () { public function __construct(private string $collidingKey) {} - public function allocateIp(): string + public function allocateIp(int $from = 1): string { VpnPeer::create([ 'name' => 'gleichzeitig', diff --git a/tests/Feature/Provisioning/CustomerDataModelTest.php b/tests/Feature/Provisioning/CustomerDataModelTest.php index 43f8711..ed17528 100644 --- a/tests/Feature/Provisioning/CustomerDataModelTest.php +++ b/tests/Feature/Provisioning/CustomerDataModelTest.php @@ -45,7 +45,7 @@ it('enforces a unique subdomain', function () { it('encrypts nc_admin_ref at rest', function () { $instance = Instance::factory()->create(['nc_admin_ref' => 'admin']); - $raw = \DB::table('instances')->where('id', $instance->id)->value('nc_admin_ref'); + $raw = DB::table('instances')->where('id', $instance->id)->value('nc_admin_ref'); expect($raw)->not->toBe('admin') ->and($instance->fresh()->nc_admin_ref)->toBe('admin'); }); diff --git a/tests/Feature/Provisioning/CustomerProvisioningEndToEndTest.php b/tests/Feature/Provisioning/CustomerProvisioningEndToEndTest.php index a0b8ea9..f2426e8 100644 --- a/tests/Feature/Provisioning/CustomerProvisioningEndToEndTest.php +++ b/tests/Feature/Provisioning/CustomerProvisioningEndToEndTest.php @@ -7,6 +7,7 @@ use App\Models\ProvisioningRun; use App\Models\RunResource; use App\Notifications\CloudReady; use App\Provisioning\RunRunner; +use App\Provisioning\Steps\Customer\CloneVirtualMachine; use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Queue; @@ -96,7 +97,7 @@ it('does not duplicate external resources when a step re-runs after a crash', fu $runner->advance($run); } $cloneIndex = array_search( - \App\Provisioning\Steps\Customer\CloneVirtualMachine::class, + CloneVirtualMachine::class, config('provisioning.pipelines.customer'), true, ); diff --git a/tests/Feature/Provisioning/CustomerStepsTest.php b/tests/Feature/Provisioning/CustomerStepsTest.php index 1caf1c1..922e2bc 100644 --- a/tests/Feature/Provisioning/CustomerStepsTest.php +++ b/tests/Feature/Provisioning/CustomerStepsTest.php @@ -269,7 +269,7 @@ it('deploys the stack, polling until healthy, with an encrypted reusable db pass $enc = $run->fresh()->context('db_password'); expect($enc)->not->toBeNull(); // the persisted context holds only the ENCRYPTED credential, never plaintext - $raw = \DB::table('provisioning_runs')->where('id', $run->id)->value('context'); + $raw = DB::table('provisioning_runs')->where('id', $run->id)->value('context'); expect($raw)->not->toContain(Crypt::decryptString($enc)); // healthy → advance, secret scrubbed @@ -301,7 +301,7 @@ it('creates the admin once, never persisting the password in plaintext', functio ->and(RunResource::where('run_id', $run->id)->where('kind', 'nc_admin')->count())->toBe(1); // raw context in DB must not contain the decrypted password - $rawContext = \DB::table('provisioning_runs')->where('id', $run->id)->value('context'); + $rawContext = DB::table('provisioning_runs')->where('id', $run->id)->value('context'); expect($rawContext)->not->toContain(Crypt::decryptString($run->context('admin_password'))); // idempotent diff --git a/tests/Feature/Provisioning/HostOnboardingEndToEndTest.php b/tests/Feature/Provisioning/HostOnboardingEndToEndTest.php index 7c87b5b..37c0e8c 100644 --- a/tests/Feature/Provisioning/HostOnboardingEndToEndTest.php +++ b/tests/Feature/Provisioning/HostOnboardingEndToEndTest.php @@ -23,6 +23,7 @@ it('drives a fresh host all the way to active (mocked)', function () { Queue::fake(); // drive the runner manually, one step per advance $s = fakeServices(); $s['shell']->script('wg pubkey', CommandResult::success('HOSTPUBKEY0000=')); + scriptLiveTunnel($s['shell']); $s['shell']->script('uname -r', CommandResult::success('6.8.12-4-pve')); $s['shell']->script('pveum user token add', CommandResult::success( json_encode(['full-tokenid' => 'automation@pve!clupilot', 'value' => 'tok-secret-123']) @@ -86,6 +87,7 @@ it('does not duplicate external resources when a step re-runs after a crash', fu Queue::fake(); $s = fakeServices(); $s['shell']->script('wg pubkey', CommandResult::success('HOSTPUBKEY0000=')); + scriptLiveTunnel($s['shell']); $s['shell']->script('uname -r', CommandResult::success('6.8.12-4-pve')); $s['shell']->script('pveum user token add', CommandResult::success( json_encode(['full-tokenid' => 'automation@pve!clupilot', 'value' => 'tok-secret-123']) diff --git a/tests/Feature/Provisioning/HostStepsTest.php b/tests/Feature/Provisioning/HostStepsTest.php index 8facac7..665a6ed 100644 --- a/tests/Feature/Provisioning/HostStepsTest.php +++ b/tests/Feature/Provisioning/HostStepsTest.php @@ -24,7 +24,9 @@ use App\Provisioning\Steps\Host\VerifyVmTemplate; use App\Services\Secrets\SecretVault; use App\Services\Ssh\CommandResult; use App\Services\Ssh\FakeRemoteShell; +use App\Services\Wireguard\WireguardHub; use Illuminate\Support\Facades\Crypt; +use Illuminate\Support\Str; function hostRun(Host $host, array $context = []): ProvisioningRun { @@ -291,6 +293,7 @@ it('retries base preparation on an apt failure', function () { it('configures wireguard and registers the peer exactly once', function () { $s = fakeServices(); $s['shell']->script('wg pubkey', CommandResult::success('HOSTPUB=')); + scriptLiveTunnel($s['shell']); $host = Host::factory()->create(); $run = hostRun($host); @@ -352,6 +355,7 @@ it('enables wg-quick@wg0 so the tunnel comes back after the reboot', function () // and step 6 is the reboot this same pipeline performs two steps later. $s = fakeServices(); $s['shell']->script('wg pubkey', CommandResult::success('HOSTPUB=')); + scriptLiveTunnel($s['shell']); $s['shell']->script('ip link show wg0', CommandResult::failure(1)); // fresh host $s['shell']->script('systemctl is-enabled', CommandResult::failure(1)); // never enabled $run = hostRun(Host::factory()->create()); @@ -368,6 +372,7 @@ it('does not fight an interface that is already up and correct', function () { // attempt, so the step retried forever against a tunnel that was working. $s = fakeServices(); $s['shell']->script('wg pubkey', CommandResult::success('HOSTPUB=')); + scriptLiveTunnel($s['shell']); $s['shell']->script('ip link show wg0', CommandResult::success('3: wg0: ')); $s['shell']->script('systemctl is-enabled', CommandResult::success('enabled')); $host = Host::factory()->create(); @@ -383,6 +388,7 @@ it('does not fight an interface that is already up and correct', function () { RunResource::where('run_id', $run->id)->where('kind', 'wg_peer')->delete(); $again = fakeServices(); $again['shell']->script('wg pubkey', CommandResult::success('HOSTPUB=')); + scriptLiveTunnel($again['shell']); $again['shell']->script('ip link show wg0', CommandResult::success('3: wg0: ')); $again['shell']->script('systemctl is-enabled', CommandResult::success('enabled')); $again['shell']->script("cat '/etc/wireguard/wg0.conf'", CommandResult::success($written)); @@ -399,6 +405,7 @@ it('actually applies a corrected hub key instead of only writing the file', func // running tunnel kept the old peer. $s = fakeServices(); $s['shell']->script('wg pubkey', CommandResult::success('HOSTPUB=')); + scriptLiveTunnel($s['shell']); $s['shell']->script('ip link show wg0', CommandResult::success('3: wg0: ')); $s['shell']->script('systemctl is-enabled', CommandResult::success('enabled')); $s['shell']->script('cat \'/etc/wireguard/wg0.conf\'', CommandResult::success( @@ -1256,3 +1263,62 @@ it('sees an internal DNS entry registered while the purge was waiting', function expect($s['hostDns']->ips)->not->toHaveKey($name) ->and(Host::query()->whereKey($host->id)->exists())->toBeFalse(); }); + +it('does not call a tunnel up that has never handshaken', function () { + // `0` ist WireGuards Antwort für „noch nie". Die Prüfung davor war ein + // `ping` auf die Hub-Adresse — die fragte drei Dinge auf einmal und nannte + // nur eines: ob der Handshake steht, ob ICMP durchkommt, und ob es `ping` + // überhaupt GIBT. Auf Hetzners `Debian-…-base` gibt es das nicht, und der + // Schritt scheiterte fünfmal über einem Tunnel, der stehen konnte. + $s = fakeServices(); + $s['shell']->script('wg pubkey', CommandResult::success('HOSTPUB=')); + $s['shell']->script('wg show wg0 latest-handshakes', CommandResult::success(time()."\n".trim(app(WireguardHub::class)->publicKey())."\t0")); + $run = hostRun(Host::factory()->create()); + + expect(app(ConfigureWireguard::class)->execute($run)->type)->toBe('retry'); +}); + +it('gives a host an address from the host range, not the next free one', function () { + // Fortlaufend vergeben landete der erste Host zwischen zwei Notebooks. Wer + // eine Adresse in einem Protokoll liest, soll sehen, ob dahinter ein Mensch + // oder eine Maschine steht. + $s = fakeServices(); + $s['shell']->script('wg pubkey', CommandResult::success('HOSTPUB=')); + scriptLiveTunnel($s['shell']); + $host = Host::factory()->create(['wg_ip' => null]); + + app(ConfigureWireguard::class)->execute(hostRun($host)); + + expect((int) Str::afterLast($host->fresh()->wg_ip, '.')) + ->toBeGreaterThanOrEqual(config('provisioning.wireguard.host_offset')); +}); + +it('does not accept a handshake that has gone stale', function () { + // `> 0` allein hiesse „hat jemals" (Codex P1): WireGuard behält den + // Zeitstempel unbegrenzt. Ein Wiederholungslauf über einem längst toten + // Tunnel meldete damit „steht" — und die Schritte danach wählten die + // Tunneladresse für SSH, über die niemand mehr hinkommt. + $s = fakeServices(); + $s['shell']->script('wg pubkey', CommandResult::success('HOSTPUB=')); + $now = time(); + $s['shell']->script('wg show wg0 latest-handshakes', CommandResult::success( + $now."\n".trim(app(WireguardHub::class)->publicKey())."\t".($now - 3600) + )); + $run = hostRun(Host::factory()->create()); + + expect(app(ConfigureWireguard::class)->execute($run)->type)->toBe('retry'); +}); + +it('ignores a stranger on wg0 and asks only about the configured hub', function () { + // Ein fremder Peer auf der Schnittstelle ist kein Beweis dafür, dass WIR + // erreichbar sind. + $s = fakeServices(); + $s['shell']->script('wg pubkey', CommandResult::success('HOSTPUB=')); + $now = time(); + $s['shell']->script('wg show wg0 latest-handshakes', CommandResult::success( + $now."\n".'FREMDERPEER0000000000000000000000000000000='."\t".$now + )); + $run = hostRun(Host::factory()->create()); + + expect(app(ConfigureWireguard::class)->execute($run)->type)->toBe('retry'); +}); diff --git a/tests/Feature/Provisioning/ServicesTest.php b/tests/Feature/Provisioning/ServicesTest.php index 3f76334..afe0bb7 100644 --- a/tests/Feature/Provisioning/ServicesTest.php +++ b/tests/Feature/Provisioning/ServicesTest.php @@ -146,3 +146,22 @@ it('treats removing a name that was never written as done (FileHostDnsDirectory) // — a host that never got a name still has to purge cleanly. (new FileHostDnsDirectory)->remove('never-written'); })->throwsNoExceptions(); + +it('starts people at the bottom of the subnet and machines at the host offset', function () { + // Ein Bereich, keine zweite Grenze: `from` verschiebt nur den Anfang. Liefe + // er als Obergrenze, stünde die Vergabe irgendwann still, obwohl das + // Subnetz noch Platz hat. + expect((new LocalWireguardHub)->allocateIp())->toBe('10.66.0.2') + ->and((new LocalWireguardHub)->allocateIp(100))->toBe('10.66.0.100'); +}); + +it('falls back into the lower range when the host offset does not fit the subnet', function () { + // Ein /26 hat 64 Adressen; der Versatz von 100 liegt ausserhalb. Eine + // Vergabe, die dann "Subnetz erschöpft" meldet, während unten alles frei + // ist, wäre die schlechtere Antwort — der Versatz ist eine Bevorzugung, + // keine Bedingung. + config()->set('provisioning.wireguard.subnet', '10.66.7.0/26'); + config()->set('provisioning.wireguard.hub_ip', '10.66.7.1'); + + expect((new LocalWireguardHub)->allocateIp(100))->toBe('10.66.7.2'); +}); diff --git a/tests/Feature/Provisioning/SubscriptionSnapshotTest.php b/tests/Feature/Provisioning/SubscriptionSnapshotTest.php index b5127b1..deea7be 100644 --- a/tests/Feature/Provisioning/SubscriptionSnapshotTest.php +++ b/tests/Feature/Provisioning/SubscriptionSnapshotTest.php @@ -4,10 +4,12 @@ use App\Actions\OpenSubscription; use App\Models\Host; use App\Models\Instance; use App\Models\Order; +use App\Models\PlanPrice; use App\Models\PlanVersion; use App\Models\ProvisioningRun; use App\Models\Subscription; use App\Provisioning\Jobs\AdvanceRunJob; +use App\Provisioning\RunRunner; use App\Provisioning\Steps\Customer\CloneVirtualMachine; use App\Provisioning\Steps\Customer\ReserveResources; use App\Provisioning\Steps\Customer\ValidateOrder; @@ -338,10 +340,10 @@ it('restarts a run that already failed for want of the contract', function () { Queue::fake(); // The plan cannot be priced when the payment lands, so no contract opens. - $price = app(App\Services\Billing\PlanCatalogue::class)->currentVersion('team')->priceFor('monthly'); + $price = app(PlanCatalogue::class)->currentVersion('team')->priceFor('monthly'); $amount = $price->amount_cents; // Forced past the guard, the way a bad repair script would. - App\Models\PlanPrice::query()->whereKey($price->id)->delete(); + PlanPrice::query()->whereKey($price->id)->delete(); $event = [ 'id' => 'evt_strand', @@ -362,7 +364,7 @@ it('restarts a run that already failed for want of the contract', function () { $run = ProvisioningRun::query()->where('subject_id', $order->id)->sole(); // The run gets as far as validating and stops dead. - app(App\Provisioning\RunRunner::class)->advance($run); + app(RunRunner::class)->advance($run); expect($run->fresh()->status)->toBe('failed') ->and($run->fresh()->error)->toContain('no_subscription') @@ -370,7 +372,7 @@ it('restarts a run that already failed for want of the contract', function () { // The owner restores the price. Nothing sweeps failed runs, so without the // retry repairing it this customer would stay paid-for and unprovisioned. - app(App\Services\Billing\PlanCatalogue::class)->currentVersion('team') + app(PlanCatalogue::class)->currentVersion('team') ->prices()->create(['term' => 'monthly', 'amount_cents' => $amount, 'currency' => 'EUR']); $this->postJson(route('webhooks.stripe'), $event)->assertOk(); @@ -380,7 +382,7 @@ it('restarts a run that already failed for want of the contract', function () { ->and($run->fresh()->error)->toBeNull() ->and($order->fresh()->status)->toBe('paid'); - Queue::assertPushed(App\Provisioning\Jobs\AdvanceRunJob::class); + Queue::assertPushed(AdvanceRunJob::class); }); it('keeps the record of a payment even when no contract can be opened', function () { @@ -388,8 +390,8 @@ it('keeps the record of a payment even when no contract can be opened', function // The monthly price row is gone — the plan looks live but cannot be sold. // Forced past the guard, the way a bad repair script would. - App\Models\PlanPrice::query() - ->whereKey(app(App\Services\Billing\PlanCatalogue::class)->currentVersion('team')->priceFor('monthly')->id) + PlanPrice::query() + ->whereKey(app(PlanCatalogue::class)->currentVersion('team')->priceFor('monthly')->id) ->delete(); $this->postJson(route('webhooks.stripe'), [ diff --git a/tests/Pest.php b/tests/Pest.php index 26b48b7..f901758 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -12,6 +12,7 @@ use App\Services\Monitoring\MonitoringClient; use App\Services\Proxmox\FakeProxmoxClient; use App\Services\Proxmox\ProxmoxClient; use App\Services\Secrets\SecretVault; +use App\Services\Ssh\CommandResult; use App\Services\Ssh\FakeRemoteShell; use App\Services\Ssh\RemoteShell; use App\Services\Traefik\FakeTraefikWriter; @@ -222,3 +223,24 @@ function withRealEnv(array $vars, Closure $callback): mixed setRealEnv($previous); } } + +/** + * Was ein stehender Tunnel auf dem Host meldet. + * + * `date +%s` zuerst, dann eine Zeile je Peer. Beide Zeitstempel sind JETZT, + * damit der Test nicht an einer festen Zahl hängt, die eines Tages zu alt ist — + * die Prüfung verlangt einen FRISCHEN Handshake, nicht irgendeinen. + * + * Nach fakeServices() aufrufen: der Hub-Schlüssel kommt aus der gebundenen + * Attrappe, denn genau gegen den vergleicht der Schritt. + */ +function scriptLiveTunnel(FakeRemoteShell $shell): void +{ + $now = time(); + $hub = trim(app(WireguardHub::class)->publicKey()); + + $shell->script( + 'wg show wg0 latest-handshakes', + CommandResult::success($now."\n".$hub."\t".$now), + ); +}