dkimKeys() ->where('status', 'active') ->latest('id') ->value('selector'); $publicKey = $domain->dkimKeys() ->where('status', 'active') ->latest('id') ->value('public_key_txt'); if (! $selector || ! $publicKey) { throw new \RuntimeException('Missing DKIM key for '.$domain->host); } } $zoneName = $this->zoneName($domain); $rrsets = $this->buildRRSets($domain, $selector, $publicKey); $url = rtrim(config('pdns.api_url'), '/')."/servers/{$this->server()}/zones"; \Illuminate\Support\Facades\Log::info('PDNS API: POST '.$url, [ 'zone' => $zoneName, 'rrset_count' => count($rrsets), ]); $response = $this->httpClient()->post("servers/{$this->server()}/zones", [ 'name' => $zoneName, 'kind' => 'Master', 'rrsets' => $rrsets, ]); \Illuminate\Support\Facades\Log::info('PDNS API: Response', [ 'status' => $response->status(), 'body' => mb_substr($response->body(), 0, 500), ]); if ($response->status() === 409) { // Zone already exists — patch only the managed records so any // custom records (e.g. mail A, additional subdomains) are preserved. return $this->patchZone($zoneName, $rrsets); } if ($response->failed()) { $response->throw(); } return $response->json() ?? ['status' => 'created']; } public function ensureSlaveZone(Domain $domain, array $masters): array { if (empty($masters)) { throw new \RuntimeException('No master IPs configured for slave zone'); } $slaveApiUrl = config('pdns.slave_api_url'); if (! $slaveApiUrl) { throw new \RuntimeException('PDNS_SLAVE_API_URL is not configured'); } $zoneName = $this->zoneName($domain); $slaveServer = config('pdns.slave_server', 'localhost'); $response = $this->slaveHttpClient()->post("servers/{$slaveServer}/zones", [ 'name' => $zoneName, 'kind' => 'Slave', 'masters' => array_values($masters), ]); if ($response->status() === 409) { return ['status' => 'already_exists']; } if ($response->failed()) { $response->throw(); } return $response->json() ?? ['status' => 'created']; } /** * Upsert specific records into an already-existing zone on the master, * without disturbing any other records in the zone (changetype=REPLACE * is scoped to each name+type rrset). * * @param string $zoneName e.g. "ladill.com" (trailing dot optional) * @param array}> $records * * @throws \Illuminate\Http\Client\RequestException */ public function upsertRecords(string $zoneName, array $records): array { if ($records === []) { return ['status' => 'noop']; } $zoneName = rtrim($zoneName, '.').'.'; $rrsets = array_map(function (array $record): array { return [ 'name' => rtrim($record['name'], '.').'.', 'type' => strtoupper($record['type'] ?? 'A'), 'ttl' => (int) ($record['ttl'] ?? 3600), 'changetype' => 'REPLACE', 'records' => array_map( static fn (string $content): array => ['content' => $content, 'disabled' => false], array_values($record['contents']), ), ]; }, $records); $response = $this->httpClient()->patch("servers/{$this->server()}/zones/{$zoneName}", [ 'rrsets' => $rrsets, ]); if ($response->failed()) { $response->throw(); } return ['status' => 'updated', 'count' => count($rrsets)]; } /** * Delete specific name+type rrsets from an existing zone (best-effort). * * @param string $zoneName * @param array $records */ public function deleteRecords(string $zoneName, array $records): array { if ($records === []) { return ['status' => 'noop']; } $zoneName = rtrim($zoneName, '.').'.'; $rrsets = array_map(function (array $record): array { return [ 'name' => rtrim($record['name'], '.').'.', 'type' => strtoupper($record['type'] ?? 'A'), 'changetype' => 'DELETE', 'records' => [], ]; }, $records); $response = $this->httpClient()->patch("servers/{$this->server()}/zones/{$zoneName}", [ 'rrsets' => $rrsets, ]); if ($response->failed() && $response->status() !== 404) { $response->throw(); } return ['status' => 'deleted', 'count' => count($rrsets)]; } public function deleteZone(Domain $domain): void { $zoneName = $this->zoneName($domain); $response = $this->httpClient()->delete("servers/{$this->server()}/zones/{$zoneName}"); // 204 = deleted, 404 = already gone — both are fine. if ($response->failed() && $response->status() !== 404) { $response->throw(); } } private function patchZone(string $zoneName, array $rrsets): array { $patchSets = array_map(function (array $rrset) { $rrset['changetype'] = 'REPLACE'; return $rrset; }, $rrsets); $response = $this->httpClient()->patch("servers/{$this->server()}/zones/{$zoneName}", [ 'rrsets' => $patchSets, ]); if ($response->failed()) { $response->throw(); } return $response->json() ?? ['status' => 'updated']; } private function buildRRSets(Domain $domain, string $selector, string $publicKey): array { $records = $this->blueprints->managedRecords($domain, $selector, $publicKey); $owner = rtrim($domain->host, '.').'.'; $nameservers = $this->blueprints->expectedNameservers(); $groups = []; $soaContent = sprintf( 'ns1.ladill.com. hostmaster.ladill.com. %d 10800 3600 604800 3600', now()->format('U') ); $groups[$owner.'|SOA'] = [ 'name' => $owner, 'type' => 'SOA', 'ttl' => 3600, 'records' => [ ['content' => $soaContent, 'disabled' => false], ], ]; $nsRecords = []; foreach ($nameservers as $ns) { $nsRecords[] = ['content' => rtrim($ns, '.').'.', 'disabled' => false]; } if ($nsRecords !== []) { $groups[$owner.'|NS'] = [ 'name' => $owner, 'type' => 'NS', 'ttl' => 3600, 'records' => $nsRecords, ]; } foreach ($records as $record) { $name = $this->fqdn($domain->host, (string) ($record['name'] ?? '@')); $type = strtoupper((string) ($record['type'] ?? 'TXT')); $key = $name.'|'.$type; $groups[$key]['name'] = $name; $groups[$key]['type'] = $type; $groups[$key]['ttl'] = (int) ($record['ttl'] ?? 3600); $value = (string) ($record['value'] ?? ''); if (trim($value) === '@') { $value = $domain->host; } $content = $this->formatContent($value, $type, (int) ($record['priority'] ?? 0)); $groups[$key]['records'][] = [ 'content' => $content, 'disabled' => false, ]; } return array_values($groups); } private function formatContent(string $value, string $type, int $priority = 0): string { $value = trim($value); if ($value === '') { return $value; } return match ($type) { 'MX' => $priority.' '.rtrim($value, '.').'.', 'CNAME', 'NS', 'PTR' => rtrim($value, '.').'.', 'TXT' => '"'.trim($value, '"').'"', default => $value, }; } private function fqdn(string $host, string $name): string { $name = trim($name); $zone = rtrim($host, '.').'.'; if ($name === '' || $name === '@') { return $zone; } return trim($name, '.').'.'.$zone; } private function zoneName(Domain $domain): string { return rtrim($domain->host, '.').'.'; } private function httpClient() { $base = rtrim(config('pdns.api_url'), '/'); // Ensure the /api/v1 prefix is present. if (! str_contains($base, '/api/v1')) { $base .= '/api/v1'; } return Http::withHeaders([ 'X-API-Key' => config('pdns.api_key'), ])->baseUrl($base)->timeout(config('pdns.timeout')); } private function slaveHttpClient() { $base = rtrim(config('pdns.slave_api_url'), '/'); if (! str_contains($base, '/api/v1')) { $base .= '/api/v1'; } return Http::withHeaders([ 'X-API-Key' => config('pdns.slave_api_key'), ])->baseUrl($base)->timeout(config('pdns.timeout')); } private function server(): string { return config('pdns.server', 'localhost'); } }