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):
# 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.
+4 -4
View File
@@ -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)"
+11 -1
View File
@@ -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({
+18 -2
View File
@@ -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);
},
@@ -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)
+150 -31
View File
@@ -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<string>('SSL_SYNC_AGENT_URL')?.trim();
const token = this.config.get<string>('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,
};
}