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 <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-09 16:32:05 +03:30
co-authored by Cursor
parent 8f05ee2b58
commit a9d5f2e48d
6 changed files with 188 additions and 39 deletions
+4 -1
View File
@@ -2,8 +2,11 @@
# #
# Endpoints (X-Deploy-Token): # Endpoints (X-Deploy-Token):
# POST /deploy { slug } — git pull + build + pm2 restart # 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) # POST /ssl { host, slug? } — certbot for apex + www (nginx must exist)
# GET /health # GET /health
# #
# Env (.env): PORT, DEPLOY_TOKEN, ALLOWED_SLUGS # 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.
+4 -4
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Provision a new storefront site on the websites VM (clone + nginx + pm2 entry + allowlist). # 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 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 set -euo pipefail
SLUG="${1:-}" SLUG="${1:-}"
@@ -142,10 +143,9 @@ else
echo "nginx site already exists: $NGINX_AVAILABLE" echo "nginx site already exists: $NGINX_AVAILABLE"
fi fi
if command -v certbot >/dev/null 2>&1; then # SSL is issued separately via ssl.sh / Super Admin "Issue SSL" — do not run
certbot --nginx -d "$HOST" -d "www.$HOST" --non-interactive --agree-tos --register-unsafely-without-email --redirect \ # certbot here. It often hangs on www DNS mismatches and blocks the API request
|| echo "certbot skipped/failed (non-fatal)" # (Edit domain modal stays open until the proxy times out).
fi
if [[ -f "$ENV_FILE" ]]; then if [[ -f "$ENV_FILE" ]]; then
CURRENT="$(grep -E '^ALLOWED_SLUGS=' "$ENV_FILE" | head -1 | cut -d= -f2- || true)" CURRENT="$(grep -E '^ALLOWED_SLUGS=' "$ENV_FILE" | head -1 | cut -d= -f2- || true)"
+11 -1
View File
@@ -583,7 +583,14 @@ export class BusinessAdminService {
let provisionError: string | null = null; let provisionError: string | null = null;
let nextGitRepoUrl = domain.gitRepoUrl; 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); const slug = domain.deploySlug?.trim() || deploySlugFromHost(host);
if (!slug) { if (!slug) {
throw new BadRequestException('Could not derive deploy slug from host'); throw new BadRequestException('Could not derive deploy slug from host');
@@ -612,6 +619,9 @@ export class BusinessAdminService {
provisionError = 'Storefront provision failed on websites server'; provisionError = 'Storefront provision failed on websites server';
} }
} }
} else if (alreadyWired) {
deploySlug = domain.deploySlug;
nextGitRepoUrl = gitRepoUrl;
} }
const updated = await this.prisma.domain.update({ const updated = await this.prisma.domain.update({
+18 -2
View File
@@ -2,8 +2,22 @@ import * as tls from 'tls';
const DEFAULT_TIMEOUT_MS = 2500; 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. * Returns false on timeout, DNS failure, or cert/hostname errors.
*/ */
export function probeTlsHost( export function probeTlsHost(
@@ -34,7 +48,9 @@ export function probeTlsHost(
timeout: timeoutMs, 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(); socket.destroy();
finish(ok); finish(ok);
}, },
@@ -51,6 +51,7 @@ export class DomainAdminController {
return this.service.deploy(domainId, user); return this.service.deploy(domainId, user);
} }
/** Issue / renew Let's Encrypt for one domain: apex + business.* + customer.*. */
@Post(':domainId/issue-ssl') @Post(':domainId/issue-ssl')
@HttpCode(200) @HttpCode(200)
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
+148 -29
View File
@@ -92,7 +92,15 @@ export class DomainAdminService {
async syncSsl(actor: AuthUser) { async syncSsl(actor: AuthUser) {
await this.assertSuperAdmin(actor); 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<string>('SSL_SYNC_AGENT_URL')?.trim(); const agentUrl = this.config.get<string>('SSL_SYNC_AGENT_URL')?.trim();
const token = this.config.get<string>('SSL_SYNC_AGENT_TOKEN')?.trim(); const token = this.config.get<string>('SSL_SYNC_AGENT_TOKEN')?.trim();
if (!agentUrl || !token) { if (!agentUrl || !token) {
@@ -107,7 +115,7 @@ export class DomainAdminService {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-SSL-Sync-Agent-Token': token, 'X-SSL-Sync-Agent-Token': token,
}, },
body: '{}', body: JSON.stringify({ wait: opts.wait }),
}); });
} catch { } catch {
throw new ServiceUnavailableException('Could not reach SSL sync agent'); throw new ServiceUnavailableException('Could not reach SSL sync agent');
@@ -124,29 +132,50 @@ export class DomainAdminService {
); );
} }
return { return response.json().catch(() => ({ status: opts.wait ? 'ok' : 'accepted' }));
status: 'accepted' as const,
message: 'Dashboard SSL sync started (manage / business.* / customer.*)',
};
} }
/** /**
* Issue Let's Encrypt certs on the websites VM for storefront domains * 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) { async issueWebsiteSsl(actor: AuthUser) {
await this.assertSuperAdmin(actor); await this.assertSuperAdmin(actor);
const targets = await this.prisma.domain.findMany({ const candidates = await this.prisma.domain.findMany({
where: { where: {
isActive: true, isActive: true,
sslEnabled: false,
deploySlug: { not: null }, deploySlug: { not: null },
}, },
select: { id: true, host: true, deploySlug: true }, select: { id: true, host: true, deploySlug: true, sslEnabled: true },
orderBy: { host: 'asc' }, 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) { if (targets.length === 0) {
return { return {
status: 'ok' as const, status: 'ok' as const,
@@ -194,6 +223,12 @@ export class DomainAdminService {
return { status: 'ok' as const, message, issued, failed }; 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) { async issueSsl(domainIdRaw: string, actor: AuthUser) {
await this.assertSuperAdmin(actor); await this.assertSuperAdmin(actor);
@@ -203,41 +238,125 @@ export class DomainAdminService {
throw new NotFoundException('Domain not found'); 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; 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',
);
}
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 { try {
await this.websiteDeployAgent.issueSsl({ host: domain.host, slug }); 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) { } catch (err) {
if (err instanceof ServiceUnavailableException) { hosts.apex = {
throw err; 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({ const updated = await this.prisma.domain.update({
where: { id: domainId }, where: { id: domainId },
data: { sslEnabled: ok }, data: { sslEnabled: apexOk },
}); });
if (!ok) { const parts = [hosts.apex, hosts.business, hosts.customer];
throw new ServiceUnavailableException( const failed = parts.filter((p) => p.status === 'failed');
`Certbot ran for ${domain.host} but HTTPS probe failed — check DNS for apex and www`, 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 { return {
status: 'issued' as const, status: failed.length === 0 ? ('ok' as const) : ('partial' as const),
host: domain.host, host: apex,
sslEnabled: updated.sslEnabled, 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,
}; };
} }