Add first-class hosting subdomain create/attach in Domains panel.
Deploy Ladill Hosting / deploy (push) Successful in 1m9s
Deploy Ladill Hosting / deploy (push) Successful in 1m9s
Customers can create label-based subdomains under primary/addon sites with a separate max_domains×5 quota, nginx/docroot/SSL provisioning, and managed DNS A records when Ladill controls the parent zone. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,6 +7,7 @@ use App\Jobs\ProvisionHostingSslJob;
|
||||
use App\Models\AppInstallation;
|
||||
use App\Models\CustomerHostingOrder;
|
||||
use App\Models\Domain;
|
||||
use App\Models\DomainDnsRecord;
|
||||
use App\Models\HostedDatabase;
|
||||
use App\Models\HostedSite;
|
||||
use App\Models\HostingAccount;
|
||||
@@ -15,6 +16,7 @@ use App\Models\HostingOrder;
|
||||
use App\Models\RcServiceOrder;
|
||||
use App\Models\Website;
|
||||
use App\Models\WebsiteMember;
|
||||
use App\Services\Dns\PowerDnsClient;
|
||||
use App\Services\Domain\DomainDnsBlueprintService;
|
||||
use App\Services\Domain\DomainVerificationService;
|
||||
use App\Services\Hosting\Contracts\PanelRuntimeInterface;
|
||||
@@ -1074,10 +1076,21 @@ class HostingPanelController extends Controller
|
||||
$account->load(['node', 'product', 'sites']);
|
||||
|
||||
$ownedDomains = $this->availableHostingDomainsForUser($request->user()->id, $account);
|
||||
$parentSites = $account->sites
|
||||
->whereIn('type', ['primary', 'addon'])
|
||||
->sortBy(fn (HostedSite $site) => [$site->type !== 'primary', $site->domain])
|
||||
->values();
|
||||
|
||||
return view('hosting.panel.domains', [
|
||||
'account' => $account,
|
||||
'ownedDomains' => $ownedDomains,
|
||||
'parentSites' => $parentSites,
|
||||
'maxDomains' => $account->maxDomainsLimit(),
|
||||
'maxSubdomains' => $account->maxSubdomainsLimit(),
|
||||
'domainSitesCount' => $account->domainSitesCount(),
|
||||
'subdomainSitesCount' => $account->subdomainSitesCount(),
|
||||
'canAddDomain' => $account->canAddDomain(),
|
||||
'canAddSubdomain' => $account->canAddSubdomain(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1091,12 +1104,15 @@ class HostingPanelController extends Controller
|
||||
$this->authorizeForRequestUser($request, 'manageDomains', $account);
|
||||
$account->load(['node', 'product', 'sites']);
|
||||
|
||||
$maxDomains = $account->resource_limits['max_domains'] ?? $account->product?->max_domains ?? 1;
|
||||
if ($account->sites->count() >= $maxDomains) {
|
||||
if (! $account->canAddDomain()) {
|
||||
$maxDomains = $account->maxDomainsLimit();
|
||||
$message = $maxDomains === -1
|
||||
? 'You cannot add more domains right now.'
|
||||
: "You have reached the maximum number of domains ({$maxDomains}).";
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['message' => "You have reached the maximum number of domains ({$maxDomains})."], 422);
|
||||
return response()->json(['message' => $message], 422);
|
||||
}
|
||||
return back()->with('error', "You have reached the maximum number of domains ({$maxDomains}).");
|
||||
return back()->with('error', $message);
|
||||
}
|
||||
|
||||
$isOwnedDomain = $request->boolean('is_owned_domain');
|
||||
@@ -1283,15 +1299,158 @@ class HostingPanelController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
public function addSubdomain(
|
||||
Request $request,
|
||||
HostingAccount $account,
|
||||
PowerDnsClient $pdns,
|
||||
): RedirectResponse|JsonResponse {
|
||||
$this->authorizeForRequestUser($request, 'manageDomains', $account);
|
||||
$account->load(['node', 'product', 'sites.domain']);
|
||||
|
||||
if (! $account->canAddSubdomain()) {
|
||||
$maxSubdomains = $account->maxSubdomainsLimit();
|
||||
$message = $maxSubdomains === -1
|
||||
? 'You cannot add more subdomains right now.'
|
||||
: "You have reached the maximum number of subdomains ({$maxSubdomains}).";
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['message' => $message], 422);
|
||||
}
|
||||
|
||||
return back()->with('error', $message);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'parent_site_id' => ['required', 'integer'],
|
||||
'subdomain' => ['required', 'string', 'max:63', 'regex:/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/'],
|
||||
'document_root' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$label = strtolower(trim($validated['subdomain']));
|
||||
$reserved = ['www', 'mail', 'ftp', 'ns1', 'ns2'];
|
||||
if (in_array($label, $reserved, true)) {
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['message' => "The subdomain \"{$label}\" is reserved."], 422);
|
||||
}
|
||||
|
||||
return back()->with('error', "The subdomain \"{$label}\" is reserved.");
|
||||
}
|
||||
|
||||
$parent = $account->sites
|
||||
->first(fn (HostedSite $site) => (int) $site->id === (int) $validated['parent_site_id']
|
||||
&& in_array($site->type, ['primary', 'addon'], true));
|
||||
|
||||
if (! $parent) {
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['message' => 'Choose a parent domain attached to this hosting account.'], 422);
|
||||
}
|
||||
|
||||
return back()->with('error', 'Choose a parent domain attached to this hosting account.');
|
||||
}
|
||||
|
||||
$fqdn = $label.'.'.strtolower(trim((string) $parent->domain));
|
||||
$serverIp = $account->node?->ip_address ?? '161.97.138.149';
|
||||
$docRoot = ! empty($validated['document_root'])
|
||||
? '/home/'.$account->username.'/'.ltrim($validated['document_root'], '/')
|
||||
: '/home/'.$account->username.'/public_html/'.$fqdn;
|
||||
|
||||
$existingSite = HostedSite::withTrashed()->where('domain', $fqdn)->first();
|
||||
if ($existingSite) {
|
||||
if ($existingSite->trashed() && (int) $existingSite->hosting_account_id === (int) $account->id) {
|
||||
$existingSite->restore();
|
||||
$existingSite->fill([
|
||||
'document_root' => $docRoot,
|
||||
'php_version' => $account->php_version ?? $existingSite->php_version ?? '8.2',
|
||||
'status' => 'active',
|
||||
'type' => 'subdomain',
|
||||
'domain_id' => $parent->domain_id,
|
||||
])->save();
|
||||
|
||||
try {
|
||||
$existingSite->loadMissing(['account.node']);
|
||||
$this->runtimeFor($account)->addSite($existingSite);
|
||||
$this->provisionSubdomainDns($account, $parent, $label, $fqdn, $serverIp, $pdns);
|
||||
ProvisionHostingSslJob::dispatch($existingSite->id)->delay(now()->addMinutes(2));
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['success' => true, 'message' => "Subdomain {$fqdn} has been restored."]);
|
||||
}
|
||||
|
||||
return back()->with('success', "Subdomain {$fqdn} has been restored. SSL will be provisioned automatically.");
|
||||
} catch (\Exception $e) {
|
||||
$existingSite->delete();
|
||||
Log::error('Failed to restore subdomain nginx config: '.$e->getMessage());
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['message' => 'Failed to restore subdomain. Please try again.'], 500);
|
||||
}
|
||||
|
||||
return back()->with('error', 'Failed to restore subdomain. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
if ((int) $existingSite->hosting_account_id !== (int) $account->id) {
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['message' => 'This subdomain is already in use on another hosting account.'], 422);
|
||||
}
|
||||
|
||||
return back()->with('error', 'This subdomain is already in use on another hosting account.');
|
||||
}
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['success' => true, 'message' => "Subdomain {$fqdn} is already linked to this hosting account."]);
|
||||
}
|
||||
|
||||
return back()->with('success', "Subdomain {$fqdn} is already linked to this hosting account.");
|
||||
}
|
||||
|
||||
$site = HostedSite::create([
|
||||
'hosting_account_id' => $account->id,
|
||||
'domain_id' => $parent->domain_id,
|
||||
'domain' => $fqdn,
|
||||
'document_root' => $docRoot,
|
||||
'type' => 'subdomain',
|
||||
'php_version' => $account->php_version ?? '8.2',
|
||||
'ssl_enabled' => false,
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->runtimeFor($account)->addSite($site);
|
||||
$dnsManaged = $this->provisionSubdomainDns($account, $parent, $label, $fqdn, $serverIp, $pdns);
|
||||
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.";
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['success' => true, 'message' => $message]);
|
||||
}
|
||||
|
||||
return back()->with('success', $message);
|
||||
} catch (\Exception $e) {
|
||||
$site->delete();
|
||||
Log::error('Failed to add subdomain: '.$e->getMessage());
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['message' => 'Failed to create subdomain. Please try again.'], 500);
|
||||
}
|
||||
|
||||
return back()->with('error', 'Failed to create subdomain. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
public function removeDomain(Request $request, HostingAccount $account, HostedSite $site): RedirectResponse
|
||||
{
|
||||
abort_unless((int) $account->user_id === (int) $request->user()->id, 403);
|
||||
$this->ensureSiteBelongsToAccount($site, $account);
|
||||
|
||||
$account->load(['node']);
|
||||
$site->loadMissing('domain');
|
||||
|
||||
try {
|
||||
$this->runtimeFor($account)->removeSite($site);
|
||||
$this->cleanupSubdomainDns($site);
|
||||
$site->delete();
|
||||
|
||||
return back()->with('success', 'Domain removed successfully.');
|
||||
@@ -1301,6 +1460,105 @@ class HostingPanelController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool True when DNS was managed/updated automatically
|
||||
*/
|
||||
private function provisionSubdomainDns(
|
||||
HostingAccount $account,
|
||||
HostedSite $parent,
|
||||
string $label,
|
||||
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;
|
||||
|
||||
if (! $parentDomain || $parentDomain->dns_mode !== 'managed') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdns->upsertRecords((string) $parentDomain->host, [[
|
||||
'name' => $fqdn,
|
||||
'type' => 'A',
|
||||
'ttl' => 3600,
|
||||
'contents' => [$serverIp],
|
||||
]]);
|
||||
|
||||
DomainDnsRecord::query()->updateOrCreate(
|
||||
[
|
||||
'domain_id' => $parentDomain->id,
|
||||
'name' => $label,
|
||||
'type' => 'A',
|
||||
'value' => $serverIp,
|
||||
],
|
||||
[
|
||||
'ttl' => 3600,
|
||||
'source' => 'hosting_subdomain',
|
||||
'status' => 'active',
|
||||
'notes' => 'Subdomain for hosting account '.$account->id,
|
||||
'last_checked_at' => now(),
|
||||
],
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Failed to provision subdomain DNS record', [
|
||||
'fqdn' => $fqdn,
|
||||
'parent_domain_id' => $parentDomain->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function cleanupSubdomainDns(HostedSite $site): void
|
||||
{
|
||||
if ($site->type !== 'subdomain' || ! $site->domain_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$parentDomain = Domain::query()->find($site->domain_id);
|
||||
if (! $parentDomain || $parentDomain->dns_mode !== 'managed') {
|
||||
return;
|
||||
}
|
||||
|
||||
$host = strtolower(trim((string) $site->getAttribute('domain')));
|
||||
$apex = strtolower((string) $parentDomain->host);
|
||||
$suffix = '.'.$apex;
|
||||
if (! str_ends_with($host, $suffix)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$label = substr($host, 0, -strlen($suffix));
|
||||
if ($label === '' || str_contains($label, '.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
app(PowerDnsClient::class)->deleteRecords($apex, [[
|
||||
'name' => $host,
|
||||
'type' => 'A',
|
||||
]]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::info('Best-effort subdomain DNS cleanup failed', [
|
||||
'domain' => $host,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
DomainDnsRecord::query()
|
||||
->where('domain_id', $parentDomain->id)
|
||||
->where('name', $label)
|
||||
->where('type', 'A')
|
||||
->where('source', 'hosting_subdomain')
|
||||
->delete();
|
||||
}
|
||||
|
||||
private function availableHostingDomainsForUser(int $userId, HostingAccount $account): \Illuminate\Support\Collection
|
||||
{
|
||||
$linkedDomains = HostedSite::whereNotNull('domain')
|
||||
|
||||
@@ -209,7 +209,7 @@ class HostingProductController extends Controller
|
||||
$renewals ??= app(HostingRenewalCheckoutService::class);
|
||||
|
||||
$account->load(['product', 'user', 'node', 'sites']);
|
||||
$maxDomains = $account->resource_limits['max_domains'] ?? $account->product?->max_domains ?? 1;
|
||||
$maxDomains = $account->maxDomainsLimit();
|
||||
$freeEmailAllowance = $account->freeEmailAllowance();
|
||||
$mailboxes = $account->loadedMailboxes();
|
||||
$mailboxCount = $mailboxes->count();
|
||||
@@ -219,7 +219,7 @@ class HostingProductController extends Controller
|
||||
'account' => $account,
|
||||
'linkedSites' => $account->sites->sortBy(fn ($site) => [$site->type !== 'primary', $site->domain])->values(),
|
||||
'maxDomains' => $maxDomains,
|
||||
'canLinkMoreDomains' => $account->sites->count() < $maxDomains,
|
||||
'canLinkMoreDomains' => $account->canAddDomain(),
|
||||
'ownedDomains' => collect($this->getUserDomains($request->user())),
|
||||
'emailUsage' => [
|
||||
'mailbox_count' => $mailboxCount,
|
||||
|
||||
@@ -311,6 +311,61 @@ class HostingAccount extends Model
|
||||
return max($included, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain slot limit from resource limits / product. -1 means unlimited.
|
||||
*/
|
||||
public function maxDomainsLimit(): int
|
||||
{
|
||||
$limit = data_get($this->resource_limits, 'max_domains');
|
||||
if ($limit === null) {
|
||||
$limit = $this->product?->max_domains;
|
||||
}
|
||||
|
||||
if ($limit === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return (int) $limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subdomain slot limit: max_domains × 5. Unlimited domains → unlimited subdomains.
|
||||
*/
|
||||
public function maxSubdomainsLimit(): int
|
||||
{
|
||||
$maxDomains = $this->maxDomainsLimit();
|
||||
|
||||
return $maxDomains === -1 ? -1 : max(0, $maxDomains) * 5;
|
||||
}
|
||||
|
||||
public function domainSitesCount(): int
|
||||
{
|
||||
return $this->sites()
|
||||
->whereIn('type', ['primary', 'addon'])
|
||||
->count();
|
||||
}
|
||||
|
||||
public function subdomainSitesCount(): int
|
||||
{
|
||||
return $this->sites()
|
||||
->where('type', 'subdomain')
|
||||
->count();
|
||||
}
|
||||
|
||||
public function canAddDomain(): bool
|
||||
{
|
||||
$limit = $this->maxDomainsLimit();
|
||||
|
||||
return $limit === -1 || $this->domainSitesCount() < $limit;
|
||||
}
|
||||
|
||||
public function canAddSubdomain(): bool
|
||||
{
|
||||
$limit = $this->maxSubdomainsLimit();
|
||||
|
||||
return $limit === -1 || $this->subdomainSitesCount() < $limit;
|
||||
}
|
||||
|
||||
public function paidMailboxCount(): int
|
||||
{
|
||||
if (! static::tracksLocalMailboxes()) {
|
||||
|
||||
@@ -151,6 +151,40 @@ class PowerDnsClient
|
||||
return ['status' => 'updated', 'count' => count($rrsets)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete specific name+type rrsets from an existing zone (best-effort).
|
||||
*
|
||||
* @param string $zoneName
|
||||
* @param array<int, array{name: string, type?: string}> $records
|
||||
*/
|
||||
public function deleteRecords(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'),
|
||||
'changetype' => 'DELETE',
|
||||
'records' => [],
|
||||
];
|
||||
}, $records);
|
||||
|
||||
$response = $this->httpClient()->patch("servers/{$this->server()}/zones/{$zoneName}", [
|
||||
'rrsets' => $rrsets,
|
||||
]);
|
||||
|
||||
if ($response->failed() && $response->status() !== 404) {
|
||||
$response->throw();
|
||||
}
|
||||
|
||||
return ['status' => 'deleted', 'count' => count($rrsets)];
|
||||
}
|
||||
|
||||
public function deleteZone(Domain $domain): void
|
||||
{
|
||||
$zoneName = $this->zoneName($domain);
|
||||
|
||||
Reference in New Issue
Block a user