Tell a read-only token apart from one that can write

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/betriebsmodus
nexxo 2026-07-30 13:56:28 +02:00
parent 0f4226c488
commit 2eff3a5a3c
5 changed files with 500 additions and 0 deletions

View File

@ -0,0 +1,82 @@
<?php
namespace App\Services\Dns;
use App\Services\Secrets\SecretVault;
use App\Support\ProvisioningSettings;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Throwable;
/**
* Whether the configured Hetzner DNS token can actually WRITE to the zone,
* not merely read it.
*
* Listing the zone proves nothing: a read-only token lists it just as
* cleanly as a read-write one, and looks identical in the console it only
* fails once a customer VM needs its A record, after the customer has
* already paid. So this writes a throwaway TXT record and deletes it again
* immediately; the write (and its rejection) is the only thing that actually
* tells the two kinds of token apart.
*
* Deliberately does its own HTTP instead of going through HttpHetznerDnsClient:
* that client always reads the STORED token from the vault, and this check
* has to be able to test a candidate value before it is ever saved exactly
* the same reason StripeCheck does not go through HttpStripeClient.
*/
final class DnsTokenCheck
{
/** @return array<string, mixed> */
public function run(?string $candidate = null): array
{
$token = $candidate ?: app(SecretVault::class)->get('dns.token');
$zone = ProvisioningSettings::dnsZone();
if (blank($token) || blank($zone)) {
return ['ok' => false, 'reason' => 'missing'];
}
try {
$zones = Http::withHeaders(['Auth-API-Token' => $token])->acceptJson()->timeout(15)
->get('https://dns.hetzner.com/api/v1/zones');
} catch (Throwable) {
return ['ok' => false, 'reason' => 'unreachable'];
}
if ($zones->status() === 401 || $zones->status() === 403) {
return ['ok' => false, 'reason' => 'rejected'];
}
$zoneId = collect($zones->json('zones') ?? [])->firstWhere('name', $zone)['id'] ?? null;
if ($zoneId === null) {
return ['ok' => false, 'reason' => 'zone_not_found', 'zone' => $zone];
}
// The actual point of this whole check. A read-only token gets this
// far and looks in the console like a working one; it fails only when
// a customer VM needs its A record — after payment.
$probe = Http::withHeaders(['Auth-API-Token' => $token])->acceptJson()->timeout(15)
->post('https://dns.hetzner.com/api/v1/records', [
'zone_id' => $zoneId,
'type' => 'TXT',
'name' => '_clupilot-write-probe-'.Str::lower(Str::random(12)),
'value' => 'clupilot readiness probe',
'ttl' => 60,
]);
if (! $probe->successful()) {
return ['ok' => false, 'reason' => 'read_only', 'status' => $probe->status()];
}
// Always clean up, even when the delete itself fails: a leftover TXT
// record with this name is harmless, but has to be named in the
// result so someone can remove it by hand.
$recordId = $probe->json('record.id');
$removed = Http::withHeaders(['Auth-API-Token' => $token])->acceptJson()->timeout(15)
->delete('https://dns.hetzner.com/api/v1/records/'.$recordId)
->successful();
return ['ok' => true, 'reason' => 'writable', 'probe_removed' => $removed];
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Services\Proxmox;
use App\Models\Host;
use App\Models\PlanVersion;
use Throwable;
/**
* Whether every published plan's VM template actually EXISTS on a node, not
* merely that a `template_vmid` is filled in somewhere.
*
* Asks the same question as the onboarding step VerifyVmTemplate, but over
* ALL active hosts instead of one, and BEFORE a purchase instead of after:
* ProvisioningChecks::vm_template (Task 8) is a field check only it cannot
* tell a `template_vmid` that was typed by mistake from one that is really
* there, and asking Proxmox on every page load does not belong on a
* readiness page. This is the on-demand button that actually asks.
*/
final class VmTemplateCheck
{
public function __construct(private ProxmoxClient $pve) {}
/** @return array<string, mixed> */
public function run(): array
{
$required = PlanVersion::query()
->whereNotNull('published_at')
->whereNotNull('template_vmid')
->where(fn ($q) => $q->whereNull('available_until')->orWhere('available_until', '>', now()))
->pluck('template_vmid')->map(fn ($v) => (int) $v)->unique()->values()->all();
// A catalogue with no published version requires no template. The
// same case VerifyVmTemplate explicitly lets through.
if ($required === []) {
return ['ok' => true, 'reason' => 'nothing_published'];
}
$hosts = Host::query()->where('status', 'active')->whereNotNull('api_token_ref')->get();
if ($hosts->isEmpty()) {
return ['ok' => false, 'reason' => 'no_usable_host'];
}
$missing = [];
foreach ($required as $vmid) {
$found = false;
foreach ($hosts as $host) {
try {
if ($this->pve->forHost($host)->vmExists($host->node ?? 'pve', $vmid)) {
$found = true;
break;
}
} catch (Throwable) {
// A host that is not answering right now is not proof of
// a missing template. Keep looking at the others.
continue;
}
}
if (! $found) {
$missing[] = $vmid;
}
}
return $missing === []
? ['ok' => true, 'reason' => 'present']
: ['ok' => false, 'reason' => 'template_missing', 'vmids' => $missing];
}
}

View File

@ -0,0 +1,92 @@
<?php
namespace App\Services\Vpn;
use App\Support\ProvisioningSettings;
use Illuminate\Support\Str;
/**
* Whether the WireGuard hub endpoint an onboarding host is told to dial is
* even CAPABLE of being reached from outside this network.
*
* Deliberately makes no network call. A UDP port cannot be knocked on
* cleanly from the outside there is no handshake to observe without a real
* peer, and this class runs on every readiness page load, not on demand. What
* IS decidable without touching the wire: whether the address configured is
* one a host on the internet could even attempt to reach. `10.10.90.185` and
* `203.0.113.11` (RFC 5737) both look "set" and are unreachable from any host
* outside this operator's own network or a documentation example generator
* exactly the two shapes seen in this installation's configuration on
* 2026-07-30.
*/
final class WireguardEndpointCheck
{
/**
* RFC 5737 "TEST-NET" ranges: documentation and example addresses, never
* routed on the real internet. PHP's own FILTER_FLAG_NO_RES_RANGE does
* NOT reject these (verified: 203.0.113.11 passes filter_var with that
* flag) exactly the range a seed value or a copy-pasted example lands
* in, which is precisely the failure mode this check exists to catch.
*
* @var array<int, string>
*/
private const DOCUMENTATION_RANGES = [
'192.0.2.0/24', // TEST-NET-1
'198.51.100.0/24', // TEST-NET-2
'203.0.113.0/24', // TEST-NET-3
];
/** @return array<string, mixed> */
public function run(?string $endpoint = null): array
{
$endpoint ??= ProvisioningSettings::wgEndpoint();
if (blank($endpoint)) {
return ['ok' => false, 'reason' => 'missing'];
}
if (! str_contains($endpoint, ':')) {
return ['ok' => false, 'reason' => 'no_port'];
}
$host = Str::beforeLast($endpoint, ':');
// No valid IP means: a name. It can resolve publicly, and guessing
// here would be worse than letting it through — the check states what
// it knows, not what it suspects.
if (! filter_var($host, FILTER_VALIDATE_IP)) {
return ['ok' => true, 'reason' => 'hostname', 'address' => $host];
}
// NO_PRIV_RANGE covers RFC 1918, NO_RES_RANGE covers loopback and
// link-local among others — exactly the private address that stood
// at one host on 2026-07-30.
$public = filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
if ($public === false || $this->isDocumentationAddress($host)) {
return ['ok' => false, 'reason' => 'not_public', 'address' => $host];
}
return ['ok' => true, 'reason' => 'public', 'address' => $host];
}
private function isDocumentationAddress(string $ip): bool
{
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return false;
}
$long = ip2long($ip);
foreach (self::DOCUMENTATION_RANGES as $cidr) {
[$subnet, $bits] = explode('/', $cidr);
$mask = -1 << (32 - (int) $bits);
if (($long & $mask) === (ip2long($subnet) & $mask)) {
return true;
}
}
return false;
}
}

View File

@ -39,6 +39,15 @@
though no current test happens to need it. -->
<env name="STRIPE_WEBHOOK_SECRET" value="" force="true"/>
<env name="STRIPE_WEBHOOK_SECRET_TEST" value="" force="true"/>
<!-- Blank so the suite never reads this operator's REAL Hetzner DNS token
from .env. This one is load-bearing in a way the two above are not:
DnsTokenCheck proves write access by actually WRITING a TXT record
to the configured zone and deleting it again — a suite that fell
through to the real token would write to and delete from the real
clupilot.cloud zone on every test run. Every test that exercises
DnsTokenCheck uses Http::fake() and supplies its own candidate
token instead. -->
<env name="HETZNER_DNS_TOKEN" value="" force="true"/>
<!-- Pinned so the suite stops depending on the operator's .env. It did:
HostStepsTest asserted `fsn-01.node.clupilot.com` and passed only
because this server's .env carried the wrong zone. Correcting the

View File

@ -0,0 +1,245 @@
<?php
use App\Models\Host;
use App\Models\PlanFamily;
use App\Models\PlanVersion;
use App\Services\Dns\DnsTokenCheck;
use App\Services\Proxmox\FakeProxmoxClient;
use App\Services\Proxmox\ProxmoxClient;
use App\Services\Proxmox\VmTemplateCheck;
use App\Services\Vpn\WireguardEndpointCheck;
use App\Support\Settings;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
/**
* Ein UDP-Port lässt sich von außen nicht sauber anklopfen es gibt keinen
* Handshake zu beobachten. Was ENTSCHEIDBAR ist: ob dort überhaupt eine
* Adresse steht, die ein Host im Internet erreichen kann.
*
* Am 2026-07-30 stand `10.10.90.185:51820` in der Konfiguration. Das sieht
* gesetzt aus und ist für jeden Host außerhalb dieses Netzes unerreichbar.
*
* Jeder Test ruft trotzdem Http::fake() ohne Ausnahme, wie jeder Test dieser
* drei Prüfungen. WireguardEndpointCheck macht heute keinen Netzverkehr, aber
* die Zusicherung gilt der Prüfung, nicht der heutigen Implementierung.
*/
it('rejects a private address', function () {
Http::fake();
expect((new WireguardEndpointCheck)->run('10.10.90.185:51820')['ok'])->toBeFalse();
});
it('rejects a documentation address', function () {
// RFC 5737 — Seed- und Beispieldaten landen erfahrungsgemäß in echten
// Installationen.
Http::fake();
expect((new WireguardEndpointCheck)->run('203.0.113.11:51820')['ok'])->toBeFalse();
});
it('rejects loopback', function () {
Http::fake();
expect((new WireguardEndpointCheck)->run('127.0.0.1:51820')['ok'])->toBeFalse();
});
it('accepts a public address', function () {
Http::fake();
expect((new WireguardEndpointCheck)->run('198.51.45.9:51820')['ok'])->toBeTrue();
});
it('accepts a hostname, which it cannot judge', function () {
// Ein Name kann öffentlich auflösen; hier zu raten wäre schlechter als
// durchzulassen — die Prüfung sagt, was sie weiß, nicht was sie vermutet.
Http::fake();
expect((new WireguardEndpointCheck)->run('vpn.clupilot.cloud:51820')['ok'])->toBeTrue();
});
it('rejects an endpoint without a port', function () {
Http::fake();
expect((new WireguardEndpointCheck)->run('198.51.45.9')['ok'])->toBeFalse();
});
it('names the offending address, so the page can show it', function () {
Http::fake();
expect((new WireguardEndpointCheck)->run('10.10.90.185:51820')['reason'])->toBe('not_public');
});
it('rejects a blank endpoint', function () {
Http::fake();
expect((new WireguardEndpointCheck)->run('')['reason'])->toBe('missing');
});
// --- DnsTokenCheck --------------------------------------------------------
//
// SICHERHEITSAUFLAGE: jeder dieser Tests benutzt Http::fake() und darf unter
// keinen Umständen die echte Hetzner-API oder die echte Zone berühren. Ein
// Test, der clupilot.cloud tatsächlich anfasst, ist ein Fehlschlag der
// Aufgabe, auch wenn er grün ist.
beforeEach(function () {
// Eine feste, erfundene Zone statt des .env-Werts dieser Installation —
// ein Test, der den echten Zonennamen des Entwicklerrechners nachrechnet,
// beweist nichts (siehe phpunit.xml zu CLUPILOT_DNS_ZONE).
Settings::set('provisioning.dns_zone', 'probe.example');
});
it('confirms write access by writing a probe record and removing it again', function () {
// Genau der Punkt der ganzen Prüfung: Zone LISTEN allein beweist nichts,
// weil ein Leserecht-Token das ebenfalls anstandslos tut. Erst Schreiben
// und Löschen beweisen ein Schreibrecht-Token.
Http::fake([
'dns.hetzner.com/api/v1/zones' => Http::response(['zones' => [['id' => 'zone-1', 'name' => 'probe.example']]]),
'dns.hetzner.com/api/v1/records' => Http::response(['record' => ['id' => 'rec-probe-1']]),
'dns.hetzner.com/api/v1/records/*' => Http::response([], 200),
]);
$result = (new DnsTokenCheck)->run('rw-token');
expect($result['ok'])->toBeTrue()
->and($result['reason'])->toBe('writable')
->and($result['probe_removed'])->toBeTrue();
Http::assertSent(fn ($request) => $request->method() === 'POST'
&& str_contains((string) $request->url(), '/records')
&& $request->hasHeader('Auth-API-Token', 'rw-token'));
Http::assertSent(fn ($request) => $request->method() === 'DELETE'
&& str_contains((string) $request->url(), '/records/rec-probe-1'));
});
it('reports the zone as not found when the token cannot see it', function () {
// Ein Token für eine andere Zone (oder gar keine) listet erfolgreich,
// findet die konfigurierte Zone darin aber nicht.
Http::fake([
'dns.hetzner.com/api/v1/zones' => Http::response(['zones' => [['id' => 'zone-9', 'name' => 'someone-elses-zone.example']]]),
]);
$result = (new DnsTokenCheck)->run('some-token');
expect($result['ok'])->toBeFalse()
->and($result['reason'])->toBe('zone_not_found')
->and($result['zone'])->toBe('probe.example');
// Kein Schreibversuch, wenn die Zone gar nicht gefunden wurde.
Http::assertNotSent(fn ($request) => $request->method() === 'POST');
});
it('tells a read-only token apart from one that can write', function () {
// Genau der Leserecht-Token aus dem Handoff: er listet die Zone
// anstandslos (200) und sieht in der Konsole aus wie ein funktionierender
// — bis zum Schreibversuch, den Hetzner mit 403 quittiert.
Http::fake([
'dns.hetzner.com/api/v1/zones' => Http::response(['zones' => [['id' => 'zone-1', 'name' => 'probe.example']]]),
'dns.hetzner.com/api/v1/records' => Http::response(['error' => 'forbidden'], 403),
]);
$result = (new DnsTokenCheck)->run('read-only-token');
expect($result['ok'])->toBeFalse()
->and($result['reason'])->toBe('read_only')
->and($result['status'])->toBe(403);
// Kein Löschversuch für einen Eintrag, der nie geschrieben wurde.
Http::assertNotSent(fn ($request) => $request->method() === 'DELETE');
});
it('does not attempt anything without a token or a zone', function () {
// Zurückgesetzt statt sich auf den .env-Wert dieser Installation zu
// verlassen — genau die Falle, die phpunit.xml zu CLUPILOT_DNS_ZONE
// dokumentiert, und der Grund, warum HETZNER_DNS_TOKEN dort jetzt
// ebenfalls auf leer gepinnt ist.
config()->set('provisioning.dns.token', '');
Http::fake();
expect((new DnsTokenCheck)->run('')['reason'])->toBe('missing');
Http::assertNothingSent();
});
// --- VmTemplateCheck -------------------------------------------------------
beforeEach(function () {
// 2026_07_26_040000_create_plan_catalogue_tables.php seeds a real baseline
// catalogue (start/team/business/enterprise, all template_vmid 9000) as
// part of the migration itself — not a Seeder, so it is present in every
// test database and does not roll away with RefreshDatabase. Left alone,
// it is unconditionally "required" by VmTemplateCheck alongside whatever
// a test creates. Closed here instead of deleted: a published version
// cannot be deleted (see PlanVersion::booted()), but its availability
// WINDOW is deliberately not frozen — closing it changes nothing about
// what the baseline promises, it only takes it out of "currently sold"
// so these tests can speak about their own versions in isolation.
PlanVersion::query()->whereNotNull('published_at')->update(['available_until' => now()->subDay()]);
});
function makePublishedVersion(int $templateVmid, ?string $familyKey = null): void
{
$family = PlanFamily::query()->create([
'key' => $familyKey ?? 'active-check-'.Str::random(8),
'name' => 'Active Check Plan',
'tier' => 9,
]);
$version = $family->versions()->create([
'version' => 1, 'quota_gb' => 10, 'traffic_gb' => 100, 'seats' => 1, 'ram_mb' => 1024,
'cores' => 1, 'disk_gb' => 20, 'performance' => 'standard', 'features' => [],
'available_from' => now(),
]);
$version->update(['published_at' => now(), 'template_vmid' => $templateVmid]);
}
it('is satisfied once the template actually exists on an active host', function () {
// FakeProxmoxClient talks to nothing real; Http::fake() rides along anyway
// — every test of these three checks does, without exception.
Http::fake();
$pve = new FakeProxmoxClient;
$pve->clonedVmids = [4001];
app()->instance(ProxmoxClient::class, $pve);
Host::factory()->create(['status' => 'active', 'api_token_ref' => 'ref-1', 'node' => 'pve']);
makePublishedVersion(4001);
$result = app(VmTemplateCheck::class)->run();
expect($result['ok'])->toBeTrue()
->and($result['reason'])->toBe('present');
});
it('names the missing vmid when the template is not on any active host', function () {
Http::fake();
$pve = new FakeProxmoxClient; // clonedVmids/runningVmids stay empty: nothing exists anywhere
app()->instance(ProxmoxClient::class, $pve);
Host::factory()->create(['status' => 'active', 'api_token_ref' => 'ref-1', 'node' => 'pve']);
makePublishedVersion(4002);
$result = app(VmTemplateCheck::class)->run();
expect($result['ok'])->toBeFalse()
->and($result['reason'])->toBe('template_missing')
->and($result['vmids'])->toContain(4002);
});
it('reports no usable host when nothing is left to ask', function () {
Http::fake();
app()->instance(ProxmoxClient::class, new FakeProxmoxClient);
// Kein aktiver Host mit lesbarem Token — derselbe Zustand, den
// ProvisioningChecks::usable_host als Sperre meldet.
Host::factory()->create(['status' => 'onboarding', 'api_token_ref' => 'ref-1', 'node' => 'pve']);
makePublishedVersion(4003);
$result = app(VmTemplateCheck::class)->run();
expect($result['ok'])->toBeFalse()
->and($result['reason'])->toBe('no_usable_host');
});
it('is satisfied when no plan version is published yet', function () {
Http::fake();
app()->instance(ProxmoxClient::class, new FakeProxmoxClient);
$result = app(VmTemplateCheck::class)->run();
expect($result['ok'])->toBeTrue()
->and($result['reason'])->toBe('nothing_published');
});