From a9d5f2e48da1dbb74b434bcc11b8f18e55d86c01 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Sun, 9 Aug 2026 16:32:05 +0330 Subject: [PATCH] Fix storefront SSL probes and per-domain ensure for dashboards. Skip redundant provision on edit, harden TLS hostname checks, and issue apex/business/customer SSL from one endpoint. Co-authored-by: Cursor --- scripts/websites-agent/README.md | 5 +- scripts/websites-agent/provision.sh | 8 +- src/business-admin/business-admin.service.ts | 12 +- src/common/tls-probe.ts | 20 +- src/domain-admin/domain-admin.controller.ts | 1 + src/domain-admin/domain-admin.service.ts | 181 +++++++++++++++---- 6 files changed, 188 insertions(+), 39 deletions(-) diff --git a/scripts/websites-agent/README.md b/scripts/websites-agent/README.md index fcce156..0a052dd 100644 --- a/scripts/websites-agent/README.md +++ b/scripts/websites-agent/README.md @@ -2,8 +2,11 @@ # # Endpoints (X-Deploy-Token): # POST /deploy { slug } — git pull + build + pm2 restart -# POST /provision { slug, host, gitRepoUrl } — clone + nginx + ecosystem + allowlist +# POST /provision { slug, host, gitRepoUrl } — clone + nginx + ecosystem + allowlist (no certbot) # POST /ssl { host, slug? } — certbot for apex + www (nginx must exist) # GET /health # # Env (.env): PORT, DEPLOY_TOKEN, ALLOWED_SLUGS +# +# Note: provision.sh intentionally skips certbot. SSL is issued via POST /ssl so +# Edit/Add Domain does not hang when www DNS is wrong. diff --git a/scripts/websites-agent/provision.sh b/scripts/websites-agent/provision.sh index d6c3b68..491f1d8 100755 --- a/scripts/websites-agent/provision.sh +++ b/scripts/websites-agent/provision.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # Provision a new storefront site on the websites VM (clone + nginx + pm2 entry + allowlist). # Does NOT run npm ci/build — first build happens via deploy.sh (Super Admin Deploy). +# Does NOT run certbot — use ssl.sh / Super Admin Issue SSL (certbot blocked Edit Domain saves). set -euo pipefail SLUG="${1:-}" @@ -142,10 +143,9 @@ else echo "nginx site already exists: $NGINX_AVAILABLE" fi -if command -v certbot >/dev/null 2>&1; then - certbot --nginx -d "$HOST" -d "www.$HOST" --non-interactive --agree-tos --register-unsafely-without-email --redirect \ - || echo "certbot skipped/failed (non-fatal)" -fi +# SSL is issued separately via ssl.sh / Super Admin "Issue SSL" — do not run +# certbot here. It often hangs on www DNS mismatches and blocks the API request +# (Edit domain modal stays open until the proxy times out). if [[ -f "$ENV_FILE" ]]; then CURRENT="$(grep -E '^ALLOWED_SLUGS=' "$ENV_FILE" | head -1 | cut -d= -f2- || true)" diff --git a/src/business-admin/business-admin.service.ts b/src/business-admin/business-admin.service.ts index 4459c3e..ce54633 100644 --- a/src/business-admin/business-admin.service.ts +++ b/src/business-admin/business-admin.service.ts @@ -583,7 +583,14 @@ export class BusinessAdminService { let provisionError: string | null = null; let nextGitRepoUrl = domain.gitRepoUrl; - if (gitRepoUrl) { + // Only provision when wiring a new/changed repo or when deploy slug is missing. + // Re-saving the same git URL must not block the API on clone/nginx/certbot again. + const alreadyWired = + !!gitRepoUrl && + !!domain.deploySlug?.trim() && + domain.gitRepoUrl?.trim() === gitRepoUrl; + + if (gitRepoUrl && !alreadyWired) { const slug = domain.deploySlug?.trim() || deploySlugFromHost(host); if (!slug) { throw new BadRequestException('Could not derive deploy slug from host'); @@ -612,6 +619,9 @@ export class BusinessAdminService { provisionError = 'Storefront provision failed on websites server'; } } + } else if (alreadyWired) { + deploySlug = domain.deploySlug; + nextGitRepoUrl = gitRepoUrl; } const updated = await this.prisma.domain.update({ diff --git a/src/common/tls-probe.ts b/src/common/tls-probe.ts index b1adb84..6d5483d 100644 --- a/src/common/tls-probe.ts +++ b/src/common/tls-probe.ts @@ -2,8 +2,22 @@ import * as tls from 'tls'; const DEFAULT_TIMEOUT_MS = 2500; +function peerMatchesHost(host: string, socket: tls.TLSSocket): boolean { + const cert = socket.getPeerCertificate(); + if (!cert || Object.keys(cert).length === 0) { + return false; + } + try { + const err = tls.checkServerIdentity(host, cert); + return !err; + } catch { + return false; + } +} + /** - * Live TLS check: connect to host:443 with SNI and require a trusted cert. + * Live TLS check: connect to host:443 with SNI and require a trusted cert + * whose identity matches the hostname (not merely a valid LE chain for another name). * Returns false on timeout, DNS failure, or cert/hostname errors. */ export function probeTlsHost( @@ -34,7 +48,9 @@ export function probeTlsHost( timeout: timeoutMs, }, () => { - const ok = socket.authorized === true; + // authorized=true alone is not enough: a default vhost can present + // another site's valid cert (ERR_CERT_COMMON_NAME_INVALID in browsers). + const ok = socket.authorized === true && peerMatchesHost(host, socket); socket.destroy(); finish(ok); }, diff --git a/src/domain-admin/domain-admin.controller.ts b/src/domain-admin/domain-admin.controller.ts index 7aa7ab3..4701db2 100644 --- a/src/domain-admin/domain-admin.controller.ts +++ b/src/domain-admin/domain-admin.controller.ts @@ -51,6 +51,7 @@ export class DomainAdminController { return this.service.deploy(domainId, user); } + /** Issue / renew Let's Encrypt for one domain: apex + business.* + customer.*. */ @Post(':domainId/issue-ssl') @HttpCode(200) @UseGuards(JwtAuthGuard) diff --git a/src/domain-admin/domain-admin.service.ts b/src/domain-admin/domain-admin.service.ts index c9424b5..a5c5849 100644 --- a/src/domain-admin/domain-admin.service.ts +++ b/src/domain-admin/domain-admin.service.ts @@ -92,7 +92,15 @@ export class DomainAdminService { async syncSsl(actor: AuthUser) { await this.assertSuperAdmin(actor); + await this.callDashboardSslSync({ wait: false }); + return { + status: 'accepted' as const, + message: 'Dashboard SSL sync started (manage / business.* / customer.*)', + }; + } + /** Fire-and-forget or blocking call to the dashboards VPS ssl-sync agent. */ + private async callDashboardSslSync(opts: { wait: boolean }) { const agentUrl = this.config.get('SSL_SYNC_AGENT_URL')?.trim(); const token = this.config.get('SSL_SYNC_AGENT_TOKEN')?.trim(); if (!agentUrl || !token) { @@ -107,7 +115,7 @@ export class DomainAdminService { 'Content-Type': 'application/json', 'X-SSL-Sync-Agent-Token': token, }, - body: '{}', + body: JSON.stringify({ wait: opts.wait }), }); } catch { throw new ServiceUnavailableException('Could not reach SSL sync agent'); @@ -124,29 +132,50 @@ export class DomainAdminService { ); } - return { - status: 'accepted' as const, - message: 'Dashboard SSL sync started (manage / business.* / customer.*)', - }; + return response.json().catch(() => ({ status: opts.wait ? 'ok' : 'accepted' })); } /** * Issue Let's Encrypt certs on the websites VM for storefront domains - * that have deploy_slug and currently report ssl_enabled=false. + * whose live TLS probe fails. Do not trust ssl_enabled alone — a wrong + * default-vhost cert can leave the flag true while browsers show + * NET::ERR_CERT_COMMON_NAME_INVALID. */ async issueWebsiteSsl(actor: AuthUser) { await this.assertSuperAdmin(actor); - const targets = await this.prisma.domain.findMany({ + const candidates = await this.prisma.domain.findMany({ where: { isActive: true, - sslEnabled: false, deploySlug: { not: null }, }, - select: { id: true, host: true, deploySlug: true }, + select: { id: true, host: true, deploySlug: true, sslEnabled: true }, orderBy: { host: 'asc' }, }); + const targets: Array<{ id: bigint; host: string; deploySlug: string | null }> = []; + + for (const domain of candidates) { + const ok = await probeTlsHost(domain.host); + if (ok) { + if (!domain.sslEnabled) { + await this.prisma.domain.update({ + where: { id: domain.id }, + data: { sslEnabled: true }, + }); + } + continue; + } + + if (domain.sslEnabled) { + await this.prisma.domain.update({ + where: { id: domain.id }, + data: { sslEnabled: false }, + }); + } + targets.push(domain); + } + if (targets.length === 0) { return { status: 'ok' as const, @@ -194,6 +223,12 @@ export class DomainAdminService { return { status: 'ok' as const, message, issued, failed }; } + /** + * Per-row SSL ensure: probe apex + business.* + customer.*. + * Issue storefront SSL on websites VM when apex fails (deploy_slug required). + * Expand dashboard cert when business/customer fail. + * Skip hosts that already have valid TLS. + */ async issueSsl(domainIdRaw: string, actor: AuthUser) { await this.assertSuperAdmin(actor); @@ -203,41 +238,125 @@ export class DomainAdminService { throw new NotFoundException('Domain not found'); } + const apex = domain.host.trim().toLowerCase(); + const businessHost = `business.${apex}`; + const customerHost = `customer.${apex}`; const slug = domain.deploySlug?.trim() || null; - if (!slug) { - throw new BadRequestException( - 'This domain has no storefront deploy configured — use Sync dashboard SSL for business/customer hosts', - ); - } - try { - await this.websiteDeployAgent.issueSsl({ host: domain.host, slug }); - } catch (err) { - if (err instanceof ServiceUnavailableException) { - throw err; + type HostStatus = 'ok' | 'issued' | 'failed' | 'skipped'; + type HostResult = { host: string; status: HostStatus; detail?: string }; + + const hosts: { apex: HostResult; business: HostResult; customer: HostResult } = { + apex: { host: apex, status: 'ok' }, + business: { host: businessHost, status: 'ok' }, + customer: { host: customerHost, status: 'ok' }, + }; + + let apexOk = await probeTlsHost(apex); + let businessOk = await probeTlsHost(businessHost); + let customerOk = await probeTlsHost(customerHost); + + if (apexOk) { + hosts.apex = { host: apex, status: 'ok', detail: 'Already valid' }; + } else if (!slug) { + hosts.apex = { + host: apex, + status: 'skipped', + detail: 'No storefront deploy — apex SSL is issued on the websites VM only when git/deploy is configured', + }; + } else { + try { + await this.websiteDeployAgent.issueSsl({ host: apex, slug }); + apexOk = await probeTlsHost(apex); + hosts.apex = apexOk + ? { host: apex, status: 'issued', detail: 'Issued on websites VM' } + : { + host: apex, + status: 'failed', + detail: 'Certbot ran but HTTPS probe still failed — check DNS for apex and www', + }; + } catch (err) { + hosts.apex = { + host: apex, + status: 'failed', + detail: err instanceof Error ? err.message : 'Storefront SSL issue failed', + }; + } + } + + if (businessOk) { + hosts.business = { host: businessHost, status: 'ok', detail: 'Already valid' }; + } + if (customerOk) { + hosts.customer = { host: customerHost, status: 'ok', detail: 'Already valid' }; + } + + if (!businessOk || !customerOk) { + try { + await this.callDashboardSslSync({ wait: true }); + businessOk = await probeTlsHost(businessHost); + customerOk = await probeTlsHost(customerHost); + + if (!hosts.business.detail) { + hosts.business = businessOk + ? { host: businessHost, status: 'issued', detail: 'Covered by dashboard cert' } + : { + host: businessHost, + status: 'failed', + detail: 'Dashboard SSL sync finished but probe still failed', + }; + } + if (!hosts.customer.detail) { + hosts.customer = customerOk + ? { host: customerHost, status: 'issued', detail: 'Covered by dashboard cert' } + : { + host: customerHost, + status: 'failed', + detail: 'Dashboard SSL sync finished but probe still failed', + }; + } + } catch (err) { + const detail = err instanceof Error ? err.message : 'Dashboard SSL sync failed'; + if (!businessOk) { + hosts.business = { host: businessHost, status: 'failed', detail }; + } + if (!customerOk) { + hosts.customer = { host: customerHost, status: 'failed', detail }; + } } - throw new ServiceUnavailableException( - err instanceof Error ? err.message : 'SSL issue failed', - ); } - const ok = await probeTlsHost(domain.host); const updated = await this.prisma.domain.update({ where: { id: domainId }, - data: { sslEnabled: ok }, + data: { sslEnabled: apexOk }, }); - if (!ok) { - throw new ServiceUnavailableException( - `Certbot ran for ${domain.host} but HTTPS probe failed — check DNS for apex and www`, - ); + const parts = [hosts.apex, hosts.business, hosts.customer]; + const failed = parts.filter((p) => p.status === 'failed'); + const issued = parts.filter((p) => p.status === 'issued'); + const skipped = parts.filter((p) => p.status === 'skipped'); + + let message: string; + if (failed.length === 0 && issued.length === 0 && skipped.length === 0) { + message = `SSL already valid for ${apex}, ${businessHost}, ${customerHost}`; + } else if (failed.length === 0) { + const bits = [ + ...issued.map((p) => `${p.host} issued`), + ...skipped.map((p) => `${p.host} skipped`), + ]; + message = bits.join(' · '); + } else { + message = `SSL ensure partial: ${failed.map((p) => p.host).join(', ')} failed`; } return { - status: 'issued' as const, - host: domain.host, + status: failed.length === 0 ? ('ok' as const) : ('partial' as const), + host: apex, sslEnabled: updated.sslEnabled, - message: `SSL issued for ${domain.host}`, + hosts, + issued: issued.map((p) => p.host), + failed: failed.map((p) => ({ host: p.host, error: p.detail || 'failed' })), + message, }; }