Fix subdomain DNS messaging for Ladill nameservers and retire pending-setup primaries.
Deploy Ladill Hosting / deploy (push) Successful in 47s

Treat ns_auto domains as managed DNS, publish subdomain A records via PowerDNS without failing the UX when the local DNS registry is missing, and promote real linked domains over pending-setup.local placeholders.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-13 16:47:50 +00:00
co-authored by Cursor
parent 460edb8719
commit 136cf8b719
12 changed files with 686 additions and 45 deletions
@@ -0,0 +1,69 @@
<?php
namespace App\Console\Commands;
use App\Models\HostingAccount;
use App\Services\Hosting\PlaceholderPrimaryDomainService;
use Illuminate\Console\Command;
class RepairPlaceholderPrimaryDomainsCommand extends Command
{
protected $signature = 'hosting:repair-placeholder-primaries
{--account= : Limit to one hosting account id}
{--dry-run : Show what would change without writing}';
protected $description = 'Promote real linked domains over pending-setup.local / placeholder primaries.';
public function handle(PlaceholderPrimaryDomainService $placeholders): int
{
$accounts = HostingAccount::query()
->with(['sites', 'node'])
->when($this->option('account'), fn ($q, $id) => $q->whereKey($id))
->orderBy('id')
->get()
->filter(fn (HostingAccount $account) => $placeholders->accountNeedsPromotion($account)
|| $account->sites->contains(
fn ($site) => PlaceholderPrimaryDomainService::isPlaceholderDomain($site->domain)
));
if ($accounts->isEmpty()) {
$this->info('No accounts need placeholder primary repair.');
return self::SUCCESS;
}
$healed = 0;
foreach ($accounts as $account) {
$before = $account->primary_domain ?: '(null)';
$this->line(sprintf(
'[%d] %s primary=%s sites=%s',
$account->id,
$account->username,
$before,
$account->sites->pluck('domain')->implode(', ') ?: '(none)'
));
if ($this->option('dry-run')) {
continue;
}
if ($placeholders->healAccount($account)) {
$account->refresh();
$this->info(sprintf(
' → healed to primary=%s',
$account->primary_domain ?: '(null)'
));
$healed++;
}
}
if ($this->option('dry-run')) {
$this->warn('Dry run: no changes applied.');
} else {
$this->info("Healed {$healed} account(s).");
}
return self::SUCCESS;
}
}
@@ -1162,16 +1162,23 @@ class HostingPanelController extends Controller
// Restore the soft-deleted site and recreate nginx config
$existingSite->restore();
$placeholders = app(\App\Services\Hosting\PlaceholderPrimaryDomainService::class);
$promoteToPrimary = $placeholders->shouldBecomePrimary($account, $domainHost);
$existingSite->fill([
'document_root' => $docRoot,
'php_version' => $account->php_version ?? $existingSite->php_version ?? '8.2',
'status' => 'active',
'type' => $existingSite->type ?: 'addon',
'type' => $promoteToPrimary ? 'primary' : ($existingSite->type ?: 'addon'),
])->save();
try {
$existingSite->loadMissing(['account.node']);
$this->runtimeFor($account)->addSite($existingSite);
if ($promoteToPrimary) {
$placeholders->promoteSite($account, $existingSite);
}
// Queue SSL provisioning for restored domain
ProvisionHostingSslJob::dispatch($existingSite->id)->delay(now()->addMinutes(2));
@@ -1256,12 +1263,15 @@ class HostingPanelController extends Controller
]);
}
$placeholders = app(\App\Services\Hosting\PlaceholderPrimaryDomainService::class);
$promoteToPrimary = $placeholders->shouldBecomePrimary($account, $domainHost);
$site = HostedSite::create([
'hosting_account_id' => $account->id,
'domain_id' => $domain->id,
'domain' => $domainHost,
'document_root' => $docRoot,
'type' => 'addon',
'type' => $promoteToPrimary ? 'primary' : 'addon',
'php_version' => $account->php_version ?? '8.2',
'ssl_enabled' => false,
'status' => 'active',
@@ -1270,6 +1280,10 @@ class HostingPanelController extends Controller
try {
$this->runtimeFor($account)->addSite($site);
if ($promoteToPrimary) {
$placeholders->promoteSite($account, $site);
}
// Queue DNS zone provisioning (manual pack is local-only).
if ($onboardingMode === Domain::MODE_MANUAL_DNS) {
$verification->prepareManualDnsPack($domain);
@@ -1368,14 +1382,23 @@ class HostingPanelController extends Controller
try {
$existingSite->loadMissing(['account.node']);
$this->runtimeFor($account)->addSite($existingSite);
$this->provisionSubdomainDns($account, $parent, $label, $fqdn, $serverIp, $pdns);
$dnsResult = $this->provisionSubdomainDns($account, $parent, $label, $fqdn, $serverIp, $pdns);
if (! empty($dnsResult['domain_id']) && (int) $existingSite->domain_id !== (int) $dnsResult['domain_id']) {
$existingSite->update(['domain_id' => $dnsResult['domain_id']]);
}
ProvisionHostingSslJob::dispatch($existingSite->id)->delay(now()->addMinutes(2));
$message = match ($dnsResult['status']) {
'managed' => "Subdomain {$fqdn} has been restored. DNS was updated on Ladill nameservers; SSL will provision automatically.",
'managed_failed' => "Subdomain {$fqdn} has been restored. Ladill nameservers are in use, but we could not publish the A record automatically. Check DNS for {$parent->domain} in Ladill Domains.",
default => "Subdomain {$fqdn} has been restored. Because this domain uses external DNS, add an A record for {$fqdn}{$serverIp}. SSL will provision automatically.",
};
if ($request->wantsJson()) {
return response()->json(['success' => true, 'message' => "Subdomain {$fqdn} has been restored."]);
return response()->json(['success' => true, 'message' => $message]);
}
return back()->with('success', "Subdomain {$fqdn} has been restored. SSL will be provisioned automatically.");
return back()->with('success', $message);
} catch (\Exception $e) {
$existingSite->delete();
Log::error('Failed to restore subdomain nginx config: '.$e->getMessage());
@@ -1416,12 +1439,17 @@ class HostingPanelController extends Controller
try {
$this->runtimeFor($account)->addSite($site);
$dnsManaged = $this->provisionSubdomainDns($account, $parent, $label, $fqdn, $serverIp, $pdns);
$dnsResult = $this->provisionSubdomainDns($account, $parent, $label, $fqdn, $serverIp, $pdns);
if (! empty($dnsResult['domain_id']) && (int) $site->domain_id !== (int) $dnsResult['domain_id']) {
$site->update(['domain_id' => $dnsResult['domain_id']]);
}
ProvisionHostingSslJob::dispatch($site->id)->delay(now()->addMinutes(2));
$message = $dnsManaged
? "Subdomain {$fqdn} created. DNS A record was added for the parent zone; SSL will provision automatically."
: "Subdomain {$fqdn} created. Point an A record for {$fqdn} to {$serverIp}, then SSL will provision automatically.";
$message = match ($dnsResult['status']) {
'managed' => "Subdomain {$fqdn} created. DNS was updated on Ladill nameservers; SSL will provision automatically.",
'managed_failed' => "Subdomain {$fqdn} created. Ladill nameservers are in use, but we could not publish the A record automatically. Check DNS for {$parent->domain} in Ladill Domains (expect {$fqdn} {$serverIp}). SSL will retry once DNS resolves.",
default => "Subdomain {$fqdn} created. Because this domain uses external DNS, add an A record for {$fqdn}{$serverIp}, then SSL will provision automatically.",
};
if ($request->wantsJson()) {
return response()->json(['success' => true, 'message' => $message]);
@@ -1461,7 +1489,7 @@ class HostingPanelController extends Controller
}
/**
* @return bool True when DNS was managed/updated automatically
* @return array{status: 'managed'|'managed_failed'|'manual', domain_id: ?int}
*/
private function provisionSubdomainDns(
HostingAccount $account,
@@ -1470,14 +1498,20 @@ class HostingPanelController extends Controller
string $fqdn,
string $serverIp,
PowerDnsClient $pdns,
): bool {
// HostedSite has both a `domain` column and a domain() relation — load via domain_id.
$parentDomain = $parent->domain_id
? Domain::query()->find($parent->domain_id)
: null;
): array {
$parentDomain = $this->resolveParentDomainRecord($parent);
if (! $parentDomain || $parentDomain->dns_mode !== 'managed') {
return false;
if (! $parentDomain || ! $parentDomain->usesManagedDns()) {
return ['status' => 'manual', 'domain_id' => $parentDomain?->id];
}
// Heal legacy rows that were on ns_auto but never stamped dns_mode=managed.
if ((string) $parentDomain->dns_mode !== 'managed') {
$parentDomain->update(['dns_mode' => 'managed']);
}
if (! $parent->domain_id) {
$parent->update(['domain_id' => $parentDomain->id]);
}
try {
@@ -1487,7 +1521,18 @@ class HostingPanelController extends Controller
'ttl' => 3600,
'contents' => [$serverIp],
]]);
} catch (\Throwable $e) {
Log::warning('Failed to provision subdomain DNS record', [
'fqdn' => $fqdn,
'parent_domain_id' => $parentDomain->id,
'error' => $e->getMessage(),
]);
return ['status' => 'managed_failed', 'domain_id' => $parentDomain->id];
}
// Local registry is best-effort; PowerDNS publish is what matters for Ladill NS.
try {
DomainDnsRecord::query()->updateOrCreate(
[
'domain_id' => $parentDomain->id,
@@ -1503,17 +1548,32 @@ class HostingPanelController extends Controller
'last_checked_at' => now(),
],
);
return true;
} catch (\Throwable $e) {
Log::warning('Failed to provision subdomain DNS record', [
Log::info('Subdomain DNS published but local domain_dns_records sync failed', [
'fqdn' => $fqdn,
'parent_domain_id' => $parentDomain->id,
'error' => $e->getMessage(),
]);
return false;
}
return ['status' => 'managed', 'domain_id' => $parentDomain->id];
}
private function resolveParentDomainRecord(HostedSite $parent): ?Domain
{
if ($parent->domain_id) {
$byId = Domain::query()->find($parent->domain_id);
if ($byId) {
return $byId;
}
}
$host = strtolower(trim((string) $parent->getAttribute('domain')));
if ($host === '') {
return null;
}
return Domain::query()->where('host', $host)->first();
}
private function cleanupSubdomainDns(HostedSite $site): void
@@ -1523,7 +1583,7 @@ class HostingPanelController extends Controller
}
$parentDomain = Domain::query()->find($site->domain_id);
if (! $parentDomain || $parentDomain->dns_mode !== 'managed') {
if (! $parentDomain || ! $parentDomain->usesManagedDns()) {
return;
}
@@ -1732,17 +1792,33 @@ class HostingPanelController extends Controller
try {
$this->runtimeFor($account)->requestLetsEncryptCertificate($site);
$expiresAt = now()->addDays(90);
$site->update([
'ssl_enabled' => true,
'ssl_status' => 'issued',
'ssl_provisioned_at' => now(),
'ssl_expires_at' => $expiresAt,
'ssl_error' => null,
]);
if ($site->domain_id) {
Domain::query()->whereKey($site->domain_id)->update([
'ssl_status' => 'active',
'ssl_expires_at' => $expiresAt,
]);
}
app(\App\Services\Ssl\CentralSslRegistry::class)->register($site->domain, $expiresAt);
return back()->with('success', "SSL certificate issued for {$site->domain} successfully.");
} catch (\Exception $e) {
Log::error("Failed to request SSL: " . $e->getMessage());
$site->update([
'ssl_status' => 'failed',
'ssl_error' => $e->getMessage(),
]);
$message = trim($e->getMessage());
return back()->with(
@@ -209,6 +209,13 @@ class HostingProductController extends Controller
$renewals ??= app(HostingRenewalCheckoutService::class);
$account->load(['product', 'user', 'node', 'sites']);
// Heal legacy Sales Centre placeholders (pending-setup.local) that stayed
// primary after a real domain was linked as an addon.
app(\App\Services\Hosting\PlaceholderPrimaryDomainService::class)->healAccount($account);
$account->refresh()->load(['product', 'user', 'node', 'sites']);
$placeholders = app(\App\Services\Hosting\PlaceholderPrimaryDomainService::class);
$maxDomains = $account->maxDomainsLimit();
$freeEmailAllowance = $account->freeEmailAllowance();
$mailboxes = $account->loadedMailboxes();
@@ -217,7 +224,11 @@ class HostingProductController extends Controller
return view('hosting.account', [
'account' => $account,
'linkedSites' => $account->sites->sortBy(fn ($site) => [$site->type !== 'primary', $site->domain])->values(),
'accountLabel' => $placeholders->displayLabel($account),
'linkedSites' => $account->sites
->filter(fn ($site) => ! \App\Services\Hosting\PlaceholderPrimaryDomainService::isPlaceholderDomain($site->domain))
->sortBy(fn ($site) => [$site->type !== 'primary', $site->domain])
->values(),
'maxDomains' => $maxDomains,
'canLinkMoreDomains' => $account->canAddDomain(),
'ownedDomains' => collect($this->getUserDomains($request->user())),
+17 -11
View File
@@ -141,12 +141,16 @@ class ProvisionHostingOrderJob implements ShouldQueue
]);
// Create hosting account record
$primaryDomain = \App\Services\Hosting\PlaceholderPrimaryDomainService::isPlaceholderDomain($this->order->domain_name)
? null
: $this->order->domain_name;
$account = HostingAccount::create([
'user_id' => $this->order->user_id,
'hosting_node_id' => $node->id,
'hosting_product_id' => $product->id,
'username' => $username,
'primary_domain' => $this->order->domain_name,
'primary_domain' => $primaryDomain,
'type' => HostingAccount::TYPE_SHARED,
'status' => HostingAccount::STATUS_ACTIVE,
'home_directory' => $result['home_directory'] ?? "/home/{$username}",
@@ -178,16 +182,18 @@ class ProvisionHostingOrderJob implements ShouldQueue
], $resourceLimits),
]);
HostedSite::create([
'hosting_account_id' => $account->id,
'domain_id' => $this->order->domain_id,
'domain' => $this->order->domain_name,
'document_root' => ($result['document_root'] ?? "/home/{$username}/public_html"),
'type' => 'primary',
'status' => 'active',
'php_version' => '8.4',
'ssl_enabled' => false,
]);
if ($primaryDomain) {
HostedSite::create([
'hosting_account_id' => $account->id,
'domain_id' => $this->order->domain_id,
'domain' => $primaryDomain,
'document_root' => ($result['document_root'] ?? "/home/{$username}/public_html"),
'type' => 'primary',
'status' => 'active',
'php_version' => '8.4',
'ssl_enabled' => false,
]);
}
$sharedProvider->applyAccountResourceProfile($account, false);
+7
View File
@@ -76,6 +76,13 @@ class ProvisionHostingSslJob implements ShouldQueue
'ssl_expires_at' => now()->addDays(90),
]);
if ($site->domain_id) {
\App\Models\Domain::query()->whereKey($site->domain_id)->update([
'ssl_status' => 'active',
'ssl_expires_at' => $site->ssl_expires_at,
]);
}
Log::info('ProvisionHostingSslJob: SSL provisioned successfully', [
'site_id' => $this->siteId,
'domain' => $site->domain,
+13
View File
@@ -121,4 +121,17 @@ class Domain extends Model
return (string) $this->status === 'verified'
&& (string) $this->onboarding_state === self::STATE_ACTIVE;
}
/** True when Ladill should publish zone records (nameserver / managed DNS path). */
public function usesManagedDns(): bool
{
if ((string) $this->dns_mode === 'managed') {
return true;
}
return in_array((string) $this->onboarding_mode, [
self::MODE_NS_AUTO,
self::MODE_RESELLER_AUTO,
], true);
}
}
@@ -42,9 +42,13 @@ class AdminHostingAssignmentService
}
$primaryDomain = isset($input['primary_domain']) && $input['primary_domain'] !== ''
? (string) $input['primary_domain']
? strtolower(trim((string) $input['primary_domain']))
: null;
if ($primaryDomain !== null && PlaceholderPrimaryDomainService::isPlaceholderDomain($primaryDomain)) {
$primaryDomain = null;
}
$accountType = match ($product->type) {
'single_domain', 'multi_domain', 'wordpress' => HostingAccount::TYPE_SHARED,
'vps' => HostingAccount::TYPE_VPS,
@@ -0,0 +1,194 @@
<?php
namespace App\Services\Hosting;
use App\Models\HostedSite;
use App\Models\HostingAccount;
use App\Services\Hosting\Providers\SharedNodeProvider;
use Illuminate\Support\Facades\Log;
/**
* Sales Centre used to stamp pending-setup.local as primary_domain (and sometimes a
* primary HostedSite). Link Domain historically only created addons, so the
* placeholder kept hijacking the account label. This service promotes a real
* linked domain and retires placeholder primaries.
*/
class PlaceholderPrimaryDomainService
{
public function __construct(
private SharedNodeProvider $provider,
) {}
public static function isPlaceholderDomain(?string $domain): bool
{
$domain = strtolower(trim((string) $domain));
if ($domain === '' || $domain === 'pending-setup.local') {
return true;
}
// Temporary nginx host used when an account is provisioned without a domain.
return str_ends_with($domain, '.ladill.com');
}
public function accountNeedsPromotion(HostingAccount $account): bool
{
$account->loadMissing('sites');
$primarySites = $account->sites->where('type', 'primary')->values();
$hasPlaceholderPrimarySite = $primarySites->contains(
fn (HostedSite $site): bool => static::isPlaceholderDomain($site->domain)
);
$hasRealPrimarySite = $primarySites->contains(
fn (HostedSite $site): bool => ! static::isPlaceholderDomain($site->domain)
);
// Never steal primary from an account that already has a real primary site.
if ($hasRealPrimarySite) {
return false;
}
// Placeholder primary_domain (incl. null/empty) or only placeholder primary sites.
if (static::isPlaceholderDomain($account->primary_domain) || $hasPlaceholderPrimarySite) {
return true;
}
return false;
}
/**
* Whether a newly linked host should be stored as the account primary site.
*/
public function shouldBecomePrimary(HostingAccount $account, string $domainHost): bool
{
if ($this->accountNeedsPromotion($account)) {
return true;
}
$primary = strtolower(trim((string) $account->primary_domain));
return $primary !== ''
&& $primary === strtolower(trim($domainHost))
&& ! $account->sites->contains(
fn (HostedSite $site): bool => $site->type === 'primary'
&& ! static::isPlaceholderDomain($site->domain)
);
}
/**
* Prefer a real hosted site over a placeholder primary_domain for UI labels.
*/
public function displayLabel(HostingAccount $account): string
{
$account->loadMissing('sites');
$real = $account->sites
->filter(fn (HostedSite $site): bool => ! static::isPlaceholderDomain($site->domain))
->sortBy(fn (HostedSite $site): array => [$site->type !== 'primary', $site->domain])
->values();
if ($real->isNotEmpty()) {
if ($real->count() === 1) {
return (string) $real->first()->domain;
}
$preview = $real->take(2)->pluck('domain')->implode(', ');
return $real->count() > 2
? "{$preview} +".($real->count() - 2)
: $preview;
}
if (! static::isPlaceholderDomain($account->primary_domain)) {
return (string) $account->primary_domain;
}
return (string) $account->username;
}
public function promoteSite(HostingAccount $account, HostedSite $site): void
{
if (static::isPlaceholderDomain($site->domain)) {
return;
}
$account->loadMissing(['sites', 'node']);
$site->update(['type' => 'primary']);
$account->update([
'primary_domain' => $site->domain,
]);
foreach ($account->sites as $other) {
if ((int) $other->id === (int) $site->id) {
continue;
}
if ($other->type === 'primary' && static::isPlaceholderDomain($other->domain)) {
$this->retirePlaceholderSite($account, $other);
} elseif ($other->type === 'primary') {
$other->update(['type' => 'addon']);
}
}
$account->unsetRelation('sites');
}
/**
* Heal accounts that already have a real linked domain but still carry a
* placeholder primary_domain / primary HostedSite.
*/
public function healAccount(HostingAccount $account): bool
{
if (! $this->accountNeedsPromotion($account)) {
return false;
}
$account->loadMissing(['sites', 'node']);
$candidate = $account->sites
->filter(fn (HostedSite $site): bool => ! static::isPlaceholderDomain($site->domain)
&& in_array($site->type, ['primary', 'addon'], true))
->sortBy(fn (HostedSite $site): array => [$site->type !== 'primary', $site->id])
->first();
if (! $candidate) {
// No real site yet — just clear a stale placeholder label.
if (static::isPlaceholderDomain($account->primary_domain) && filled($account->primary_domain)) {
$account->update(['primary_domain' => null]);
foreach ($account->sites->where('type', 'primary') as $placeholder) {
if (static::isPlaceholderDomain($placeholder->domain)) {
$this->retirePlaceholderSite($account, $placeholder);
}
}
return true;
}
return false;
}
$this->promoteSite($account, $candidate);
return true;
}
protected function retirePlaceholderSite(HostingAccount $account, HostedSite $site): void
{
try {
if ($account->node) {
$this->provider->removeSite($site);
}
} catch (\Throwable $e) {
Log::info('PlaceholderPrimaryDomainService: best-effort nginx cleanup failed', [
'site_id' => $site->id,
'domain' => $site->domain,
'error' => $e->getMessage(),
]);
}
$site->delete();
}
}