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):
|
# 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.
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user