diff --git a/src/common/tls-probe.ts b/src/common/tls-probe.ts index 6d5483d..c30de81 100644 --- a/src/common/tls-probe.ts +++ b/src/common/tls-probe.ts @@ -1,14 +1,15 @@ import * as tls from 'tls'; -const DEFAULT_TIMEOUT_MS = 2500; +const DEFAULT_TIMEOUT_MS = 4000; function peerMatchesHost(host: string, socket: tls.TLSSocket): boolean { - const cert = socket.getPeerCertificate(); + // detailed=true so SAN is present for checkServerIdentity + const cert = socket.getPeerCertificate(true); if (!cert || Object.keys(cert).length === 0) { return false; } try { - const err = tls.checkServerIdentity(host, cert); + const err = tls.checkServerIdentity(host, cert as tls.PeerCertificate); return !err; } catch { return false; diff --git a/src/domain-admin/domain-admin.service.ts b/src/domain-admin/domain-admin.service.ts index a5c5849..b63c24c 100644 --- a/src/domain-admin/domain-admin.service.ts +++ b/src/domain-admin/domain-admin.service.ts @@ -156,7 +156,12 @@ export class DomainAdminService { const targets: Array<{ id: bigint; host: string; deploySlug: string | null }> = []; for (const domain of candidates) { - const ok = await probeTlsHost(domain.host); + const apexOk = await probeTlsHost(domain.host); + const wwwHost = domain.host.startsWith('www.') + ? domain.host + : `www.${domain.host}`; + const wwwOk = await probeTlsHost(wwwHost); + const ok = apexOk && wwwOk; if (ok) { if (!domain.sslEnabled) { await this.prisma.domain.update({ @@ -195,16 +200,20 @@ export class DomainAdminService { slug: domain.deploySlug, }); const ok = await probeTlsHost(domain.host); + const wwwHost = domain.host.startsWith('www.') + ? domain.host + : `www.${domain.host}`; + const wwwOk = ok && (await probeTlsHost(wwwHost)); await this.prisma.domain.update({ where: { id: domain.id }, - data: { sslEnabled: ok }, + data: { sslEnabled: wwwOk }, }); - if (ok) { + if (wwwOk) { issued.push(domain.host); } else { failed.push({ host: domain.host, - error: 'Certbot finished but TLS probe still failed', + error: 'Certbot finished but TLS probe still failed (apex or www)', }); } } catch (err) { @@ -224,8 +233,8 @@ export class DomainAdminService { } /** - * Per-row SSL ensure: probe apex + business.* + customer.*. - * Issue storefront SSL on websites VM when apex fails (deploy_slug required). + * Per-row SSL ensure: probe apex + www + business.* + customer.*. + * Issue storefront SSL on websites VM when apex/www fail (deploy_slug required). * Expand dashboard cert when business/customer fail. * Skip hosts that already have valid TLS. */ @@ -239,6 +248,7 @@ export class DomainAdminService { } const apex = domain.host.trim().toLowerCase(); + const wwwHost = apex.startsWith('www.') ? apex : `www.${apex}`; const businessHost = `business.${apex}`; const customerHost = `customer.${apex}`; const slug = domain.deploySlug?.trim() || null; @@ -246,40 +256,81 @@ export class DomainAdminService { type HostStatus = 'ok' | 'issued' | 'failed' | 'skipped'; type HostResult = { host: string; status: HostStatus; detail?: string }; - const hosts: { apex: HostResult; business: HostResult; customer: HostResult } = { + const hosts: { + apex: HostResult; + www: HostResult; + business: HostResult; + customer: HostResult; + } = { apex: { host: apex, status: 'ok' }, + www: { host: wwwHost, status: 'ok' }, business: { host: businessHost, status: 'ok' }, customer: { host: customerHost, status: 'ok' }, }; let apexOk = await probeTlsHost(apex); + let wwwOk = await probeTlsHost(wwwHost); let businessOk = await probeTlsHost(businessHost); let customerOk = await probeTlsHost(customerHost); - if (apexOk) { + const storefrontNeedsIssue = !apexOk || !wwwOk; + + if (!storefrontNeedsIssue) { hosts.apex = { host: apex, status: 'ok', detail: 'Already valid' }; + hosts.www = { host: wwwHost, 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', + status: apexOk ? 'ok' : 'skipped', + detail: apexOk + ? 'Already valid' + : 'No storefront deploy — apex SSL needs git/deploy on the websites VM', + }; + hosts.www = { + host: wwwHost, + status: wwwOk ? 'ok' : 'skipped', + detail: wwwOk + ? 'Already valid' + : 'No storefront deploy — www SSL needs git/deploy on the websites VM', }; } else { try { await this.websiteDeployAgent.issueSsl({ host: apex, slug }); apexOk = await probeTlsHost(apex); + wwwOk = await probeTlsHost(wwwHost); hosts.apex = apexOk - ? { host: apex, status: 'issued', detail: 'Issued on websites VM' } + ? { + 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', + detail: 'Certbot ran but HTTPS probe still failed — check DNS for apex', + }; + hosts.www = wwwOk + ? { + host: wwwHost, + status: 'issued', + detail: 'Issued on websites VM', + } + : { + host: wwwHost, + status: 'failed', + detail: 'Certbot ran but HTTPS probe still failed — check DNS for www', }; } catch (err) { + const detail = err instanceof Error ? err.message : 'Storefront SSL issue failed'; hosts.apex = { host: apex, - status: 'failed', - detail: err instanceof Error ? err.message : 'Storefront SSL issue failed', + status: apexOk ? 'ok' : 'failed', + detail: apexOk ? 'Already valid' : detail, + }; + hosts.www = { + host: wwwHost, + status: wwwOk ? 'ok' : 'failed', + detail: wwwOk ? 'Already valid' : detail, }; } } @@ -326,28 +377,20 @@ export class DomainAdminService { } } + const storefrontOk = apexOk && wwwOk; const updated = await this.prisma.domain.update({ where: { id: domainId }, - data: { sslEnabled: apexOk }, + data: { sslEnabled: storefrontOk }, }); - const parts = [hosts.apex, hosts.business, hosts.customer]; + const parts = [hosts.apex, hosts.www, 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`; - } + // Always spell out each host so dashboard-only OK is never mistaken for storefront OK. + const message = parts + .map((p) => `${p.host}: ${p.status}${p.detail ? ` (${p.detail})` : ''}`) + .join(' · '); return { status: failed.length === 0 ? ('ok' as const) : ('partial' as const), diff --git a/src/internal-ssl/internal-ssl.service.ts b/src/internal-ssl/internal-ssl.service.ts index 1885876..bc30cb7 100644 --- a/src/internal-ssl/internal-ssl.service.ts +++ b/src/internal-ssl/internal-ssl.service.ts @@ -88,12 +88,20 @@ export class InternalSslService implements OnModuleInit { const started = Date.now(); try { const domains = await this.listActiveDomains(); - const sslByHost = await probeTlsHosts(domains.map((d) => d.host)); + const hostsToProbe = domains.flatMap((d) => { + const apex = d.host.trim().toLowerCase(); + if (!apex) return []; + const www = apex.startsWith('www.') ? apex : `www.${apex}`; + return [apex, www]; + }); + const sslByHost = await probeTlsHosts(hostsToProbe); let updated = 0; for (const domain of domains) { const hostKey = domain.host.trim().toLowerCase(); - const sslEnabled = sslByHost.get(hostKey) ?? false; + const wwwKey = hostKey.startsWith('www.') ? hostKey : `www.${hostKey}`; + const sslEnabled = + (sslByHost.get(hostKey) ?? false) && (sslByHost.get(wwwKey) ?? false); if (sslEnabled === domain.sslEnabled) continue; await this.prisma.domain.update({