Add parked alias domains with Arvan or Cloudflare DNS and websites SSL.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-31 18:09:09 +03:30
co-authored by Cursor
parent 5813e9fc53
commit d1f60b5313
18 changed files with 1007 additions and 29 deletions
@@ -7,6 +7,7 @@ import {
Param,
Patch,
Post,
Put,
Query,
UseGuards,
} from '@nestjs/common';
@@ -17,6 +18,7 @@ import { DisableDomainDto } from './dto/disable-domain.dto';
import { ListDomainsDto } from './dto/list-domains.dto';
import { ToggleSslDto } from './dto/toggle-ssl.dto';
import { UpdateDomainAdminDto } from './dto/update-domain-admin.dto';
import { UpdateParkedHostsDto } from './dto/update-parked-hosts.dto';
import { DomainAdminService } from './domain-admin.service';
@Controller('domains')
@@ -59,6 +61,16 @@ export class DomainAdminController {
return this.service.issueSsl(domainId, user);
}
@Put(':domainId/parked-hosts')
@UseGuards(JwtAuthGuard)
setParkedHosts(
@Param('domainId') domainId: string,
@Body() dto: UpdateParkedHostsDto,
@CurrentUser() user: AuthUser,
) {
return this.service.setParkedHosts(domainId, dto, user);
}
@Patch(':domainId')
@UseGuards(JwtAuthGuard)
update(
+2 -1
View File
@@ -2,12 +2,13 @@ import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuthModule } from '../auth/auth.module';
import { ArvanDnsModule } from '../arvan-dns/arvan-dns.module';
import { CloudflareDnsModule } from '../cloudflare-dns/cloudflare-dns.module';
import { WebsiteDeployModule } from '../website-deploy/website-deploy.module';
import { DomainAdminController } from './domain-admin.controller';
import { DomainAdminService } from './domain-admin.service';
@Module({
imports: [AuthModule, ConfigModule, WebsiteDeployModule, ArvanDnsModule],
imports: [AuthModule, ConfigModule, WebsiteDeployModule, ArvanDnsModule, CloudflareDnsModule],
controllers: [DomainAdminController],
providers: [DomainAdminService],
})
+316 -23
View File
@@ -10,6 +10,7 @@ import {
import { ConfigService } from '@nestjs/config';
import { Prisma } from '@prisma/client';
import { ArvanDnsService } from '../arvan-dns/arvan-dns.service';
import { CloudflareDnsService } from '../cloudflare-dns/cloudflare-dns.service';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import {
@@ -28,6 +29,9 @@ import { DisableDomainDto } from './dto/disable-domain.dto';
import { ListDomainsDto } from './dto/list-domains.dto';
import { ToggleSslDto } from './dto/toggle-ssl.dto';
import { UpdateDomainAdminDto } from './dto/update-domain-admin.dto';
import { UpdateParkedHostsDto } from './dto/update-parked-hosts.dto';
import { isValidParkedHost, parseParkedDnsMap, uniqueParkedAliases, uniqueParkedHosts } from './parked-host.util';
import type { ParkedDnsProvider } from './parked-host.util';
type DomainRow = {
id: bigint;
@@ -43,6 +47,8 @@ type DomainRow = {
lastDeployStatus: string | null;
deploySlug: string | null;
gitRepoUrl: string | null;
parkedHosts: string[];
parkedDns: unknown;
};
@Injectable()
@@ -53,6 +59,7 @@ export class DomainAdminService {
private readonly config: ConfigService,
private readonly websiteDeployAgent: WebsiteDeployAgentService,
private readonly arvanDns: ArvanDnsService,
private readonly cloudflareDns: CloudflareDnsService,
) {}
private async assertSuperAdmin(actor: AuthUser) {
@@ -89,7 +96,9 @@ export class DomainAdminService {
d.last_deployed_at AS "lastDeployedAt",
d.last_deploy_status AS "lastDeployStatus",
d.deploy_slug AS "deploySlug",
d.git_repo_url AS "gitRepoUrl"
d.git_repo_url AS "gitRepoUrl",
COALESCE(d.parked_hosts, '{}') AS "parkedHosts",
COALESCE(d.parked_dns, '{}'::jsonb) AS "parkedDns"
FROM domains d
JOIN businesses b ON b.id = d.business_id
${where}
@@ -114,6 +123,11 @@ export class DomainAdminService {
...row,
sslEnabled,
sslExpiresAt: row.sslExpiresAt,
parkedHosts: Array.isArray(row.parkedHosts) ? row.parkedHosts : [],
parkedAliases: (Array.isArray(row.parkedHosts) ? row.parkedHosts : []).map((host) => ({
host,
dns: parseParkedDnsMap(row.parkedDns)[host] ?? ('arvan' as const),
})),
};
});
@@ -238,15 +252,6 @@ export class DomainAdminService {
targets.push(domain);
}
if (targets.length === 0) {
return {
status: 'ok' as const,
message: 'No storefront domains need SSL',
issued: [] as string[],
failed: [] as Array<{ host: string; error: string }>,
};
}
const issued: string[] = [];
const failed: Array<{ host: string; error: string }> = [];
@@ -288,6 +293,47 @@ export class DomainAdminService {
}
}
const parkedTargets = await this.prisma.domain.findMany({
where: { isActive: true, parkedHosts: { isEmpty: false } },
select: { parkedHosts: true },
});
const parkedSeen = new Set<string>();
for (const row of parkedTargets) {
for (const parked of uniqueParkedHosts(row.parkedHosts ?? [])) {
if (parkedSeen.has(parked)) continue;
parkedSeen.add(parked);
const apexOk = await probeTlsHost(parked);
const wwwOk = await probeTlsHost(`www.${parked}`);
if (apexOk && wwwOk) continue;
try {
await this.websiteDeployAgent.issueSsl({ host: parked });
const okNow =
(await probeTlsHost(parked)) && (await probeTlsHost(`www.${parked}`));
if (okNow) issued.push(parked);
else {
failed.push({
host: parked,
error: 'Certbot finished but parked TLS probe still failed',
});
}
} catch (err) {
failed.push({
host: parked,
error: err instanceof Error ? err.message : 'Parked SSL issue failed',
});
}
}
}
if (targets.length === 0 && issued.length === 0 && failed.length === 0) {
return {
status: 'ok' as const,
message: 'No storefront or parked domains need SSL',
issued,
failed,
};
}
const message =
failed.length === 0
? `Issued SSL for ${issued.length} website(s)`
@@ -471,7 +517,43 @@ export class DomainAdminService {
},
});
const parts = [hosts.apex, hosts.www, hosts.business, hosts.customer];
const parkedResults: HostResult[] = [];
for (const parked of uniqueParkedHosts(domain.parkedHosts ?? [])) {
const parkedWww = `www.${parked}`;
const parkedApexOk = await probeTlsHost(parked);
const parkedWwwOk = await probeTlsHost(parkedWww);
if (parkedApexOk && parkedWwwOk) {
parkedResults.push({
host: parked,
status: 'ok',
detail: 'Already valid',
});
continue;
}
try {
await this.websiteDeployAgent.park({
host: parked,
canonicalHost: apex.replace(/^www\./, ''),
});
await this.websiteDeployAgent.issueSsl({ host: parked });
const okNow = (await probeTlsHost(parked)) && (await probeTlsHost(parkedWww));
parkedResults.push({
host: parked,
status: okNow ? 'issued' : 'failed',
detail: okNow
? 'Parked alias SSL issued on websites VM'
: 'Certbot ran but parked HTTPS probe still failed',
});
} catch (err) {
parkedResults.push({
host: parked,
status: 'failed',
detail: err instanceof Error ? err.message : 'Parked SSL failed',
});
}
}
const parts = [...[hosts.apex, hosts.www, hosts.business, hosts.customer], ...parkedResults];
const failed = parts.filter((p) => p.status === 'failed');
const issued = parts.filter((p) => p.status === 'issued');
@@ -647,26 +729,237 @@ export class DomainAdminService {
};
}
async setParkedHosts(domainIdRaw: string, dto: UpdateParkedHostsDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const domainId = BigInt(domainIdRaw);
const domain = await this.prisma.domain.findUnique({ where: { id: domainId } });
if (!domain) {
throw new NotFoundException('Domain not found');
}
const canonical = domain.host.trim().toLowerCase().replace(/^www\./, '');
const nextAliases = uniqueParkedAliases(dto.aliases ?? []);
const currentDns = parseParkedDnsMap(domain.parkedDns);
const currentHosts = uniqueParkedHosts(domain.parkedHosts ?? []);
const currentAliases = currentHosts.map((host) => ({
host,
dns: currentDns[host] ?? ('arvan' as ParkedDnsProvider),
}));
for (const alias of nextAliases) {
if (!isValidParkedHost(alias.host)) {
throw new BadRequestException(`Invalid parked domain: ${alias.host}`);
}
if (alias.host === canonical) {
throw new BadRequestException('Parked domain cannot be the same as the main domain');
}
if (/^(business|customer|api)\./.test(alias.host)) {
throw new BadRequestException(
`Parked domain cannot be a dashboard/API host: ${alias.host}`,
);
}
}
const nextHosts = nextAliases.map((item) => item.host);
for (const host of nextHosts) {
const clash = await this.prisma.domain.findFirst({
where: {
NOT: { id: domainId },
OR: [{ host }, { parkedHosts: { has: host } }],
},
select: { host: true },
});
if (clash) {
throw new ConflictException(
`"${host}" is already used by ${clash.host}`,
);
}
}
const currentSet = new Set(currentHosts);
const nextSet = new Set(nextHosts);
const currentDnsByHost = new Map(currentAliases.map((item) => [item.host, item.dns]));
const nextDnsByHost = new Map(nextAliases.map((item) => [item.host, item.dns]));
const toAdd = nextHosts.filter((h) => !currentSet.has(h));
const toRemove = currentHosts.filter((h) => !nextSet.has(h));
const toRefreshDns = nextHosts.filter(
(h) => currentSet.has(h) && currentDnsByHost.get(h) !== nextDnsByHost.get(h),
);
const results: Array<{
host: string;
action: 'add' | 'remove' | 'keep';
ok: boolean;
dns?: string | null;
redirect?: string | null;
ssl?: string | null;
error?: string;
}> = [];
const saved = new Set(currentHosts);
for (const host of toRemove) {
try {
await this.websiteDeployAgent.unpark({ host });
saved.delete(host);
results.push({ host, action: 'remove', ok: true });
} catch (err) {
const error = err instanceof Error ? err.message : 'Unpark failed';
results.push({ host, action: 'remove', ok: false, error });
}
}
for (const host of toAdd) {
const dnsProvider = nextDnsByHost.get(host) ?? 'arvan';
const dnsError = await this.applyParkedDns(host, dnsProvider);
if (dnsError) {
results.push({
host,
action: 'add',
ok: false,
dns: dnsError,
error: dnsError,
});
continue;
}
try {
await this.websiteDeployAgent.park({ host, canonicalHost: canonical });
} catch (err) {
const error = err instanceof Error ? err.message : 'Redirect vhost failed';
results.push({
host,
action: 'add',
ok: false,
dns: 'ok',
redirect: error,
error,
});
continue;
}
let sslDetail: string | null = 'issued';
try {
await this.websiteDeployAgent.issueSsl({ host });
} catch (err) {
sslDetail = err instanceof Error ? err.message : 'SSL issue failed';
}
saved.add(host);
results.push({
host,
action: 'add',
ok: sslDetail === 'issued',
dns: 'ok',
redirect: 'ok',
ssl: sslDetail,
...(sslDetail === 'issued' ? {} : { error: sslDetail }),
});
}
for (const host of toRefreshDns) {
const dnsProvider = nextDnsByHost.get(host) ?? 'arvan';
const dnsError = await this.applyParkedDns(host, dnsProvider);
results.push({
host,
action: 'keep',
ok: !dnsError,
dns: dnsError || 'ok',
...(dnsError ? { error: dnsError } : {}),
});
}
for (const host of nextHosts) {
if (!toAdd.includes(host) && !toRefreshDns.includes(host)) {
results.push({ host, action: 'keep', ok: true });
}
}
const parkedHosts = [
...nextHosts.filter((h) => saved.has(h)),
...[...saved].filter((h) => !nextSet.has(h)),
];
const parkedDns: Record<string, ParkedDnsProvider> = {};
for (const alias of nextAliases) {
if (saved.has(alias.host)) parkedDns[alias.host] = alias.dns;
}
for (const host of [...saved]) {
if (!parkedDns[host]) parkedDns[host] = currentDnsByHost.get(host) ?? 'arvan';
}
const updated = await this.prisma.domain.update({
where: { id: domainId },
data: { parkedHosts, parkedDns },
});
const parkedAliases = parkedHosts.map((host) => ({
host,
dns: parkedDns[host] ?? ('arvan' as const),
}));
const failed = results.filter((r) => !r.ok);
const message =
failed.length === 0
? parkedHosts.length
? `Parked domains updated (${parkedHosts.join(', ')}).`
: 'Parked domains cleared.'
: failed.map((f) => `${f.host}: ${f.error || 'failed'}`).join(' · ');
return {
host: updated.host,
parkedHosts,
parkedAliases,
results,
status: failed.length === 0 ? ('ok' as const) : ('partial' as const),
message,
};
}
private async applyParkedDns(
host: string,
provider: ParkedDnsProvider,
): Promise<string | null> {
try {
if (provider === 'cloudflare') {
await this.cloudflareDns.ensureParkedRecords(host);
} else {
await this.arvanDns.ensureParkedRecords(host);
}
return null;
} catch (err) {
return this.httpErrorMessage(
err,
provider === 'cloudflare' ? 'Cloudflare DNS update failed' : 'Arvan DNS update failed',
);
}
}
private async applyArvanDns(host: string): Promise<string | null> {
try {
await this.arvanDns.ensureTenantRecords(host);
return null;
} catch (err) {
if (err instanceof HttpException) {
const res = err.getResponse();
if (typeof res === 'string') return res;
if (res && typeof res === 'object' && 'message' in res) {
const message = (res as { message: unknown }).message;
return Array.isArray(message) ? message.map(String).join(', ') : String(message);
}
}
if (err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') {
return err.message;
}
return 'Arvan DNS update failed';
return this.httpErrorMessage(err, 'Arvan DNS update failed');
}
}
private httpErrorMessage(err: unknown, fallback: string): string {
if (err instanceof HttpException) {
const res = err.getResponse();
if (typeof res === 'string') return res;
if (res && typeof res === 'object' && 'message' in res) {
const message = (res as { message: unknown }).message;
return Array.isArray(message) ? message.map(String).join(', ') : String(message);
}
}
if (err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') {
return err.message;
}
return fallback;
}
async disable(domainIdRaw: string, dto: DisableDomainDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
@@ -0,0 +1,18 @@
import { IsArray, IsIn, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { PARKED_DNS_PROVIDERS } from '../parked-host.util';
export class ParkedAliasDto {
@IsString()
host!: string;
@IsIn(PARKED_DNS_PROVIDERS)
dns!: (typeof PARKED_DNS_PROVIDERS)[number];
}
export class UpdateParkedHostsDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => ParkedAliasDto)
aliases!: ParkedAliasDto[];
}
+61
View File
@@ -0,0 +1,61 @@
const HOST_RE =
/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/;
export const PARKED_DNS_PROVIDERS = ['arvan', 'cloudflare'] as const;
export type ParkedDnsProvider = (typeof PARKED_DNS_PROVIDERS)[number];
export type ParkedAlias = {
host: string;
dns: ParkedDnsProvider;
};
/** Strip protocol, path, trailing dot, and leading www. */
export function normalizeParkedHost(raw: string): string {
let host = raw.trim().toLowerCase();
host = host.replace(/^https?:\/\//, '');
host = host.split('/')[0] ?? host;
host = host.split(':')[0] ?? host;
host = host.replace(/\.$/, '');
host = host.replace(/^www\./, '');
return host;
}
export function isValidParkedHost(host: string): boolean {
return HOST_RE.test(host) && !host.endsWith('.local');
}
export function isParkedDnsProvider(value: unknown): value is ParkedDnsProvider {
return value === 'arvan' || value === 'cloudflare';
}
export function parseParkedDnsMap(raw: unknown): Record<string, ParkedDnsProvider> {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
const out: Record<string, ParkedDnsProvider> = {};
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
const host = normalizeParkedHost(key);
if (host && isParkedDnsProvider(value)) out[host] = value;
}
return out;
}
export function uniqueParkedAliases(
raw: Array<{ host?: string; dns?: string } | string>,
): ParkedAlias[] {
const seen = new Set<string>();
const out: ParkedAlias[] = [];
for (const item of raw) {
const host = normalizeParkedHost(typeof item === 'string' ? item : (item.host ?? ''));
if (!host || seen.has(host)) continue;
seen.add(host);
const dnsRaw = typeof item === 'string' ? 'arvan' : item.dns;
out.push({
host,
dns: isParkedDnsProvider(dnsRaw) ? dnsRaw : 'arvan',
});
}
return out;
}
export function uniqueParkedHosts(raw: string[]): string[] {
return uniqueParkedAliases(raw).map((item) => item.host);
}