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 { 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
@@ -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' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user