Deploy Ladill Hosting / deploy (push) Failing after 17s
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>
305 lines
9.5 KiB
PHP
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');
|
|
}
|
|
}
|