Add opt-in custom domains with automatic SSL
Deploy Ladill Events / deploy (push) Successful in 1m23s
Deploy Ladill Events / deploy (push) Successful in 1m23s
Customers can connect their own domain to a events 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:
co-authored by
Claude Opus 4.8
parent
5ddf674983
commit
8cca4fb6e3
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\CustomDomain\CustomDomainService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Signed completion callback from Ladill Domains' SSL service. Flips a custom
|
||||
* domain to live (or failed) once the certificate is issued. Auth is the HMAC
|
||||
* signature over the raw body (shared SSL_CALLBACK_SECRET), not a session.
|
||||
*/
|
||||
class SslCallbackController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, CustomDomainService $service): JsonResponse
|
||||
{
|
||||
$secret = (string) config('customdomain.callback_secret');
|
||||
$body = $request->getContent();
|
||||
$signature = (string) $request->header('X-Ladill-Signature', '');
|
||||
|
||||
if ($secret === '' || ! hash_equals(hash_hmac('sha256', $body, $secret), $signature)) {
|
||||
return response()->json(['error' => 'Invalid signature.'], 401);
|
||||
}
|
||||
|
||||
$payload = json_decode($body, true);
|
||||
$data = (array) ($payload['data'] ?? []);
|
||||
$host = (string) ($data['host'] ?? '');
|
||||
if ($host === '') {
|
||||
return response()->json(['error' => 'Missing host.'], 422);
|
||||
}
|
||||
|
||||
$service->applyCallback(
|
||||
$host,
|
||||
(string) ($data['status'] ?? 'failed'),
|
||||
$data['expires_at'] ?? null,
|
||||
$data['last_error'] ?? null,
|
||||
);
|
||||
|
||||
return response()->json(['status' => 'ok']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Events;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CustomDomain;
|
||||
use App\Models\QrCode;
|
||||
use App\Services\CustomDomain\CustomDomainService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CustomDomainController extends Controller
|
||||
{
|
||||
public function __construct(private readonly CustomDomainService $service) {}
|
||||
|
||||
public function store(Request $request, QrCode $event): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $event);
|
||||
abort_unless($this->service->enabled(), 404);
|
||||
|
||||
$data = $request->validate([
|
||||
'host' => ['required', 'string', 'max:255', 'regex:/^(?!-)([a-z0-9-]+\.)+[a-z]{2,}$/i'],
|
||||
'include_www' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$host = strtolower(preg_replace('/^www\./', '', trim($data['host'])));
|
||||
|
||||
if (CustomDomain::where('host', $host)->exists()) {
|
||||
return back()->withErrors(['host' => 'That domain is already connected.']);
|
||||
}
|
||||
|
||||
$this->service->attach($event, $host, (bool) ($data['include_www'] ?? true));
|
||||
|
||||
return back()->with('success', "Domain added. Point an A record for {$host} (and www) to ".config('customdomain.server_ip').', then click Verify.');
|
||||
}
|
||||
|
||||
public function verify(Request $request, CustomDomain $customDomain): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $customDomain->qrCode);
|
||||
|
||||
[$ok, $message] = $this->service->verifyAndProvision($customDomain);
|
||||
|
||||
return back()->with($ok ? 'success' : 'error', $message);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, CustomDomain $customDomain): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $customDomain->qrCode);
|
||||
|
||||
$customDomain->delete();
|
||||
|
||||
return back()->with('success', 'Custom domain removed.');
|
||||
}
|
||||
}
|
||||
@@ -226,6 +226,9 @@ class QrCodeController extends Controller
|
||||
'minTopup' => QrWallet::minTopupGhs(),
|
||||
'ladillWalletBalance' => $ladillWalletBalance,
|
||||
'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
|
||||
'customDomains' => \App\Models\CustomDomain::where('qr_code_id', $qrCode->id)->get(),
|
||||
'customDomainsEnabled' => app(\App\Services\CustomDomain\CustomDomainService::class)->enabled(),
|
||||
'customDomainServerIp' => config('customdomain.server_ip'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* A customer's own domain (e.g. brand.com) pointed at one storefront (QrCode).
|
||||
* Visiting the domain serves that storefront's public page. DNS is verified to
|
||||
* resolve to the app, then Ladill Domains issues + installs the TLS cert.
|
||||
*/
|
||||
class CustomDomain extends Model
|
||||
{
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
protected $fillable = [
|
||||
'qr_code_id', 'user_id', 'host', 'include_www', 'status', 'ssl_status',
|
||||
'dns_verified_at', 'ssl_issued_at', 'ssl_expires_at', 'last_error',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'include_www' => 'boolean',
|
||||
'dns_verified_at' => 'datetime',
|
||||
'ssl_issued_at' => 'datetime',
|
||||
'ssl_expires_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function qrCode(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(QrCode::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function isLive(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_ACTIVE && $this->ssl_status === self::STATUS_ACTIVE;
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (CustomDomain $d) {
|
||||
$d->host = strtolower(trim((string) $d->host, " \t\n\r\0\x0B./"));
|
||||
$d->host = preg_replace('/^www\./', '', $d->host) ?: $d->host;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user