Add opt-in custom domains with automatic SSL
Deploy Ladill Merchant / deploy (push) Successful in 29s

Customers can connect their own domain to a merchant page (storefront/event):
add a domain, point an A record (apex + www) to the app server, click Verify —
DNS is checked, then Ladill Domains' central SSL service issues + installs the
Let's Encrypt cert and calls back to flip it live. The custom domain then serves
the mapped page (host resolution on /). Feature-gated: only active when a Domains
SSL API key is set, so this deploy is inert until wired.

- custom_domains table + CustomDomain model
- CustomDomainService (DNS verify, request cert), DomainsSslClient, DnsResolver
- settings UI panel, signed SSL callback receiver, host resolution on /
- feature tests (DNS verify/fail, signed callback, ownership)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
isaacclad
2026-06-26 16:59:19 +00:00
co-authored by Claude Opus 4.8
parent a4974098ee
commit b2aede5963
13 changed files with 579 additions and 3 deletions
@@ -0,0 +1,103 @@
<?php
namespace App\Services\CustomDomain;
use App\Models\CustomDomain;
use App\Models\QrCode;
use Illuminate\Support\Carbon;
class CustomDomainService
{
public function __construct(
private readonly DnsResolver $dns,
private readonly DomainsSslClient $ssl,
) {}
public function enabled(): bool
{
return (bool) config('customdomain.enabled', true) && $this->ssl->configured();
}
/** Attach a custom domain to a storefront (pending DNS). */
public function attach(QrCode $storefront, string $host, bool $includeWww = true): CustomDomain
{
return CustomDomain::create([
'qr_code_id' => $storefront->id,
'user_id' => $storefront->user_id,
'host' => $host,
'include_www' => $includeWww,
'status' => CustomDomain::STATUS_PENDING,
'ssl_status' => CustomDomain::STATUS_PENDING,
]);
}
/**
* Verify the domain's A record resolves to our app server, then ask Domains
* to issue the certificate. Returns [ok, message].
*
* @return array{0:bool,1:string}
*/
public function verifyAndProvision(CustomDomain $domain): array
{
$serverIp = (string) config('customdomain.server_ip');
$ips = $this->dns->aRecords($domain->host);
if (! in_array($serverIp, $ips, true)) {
$domain->forceFill([
'last_error' => 'DNS not pointing to '.$serverIp.' yet (found: '.(implode(', ', $ips) ?: 'none').').',
])->save();
return [false, 'DNS not verified yet. Add an A record for '.$domain->host.' pointing to '.$serverIp.', then try again.'];
}
$domain->forceFill(['dns_verified_at' => Carbon::now(), 'last_error' => null])->save();
$requested = $this->ssl->requestCertificate(
$domain->host,
$domain->include_www,
route('api.ssl-callback'),
);
if (! $requested) {
$domain->forceFill(['last_error' => 'Could not reach the SSL service. Please try again shortly.'])->save();
return [false, 'Domain verified, but issuing the certificate failed. Please retry in a moment.'];
}
return [true, 'Domain verified. Issuing your SSL certificate — this usually takes under a minute.'];
}
/** Apply a signed SSL completion callback from Ladill Domains. */
public function applyCallback(string $host, string $status, ?string $expiresAt, ?string $error): void
{
$domain = CustomDomain::where('host', strtolower(trim($host)))->first();
if (! $domain) {
return;
}
if ($status === 'active') {
$domain->forceFill([
'ssl_status' => CustomDomain::STATUS_ACTIVE,
'status' => CustomDomain::STATUS_ACTIVE,
'ssl_issued_at' => Carbon::now(),
'ssl_expires_at' => $expiresAt ? Carbon::parse($expiresAt) : null,
'last_error' => null,
])->save();
} else {
$domain->forceFill([
'ssl_status' => CustomDomain::STATUS_FAILED,
'status' => CustomDomain::STATUS_FAILED,
'last_error' => $error ?: 'Certificate issuance failed.',
])->save();
}
}
/** Resolve an incoming host to a live storefront, if any. */
public function resolveStorefront(string $host): ?QrCode
{
$host = preg_replace('/^www\./', '', strtolower(trim($host)));
$domain = CustomDomain::where('host', $host)->where('status', CustomDomain::STATUS_ACTIVE)->first();
return $domain?->qrCode;
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Services\CustomDomain;
class DnsResolver
{
/** @return array<int,string> the A-record IPs for a host */
public function aRecords(string $host): array
{
$records = @dns_get_record($host, DNS_A);
if (! is_array($records)) {
return [];
}
return array_values(array_filter(array_map(fn ($r) => $r['ip'] ?? null, $records)));
}
}
@@ -0,0 +1,52 @@
<?php
namespace App\Services\CustomDomain;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Thin client for Ladill Domains' central SSL API. Requests/queries certificates
* for a custom domain on the shared app server (target = app).
*/
class DomainsSslClient
{
public function configured(): bool
{
return (string) config('customdomain.ssl_api_key', '') !== '';
}
/**
* Ask Domains to issue + install a certificate for $host, calling back when done.
*/
public function requestCertificate(string $host, bool $includeWww, string $callbackUrl): bool
{
if (! $this->configured()) {
return false;
}
try {
$res = Http::withToken((string) config('customdomain.ssl_api_key'))
->acceptJson()->timeout(20)
->post(config('customdomain.ssl_api_url').'/ssl/certificates', [
'host' => $host,
'target' => 'app',
'include_www' => $includeWww,
'callback_url' => $callbackUrl,
'metadata' => ['nginx_include' => config('customdomain.nginx_include')],
]);
if ($res->failed()) {
Log::warning('DomainsSslClient: request failed', ['host' => $host, 'status' => $res->status()]);
return false;
}
return true;
} catch (\Throwable $e) {
Log::warning('DomainsSslClient: request error', ['host' => $host, 'error' => $e->getMessage()]);
return false;
}
}
}