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 { $domain = $domain->fresh() ?? $domain; $this->ensureDkimKeys($domain); // Create the authoritative zone immediately when possible. Async-only // provisioning left Ladill NS delegated with REFUSED answers (SERVFAIL // for Let's Encrypt) whenever the queue lagged or failed. try { $this->pdns->ensureZone($domain->fresh()); ProvisionDomainSlaveZoneJob::dispatch($domain->id); } catch (\Throwable $e) { Log::warning('Immediate PDNS ensureZone failed; queueing retry', [ 'domain_id' => $domain->id, 'host' => $domain->host, 'error' => $e->getMessage(), ]); ProvisionDomainZoneJob::dispatch($domain->id); } MailProvisioningJob::dispatch($domain->id); SslProvisioningJob::dispatch($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(), ]); } }