Files
ladill-hosting/app/Services/Dns/PowerDnsClient.php
T
isaaccladandCursor e251a4cf60
Deploy Ladill Hosting / deploy (push) Failing after 17s
Initial Ladill Hosting app with Gitea deploy pipeline.
Shared web hosting extracted from the platform monolith, with CI deploy
to /var/www/ladill-hosting matching Bird/Domains/Email.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 16:24:20 +00:00

305 lines
9.5 KiB
PHP

<?php
namespace App\Services\Dns;
use App\Models\Domain;
use App\Services\Domain\DomainDnsBlueprintService;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
class PowerDnsClient
{
public function __construct(
private readonly DomainDnsBlueprintService $blueprints,
) {
}
/**
* Ensure the domain is provisioned into PowerDNS.
*
* $selectorOverride and $publicKeyOverride let callers supply the DKIM key directly
* (e.g. for email-only domains that use EmailDomainDkimKey instead of DomainDkimKey).
* $publicKeyOverride must be the raw base64 key — NOT the full "v=DKIM1; k=rsa; p=…" string.
*
* @throws \Illuminate\Http\Client\RequestException|\RuntimeException
*/
public function ensureZone(Domain $domain, ?string $selectorOverride = null, ?string $publicKeyOverride = null): array
{
if ($selectorOverride !== null && $publicKeyOverride !== null) {
$selector = $selectorOverride;
$publicKey = $publicKeyOverride;
} else {
$selector = $domain->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<int, array{name: string, type?: string, ttl?: int, contents: array<int, string>}> $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)];
}
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');
}
}