diff --git a/app/Services/Dns/DnsTokenCheck.php b/app/Services/Dns/DnsTokenCheck.php new file mode 100644 index 0000000..8c7f2f2 --- /dev/null +++ b/app/Services/Dns/DnsTokenCheck.php @@ -0,0 +1,82 @@ + */ + 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]; + } +} diff --git a/app/Services/Proxmox/VmTemplateCheck.php b/app/Services/Proxmox/VmTemplateCheck.php new file mode 100644 index 0000000..c3c4dca --- /dev/null +++ b/app/Services/Proxmox/VmTemplateCheck.php @@ -0,0 +1,72 @@ + */ + 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]; + } +} diff --git a/app/Services/Vpn/WireguardEndpointCheck.php b/app/Services/Vpn/WireguardEndpointCheck.php new file mode 100644 index 0000000..6ab3f6b --- /dev/null +++ b/app/Services/Vpn/WireguardEndpointCheck.php @@ -0,0 +1,92 @@ + + */ + 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 */ + 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; + } +} diff --git a/phpunit.xml b/phpunit.xml index 9a33a84..813c785 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -39,6 +39,15 @@ though no current test happens to need it. --> + +