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 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-02 16:19:53 +03:30
parent 4301277b48
commit 0b08b995f0
2 changed files with 42 additions and 8 deletions
+41 -7
View File
@@ -13,6 +13,7 @@ import { Application } from './entities/application.entity';
import { PlatformSetting } from '../billing/entities/platform-setting.entity'; import { PlatformSetting } from '../billing/entities/platform-setting.entity';
import { CustomDomainStatus } from '../common/enums'; import { CustomDomainStatus } from '../common/enums';
import { ensureAppUrlEnv } from './app-url.util'; import { ensureAppUrlEnv } from './app-url.util';
import { KubernetesService } from '../kubernetes/kubernetes.service';
@Injectable() @Injectable()
export class DomainService { export class DomainService {
@@ -24,6 +25,7 @@ export class DomainService {
@InjectRepository(PlatformSetting) @InjectRepository(PlatformSetting)
private settingsRepo: Repository<PlatformSetting>, private settingsRepo: Repository<PlatformSetting>,
private configService: ConfigService, private configService: ConfigService,
private kubernetesService: KubernetesService,
) {} ) {}
async getCustomDomainPrice(): Promise<number> { async getCustomDomainPrice(): Promise<number> {
@@ -33,11 +35,25 @@ export class DomainService {
return setting ? Number(setting.value) : 0; 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<string> { async getPlatformCnameTarget(): Promise<string> {
const setting = await this.settingsRepo.findOne({ const setting = await this.settingsRepo.findOne({
where: { key: 'platform_cname_target' }, 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<string>('platform.previewRootDomain') ||
this.configService.get<string>('platform.domain') ||
'apps.cloudhost.ir'
);
} }
async setCustomDomain(appId: string, userId: string, domain: string): Promise<Application> { async setCustomDomain(appId: string, userId: string, domain: string): Promise<Application> {
@@ -98,9 +114,27 @@ export class DomainService {
app.envVars = ensureAppUrlEnv(app, platformDomain); app.envVars = ensureAppUrlEnv(app, platformDomain);
const saved = await this.appRepo.save(app); const saved = await this.appRepo.save(app);
this.logger.log(`DNS verified for ${app.name}: ${app.customDomain}`); 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 { return {
verified: true, 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, application: saved,
}; };
} }
@@ -143,9 +177,11 @@ export class DomainService {
const app = await this.appRepo.findOne({ where: { id: appId, userId } }); const app = await this.appRepo.findOne({ where: { id: appId, userId } });
if (!app) throw new NotFoundException('Application not found'); if (!app) throw new NotFoundException('Application not found');
const platformDomain = this.configService.get('platform.domain') || 'apps.cloudhost.ir';
const cnameTarget = await this.getPlatformCnameTarget(); 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. <subdomain>.3fase.ir) that resolves to our ingress node.
const platformDomain = cnameTarget;
const fullPlatformUrl = `${app.subdomain}.${cnameTarget}`;
const instructions = [ const instructions = [
`1. Log in to your domain registrar (e.g. Cloudflare, Namecheap, GoDaddy)`, `1. Log in to your domain registrar (e.g. Cloudflare, Namecheap, GoDaddy)`,
@@ -179,10 +215,8 @@ export class DomainService {
cnameTarget: string; cnameTarget: string;
instructions: string[]; instructions: string[];
}> { }> {
const platformDomain =
this.configService.get('platform.domain') || 'apps.cloudhost.ir';
const cnameTarget = await this.getPlatformCnameTarget(); const cnameTarget = await this.getPlatformCnameTarget();
const fullPlatformUrl = `${appName}.${platformDomain}`; const fullPlatformUrl = `${appName}.${cnameTarget}`;
const instructions = [ const instructions = [
`1. Log in to your domain registrar (e.g. Cloudflare, Namecheap, GoDaddy)`, `1. Log in to your domain registrar (e.g. Cloudflare, Namecheap, GoDaddy)`,
+1 -1
View File
@@ -55,7 +55,7 @@ async function bootstrap() {
const defaults = [ const defaults = [
{ key: 'custom_domain_monthly_price_toman', value: '50000', description: 'Monthly price for custom domain addon (Toman)' }, { 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' }, { key: 'access_max_duration_minutes', value: '240', description: 'Maximum duration (minutes) for temporary external service access' },
]; ];