Extract Ladill Servers as standalone app at servers.ladill.com.

VPS and dedicated server ordering, managed panels, SSO, billing, and launcher integration — forked from hosting infrastructure with server-focused routes and dashboard.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-06 19:18:30 +00:00
co-authored by Cursor
commit b6c8ac343f
382 changed files with 67315 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Services\Domain;
use Illuminate\Support\Facades\Http;
/** Optional stub — fetch user domains from the platform Domains API. */
class DomainClient
{
private function base(): string
{
return rtrim((string) config('domain.api_url', env('DOMAIN_API_URL', '')), '/');
}
private function token(): string
{
return (string) config('domain.api_key', env('DOMAIN_API_KEY_SERVERS', ''));
}
/** @return list<array<string, mixed>> */
public function listForUser(string $publicId): array
{
if ($this->base() === '' || $this->token() === '') {
return [];
}
$res = Http::withToken($this->token())->acceptJson()->timeout(10)
->get($this->base().'/domains', ['user' => $publicId]);
if ($res->failed()) {
return [];
}
return (array) ($res->json('data') ?? $res->json() ?? []);
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
namespace App\Services\Domain;
use App\Models\Domain;
use App\Models\DomainDkimKey;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class DomainDkimService
{
public function ensureActive(Domain $domain): DomainDkimKey
{
$existing = DomainDkimKey::query()
->where('domain_id', $domain->id)
->where('status', 'active')
->latest('id')
->first();
if ($existing) {
return $existing;
}
$selectorPrefix = (string) config('mailinfra.dkim_selector_prefix', 'ladill');
$selector = Str::lower(trim($selectorPrefix)) ?: '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(),
]);
Log::info('Generated DKIM key for domain', ['domain_id' => $domain->id, 'selector' => $selector]);
return $key;
}
public function activeKey(Domain $domain): ?DomainDkimKey
{
return DomainDkimKey::query()
->where('domain_id', $domain->id)
->where('status', 'active')
->latest('id')
->first();
}
}
@@ -0,0 +1,176 @@
<?php
namespace App\Services\Domain;
use App\Models\Domain;
class DomainDnsBlueprintService
{
public function expectedNameservers(): array
{
$nameservers = (array) config('mailinfra.nameservers', ['ns1.ladill.com', 'ns2.ladill.com']);
return collect($nameservers)
->map(fn ($value) => strtolower(trim((string) $value, ". \t\n\r\0\x0B")))
->filter()
->unique()
->values()
->all();
}
public function mailSetupInstructions(): array
{
return [
'imap' => [
'server' => (string) config('mailinfra.mail_host', 'mail.ladill.com'),
'port' => (int) config('mailinfra.imap_port', 993),
'security' => 'SSL/TLS',
'username' => 'full_email',
],
'smtp' => [
'server' => (string) config('mailinfra.mail_host', 'mail.ladill.com'),
'port' => (int) config('mailinfra.smtp_port', 587),
'security' => 'STARTTLS',
'authentication' => true,
'username' => 'full_email',
],
'smtp_ssl' => [
'server' => (string) config('mailinfra.mail_host', 'mail.ladill.com'),
'port' => (int) config('mailinfra.smtp_ssl_port', 465),
'security' => 'SSL/TLS',
],
];
}
public function managedRecords(Domain $domain, string $selector, string $publicKey): array
{
$mailHost = strtolower(trim((string) config('mailinfra.mail_host', 'mail.ladill.com')));
$autodiscoverHost = strtolower(trim((string) config('mailinfra.autodiscover_host', 'autodiscover.ladill.com')));
$autoconfigHost = strtolower(trim((string) config('mailinfra.autoconfig_host', 'autoconfig.ladill.com')));
$ttl = (int) config('mailinfra.default_ttl', 3600);
$websiteIp = trim((string) config('mailinfra.website_a_record', ''));
$wwwTarget = trim((string) config('mailinfra.website_www_cname', '@'));
$rua = trim((string) config('mailinfra.dmarc_rua', 'mailto:dmarc@ladill.com'));
$records = [];
// Always include A record for apex domain (use configured IP or placeholder)
$records[] = [
'name' => '@',
'type' => 'A',
'value' => $websiteIp !== '' ? $websiteIp : '161.97.138.149',
'ttl' => $ttl,
'priority' => null,
'source' => 'managed',
'status' => 'active',
'notes' => 'Website apex record',
];
// Always include www record (CNAME to @ or A record to same IP)
if ($wwwTarget !== '' && $wwwTarget !== '@') {
$records[] = [
'name' => 'www',
'type' => 'CNAME',
'value' => $wwwTarget,
'ttl' => $ttl,
'priority' => null,
'source' => 'managed',
'status' => 'active',
'notes' => 'Website www record',
];
} else {
// Default: www points to same IP as apex
$records[] = [
'name' => 'www',
'type' => 'A',
'value' => $websiteIp !== '' ? $websiteIp : '161.97.138.149',
'ttl' => $ttl,
'priority' => null,
'source' => 'managed',
'status' => 'active',
'notes' => 'Website www record',
];
}
$records[] = [
'name' => '@',
'type' => 'MX',
'value' => $mailHost.'.',
'ttl' => $ttl,
'priority' => 10,
'source' => 'managed',
'status' => 'active',
'notes' => 'Primary mail exchanger',
];
$records[] = [
'name' => '@',
'type' => 'TXT',
'value' => (string) config('mailinfra.spf_customer_txt'),
'ttl' => $ttl,
'priority' => null,
'source' => 'managed',
'status' => 'active',
'notes' => 'SPF policy',
];
$records[] = [
'name' => $selector.'._domainkey',
'type' => 'TXT',
'value' => sprintf('v=DKIM1; k=rsa; p=%s', $publicKey),
'ttl' => $ttl,
'priority' => null,
'source' => 'managed',
'status' => 'active',
'notes' => 'DKIM public key',
];
$records[] = [
'name' => '_dmarc',
'type' => 'TXT',
'value' => sprintf('v=DMARC1; p=none; rua=%s; adkim=s; aspf=s', $rua),
'ttl' => $ttl,
'priority' => null,
'source' => 'managed',
'status' => 'active',
'notes' => 'DMARC policy',
];
$records[] = [
'name' => 'autodiscover',
'type' => 'CNAME',
'value' => $autodiscoverHost.'.',
'ttl' => $ttl,
'priority' => null,
'source' => 'managed',
'status' => 'active',
'notes' => 'Autodiscover helper',
];
$records[] = [
'name' => 'autoconfig',
'type' => 'CNAME',
'value' => $autoconfigHost.'.',
'ttl' => $ttl,
'priority' => null,
'source' => 'managed',
'status' => 'active',
'notes' => 'Autoconfig helper',
];
return $records;
}
public function manualDnsPack(Domain $domain, string $selector, string $publicKey): array
{
return collect($this->managedRecords($domain, $selector, $publicKey))
->map(function (array $record) {
$record['source'] = 'required_manual';
$record['status'] = 'pending';
return $record;
})
->values()
->all();
}
}
@@ -0,0 +1,285 @@
<?php
namespace App\Services\Domain;
use App\Models\DomainPricing;
use App\Services\Currency\CurrencyConverterService;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class DomainPricingService
{
private const TLD_PRICING_CACHE_KEY = 'dynadot:tld_pricing';
private const LIVE_TLD_PRICING_CACHE_KEY = 'dynadot:tld_pricing:live';
private const TLD_PRICING_CACHE_TTL_HOURS = 6;
public function __construct(
private DynadotService $dynadot,
private CurrencyConverterService $currency,
) {}
/**
* Get the markup percentage from config.
*/
public function getMarkupPercent(): float
{
return (float) config('mailinfra.dynadot_price_markup_percent', 35);
}
/**
* Get pricing for a specific TLD in GHS with markup.
*
* @return array{register: int, renew: int, transfer: int, currency: string, usd_rate: float}|null
*/
public function getPricingForTld(string $tld): ?array
{
$tld = ltrim(strtolower(trim($tld)), '.');
$allPricing = $this->getAllTldPricing();
if (! isset($allPricing[$tld])) {
return null;
}
return $allPricing[$tld];
}
/**
* Get pricing for multiple TLDs.
*
* @return array<string, array{register: int, renew: int, transfer: int, currency: string}>
*/
public function getPricingForTlds(array $tlds): array
{
$allPricing = $this->getAllTldPricing();
$result = [];
foreach ($tlds as $tld) {
$tld = ltrim(strtolower(trim($tld)), '.');
if (isset($allPricing[$tld])) {
$result[$tld] = $allPricing[$tld];
}
}
return $result;
}
/**
* Get only TLDs that currently have live Dynadot pricing.
*
* @return list<string>
*/
public function getLivePricedTlds(): array
{
return array_keys($this->getLiveDynadotPricing());
}
/**
* Get all TLD pricing with conversion and markup applied.
* Price overrides from DomainPricing model take precedence.
*
* @return array<string, array{register: int, renew: int, transfer: int, currency: string, usd_rate: float, is_override: bool}>
*/
public function getAllTldPricing(): array
{
$cacheKey = self::TLD_PRICING_CACHE_KEY . ':ghs';
return Cache::remember($cacheKey, now()->addHours(self::TLD_PRICING_CACHE_TTL_HOURS), function () {
$usdPricing = $this->fetchDynadotPricing();
$overrides = DomainPricing::getOverrides();
if (empty($usdPricing) && empty($overrides)) {
return [];
}
$markup = $this->getMarkupPercent();
$usdRate = $this->currency->getUsdToGhsRate();
$converted = [];
// First, add all Dynadot prices with conversion
foreach ($usdPricing as $tld => $prices) {
$converted[$tld] = [
'register' => $this->convertPrice($prices['register'] ?? 0, $markup),
'renew' => $this->convertPrice($prices['renew'] ?? 0, $markup),
'transfer' => $this->convertPrice($prices['transfer'] ?? 0, $markup),
'currency' => 'GHS',
'usd_rate' => $usdRate,
'usd_register' => $prices['register'] ?? 0,
'usd_renew' => $prices['renew'] ?? 0,
'usd_transfer' => $prices['transfer'] ?? 0,
'is_override' => false,
];
}
// Apply overrides (manual GHS prices take precedence)
foreach ($overrides as $tld => $override) {
$converted[$tld] = [
'register' => $override['register'],
'renew' => $override['renew'],
'transfer' => $override['transfer'],
'currency' => 'GHS',
'usd_rate' => $usdRate,
'usd_register' => null,
'usd_renew' => null,
'usd_transfer' => null,
'is_override' => true,
];
}
return $converted;
});
}
/**
* Convert USD cents to GHS pesewas with markup.
*/
private function convertPrice(int $usdCents, float $markupPercent): int
{
return $this->currency->convertCentsTopesewaswithMarkup($usdCents, $markupPercent);
}
/**
* Fetch TLD pricing from Dynadot API.
*
* @return array<string, array{register: int, renew: int, transfer: int}>
*/
private function fetchDynadotPricing(): array
{
$livePricing = $this->getLiveDynadotPricing();
if ($livePricing !== []) {
return $livePricing;
}
return $this->getFallbackPricing();
}
/**
* Fallback pricing in USD cents for common TLDs.
*/
private function getFallbackPricing(): array
{
return [
'com' => ['register' => 1099, 'renew' => 1099, 'transfer' => 1099],
'net' => ['register' => 1199, 'renew' => 1199, 'transfer' => 1199],
'org' => ['register' => 1099, 'renew' => 1099, 'transfer' => 1099],
'io' => ['register' => 3999, 'renew' => 3999, 'transfer' => 3999],
'co' => ['register' => 2999, 'renew' => 2999, 'transfer' => 2999],
'info' => ['register' => 1899, 'renew' => 1899, 'transfer' => 1899],
'biz' => ['register' => 1699, 'renew' => 1699, 'transfer' => 1699],
'xyz' => ['register' => 1299, 'renew' => 1299, 'transfer' => 1299],
'online' => ['register' => 2999, 'renew' => 2999, 'transfer' => 2999],
'store' => ['register' => 4999, 'renew' => 4999, 'transfer' => 4999],
'tech' => ['register' => 4999, 'renew' => 4999, 'transfer' => 4999],
'site' => ['register' => 2999, 'renew' => 2999, 'transfer' => 2999],
];
}
/**
* Fetch raw live TLD pricing from Dynadot without fallback expansion.
*
* @return array<string, array{register: int, renew: int, transfer: int}>
*/
private function getLiveDynadotPricing(): array
{
return Cache::remember(self::LIVE_TLD_PRICING_CACHE_KEY, now()->addHours(self::TLD_PRICING_CACHE_TTL_HOURS), function () {
if (! $this->dynadot->isConfigured()) {
Log::warning('DomainPricingService: Dynadot not configured');
return [];
}
try {
$result = $this->dynadot->getTldPricing();
if (! ($result['success'] ?? false) || empty($result['pricing'])) {
Log::warning('DomainPricingService: Failed to fetch Dynadot pricing', $result);
return [];
}
return $result['pricing'];
} catch (\Throwable $e) {
Log::error('DomainPricingService: Exception fetching pricing', ['error' => $e->getMessage()]);
return [];
}
});
}
/**
* Format price for display.
*/
public function formatPrice(int $pesewas): string
{
return 'GH₵ ' . number_format($pesewas / 100, 2);
}
public function convertRegistrarAmountToGhsMinor(int $amountMinor, string $currency): ?int
{
$currency = strtoupper(trim($currency));
if ($currency === 'GHS') {
return $amountMinor;
}
if ($currency === 'USD') {
return $this->convertPrice($amountMinor, $this->getMarkupPercent());
}
return null;
}
/**
* Get price info for a domain (for checkout/cart).
*/
public function getDomainPrice(string $domain, string $action = 'register'): ?array
{
$parts = explode('.', strtolower($domain), 2);
if (count($parts) < 2) {
return null;
}
$tld = $parts[1];
$pricing = $this->getPricingForTld($tld);
if (! $pricing) {
return null;
}
$priceKey = match ($action) {
'renew' => 'renew',
'transfer' => 'transfer',
default => 'register',
};
return [
'domain' => $domain,
'tld' => $tld,
'action' => $action,
'amount_minor' => $pricing[$priceKey],
'currency' => 'GHS',
'price_label' => $this->formatPrice($pricing[$priceKey]),
];
}
/**
* Clear cached pricing.
*/
public function clearCache(): void
{
Cache::forget(self::TLD_PRICING_CACHE_KEY . ':ghs');
Cache::forget(self::LIVE_TLD_PRICING_CACHE_KEY);
}
/**
* Get current exchange rate info.
*/
public function getExchangeRateInfo(): array
{
return [
...$this->currency->getRateInfo(),
'markup_percent' => $this->getMarkupPercent(),
];
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,137 @@
<?php
namespace App\Services\Domain;
use App\Models\Domain;
use App\Models\DomainOrder;
use App\Models\RcServiceOrder;
class DomainRegistryService
{
/**
* Create or update the unified domain registry entry for a registrar purchase.
*/
public function recordPurchasedDomain(
int $userId,
string $host,
string $registrar,
?string $registrarOrderId = null,
bool $verified = true,
array $meta = [],
): Domain {
$host = strtolower(trim($host));
$existing = Domain::query()->where('host', $host)->first();
$verificationMeta = array_merge(
(array) ($existing?->verification_meta ?? []),
$meta,
array_filter([
'registrar' => $registrar,
'registrar_order_id' => $registrarOrderId,
'registered_at' => $verified ? now()->toIso8601String() : null,
]),
);
$attributes = [
'user_id' => $existing?->user_id ?: $userId,
'host' => $host,
'type' => $existing?->type ?: 'custom',
'source' => $this->resolveSource($existing),
'onboarding_mode' => $existing?->onboarding_mode ?: Domain::MODE_RESELLER_AUTO,
'status' => $verified ? 'verified' : ($existing?->status ?: 'pending'),
'onboarding_state' => $verified
? Domain::STATE_ACTIVE
: ($existing?->onboarding_state ?: Domain::STATE_PENDING_NS),
'registrar' => $registrar ?: $existing?->registrar,
'registrar_order_id' => $registrarOrderId ?: $existing?->registrar_order_id,
'verified_at' => $verified ? ($existing?->verified_at ?: now()) : $existing?->verified_at,
'verification_meta' => $verificationMeta,
];
if ($existing) {
$existing->update($attributes);
return $existing->fresh();
}
return Domain::create($attributes);
}
public function recordFromDomainOrder(DomainOrder $order, bool $verified = true): Domain
{
if (! $order->user_id) {
throw new \InvalidArgumentException('Domain order is missing user_id.');
}
return $this->recordPurchasedDomain(
$order->user_id,
$order->domain_name,
(string) ($order->registrar ?: DomainOrder::REGISTRAR_DYNADOT),
$order->registrar_order_id ? (string) $order->registrar_order_id : null,
$verified,
[
'domain_order_id' => $order->id,
'order_type' => $order->order_type,
],
);
}
public function recordFromRcServiceOrder(RcServiceOrder $order, bool $verified = false): ?Domain
{
if (! $order->user_id || ! filled($order->domain_name)) {
return null;
}
if (! in_array($order->order_type, [
RcServiceOrder::TYPE_DOMAIN_REGISTRATION,
RcServiceOrder::TYPE_DOMAIN_TRANSFER,
], true)) {
return null;
}
return $this->recordPurchasedDomain(
$order->user_id,
$order->domain_name,
DomainOrder::REGISTRAR_RESELLERCLUB,
$order->rc_order_id ? (string) $order->rc_order_id : null,
$verified,
[
'rc_service_order_id' => $order->id,
'order_type' => $order->order_type,
],
);
}
public function markVerified(string $host): ?Domain
{
$domain = Domain::query()->where('host', strtolower(trim($host)))->first();
if (! $domain) {
return null;
}
$domain->update([
'status' => 'verified',
'onboarding_state' => Domain::STATE_ACTIVE,
'verified_at' => $domain->verified_at ?: now(),
]);
return $domain->fresh();
}
private function resolveSource(?Domain $existing): string
{
if (! $existing) {
return 'purchased';
}
$source = (string) ($existing->source ?: '');
if (in_array($source, ['website', 'hosting', 'email', 'hosting_email', 'website_email'], true)) {
return $source;
}
return 'purchased';
}
}
@@ -0,0 +1,519 @@
<?php
namespace App\Services\Domain;
use App\Jobs\MailProvisioningJob;
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\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
{
$this->dispatchProvisioningChain($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(),
]);
}
}