From 0b08b995f01eadfe09ff1efc0efaa1ed78d20eb5 Mon Sep 17 00:00:00 2001 From: keyhan Date: Tue, 2 Jun 2026 16:19:53 +0330 Subject: [PATCH] Issue SSL for custom domains and show a public CNAME target. Derive the CNAME target shown to users from the site's public root domain (PREVIEW_BASE_DOMAIN, e.g. 3fase.ir) instead of the internal platform domain, which is not a valid public suffix and cannot get a Let's Encrypt cert. On DNS verification, re-apply the Ingress so Traefik routes the custom domain and cert-manager issues a cert via HTTP-01, mirroring the preview-domain flow. Co-Authored-By: Claude Opus 4.7 --- backend/src/applications/domain.service.ts | 48 ++++++++++++++++++---- backend/src/seed.ts | 2 +- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/backend/src/applications/domain.service.ts b/backend/src/applications/domain.service.ts index 8291141..91c2154 100644 --- a/backend/src/applications/domain.service.ts +++ b/backend/src/applications/domain.service.ts @@ -13,6 +13,7 @@ import { Application } from './entities/application.entity'; import { PlatformSetting } from '../billing/entities/platform-setting.entity'; import { CustomDomainStatus } from '../common/enums'; import { ensureAppUrlEnv } from './app-url.util'; +import { KubernetesService } from '../kubernetes/kubernetes.service'; @Injectable() export class DomainService { @@ -24,6 +25,7 @@ export class DomainService { @InjectRepository(PlatformSetting) private settingsRepo: Repository, private configService: ConfigService, + private kubernetesService: KubernetesService, ) {} async getCustomDomainPrice(): Promise { @@ -33,11 +35,25 @@ export class DomainService { return setting ? Number(setting.value) : 0; } + /** + * Base domain users point their custom domain at via CNAME. This must be a + * real, publicly-resolvable wildcard host (the same one used for preview URLs, + * e.g. *.3fase.ir → node IP) — NOT the internal platform domain + * (apps.cloudhost.local), which is not a valid public suffix and cannot get a + * Let's Encrypt cert. An explicit admin override (platform_cname_target) wins + * when set; otherwise we derive it from PREVIEW_BASE_DOMAIN / previewRootDomain. + */ async getPlatformCnameTarget(): Promise { const setting = await this.settingsRepo.findOne({ where: { key: 'platform_cname_target' }, }); - return setting?.value || this.configService.get('platform.domain') || 'apps.cloudhost.ir'; + const override = setting?.value?.trim(); + if (override) return override; + return ( + this.configService.get('platform.previewRootDomain') || + this.configService.get('platform.domain') || + 'apps.cloudhost.ir' + ); } async setCustomDomain(appId: string, userId: string, domain: string): Promise { @@ -98,9 +114,27 @@ export class DomainService { app.envVars = ensureAppUrlEnv(app, platformDomain); const saved = await this.appRepo.save(app); this.logger.log(`DNS verified for ${app.name}: ${app.customDomain}`); + + // Re-apply the Ingress so Traefik starts routing the custom domain and + // cert-manager issues a Let's Encrypt cert for it (HTTP-01), exactly like + // the preview domain. Done best-effort: a failure here must not fail the + // verify call — the cert will also be (re)issued on the next deploy. + try { + await this.kubernetesService.updateIngress(saved); + this.logger.log( + `Ingress updated for ${app.name}; SSL issuance started for ${app.customDomain}`, + ); + } catch (ingressErr: any) { + this.logger.warn( + `Custom domain verified but ingress/SSL update failed for ${app.customDomain}: ${ingressErr.message}. ` + + `It will be retried on the next deployment.`, + ); + } + return { verified: true, - message: 'DNS verification successful. Your custom domain is now active.', + message: + 'DNS verification successful. Your custom domain is now active and an SSL certificate is being issued (this may take a few minutes).', application: saved, }; } @@ -143,9 +177,11 @@ export class DomainService { const app = await this.appRepo.findOne({ where: { id: appId, userId } }); if (!app) throw new NotFoundException('Application not found'); - const platformDomain = this.configService.get('platform.domain') || 'apps.cloudhost.ir'; const cnameTarget = await this.getPlatformCnameTarget(); - const fullPlatformUrl = `${app.subdomain}.${platformDomain}`; + // The value users must point their CNAME at — a real, public wildcard host + // (e.g. .3fase.ir) that resolves to our ingress node. + const platformDomain = cnameTarget; + const fullPlatformUrl = `${app.subdomain}.${cnameTarget}`; const instructions = [ `1. Log in to your domain registrar (e.g. Cloudflare, Namecheap, GoDaddy)`, @@ -179,10 +215,8 @@ export class DomainService { cnameTarget: string; instructions: string[]; }> { - const platformDomain = - this.configService.get('platform.domain') || 'apps.cloudhost.ir'; const cnameTarget = await this.getPlatformCnameTarget(); - const fullPlatformUrl = `${appName}.${platformDomain}`; + const fullPlatformUrl = `${appName}.${cnameTarget}`; const instructions = [ `1. Log in to your domain registrar (e.g. Cloudflare, Namecheap, GoDaddy)`, diff --git a/backend/src/seed.ts b/backend/src/seed.ts index 38bf3fd..73d5a21 100644 --- a/backend/src/seed.ts +++ b/backend/src/seed.ts @@ -55,7 +55,7 @@ async function bootstrap() { const defaults = [ { key: 'custom_domain_monthly_price_toman', value: '50000', description: 'Monthly price for custom domain addon (Toman)' }, - { key: 'platform_cname_target', value: 'apps.cloudhost.ir', description: 'CNAME target shown to users for custom domain setup' }, + { key: 'platform_cname_target', value: '', description: 'CNAME target base shown to users for custom domain setup. Leave empty to derive from PREVIEW_BASE_DOMAIN (e.g. 3fase.ir); set to override.' }, { key: 'access_max_duration_minutes', value: '240', description: 'Maximum duration (minutes) for temporary external service access' }, ];