From b8f60c514179c759f043f5900a89260631ab8c5f Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Wed, 26 Aug 2026 00:55:58 +0330 Subject: [PATCH] Skip dashboard SSL hosts without DNS and shorten sync errors. Dashboard cert renewal no longer fails when unrelated tenants lack business/customer DNS records; Issue SSL errors summarize blocked hostnames instead of dumping certbot logs. Co-authored-by: Cursor --- src/common/dns-resolve.ts | 43 ++++++++++++++++++++++++ src/domain-admin/domain-admin.service.ts | 23 ++++++++++++- src/internal-ssl/internal-ssl.service.ts | 20 ++++++++--- 3 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 src/common/dns-resolve.ts diff --git a/src/common/dns-resolve.ts b/src/common/dns-resolve.ts new file mode 100644 index 0000000..3f14cd6 --- /dev/null +++ b/src/common/dns-resolve.ts @@ -0,0 +1,43 @@ +import { lookup } from 'node:dns/promises'; + +/** True when the hostname has at least one A or AAAA record. */ +export async function hostDnsResolves(hostRaw: string): Promise { + const host = hostRaw.trim().toLowerCase(); + if (!host || host.includes(':') || host.endsWith('.local')) { + return false; + } + + try { + await lookup(host); + return true; + } catch { + return false; + } +} + +/** Keep only hosts that resolve in public DNS (parallel, chunked). */ +export async function filterHostsWithDns( + hosts: Array, + chunkSize = 20, +): Promise { + const unique = [ + ...new Set( + hosts + .map((h) => h?.trim().toLowerCase()) + .filter((h): h is string => !!h), + ), + ]; + + const resolved: string[] = []; + for (let i = 0; i < unique.length; i += chunkSize) { + const chunk = unique.slice(i, i + chunkSize); + const probed = await Promise.all( + chunk.map(async (host) => ({ host, ok: await hostDnsResolves(host) })), + ); + for (const { host, ok } of probed) { + if (ok) resolved.push(host); + } + } + + return resolved.sort(); +} diff --git a/src/domain-admin/domain-admin.service.ts b/src/domain-admin/domain-admin.service.ts index 8145b14..427e9d7 100644 --- a/src/domain-admin/domain-admin.service.ts +++ b/src/domain-admin/domain-admin.service.ts @@ -131,13 +131,34 @@ export class DomainAdminService { if (!response.ok) { const text = await response.text().catch(() => ''); throw new ServiceUnavailableException( - `SSL sync agent rejected request (${response.status})${text ? `: ${text}` : ''}`, + `SSL sync agent rejected request (${response.status})${text ? `: ${this.summarizeSslSyncFailure(text)}` : ''}`, ); } return response.json().catch(() => ({ status: opts.wait ? 'ok' : 'accepted' })); } + private summarizeSslSyncFailure(raw: string): string { + try { + const parsed = JSON.parse(raw) as { message?: string; log?: string }; + const log = String(parsed.log ?? ''); + const blocked = [ + ...new Set( + [...log.matchAll(/Domain: ((?:business|customer)\.[^\n]+)/g)].map((m) => m[1].trim()), + ), + ]; + if (blocked.length > 0) { + return ( + `Dashboard cert blocked by missing DNS for: ${blocked.join(', ')}. ` + + 'Add business/customer CNAMEs for those tenants or deactivate the domain, then retry.' + ); + } + return String(parsed.message ?? raw).slice(0, 400); + } catch { + return raw.slice(0, 400); + } + } + /** * Issue Let's Encrypt certs on the websites VM for storefront domains * whose live TLS probe fails. Do not trust ssl_enabled alone — a wrong diff --git a/src/internal-ssl/internal-ssl.service.ts b/src/internal-ssl/internal-ssl.service.ts index bc30cb7..4d2db9b 100644 --- a/src/internal-ssl/internal-ssl.service.ts +++ b/src/internal-ssl/internal-ssl.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Cron, CronExpression } from '@nestjs/schedule'; +import { filterHostsWithDns } from '../common/dns-resolve'; import { PrismaService } from '../prisma/prisma.service'; import { probeTlsHosts } from '../common/tls-probe'; @@ -39,15 +40,26 @@ export class InternalSslService implements OnModuleInit { return hosts; } - /** Dashboards VPS: manage + business./customer. per active apex. */ + /** Dashboards VPS: manage + business./customer. per active apex (DNS-resolvable only). */ async listDashboardHosts(): Promise<{ hosts: string[] }> { const adminHost = this.config.get('DASHBOARD_ADMIN_HOST')?.trim() || 'manage.meshkee.com'; - const hosts = new Set([adminHost]); + const candidates = new Set([adminHost]); for (const apex of await this.listActiveApexHosts()) { - hosts.add(`business.${apex}`); - hosts.add(`customer.${apex}`); + candidates.add(`business.${apex}`); + candidates.add(`customer.${apex}`); + } + + const resolved = await filterHostsWithDns([...candidates]); + const hosts = new Set(resolved); + hosts.add(adminHost); + + const skipped = [...candidates].filter((h) => !hosts.has(h)); + if (skipped.length > 0) { + this.logger.warn( + `Dashboard SSL host list skipped ${skipped.length} host(s) without DNS: ${skipped.join(', ')}`, + ); } return { hosts: [...hosts].sort() };