mirror of
https://git.meshkee.com/Meshkee/backend.git
synced 2026-08-11 22:30:59 +04:30
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:
co-authored by
Cursor
parent
8f05ee2b58
commit
a9d5f2e48d
@@ -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.
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
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: 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) {
|
||||
if (err instanceof ServiceUnavailableException) {
|
||||
throw 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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user