Files
ladill-servers/app/Services/Domain/DomainVerificationService.php
T
isaaccladandCursor b6c8ac343f Extract Ladill Servers as standalone app at servers.ladill.com.
VPS and dedicated server ordering, managed panels, SSO, billing, and launcher integration — forked from hosting infrastructure with server-focused routes and dashboard.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 19:18:30 +00:00

520 lines
18 KiB
PHP

<?php
namespace App\Services\Domain;
use App\Jobs\MailProvisioningJob;
use App\Jobs\ProvisionDomainZoneJob;
use App\Jobs\SslProvisioningJob;
use App\Models\Domain;
use App\Models\DomainDkimKey;
use App\Models\DomainDnsRecord;
use App\Models\DomainNsCheck;
use App\Notifications\DomainVerifiedNotification;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
class DomainVerificationService
{
public function __construct(
private readonly DomainDnsBlueprintService $blueprints,
private readonly \App\Services\Dns\PowerDnsClient $pdns,
) {
}
public function verify(Domain $domain): bool
{
$domain->refresh();
$statusBefore = (string) $domain->status;
$stateBefore = (string) ($domain->onboarding_state ?: Domain::STATE_PENDING_NS);
$domain->update([
'status' => 'verifying',
'onboarding_state' => Domain::STATE_VERIFYING_NS,
'ns_checked_at' => now(),
]);
if ((string) $domain->onboarding_mode === Domain::MODE_MANUAL_DNS) {
return $this->verifyManualDns($domain->fresh(), $statusBefore, $stateBefore);
}
return $this->verifyNameserverTakeover($domain->fresh(), $statusBefore, $stateBefore);
}
public function prepareNameserverZone(Domain $domain): void
{
$domain = $domain->fresh();
$this->ensureDkimKeys($domain);
$this->dispatchProvisioningChain($domain->id);
}
public function prepareManualDnsPack(Domain $domain): void
{
$domain = $domain->fresh();
[$selector, $publicKey] = $this->ensureDkimKeys($domain);
$pack = $this->blueprints->manualDnsPack($domain, $selector, $publicKey);
$this->syncDnsRecords($domain, $pack, 'required_manual');
}
private function verifyNameserverTakeover(Domain $domain, string $statusBefore, string $stateBefore): bool
{
$expected = $this->normalizeNameservers((array) ($domain->ns_expected ?? $this->blueprints->expectedNameservers()));
$observed = $this->lookupNameservers($domain->host);
$domain->update([
'ns_expected' => $expected,
'ns_observed' => $observed,
]);
if (! $this->nameserversMatch($expected, $observed)) {
$domain->update([
'status' => 'pending',
'onboarding_state' => Domain::STATE_PENDING_NS,
]);
$domain = $domain->fresh();
$this->logCheck($domain, [
'check_type' => 'ns_takeover',
'result' => 'pending',
'expected_nameservers' => $expected,
'observed_nameservers' => $observed,
'has_authoritative_answer' => null,
'status_before' => $statusBefore,
'status_after' => (string) $domain->status,
'state_before' => $stateBefore,
'state_after' => (string) $domain->onboarding_state,
'notes' => 'Nameservers do not match expected Ladill nameservers yet.',
]);
return false;
}
$hasAuthoritativeAnswer = $this->zoneHasAuthoritativeAnswer($domain->host);
if (! $hasAuthoritativeAnswer) {
ProvisionDomainZoneJob::dispatch($domain->id);
$domain->update([
'status' => 'verifying',
'onboarding_state' => Domain::STATE_VERIFYING_NS,
]);
$domain = $domain->fresh();
$this->logCheck($domain, [
'check_type' => 'ns_takeover',
'result' => 'pending',
'expected_nameservers' => $expected,
'observed_nameservers' => $observed,
'has_authoritative_answer' => false,
'status_before' => $statusBefore,
'status_after' => (string) $domain->status,
'state_before' => $stateBefore,
'state_after' => (string) $domain->onboarding_state,
'notes' => 'Nameservers match, but authoritative zone answer is not ready yet.',
]);
return false;
}
return DB::transaction(function () use ($domain, $statusBefore, $stateBefore, $expected, $observed, $hasAuthoritativeAnswer): bool {
$domain->refresh();
$domain->update([
'status' => 'verifying',
'onboarding_state' => Domain::STATE_CONNECTED,
'connected_at' => $domain->connected_at ?? now(),
'dns_mode' => 'managed',
]);
[$selector, $publicKey] = $this->ensureDkimKeys($domain);
$records = $this->blueprints->managedRecords($domain, $selector, $publicKey);
$this->syncDnsRecords($domain, $records, 'managed');
$domain->update([
'onboarding_state' => Domain::STATE_MAIL_READY,
'mail_ready_at' => $domain->mail_ready_at ?? now(),
]);
$domain->update([
'status' => 'verified',
'verified_at' => $domain->verified_at ?? now(),
'onboarding_state' => Domain::STATE_ACTIVE,
'active_at' => $domain->active_at ?? now(),
]);
$domain->refresh();
$this->logCheck($domain, [
'check_type' => 'ns_takeover',
'result' => 'success',
'expected_nameservers' => $expected,
'observed_nameservers' => $observed,
'has_authoritative_answer' => $hasAuthoritativeAnswer,
'status_before' => $statusBefore,
'status_after' => (string) $domain->status,
'state_before' => $stateBefore,
'state_after' => (string) $domain->onboarding_state,
'notes' => 'Nameserver takeover complete. Managed DNS and mail records are provisioned.',
]);
$this->dispatchProvisioningChain($domain->id);
// Notify user that domain is verified
$user = $domain->website?->user;
if ($user) {
$user->notify(new DomainVerifiedNotification($domain));
}
return true;
});
}
private function verifyManualDns(Domain $domain, string $statusBefore, string $stateBefore): bool
{
$this->prepareManualDnsPack($domain);
$domain = $domain->fresh('dnsRecords');
$records = $domain->dnsRecords
->where('source', 'required_manual')
->values();
if ($records->isEmpty()) {
$domain->update([
'status' => 'failed',
'onboarding_state' => Domain::STATE_FAILED,
]);
$domain = $domain->fresh();
$this->logCheck($domain, [
'check_type' => 'manual_dns',
'result' => 'failed',
'manual_records_total' => 0,
'manual_records_verified' => 0,
'status_before' => $statusBefore,
'status_after' => (string) $domain->status,
'state_before' => $stateBefore,
'state_after' => (string) $domain->onboarding_state,
'notes' => 'Manual DNS pack is empty; cannot verify domain.',
]);
return false;
}
$allVerified = true;
foreach ($records as $record) {
$matched = $this->recordExistsInPublicDns($domain->host, $record);
$record->update([
'status' => $matched ? 'verified' : 'pending',
'last_checked_at' => now(),
]);
if (! $matched) {
$allVerified = false;
}
}
if (! $allVerified) {
$domain->update([
'status' => 'pending',
'onboarding_state' => Domain::STATE_VERIFYING_NS,
'ns_checked_at' => now(),
]);
$domain = $domain->fresh();
$this->logCheck($domain, [
'check_type' => 'manual_dns',
'result' => 'pending',
'manual_records_total' => $records->count(),
'manual_records_verified' => $records->where('status', 'verified')->count(),
'status_before' => $statusBefore,
'status_after' => (string) $domain->status,
'state_before' => $stateBefore,
'state_after' => (string) $domain->onboarding_state,
'notes' => 'Manual DNS records are not fully propagated yet.',
]);
return false;
}
$domain->update([
'status' => 'verified',
'verified_at' => $domain->verified_at ?? now(),
'onboarding_state' => Domain::STATE_ACTIVE,
'dns_mode' => 'manual',
'manual_dns_verified_at' => $domain->manual_dns_verified_at ?? now(),
'connected_at' => $domain->connected_at ?? now(),
'mail_ready_at' => $domain->mail_ready_at ?? now(),
'active_at' => $domain->active_at ?? now(),
'ns_checked_at' => now(),
]);
$domain = $domain->fresh();
$this->logCheck($domain, [
'check_type' => 'manual_dns',
'result' => 'success',
'manual_records_total' => $records->count(),
'manual_records_verified' => $records->where('status', 'verified')->count(),
'status_before' => $statusBefore,
'status_after' => (string) $domain->status,
'state_before' => $stateBefore,
'state_after' => (string) $domain->onboarding_state,
'notes' => 'All required manual DNS records are verified.',
]);
$this->dispatchProvisioningChain($domain->id);
// Notify user that domain is verified
$user = $domain->website?->user;
if ($user) {
$user->notify(new DomainVerifiedNotification($domain));
}
return true;
}
public function reprovision(Domain $domain): void
{
$this->dispatchProvisioningChain($domain->id);
}
private function dispatchProvisioningChain(int $domainId): void
{
ProvisionDomainZoneJob::withChain([
new MailProvisioningJob($domainId),
new SslProvisioningJob($domainId),
])->dispatch($domainId);
}
private function ensureDkimKeys(Domain $domain): array
{
$existing = DomainDkimKey::query()
->where('domain_id', $domain->id)
->where('status', 'active')
->latest('id')
->first();
if ($existing) {
return [$existing->selector, $existing->public_key_txt];
}
$prefix = (string) config('mailinfra.dkim_selector_prefix', 'ladill');
$selector = Str::lower(trim($prefix)) ?: 'ladill';
$selector .= '1';
$publicKey = '';
$privateKey = null;
if (function_exists('openssl_pkey_new')) {
$resource = openssl_pkey_new([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
if ($resource !== false) {
openssl_pkey_export($resource, $privateOut);
$details = openssl_pkey_get_details($resource);
$publicPem = (string) ($details['key'] ?? '');
$publicKey = preg_replace('/-----BEGIN PUBLIC KEY-----|-----END PUBLIC KEY-----|\s+/', '', $publicPem) ?: '';
$privateKey = $privateOut ?: null;
}
}
if ($publicKey === '') {
$publicKey = Str::upper(Str::random(360));
}
$privatePath = null;
if (is_string($privateKey) && trim($privateKey) !== '') {
$safeHost = Str::slug($domain->host, '_');
$directory = storage_path('app/mail/dkim');
File::ensureDirectoryExists($directory);
$privatePath = $directory.'/'.$safeHost.'_'.$selector.'.key';
File::put($privatePath, $privateKey);
}
$key = DomainDkimKey::query()->create([
'domain_id' => $domain->id,
'selector' => $selector,
'public_key_txt' => $publicKey,
'private_key_path' => $privatePath,
'status' => 'active',
'generated_at' => now(),
]);
return [$key->selector, $key->public_key_txt];
}
private function syncDnsRecords(Domain $domain, array $records, string $source): void
{
$keepSignatures = [];
foreach ($records as $record) {
if (! is_array($record)) {
continue;
}
$name = (string) ($record['name'] ?? '@');
$type = strtoupper((string) ($record['type'] ?? 'TXT'));
$value = (string) ($record['value'] ?? '');
$keepSignatures[] = strtolower($name.'|'.$type.'|'.$value);
DomainDnsRecord::query()->updateOrCreate(
[
'domain_id' => $domain->id,
'name' => $name,
'type' => $type,
'value' => $value,
],
[
'ttl' => (int) ($record['ttl'] ?? 3600),
'priority' => isset($record['priority']) ? (int) $record['priority'] : null,
'source' => $source,
'status' => (string) ($record['status'] ?? 'pending'),
'notes' => $record['notes'] ?? null,
'last_checked_at' => now(),
]
);
}
DomainDnsRecord::query()
->where('domain_id', $domain->id)
->where('source', $source)
->get()
->each(function (DomainDnsRecord $record) use ($keepSignatures): void {
$signature = strtolower($record->name.'|'.$record->type.'|'.$record->value);
if (! in_array($signature, $keepSignatures, true)) {
$record->delete();
}
});
}
private function lookupNameservers(string $host): array
{
if (! function_exists('dns_get_record')) {
return [];
}
$records = @dns_get_record($host, DNS_NS);
if (! is_array($records)) {
return [];
}
return collect($records)
->map(fn ($record) => strtolower(trim((string) ($record['target'] ?? ''), ". \t\n\r\0\x0B")))
->filter()
->unique()
->values()
->all();
}
private function normalizeNameservers(array $nameservers): array
{
return collect($nameservers)
->map(fn ($record) => strtolower(trim((string) $record, ". \t\n\r\0\x0B")))
->filter()
->unique()
->values()
->all();
}
private function nameserversMatch(array $expected, array $observed): bool
{
$expected = $this->normalizeNameservers($expected);
$observed = $this->normalizeNameservers($observed);
if ($expected === [] || $observed === []) {
return false;
}
return collect($expected)->diff($observed)->isEmpty();
}
private function recordExistsInPublicDns(string $zoneHost, DomainDnsRecord $record): bool
{
if (! function_exists('dns_get_record')) {
return false;
}
$name = trim((string) $record->name);
$fqdn = $name === '@' ? $zoneHost : $name.'.'.$zoneHost;
$type = strtoupper(trim((string) $record->type));
$expectedValue = strtolower(trim((string) $record->value, ". \t\n\r\0\x0B\""));
$flag = match ($type) {
'MX' => DNS_MX,
'CNAME' => DNS_CNAME,
'A' => DNS_A,
default => DNS_TXT,
};
$records = @dns_get_record($fqdn, $flag);
if (! is_array($records) || $records === []) {
return false;
}
foreach ($records as $item) {
if ($type === 'MX') {
$value = strtolower(trim((string) ($item['target'] ?? ''), ". \t\n\r\0\x0B"));
$priority = (int) ($item['pri'] ?? 0);
$expectedPriority = (int) ($record->priority ?? 0);
if ($value === $expectedValue && $priority === $expectedPriority) {
return true;
}
continue;
}
if ($type === 'TXT') {
$value = strtolower(trim((string) ($item['txt'] ?? ''), "\" \t\n\r\0\x0B"));
if ($value === $expectedValue) {
return true;
}
continue;
}
if ($type === 'CNAME') {
$value = strtolower(trim((string) ($item['target'] ?? ''), ". \t\n\r\0\x0B"));
if ($value === $expectedValue) {
return true;
}
continue;
}
if ($type === 'A') {
$value = strtolower(trim((string) ($item['ip'] ?? ''), ". \t\n\r\0\x0B"));
if ($value === $expectedValue) {
return true;
}
}
}
return false;
}
private function zoneHasAuthoritativeAnswer(string $host): bool
{
if (! function_exists('dns_get_record')) {
return false;
}
$soa = @dns_get_record($host, DNS_SOA);
if (is_array($soa) && $soa !== []) {
return true;
}
$ns = @dns_get_record($host, DNS_NS);
return is_array($ns) && $ns !== [];
}
private function logCheck(Domain $domain, array $payload): void
{
DomainNsCheck::query()->create([
'domain_id' => $domain->id,
'check_type' => (string) ($payload['check_type'] ?? 'onboarding'),
'result' => (string) ($payload['result'] ?? 'pending'),
'expected_nameservers' => $payload['expected_nameservers'] ?? null,
'observed_nameservers' => $payload['observed_nameservers'] ?? null,
'has_authoritative_answer' => $payload['has_authoritative_answer'] ?? null,
'manual_records_total' => (int) ($payload['manual_records_total'] ?? 0),
'manual_records_verified' => (int) ($payload['manual_records_verified'] ?? 0),
'status_before' => $payload['status_before'] ?? null,
'status_after' => $payload['status_after'] ?? null,
'state_before' => $payload['state_before'] ?? null,
'state_after' => $payload['state_after'] ?? null,
'notes' => $payload['notes'] ?? null,
'checked_at' => now(),
]);
}
}