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:
@@ -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<PlatformSetting>,
|
||||
private configService: ConfigService,
|
||||
private kubernetesService: KubernetesService,
|
||||
) {}
|
||||
|
||||
async getCustomDomainPrice(): Promise<number> {
|
||||
@@ -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<string> {
|
||||
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<string>('platform.previewRootDomain') ||
|
||||
this.configService.get<string>('platform.domain') ||
|
||||
'apps.cloudhost.ir'
|
||||
);
|
||||
}
|
||||
|
||||
async setCustomDomain(appId: string, userId: string, domain: string): Promise<Application> {
|
||||
@@ -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. <subdomain>.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)`,
|
||||
|
||||
+1
-1
@@ -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' },
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user