Add parked alias domains with Arvan or Cloudflare DNS and websites SSL.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
5813e9fc53
commit
d1f60b5313
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user