diff --git a/app/Console/Commands/RepairPlaceholderPrimaryDomainsCommand.php b/app/Console/Commands/RepairPlaceholderPrimaryDomainsCommand.php new file mode 100644 index 0000000..1a1e4f0 --- /dev/null +++ b/app/Console/Commands/RepairPlaceholderPrimaryDomainsCommand.php @@ -0,0 +1,69 @@ +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; + } +} diff --git a/app/Http/Controllers/Hosting/HostingPanelController.php b/app/Http/Controllers/Hosting/HostingPanelController.php index fbe2238..75f9291 100644 --- a/app/Http/Controllers/Hosting/HostingPanelController.php +++ b/app/Http/Controllers/Hosting/HostingPanelController.php @@ -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( diff --git a/app/Http/Controllers/Hosting/HostingProductController.php b/app/Http/Controllers/Hosting/HostingProductController.php index eddbf18..01f24c2 100644 --- a/app/Http/Controllers/Hosting/HostingProductController.php +++ b/app/Http/Controllers/Hosting/HostingProductController.php @@ -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())), diff --git a/app/Jobs/ProvisionHostingOrderJob.php b/app/Jobs/ProvisionHostingOrderJob.php index cbe7c22..e8058a7 100644 --- a/app/Jobs/ProvisionHostingOrderJob.php +++ b/app/Jobs/ProvisionHostingOrderJob.php @@ -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); diff --git a/app/Jobs/ProvisionHostingSslJob.php b/app/Jobs/ProvisionHostingSslJob.php index 61cf610..3d159f0 100644 --- a/app/Jobs/ProvisionHostingSslJob.php +++ b/app/Jobs/ProvisionHostingSslJob.php @@ -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, diff --git a/app/Models/Domain.php b/app/Models/Domain.php index eeef5d7..82d34dd 100644 --- a/app/Models/Domain.php +++ b/app/Models/Domain.php @@ -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); + } } diff --git a/app/Services/Hosting/AdminHostingAssignmentService.php b/app/Services/Hosting/AdminHostingAssignmentService.php index 8856f51..317bf57 100644 --- a/app/Services/Hosting/AdminHostingAssignmentService.php +++ b/app/Services/Hosting/AdminHostingAssignmentService.php @@ -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, diff --git a/app/Services/Hosting/PlaceholderPrimaryDomainService.php b/app/Services/Hosting/PlaceholderPrimaryDomainService.php new file mode 100644 index 0000000..84775b1 --- /dev/null +++ b/app/Services/Hosting/PlaceholderPrimaryDomainService.php @@ -0,0 +1,194 @@ +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(); + } +} diff --git a/database/migrations/2026_07_13_164500_create_domain_dns_records_table.php b/database/migrations/2026_07_13_164500_create_domain_dns_records_table.php new file mode 100644 index 0000000..0e50ccb --- /dev/null +++ b/database/migrations/2026_07_13_164500_create_domain_dns_records_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('domain_id')->constrained('domains')->cascadeOnDelete(); + $table->string('name'); + $table->string('type', 20); + $table->text('value'); + $table->unsignedInteger('ttl')->default(3600); + $table->unsignedSmallInteger('priority')->nullable(); + $table->string('source')->default('managed'); + $table->string('status')->default('pending'); + $table->timestamp('last_checked_at')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->index(['domain_id', 'source']); + $table->index(['domain_id', 'status']); + $table->unique(['domain_id', 'name', 'type', 'value'], 'domain_dns_records_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists('domain_dns_records'); + } +}; diff --git a/resources/views/hosting/account.blade.php b/resources/views/hosting/account.blade.php index 5327229..2117c6e 100644 --- a/resources/views/hosting/account.blade.php +++ b/resources/views/hosting/account.blade.php @@ -82,7 +82,7 @@ {{-- Expired Account Banner --}} @@ -150,7 +150,7 @@
{{ $account->primary_domain ?: $account->username }}
+{{ $accountLabel ?? ($account->primary_domain ?: $account->username) }}
Waiting for DNS / Let's Encrypt
+{{ \Illuminate\Support\Str::limit($site->ssl_error, 80) }}
+ @endif +