Release v1.3.85 — Handshake-Prüfung hing an einem Programm, das es nicht gibt
tests / pest (push) Failing after 8m54s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details

Die Übernahme brach in "WireGuard einrichten" ab: fünf Wiederholungen,
"WireGuard handshake not up yet". Geprüft wurde mit `ping -c1 -W2 <hub-ip>`.
Das fragte drei Dinge auf einmal und nannte nur eines: ob der Handshake steht,
ob ICMP durchkommt — und ob es `ping` auf der Maschine überhaupt GIBT.

Auf Hetzners `Debian-trixie-latest-amd64-base` gibt es das nicht. `iputils-ping`
ist im Image nicht dabei, und PrepareBaseSystem installiert
`curl gnupg ifupdown2 chrony`. Der Schritt scheiterte damit über einem Tunnel,
der stehen konnte. Auf dem alten Proxmox-Image war ping dabei, deshalb lief es
dort durch — dieselbe Pipeline, anderes Grundsystem.

Gefragt wird jetzt WireGuard selbst: `date +%s; wg show wg0 latest-handshakes`.
Die Uhr des HOSTS kommt in derselben Antwort mit, weil `latest-handshakes` eine
absolute Zeit ausgibt und ein Vergleich gegen UNSERE Uhr eine Zeitverschiebung
zwischen zwei Maschinen als Tunnelzustand läse. `date` ist in coreutils und
überall da.

Codex, zwei Runden:

- P1: `> 0` hiesse "hat jemals". WireGuard behält den Zeitstempel unbegrenzt,
  also meldete ein Wiederholungslauf über einem toten Tunnel "steht", und die
  Schritte danach wählten die Tunneladresse für SSH. Jetzt muss der Handshake
  frisch sein (180 s) und vom KONFIGURIERTEN Hub kommen — ein fremder Peer auf
  wg0 ist kein Beweis dafür, dass wir erreichbar sind.
- P1: Der neue Host-Versatz (.100) liess die Vergabe in einem Subnetz kleiner
  als /26 "erschöpft" melden, obwohl unten alles frei war. Der Versatz ist eine
  Bevorzugung, keine Bedingung: zweiter Durchgang von vorn.

Ausserdem, wie gewünscht: Hosts bekommen ihre Tunneladresse ab .100
(CLUPILOT_WG_HOST_OFFSET), Personen zählen weiter von unten. Fortlaufend
vergeben landete der erste Host zwischen zwei Notebooks, und wer eine Adresse
in einem Protokoll las, konnte nicht sagen, ob dahinter ein Mensch oder eine
Maschine steht.

2243 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main v1.3.85
nexxo 2026-08-01 03:00:19 +02:00
parent 2f15d1e7a4
commit b9fb401823
16 changed files with 230 additions and 29 deletions

View File

@ -1 +1 @@
1.3.84
1.3.85

View File

@ -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 <hub-ip>`. 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) {
// "<pubkey>\t<unix-zeit>". 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. */

View File

@ -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++;
}

View File

@ -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;
}
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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.

View File

@ -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',

View File

@ -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');
});

View File

@ -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,
);

View File

@ -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

View File

@ -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'])

View File

@ -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: <POINTOPOINT,UP>'));
$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: <POINTOPOINT,UP>'));
$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: <POINTOPOINT,UP>'));
$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');
});

View File

@ -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');
});

View File

@ -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'), [

View File

@ -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),
);
}