Deploy Ladill Hosting / deploy (push) Successful in 40s
Async-only zone jobs left Ladill NS returning REFUSED for new domains, which surfaces as Let's Encrypt SERVFAIL during SSL issuance. Co-authored-by: Cursor <cursoragent@cursor.com>
541 lines
19 KiB
PHP
541 lines
19 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Domain;
|
|
|
|
use App\Jobs\MailProvisioningJob;
|
|
use App\Jobs\ProvisionDomainSlaveZoneJob;
|
|
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\Facades\Log;
|
|
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
|
|
{
|
|
$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(),
|
|
]);
|
|
}
|
|
}
|