Update DNS writes @, www, business, customer, and api records on the chosen provider instead of Arvan only. Co-authored-by: Cursor <cursoragent@cursor.com>
1022 lines
32 KiB
TypeScript
1022 lines
32 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
ForbiddenException,
|
|
HttpException,
|
|
Injectable,
|
|
NotFoundException,
|
|
ServiceUnavailableException,
|
|
} from '@nestjs/common';
|
|
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 {
|
|
earliestTlsExpiry,
|
|
probeTlsHost,
|
|
probeTlsHostDetailed,
|
|
} from '../common/tls-probe';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { WebsiteDeployAgentService } from '../website-deploy/website-deploy-agent.service';
|
|
import {
|
|
deploySlugFromHost,
|
|
isValidGitRepoUrl,
|
|
normalizeGitRepoUrlForClone,
|
|
} from '../website-deploy/website-deploy.util';
|
|
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;
|
|
host: string;
|
|
businessId: bigint;
|
|
businessName: string;
|
|
sslEnabled: boolean;
|
|
sslExpiresAt: Date | null;
|
|
isActive: boolean;
|
|
expiresAt: Date | null;
|
|
createdAt: Date;
|
|
lastDeployedAt: Date | null;
|
|
lastDeployStatus: string | null;
|
|
deploySlug: string | null;
|
|
gitRepoUrl: string | null;
|
|
parkedHosts: string[];
|
|
parkedDns: unknown;
|
|
};
|
|
|
|
@Injectable()
|
|
export class DomainAdminService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly permissions: PermissionsService,
|
|
private readonly config: ConfigService,
|
|
private readonly websiteDeployAgent: WebsiteDeployAgentService,
|
|
private readonly arvanDns: ArvanDnsService,
|
|
private readonly cloudflareDns: CloudflareDnsService,
|
|
) {}
|
|
|
|
private async assertSuperAdmin(actor: AuthUser) {
|
|
if (!(await this.permissions.isSuperAdmin(actor.id))) {
|
|
throw new ForbiddenException('Super admin access required');
|
|
}
|
|
}
|
|
|
|
async list(query: ListDomainsDto, actor: AuthUser) {
|
|
await this.assertSuperAdmin(actor);
|
|
|
|
const page = query.page ?? 1;
|
|
const pageSize = query.pageSize ?? 24;
|
|
const skip = (page - 1) * pageSize;
|
|
const nameLike = query.name?.trim() ? `%${query.name.trim()}%` : null;
|
|
|
|
const where = Prisma.sql`
|
|
WHERE 1=1
|
|
${nameLike ? Prisma.sql`AND d.host ILIKE ${nameLike}` : Prisma.empty}
|
|
`;
|
|
|
|
const [rows, totalRow] = await Promise.all([
|
|
this.prisma.$queryRaw<DomainRow[]>(Prisma.sql`
|
|
SELECT
|
|
d.id AS "id",
|
|
d.host AS "host",
|
|
d.business_id AS "businessId",
|
|
b.name AS "businessName",
|
|
d.ssl_enabled AS "sslEnabled",
|
|
d.ssl_expires_at AS "sslExpiresAt",
|
|
d.is_active AS "isActive",
|
|
d.expires_at AS "expiresAt",
|
|
d.created_at AS "createdAt",
|
|
d.last_deployed_at AS "lastDeployedAt",
|
|
d.last_deploy_status AS "lastDeployStatus",
|
|
d.deploy_slug AS "deploySlug",
|
|
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}
|
|
ORDER BY d.created_at DESC
|
|
LIMIT ${pageSize} OFFSET ${skip}
|
|
`),
|
|
this.prisma.$queryRaw<{ total: number }[]>(Prisma.sql`
|
|
SELECT COUNT(*)::int AS "total"
|
|
FROM domains d
|
|
${where}
|
|
`),
|
|
]);
|
|
|
|
const now = Date.now();
|
|
const items = rows.map((row) => {
|
|
const expiryMs = row.sslExpiresAt?.getTime() ?? null;
|
|
const certStillValid = expiryMs == null || expiryMs > now;
|
|
// Prefer stored notAfter when present (daily probe is only a writer).
|
|
const sslEnabled =
|
|
expiryMs != null ? expiryMs > now : row.sslEnabled && certStillValid;
|
|
return {
|
|
...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),
|
|
})),
|
|
};
|
|
});
|
|
|
|
return { items, total: totalRow[0]?.total ?? 0, page, pageSize };
|
|
}
|
|
|
|
async syncSsl(actor: AuthUser) {
|
|
await this.assertSuperAdmin(actor);
|
|
await this.callDashboardSslSync({ wait: false });
|
|
return {
|
|
status: 'accepted' as const,
|
|
message: 'Dashboard SSL sync started (manage / business.* / customer.*)',
|
|
};
|
|
}
|
|
|
|
/** Fire-and-forget or blocking call to the dashboards VPS ssl-sync agent. */
|
|
private async callDashboardSslSync(opts: { wait: boolean; tenantApex?: string }) {
|
|
const agentUrl = this.config.get<string>('SSL_SYNC_AGENT_URL')?.trim();
|
|
const token = this.config.get<string>('SSL_SYNC_AGENT_TOKEN')?.trim();
|
|
if (!agentUrl || !token) {
|
|
throw new ServiceUnavailableException('SSL sync agent is not configured');
|
|
}
|
|
|
|
const tenantApex = opts.tenantApex?.trim().toLowerCase();
|
|
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(agentUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-SSL-Sync-Agent-Token': token,
|
|
},
|
|
body: JSON.stringify({
|
|
wait: opts.wait,
|
|
...(tenantApex ? { tenantApex } : {}),
|
|
}),
|
|
});
|
|
} catch {
|
|
throw new ServiceUnavailableException('Could not reach SSL sync agent');
|
|
}
|
|
|
|
if (response.status === 409) {
|
|
throw new ConflictException('SSL sync is already running');
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text().catch(() => '');
|
|
throw new ServiceUnavailableException(
|
|
`SSL sync agent rejected request (${response.status})${text ? `: ${this.summarizeSslSyncFailure(text)}` : ''}`,
|
|
);
|
|
}
|
|
|
|
return response.json().catch(() => ({ status: opts.wait ? 'ok' : 'accepted' }));
|
|
}
|
|
|
|
private summarizeSslSyncFailure(raw: string): string {
|
|
try {
|
|
const parsed = JSON.parse(raw) as { message?: string; log?: string };
|
|
const log = String(parsed.log ?? '');
|
|
const blocked = [
|
|
...new Set(
|
|
[...log.matchAll(/Domain: ((?:business|customer)\.[^\n]+)/g)].map((m) => m[1].trim()),
|
|
),
|
|
];
|
|
if (blocked.length > 0) {
|
|
return (
|
|
`Dashboard cert blocked by missing DNS for: ${blocked.join(', ')}. ` +
|
|
'Add business/customer CNAMEs for those tenants or deactivate the domain, then retry.'
|
|
);
|
|
}
|
|
return String(parsed.message ?? raw).slice(0, 400);
|
|
} catch {
|
|
return raw.slice(0, 400);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Issue Let's Encrypt certs on the websites VM for storefront domains
|
|
* whose live TLS probe fails. Do not trust ssl_enabled alone — a wrong
|
|
* default-vhost cert can leave the flag true while browsers show
|
|
* NET::ERR_CERT_COMMON_NAME_INVALID.
|
|
*/
|
|
async issueWebsiteSsl(actor: AuthUser) {
|
|
await this.assertSuperAdmin(actor);
|
|
|
|
const candidates = await this.prisma.domain.findMany({
|
|
where: {
|
|
isActive: true,
|
|
deploySlug: { not: null },
|
|
},
|
|
select: { id: true, host: true, deploySlug: true, sslEnabled: true },
|
|
orderBy: { host: 'asc' },
|
|
});
|
|
|
|
const targets: Array<{ id: bigint; host: string; deploySlug: string | null }> = [];
|
|
|
|
for (const domain of candidates) {
|
|
const apexProbe = await probeTlsHostDetailed(domain.host);
|
|
const wwwHost = domain.host.startsWith('www.')
|
|
? domain.host
|
|
: `www.${domain.host}`;
|
|
const wwwProbe = await probeTlsHostDetailed(wwwHost);
|
|
const apexOk = apexProbe.ok;
|
|
const bothOk = apexOk && wwwProbe.ok;
|
|
if (apexOk) {
|
|
const sslExpiresAt = earliestTlsExpiry(apexProbe, wwwProbe);
|
|
await this.prisma.domain.update({
|
|
where: { id: domain.id },
|
|
data: {
|
|
sslEnabled: true,
|
|
...(sslExpiresAt ? { sslExpiresAt } : {}),
|
|
},
|
|
});
|
|
if (bothOk) continue;
|
|
} else if (domain.sslEnabled) {
|
|
await this.prisma.domain.update({
|
|
where: { id: domain.id },
|
|
data: { sslEnabled: false, sslExpiresAt: null },
|
|
});
|
|
}
|
|
targets.push(domain);
|
|
}
|
|
|
|
const issued: string[] = [];
|
|
const failed: Array<{ host: string; error: string }> = [];
|
|
|
|
for (const domain of targets) {
|
|
try {
|
|
await this.websiteDeployAgent.issueSsl({
|
|
host: domain.host,
|
|
slug: domain.deploySlug,
|
|
});
|
|
const apexProbe = await probeTlsHostDetailed(domain.host);
|
|
const wwwHost = domain.host.startsWith('www.')
|
|
? domain.host
|
|
: `www.${domain.host}`;
|
|
const wwwProbe = await probeTlsHostDetailed(wwwHost);
|
|
const apexOk = apexProbe.ok;
|
|
const sslExpiresAt = apexOk
|
|
? earliestTlsExpiry(apexProbe, wwwProbe)
|
|
: null;
|
|
await this.prisma.domain.update({
|
|
where: { id: domain.id },
|
|
data: {
|
|
sslEnabled: apexOk,
|
|
sslExpiresAt,
|
|
},
|
|
});
|
|
if (apexOk) {
|
|
issued.push(domain.host);
|
|
} else {
|
|
failed.push({
|
|
host: domain.host,
|
|
error: 'Certbot finished but TLS probe still failed on apex',
|
|
});
|
|
}
|
|
} catch (err) {
|
|
failed.push({
|
|
host: domain.host,
|
|
error: err instanceof Error ? err.message : 'SSL issue failed',
|
|
});
|
|
}
|
|
}
|
|
|
|
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)`
|
|
: `Issued ${issued.length}, failed ${failed.length} website SSL`;
|
|
|
|
return { status: 'ok' as const, message, issued, failed };
|
|
}
|
|
|
|
/**
|
|
* Per-row SSL ensure: probe apex + www + business.* + customer.*.
|
|
* Issue storefront SSL on websites VM when apex/www fail (deploy_slug required).
|
|
* Expand dashboard cert when business/customer fail.
|
|
* Skip hosts that already have valid TLS.
|
|
*/
|
|
async issueSsl(domainIdRaw: string, 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 apex = domain.host.trim().toLowerCase();
|
|
const wwwHost = apex.startsWith('www.') ? apex : `www.${apex}`;
|
|
const businessHost = `business.${apex}`;
|
|
const customerHost = `customer.${apex}`;
|
|
const slug = domain.deploySlug?.trim() || null;
|
|
|
|
type HostStatus = 'ok' | 'issued' | 'failed' | 'skipped';
|
|
type HostResult = { host: string; status: HostStatus; detail?: string };
|
|
|
|
const hosts: {
|
|
apex: HostResult;
|
|
www: HostResult;
|
|
business: HostResult;
|
|
customer: HostResult;
|
|
} = {
|
|
apex: { host: apex, status: 'ok' },
|
|
www: { host: wwwHost, status: 'ok' },
|
|
business: { host: businessHost, status: 'ok' },
|
|
customer: { host: customerHost, status: 'ok' },
|
|
};
|
|
|
|
let apexProbe = await probeTlsHostDetailed(apex);
|
|
let wwwProbe = await probeTlsHostDetailed(wwwHost);
|
|
let businessOk = await probeTlsHost(businessHost);
|
|
let customerOk = await probeTlsHost(customerHost);
|
|
let apexOk = apexProbe.ok;
|
|
let wwwOk = wwwProbe.ok;
|
|
|
|
const storefrontNeedsIssue = !apexOk || !wwwOk;
|
|
|
|
if (!storefrontNeedsIssue) {
|
|
hosts.apex = { host: apex, status: 'ok', detail: 'Already valid' };
|
|
hosts.www = { host: wwwHost, status: 'ok', detail: 'Already valid' };
|
|
} else if (!slug) {
|
|
hosts.apex = {
|
|
host: apex,
|
|
status: apexOk ? 'ok' : 'skipped',
|
|
detail: apexOk
|
|
? 'Already valid'
|
|
: 'No storefront deploy — apex SSL needs git/deploy on the websites VM',
|
|
};
|
|
hosts.www = {
|
|
host: wwwHost,
|
|
status: wwwOk ? 'ok' : 'skipped',
|
|
detail: wwwOk
|
|
? 'Already valid'
|
|
: 'No storefront deploy — www SSL needs git/deploy on the websites VM',
|
|
};
|
|
} else {
|
|
try {
|
|
await this.websiteDeployAgent.issueSsl({ host: apex, slug });
|
|
apexProbe = await probeTlsHostDetailed(apex);
|
|
wwwProbe = await probeTlsHostDetailed(wwwHost);
|
|
apexOk = apexProbe.ok;
|
|
wwwOk = wwwProbe.ok;
|
|
hosts.apex = apexOk
|
|
? {
|
|
host: apex,
|
|
status: 'issued',
|
|
detail: 'Issued on websites VM',
|
|
}
|
|
: {
|
|
host: apex,
|
|
status: 'failed',
|
|
detail: 'Certbot ran but HTTPS probe still failed — check DNS for apex',
|
|
};
|
|
hosts.www = wwwOk
|
|
? {
|
|
host: wwwHost,
|
|
status: 'issued',
|
|
detail: 'Issued on websites VM',
|
|
}
|
|
: {
|
|
host: wwwHost,
|
|
status: 'failed',
|
|
detail: 'Certbot ran but HTTPS probe still failed — check DNS for www',
|
|
};
|
|
} catch (err) {
|
|
const detail = err instanceof Error ? err.message : 'Storefront SSL issue failed';
|
|
hosts.apex = {
|
|
host: apex,
|
|
status: apexOk ? 'ok' : 'failed',
|
|
detail: apexOk ? 'Already valid' : detail,
|
|
};
|
|
hosts.www = {
|
|
host: wwwHost,
|
|
status: wwwOk ? 'ok' : 'failed',
|
|
detail: wwwOk ? 'Already valid' : detail,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (businessOk) {
|
|
hosts.business = { host: businessHost, status: 'ok', detail: 'Already valid' };
|
|
}
|
|
if (customerOk) {
|
|
hosts.customer = { host: customerHost, status: 'ok', detail: 'Already valid' };
|
|
}
|
|
|
|
if (!businessOk || !customerOk) {
|
|
try {
|
|
await this.callDashboardSslSync({ wait: true, tenantApex: apex });
|
|
businessOk = await probeTlsHost(businessHost);
|
|
customerOk = await probeTlsHost(customerHost);
|
|
|
|
if (!hosts.business.detail) {
|
|
hosts.business = businessOk
|
|
? {
|
|
host: businessHost,
|
|
status: 'issued',
|
|
detail: 'Dashboard cert issued for this domain only',
|
|
}
|
|
: {
|
|
host: businessHost,
|
|
status: 'failed',
|
|
detail: 'Tenant dashboard SSL finished but probe still failed',
|
|
};
|
|
}
|
|
if (!hosts.customer.detail) {
|
|
hosts.customer = customerOk
|
|
? {
|
|
host: customerHost,
|
|
status: 'issued',
|
|
detail: 'Dashboard cert issued for this domain only',
|
|
}
|
|
: {
|
|
host: customerHost,
|
|
status: 'failed',
|
|
detail: 'Tenant dashboard SSL finished but probe still failed',
|
|
};
|
|
}
|
|
} catch (err) {
|
|
const detail =
|
|
err instanceof Error ? err.message : 'Tenant dashboard SSL failed';
|
|
if (!businessOk) {
|
|
hosts.business = { host: businessHost, status: 'failed', detail };
|
|
}
|
|
if (!customerOk) {
|
|
hosts.customer = { host: customerHost, status: 'failed', detail };
|
|
}
|
|
}
|
|
}
|
|
|
|
// List badge follows apex. www can still fail (no DNS) without marking Invalid.
|
|
if (apexOk && !apexProbe.expiresAt) {
|
|
apexProbe = await probeTlsHostDetailed(apex);
|
|
apexOk = apexProbe.ok;
|
|
}
|
|
const sslExpiresAt = apexOk
|
|
? earliestTlsExpiry(apexProbe, wwwOk ? wwwProbe : apexProbe)
|
|
: null;
|
|
|
|
const updated = await this.prisma.domain.update({
|
|
where: { id: domainId },
|
|
data: {
|
|
sslEnabled: apexOk,
|
|
sslExpiresAt: apexOk ? sslExpiresAt : null,
|
|
},
|
|
});
|
|
|
|
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');
|
|
|
|
// Always spell out each host so dashboard-only OK is never mistaken for storefront OK.
|
|
const message = parts
|
|
.map((p) => `${p.host}: ${p.status}${p.detail ? ` (${p.detail})` : ''}`)
|
|
.join(' · ');
|
|
|
|
return {
|
|
status: failed.length === 0 ? ('ok' as const) : ('partial' as const),
|
|
host: apex,
|
|
sslEnabled: updated.sslEnabled,
|
|
sslExpiresAt: updated.sslExpiresAt?.toISOString() ?? null,
|
|
hosts,
|
|
issued: issued.map((p) => p.host),
|
|
failed: failed.map((p) => ({ host: p.host, error: p.detail || 'failed' })),
|
|
message,
|
|
};
|
|
}
|
|
|
|
async deploy(domainIdRaw: string, 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 slug = domain.deploySlug?.trim() || null;
|
|
if (!slug) {
|
|
throw new BadRequestException('This domain has no storefront deploy configured');
|
|
}
|
|
|
|
const markDeploy = async (status: 'started' | 'success' | 'failed') => {
|
|
const updated = await this.prisma.domain.update({
|
|
where: { id: domainId },
|
|
data: {
|
|
lastDeployedAt: new Date(),
|
|
lastDeployStatus: status,
|
|
},
|
|
});
|
|
return updated;
|
|
};
|
|
|
|
await markDeploy('started');
|
|
|
|
try {
|
|
const result = await this.websiteDeployAgent.deploy(slug, { wait: true });
|
|
const updated = await markDeploy(
|
|
result.status === 'success' || result.status === 'accepted'
|
|
? 'success'
|
|
: 'failed',
|
|
);
|
|
|
|
return {
|
|
status: 'ok' as const,
|
|
slug,
|
|
host: domain.host,
|
|
message:
|
|
result.detail?.trim() ||
|
|
(updated.lastDeployStatus === 'success'
|
|
? 'Deploy succeeded on websites server'
|
|
: 'Deploy finished with unknown status'),
|
|
lastDeployedAt: updated.lastDeployedAt?.toISOString() ?? null,
|
|
lastDeployStatus: updated.lastDeployStatus,
|
|
};
|
|
} catch (err) {
|
|
await markDeploy('failed');
|
|
if (err instanceof ServiceUnavailableException) {
|
|
throw err;
|
|
}
|
|
throw new ServiceUnavailableException(
|
|
err instanceof Error ? err.message : 'Deploy failed',
|
|
);
|
|
}
|
|
}
|
|
|
|
async update(domainIdRaw: string, dto: UpdateDomainAdminDto, 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 host = (dto.host?.trim() || domain.host).toLowerCase();
|
|
const gitRepoUrl = dto.gitRepoUrl?.trim() || null;
|
|
|
|
if (dto.host) {
|
|
const existing = await this.prisma.domain.findUnique({ where: { host } });
|
|
if (existing && existing.id !== domainId) {
|
|
throw new ConflictException('Domain host is already taken');
|
|
}
|
|
}
|
|
|
|
if (gitRepoUrl && !isValidGitRepoUrl(gitRepoUrl)) {
|
|
throw new BadRequestException(
|
|
'gitRepoUrl must be a git URL (e.g. https://git.meshkee.com/Meshkee-Websites/oaktasty.git)',
|
|
);
|
|
}
|
|
|
|
let deploySlug = domain.deploySlug;
|
|
let provisionError: string | null = null;
|
|
let nextGitRepoUrl = domain.gitRepoUrl;
|
|
const hostChanged = domain.host !== host;
|
|
const effectiveGitRepoUrl = gitRepoUrl || domain.gitRepoUrl?.trim() || null;
|
|
|
|
const alreadyWired =
|
|
!!effectiveGitRepoUrl &&
|
|
!!domain.deploySlug?.trim() &&
|
|
(!gitRepoUrl || domain.gitRepoUrl?.trim() === gitRepoUrl) &&
|
|
!hostChanged;
|
|
|
|
if (effectiveGitRepoUrl && !alreadyWired) {
|
|
const slug = domain.deploySlug?.trim() || deploySlugFromHost(host);
|
|
if (!slug) {
|
|
throw new BadRequestException('Could not derive deploy slug from host');
|
|
}
|
|
|
|
const slugTaken = await this.prisma.domain.findFirst({
|
|
where: { deploySlug: slug, NOT: { id: domainId } },
|
|
select: { id: true },
|
|
});
|
|
if (slugTaken) {
|
|
throw new ConflictException(`Deploy slug "${slug}" is already in use`);
|
|
}
|
|
|
|
try {
|
|
await this.websiteDeployAgent.provision({
|
|
slug,
|
|
host,
|
|
gitRepoUrl: normalizeGitRepoUrlForClone(effectiveGitRepoUrl),
|
|
});
|
|
deploySlug = slug;
|
|
nextGitRepoUrl = effectiveGitRepoUrl;
|
|
} catch (err) {
|
|
if (
|
|
err &&
|
|
typeof err === 'object' &&
|
|
'message' in err &&
|
|
typeof err.message === 'string'
|
|
) {
|
|
provisionError = err.message;
|
|
} else {
|
|
provisionError = 'Storefront provision failed on websites server';
|
|
}
|
|
}
|
|
} else if (alreadyWired) {
|
|
deploySlug = domain.deploySlug;
|
|
nextGitRepoUrl = effectiveGitRepoUrl;
|
|
}
|
|
|
|
const updated = await this.prisma.domain.update({
|
|
where: { id: domainId },
|
|
data: {
|
|
host,
|
|
expiresAt: dto.expiresAt !== undefined ? new Date(dto.expiresAt) : undefined,
|
|
...(gitRepoUrl && !provisionError
|
|
? { deploySlug, gitRepoUrl: nextGitRepoUrl }
|
|
: {}),
|
|
},
|
|
});
|
|
|
|
// Same DNS helper as business-admin domain add/edit (`ensureTenantRecords`).
|
|
const dnsError = dto.updateDns
|
|
? await this.applyTenantDns(host, dto.dnsProvider === 'cloudflare' ? 'cloudflare' : 'arvan')
|
|
: null;
|
|
|
|
return {
|
|
...updated,
|
|
provisionError,
|
|
dnsError,
|
|
};
|
|
}
|
|
|
|
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 applyTenantDns(
|
|
host: string,
|
|
provider: ParkedDnsProvider,
|
|
): Promise<string | null> {
|
|
try {
|
|
if (provider === 'cloudflare') {
|
|
await this.cloudflareDns.ensureTenantRecords(host);
|
|
} else {
|
|
await this.arvanDns.ensureTenantRecords(host);
|
|
}
|
|
return null;
|
|
} catch (err) {
|
|
return this.httpErrorMessage(
|
|
err,
|
|
provider === 'cloudflare' ? 'Cloudflare DNS update failed' : '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);
|
|
|
|
const domainId = BigInt(domainIdRaw);
|
|
const domain = await this.prisma.domain.findUnique({ where: { id: domainId } });
|
|
if (!domain) {
|
|
throw new NotFoundException('Domain not found');
|
|
}
|
|
|
|
return this.prisma.domain.update({
|
|
where: { id: domainId },
|
|
data: { isActive: dto.isActive },
|
|
});
|
|
}
|
|
|
|
async toggleSsl(domainIdRaw: string, dto: ToggleSslDto, 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');
|
|
}
|
|
|
|
return this.prisma.domain.update({
|
|
where: { id: domainId },
|
|
data: {
|
|
sslEnabled: dto.sslEnabled,
|
|
...(dto.sslEnabled ? {} : { sslExpiresAt: null }),
|
|
},
|
|
});
|
|
}
|
|
|
|
async remove(domainIdRaw: string, 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');
|
|
}
|
|
|
|
await this.prisma.domain.delete({ where: { id: domainId } });
|
|
|
|
return { message: 'Domain removed' };
|
|
}
|
|
}
|