diff --git a/.env.example b/.env.example index f8425dc..5cbc382 100644 --- a/.env.example +++ b/.env.example @@ -101,6 +101,11 @@ ARVAN_API_KEY= # Optional override; default https://napi.arvancloud.ir/cdn/4.0 # ARVAN_API_BASE_URL=https://napi.arvancloud.ir/cdn/4.0 +# Cloudflare DNS (API token with Zone.Zone Read + Zone.DNS Edit) +# Header sent as: Authorization: Bearer +CLOUDFLARE_API_TOKEN= +# CLOUDFLARE_API_BASE_URL=https://api.cloudflare.com/client/v4 + # Public domain for platform invoice links (https://{domain}/invoices/{id}) INVOICE_PUBLIC_DOMAIN=meshkee.com # Optional full origin for local (overrides domain + business host), e.g. https://meshkee.app:5174 diff --git a/database/migrations/078_domain_parked_hosts.sql b/database/migrations/078_domain_parked_hosts.sql new file mode 100644 index 0000000..12122cf --- /dev/null +++ b/database/migrations/078_domain_parked_hosts.sql @@ -0,0 +1,7 @@ +-- Parked alias hosts that 301 to the canonical domain (apex + www only). + +ALTER TABLE domains + ADD COLUMN IF NOT EXISTS parked_hosts TEXT[] NOT NULL DEFAULT '{}'; + +CREATE INDEX IF NOT EXISTS idx_domains_parked_hosts + ON domains USING GIN (parked_hosts); diff --git a/database/migrations/079_domain_parked_dns.sql b/database/migrations/079_domain_parked_dns.sql new file mode 100644 index 0000000..41512fb --- /dev/null +++ b/database/migrations/079_domain_parked_dns.sql @@ -0,0 +1,4 @@ +-- DNS provider per parked alias: { "samanandish.co": "cloudflare" } + +ALTER TABLE domains + ADD COLUMN IF NOT EXISTS parked_dns JSONB NOT NULL DEFAULT '{}'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8db1057..9d0b18d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -188,6 +188,8 @@ model Domain { lastDeployStatus String? @map("last_deploy_status") @db.VarChar(32) deploySlug String? @map("deploy_slug") @db.VarChar(64) gitRepoUrl String? @map("git_repo_url") @db.VarChar(512) + parkedHosts String[] @default([]) @map("parked_hosts") + parkedDns Json @default("{}") @map("parked_dns") aiPromptsPublicId String? @unique(map: "domains_ai_prompts_public_id_unique") @map("ai_prompts_public_id") @db.VarChar(32) business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) aiPrompts DomainAiPrompt[] diff --git a/scripts/websites-agent/README.md b/scripts/websites-agent/README.md index 5c28790..2d659ce 100644 --- a/scripts/websites-agent/README.md +++ b/scripts/websites-agent/README.md @@ -7,6 +7,8 @@ # GET /deploy-status?slug=… — last deploy status JSON for slug # POST /provision { slug, host, gitRepoUrl } — clone + nginx + ecosystem + allowlist (no certbot) # POST /ssl { host, slug? } — certbot for apex + www (nginx must exist) +# POST /park { host, canonicalHost } — nginx 301 apex+www → https://canonical +# POST /unpark { host } — remove parked nginx site # GET /health # # Scripts on the VM (same folder): diff --git a/scripts/websites-agent/park.sh b/scripts/websites-agent/park.sh new file mode 100755 index 0000000..1263d7e --- /dev/null +++ b/scripts/websites-agent/park.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Parked alias: nginx 301 from parked apex+www to the canonical storefront. +# SSL is issued separately via ssl.sh (certbot --nginx) so the cert is in +# certbot's auto-renewal list. +set -euo pipefail + +HOST="${1:-}" +CANONICAL="${2:-}" + +if [[ -z "$HOST" || -z "$CANONICAL" ]]; then + echo "usage: park.sh " >&2 + exit 1 +fi + +if [[ ! "$HOST" =~ ^[a-z0-9.-]+$ || ! "$CANONICAL" =~ ^[a-z0-9.-]+$ ]]; then + echo "invalid host" >&2 + exit 1 +fi + +NGINX_AVAILABLE="/etc/nginx/sites-available/$HOST" +NGINX_ENABLED="/etc/nginx/sites-enabled/$HOST" +SSL_DIR="/etc/nginx/ssl/$HOST" +LIST_FILE="/opt/websites-agent/parked-hosts.txt" + +mkdir -p "$SSL_DIR" /opt/websites-agent /var/www/certbot + +if [[ ! -f "$SSL_DIR/fullchain.pem" || ! -f "$SSL_DIR/privkey.pem" ]]; then + openssl req -x509 -nodes -newkey rsa:2048 -days 825 \ + -keyout "$SSL_DIR/privkey.pem" \ + -out "$SSL_DIR/fullchain.pem" \ + -subj "/CN=$HOST" \ + -addext "subjectAltName=DNS:$HOST,DNS:www.$HOST" \ + >/dev/null 2>&1 + echo "origin self-signed cert created: $SSL_DIR" +fi + +# If certbot already issued a live cert, prefer it. +LE_LIVE="/etc/letsencrypt/live/$HOST" +if [[ -f "$LE_LIVE/fullchain.pem" && -f "$LE_LIVE/privkey.pem" ]]; then + CERT="$LE_LIVE/fullchain.pem" + KEY="$LE_LIVE/privkey.pem" +else + CERT="$SSL_DIR/fullchain.pem" + KEY="$SSL_DIR/privkey.pem" +fi + +cat >"$NGINX_AVAILABLE" </dev/null; then + echo "$HOST" >>"$LIST_FILE" +fi + +echo "park ok: $HOST → https://$CANONICAL (apex+www)" diff --git a/scripts/websites-agent/server.js b/scripts/websites-agent/server.js index f3cc48d..a7f5ada 100644 --- a/scripts/websites-agent/server.js +++ b/scripts/websites-agent/server.js @@ -265,6 +265,74 @@ const server = http.createServer(async (req, res) => { } } + if (req.method === 'POST' && req.url === '/park') { + if (!assertAuth(req, res)) return; + + let body; + try { + body = await readJson(req); + } catch { + return send(res, 400, { error: 'invalid json' }); + } + + const host = String(body.host || '').trim().toLowerCase(); + const canonicalHost = String(body.canonicalHost || body.canonical_host || '') + .trim() + .toLowerCase(); + + if (!host || !/^[a-z0-9.-]+$/.test(host)) { + return send(res, 400, { error: 'valid host is required' }); + } + if (!canonicalHost || !/^[a-z0-9.-]+$/.test(canonicalHost)) { + return send(res, 400, { error: 'valid canonicalHost is required' }); + } + + try { + const result = await runScript('/opt/websites-agent/park.sh', [host, canonicalHost]); + return send(res, 200, { + status: 'parked', + host, + canonicalHost, + log: (result.stdout || '').slice(-2000), + }); + } catch (err) { + return send(res, 500, { + error: 'park failed', + detail: err instanceof Error ? err.message.slice(0, 2000) : String(err), + }); + } + } + + if (req.method === 'POST' && req.url === '/unpark') { + if (!assertAuth(req, res)) return; + + let body; + try { + body = await readJson(req); + } catch { + return send(res, 400, { error: 'invalid json' }); + } + + const host = String(body.host || '').trim().toLowerCase(); + if (!host || !/^[a-z0-9.-]+$/.test(host)) { + return send(res, 400, { error: 'valid host is required' }); + } + + try { + const result = await runScript('/opt/websites-agent/unpark.sh', [host]); + return send(res, 200, { + status: 'unparked', + host, + log: (result.stdout || '').slice(-2000), + }); + } catch (err) { + return send(res, 500, { + error: 'unpark failed', + detail: err instanceof Error ? err.message.slice(0, 2000) : String(err), + }); + } + } + return send(res, 404, { error: 'not found' }); }); diff --git a/scripts/websites-agent/unpark.sh b/scripts/websites-agent/unpark.sh new file mode 100755 index 0000000..cf51d65 --- /dev/null +++ b/scripts/websites-agent/unpark.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Remove a parked-alias nginx site. Does not delete Arvan DNS or LE certs. +set -euo pipefail + +HOST="${1:-}" + +if [[ -z "$HOST" ]]; then + echo "usage: unpark.sh " >&2 + exit 1 +fi + +if [[ ! "$HOST" =~ ^[a-z0-9.-]+$ ]]; then + echo "invalid host" >&2 + exit 1 +fi + +NGINX_AVAILABLE="/etc/nginx/sites-available/$HOST" +NGINX_ENABLED="/etc/nginx/sites-enabled/$HOST" +LIST_FILE="/opt/websites-agent/parked-hosts.txt" + +rm -f "$NGINX_ENABLED" +rm -f "$NGINX_AVAILABLE" + +if [[ -f "$LIST_FILE" ]]; then + grep -vxF "$HOST" "$LIST_FILE" >"${LIST_FILE}.tmp" || true + mv "${LIST_FILE}.tmp" "$LIST_FILE" +fi + +nginx -t +systemctl reload nginx +echo "unpark ok: $HOST" diff --git a/src/arvan-dns/arvan-dns.service.ts b/src/arvan-dns/arvan-dns.service.ts index 9230383..8a8e497 100644 --- a/src/arvan-dns/arvan-dns.service.ts +++ b/src/arvan-dns/arvan-dns.service.ts @@ -35,6 +35,29 @@ export class ArvanDnsService { created: string[]; updated: string[]; skipped: string[]; + }> { + return this.ensureRecords(hostRaw, this.desiredTenantRecords); + } + + /** + * Parked alias: apex ANAME + www CNAME only (no business/customer/api). + * Zone must already exist in Arvan. + */ + async ensureParkedRecords(hostRaw: string): Promise<{ + created: string[]; + updated: string[]; + skipped: string[]; + }> { + return this.ensureRecords(hostRaw, this.desiredParkedRecords); + } + + private async ensureRecords( + hostRaw: string, + desiredFor: (apex: string) => DesiredRecord[], + ): Promise<{ + created: string[]; + updated: string[]; + skipped: string[]; }> { const host = hostRaw.trim().toLowerCase(); if (!host) { @@ -63,7 +86,7 @@ export class ArvanDnsService { const updated: string[] = []; const skipped: string[] = []; - for (const desired of this.desiredRecords(host)) { + for (const desired of desiredFor(host)) { const match = existing.find( (item) => item.name === desired.name && item.type === desired.type, ); @@ -129,7 +152,7 @@ export class ArvanDnsService { return { created, updated, skipped }; } - private desiredRecords(apex: string): DesiredRecord[] { + private desiredTenantRecords(apex: string): DesiredRecord[] { return [ { type: 'aname', @@ -159,6 +182,22 @@ export class ArvanDnsService { ]; } + /** Apex + www only — parked aliases 301 to the canonical Meshkee domain. */ + private desiredParkedRecords(apex: string): DesiredRecord[] { + return [ + { + type: 'aname', + name: '@', + value: { location: 'ns.meshkee.com.', host_header: 'source' }, + }, + { + type: 'cname', + name: 'www', + value: { host: `${apex}.`, host_header: 'source' }, + }, + ]; + } + private sameTarget(desired: DesiredRecord, existing: ArvanDnsRecord): boolean { const current = existing.value ?? {}; if (desired.type === 'aname') { diff --git a/src/cloudflare-dns/cloudflare-dns.module.ts b/src/cloudflare-dns/cloudflare-dns.module.ts new file mode 100644 index 0000000..e8d8b04 --- /dev/null +++ b/src/cloudflare-dns/cloudflare-dns.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { CloudflareDnsService } from './cloudflare-dns.service'; + +@Module({ + providers: [CloudflareDnsService], + exports: [CloudflareDnsService], +}) +export class CloudflareDnsModule {} diff --git a/src/cloudflare-dns/cloudflare-dns.service.ts b/src/cloudflare-dns/cloudflare-dns.service.ts new file mode 100644 index 0000000..2383650 --- /dev/null +++ b/src/cloudflare-dns/cloudflare-dns.service.ts @@ -0,0 +1,268 @@ +import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +const DEFAULT_BASE_URL = 'https://api.cloudflare.com/client/v4'; +const TTL = 120; +const TARGET = 'ns.meshkee.com'; + +type CfRecord = { + id: string; + type: string; + name: string; + content: string; + proxied?: boolean; + ttl?: number; +}; + +@Injectable() +export class CloudflareDnsService { + private readonly logger = new Logger(CloudflareDnsService.name); + + constructor(private readonly config: ConfigService) {} + + /** + * Parked alias: apex + www CNAME to Meshkee websites, DNS-only (not proxied) + * so Let's Encrypt HTTP-01 on the websites VM can complete. + */ + async ensureParkedRecords(hostRaw: string): Promise<{ + created: string[]; + updated: string[]; + skipped: string[]; + }> { + const host = hostRaw.trim().toLowerCase(); + if (!host) { + throw new ServiceUnavailableException('Domain host is required for Cloudflare DNS'); + } + + const token = this.apiToken(); + if (!token) { + throw new ServiceUnavailableException( + 'Cloudflare DNS is not configured (CLOUDFLARE_API_TOKEN)', + ); + } + + const zoneId = await this.findZoneId(host, token); + const existing = await this.listRecords(zoneId, token); + const created: string[] = []; + const updated: string[] = []; + const skipped: string[] = []; + + const desired: Array<{ type: 'CNAME'; relative: string; content: string }> = [ + { type: 'CNAME', relative: '@', content: TARGET }, + { type: 'CNAME', relative: 'www', content: host }, + ]; + + for (const item of desired) { + const label = `${item.type} ${item.relative}`; + const match = existing.find( + (rec) => rec.type === item.type && this.relativeName(rec.name, host) === item.relative, + ); + + if (match && this.sameCname(match, item.content) && match.proxied === false) { + skipped.push(label); + continue; + } + + // Apex CNAME cannot coexist with A/AAAA. + if (item.relative === '@') { + await this.deleteConflictingApexAddress(zoneId, token, existing, host); + } + + if (match) { + const patch = await this.request( + 'PATCH', + `/zones/${zoneId}/dns_records/${match.id}`, + token, + { + type: item.type, + name: item.relative, + content: item.content, + ttl: TTL, + proxied: false, + }, + ); + if (!patch.ok) { + throw new ServiceUnavailableException( + `Cloudflare failed to update ${label} (${patch.status})${patch.message ? `: ${patch.message}` : ''}`, + ); + } + updated.push(label); + continue; + } + + const post = await this.request('POST', `/zones/${zoneId}/dns_records`, token, { + type: item.type, + name: item.relative, + content: item.content, + ttl: TTL, + proxied: false, + }); + if (!post.ok) { + throw new ServiceUnavailableException( + `Cloudflare failed to create ${label} (${post.status})${post.message ? `: ${post.message}` : ''}`, + ); + } + created.push(label); + } + + this.logger.log( + `Cloudflare DNS ${host}: created=${created.join(',') || '-'} updated=${updated.join(',') || '-'} skipped=${skipped.join(',') || '-'}`, + ); + + return { created, updated, skipped }; + } + + private async findZoneId(host: string, token: string): Promise { + const res = await this.request( + 'GET', + `/zones?name=${encodeURIComponent(host)}&status=active`, + token, + ); + if (!res.ok) { + throw new ServiceUnavailableException( + `Cloudflare could not load zone "${host}" (${res.status})${res.message ? `: ${res.message}` : ''}`, + ); + } + const result = Array.isArray(res.body.result) ? res.body.result : []; + const zone = result.find( + (item) => + item && + typeof item === 'object' && + 'name' in item && + String((item as { name: string }).name).toLowerCase() === host, + ) as { id?: string } | undefined; + if (!zone?.id) { + throw new ServiceUnavailableException( + `Cloudflare zone "${host}" was not found. Add the domain in Cloudflare first.`, + ); + } + return zone.id; + } + + private async listRecords(zoneId: string, token: string): Promise { + const items: CfRecord[] = []; + let page = 1; + for (;;) { + const res = await this.request( + 'GET', + `/zones/${zoneId}/dns_records?per_page=100&page=${page}`, + token, + ); + if (!res.ok) { + throw new ServiceUnavailableException( + `Cloudflare could not list DNS records (${res.status})${res.message ? `: ${res.message}` : ''}`, + ); + } + const data = Array.isArray(res.body.result) ? (res.body.result as CfRecord[]) : []; + items.push(...data); + const info = res.body.result_info as { total_pages?: number } | undefined; + const lastPage = info?.total_pages ?? 1; + if (page >= lastPage) break; + page += 1; + } + return items; + } + + private async deleteConflictingApexAddress( + zoneId: string, + token: string, + existing: CfRecord[], + zone: string, + ) { + const conflicts = existing.filter( + (rec) => + (rec.type === 'A' || rec.type === 'AAAA') && this.relativeName(rec.name, zone) === '@', + ); + for (const rec of conflicts) { + const del = await this.request('DELETE', `/zones/${zoneId}/dns_records/${rec.id}`, token); + if (!del.ok) { + throw new ServiceUnavailableException( + `Cloudflare failed to replace ${rec.type} @ (${del.status})${del.message ? `: ${del.message}` : ''}`, + ); + } + } + } + + private relativeName(name: string, zone: string): string { + const n = name.replace(/\.$/, '').toLowerCase(); + const z = zone.replace(/\.$/, '').toLowerCase(); + if (n === z || n === '@') return '@'; + if (n.endsWith(`.${z}`)) return n.slice(0, -(z.length + 1)); + return n; + } + + private sameCname(rec: CfRecord, content: string): boolean { + return rec.content.replace(/\.$/, '').toLowerCase() === content.replace(/\.$/, '').toLowerCase(); + } + + private apiToken(): string | null { + return this.config.get('CLOUDFLARE_API_TOKEN')?.trim() || null; + } + + private baseUrl(): string { + return ( + this.config.get('CLOUDFLARE_API_BASE_URL')?.trim().replace(/\/$/, '') || + DEFAULT_BASE_URL + ); + } + + private async request( + method: string, + path: string, + token: string, + body?: unknown, + ): Promise<{ + ok: boolean; + status: number; + body: Record; + message: string; + }> { + let response: Response; + try { + response = await fetch(`${this.baseUrl()}${path}`, { + method, + headers: { + Accept: 'application/json', + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(20_000), + }); + } catch (err) { + const detail = err instanceof Error ? err.message : null; + this.logger.warn(`Cloudflare DNS request failed ${method} ${path}${detail ? `: ${detail}` : ''}`); + throw new ServiceUnavailableException( + detail + ? `Could not reach Cloudflare DNS API (${detail})` + : 'Could not reach Cloudflare DNS API', + ); + } + + const text = await response.text().catch(() => ''); + let parsed: Record = {}; + try { + parsed = text ? (JSON.parse(text) as Record) : {}; + } catch { + /* keep raw */ + } + + const errors = parsed.errors; + const errorMessage = + Array.isArray(errors) && errors[0] && typeof errors[0] === 'object' && 'message' in errors[0] + ? String((errors[0] as { message: unknown }).message) + : ''; + + const message = + errorMessage || + (typeof parsed.message === 'string' && parsed.message) || + (text && !response.ok ? text.slice(0, 300) : ''); + + return { + ok: response.ok && parsed.success !== false, + status: response.status, + body: parsed, + message, + }; + } +} diff --git a/src/domain-admin/domain-admin.controller.ts b/src/domain-admin/domain-admin.controller.ts index 4701db2..3488868 100644 --- a/src/domain-admin/domain-admin.controller.ts +++ b/src/domain-admin/domain-admin.controller.ts @@ -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( diff --git a/src/domain-admin/domain-admin.module.ts b/src/domain-admin/domain-admin.module.ts index 35b0faf..3f748e5 100644 --- a/src/domain-admin/domain-admin.module.ts +++ b/src/domain-admin/domain-admin.module.ts @@ -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], }) diff --git a/src/domain-admin/domain-admin.service.ts b/src/domain-admin/domain-admin.service.ts index 02ca9f7..9ab730a 100644 --- a/src/domain-admin/domain-admin.service.ts +++ b/src/domain-admin/domain-admin.service.ts @@ -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(); + 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 = {}; + 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 { + 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 { 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); diff --git a/src/domain-admin/dto/update-parked-hosts.dto.ts b/src/domain-admin/dto/update-parked-hosts.dto.ts new file mode 100644 index 0000000..106638f --- /dev/null +++ b/src/domain-admin/dto/update-parked-hosts.dto.ts @@ -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[]; +} diff --git a/src/domain-admin/parked-host.util.ts b/src/domain-admin/parked-host.util.ts new file mode 100644 index 0000000..9b447d2 --- /dev/null +++ b/src/domain-admin/parked-host.util.ts @@ -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 { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}; + const out: Record = {}; + for (const [key, value] of Object.entries(raw as Record)) { + 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(); + 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); +} diff --git a/src/website-deploy/website-deploy-agent.service.ts b/src/website-deploy/website-deploy-agent.service.ts index 30dbaf3..e2b51ee 100644 --- a/src/website-deploy/website-deploy-agent.service.ts +++ b/src/website-deploy/website-deploy-agent.service.ts @@ -1,6 +1,6 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { provisionUrlFromDeployUrl, sslUrlFromDeployUrl } from './website-deploy.util'; +import { provisionUrlFromDeployUrl, sslUrlFromDeployUrl, parkUrlFromDeployUrl, unparkUrlFromDeployUrl } from './website-deploy.util'; @Injectable() export class WebsiteDeployAgentService { @@ -136,4 +136,66 @@ export class WebsiteDeployAgentService { return response.json().catch(() => ({ status: 'issued', host: input.host })); } + + /** Nginx 301 vhost: parked apex+www → canonical HTTPS. */ + async park(input: { host: string; canonicalHost: string }) { + const { deployUrl, token } = this.credentials(); + const url = parkUrlFromDeployUrl(deployUrl); + + let response: Response; + try { + response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Deploy-Token': token, + }, + body: JSON.stringify({ + host: input.host, + canonicalHost: input.canonicalHost, + }), + signal: AbortSignal.timeout(60_000), + }); + } catch { + throw new ServiceUnavailableException('Could not reach website deploy agent'); + } + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new ServiceUnavailableException( + `Park agent rejected request (${response.status})${text ? `: ${text}` : ''}`, + ); + } + + return response.json().catch(() => ({ status: 'parked', host: input.host })); + } + + async unpark(input: { host: string }) { + const { deployUrl, token } = this.credentials(); + const url = unparkUrlFromDeployUrl(deployUrl); + + let response: Response; + try { + response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Deploy-Token': token, + }, + body: JSON.stringify({ host: input.host }), + signal: AbortSignal.timeout(30_000), + }); + } catch { + throw new ServiceUnavailableException('Could not reach website deploy agent'); + } + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new ServiceUnavailableException( + `Unpark agent rejected request (${response.status})${text ? `: ${text}` : ''}`, + ); + } + + return response.json().catch(() => ({ status: 'unparked', host: input.host })); + } } diff --git a/src/website-deploy/website-deploy.util.ts b/src/website-deploy/website-deploy.util.ts index 0d7e782..c5f6eaa 100644 --- a/src/website-deploy/website-deploy.util.ts +++ b/src/website-deploy/website-deploy.util.ts @@ -42,9 +42,23 @@ export function provisionUrlFromDeployUrl(deployUrl: string): string { /** Turn .../deploy into .../ssl (or append /ssl if bare). */ export function sslUrlFromDeployUrl(deployUrl: string): string { + return replaceDeployPath(deployUrl, 'ssl'); +} + +/** Turn .../deploy into .../park */ +export function parkUrlFromDeployUrl(deployUrl: string): string { + return replaceDeployPath(deployUrl, 'park'); +} + +/** Turn .../deploy into .../unpark */ +export function unparkUrlFromDeployUrl(deployUrl: string): string { + return replaceDeployPath(deployUrl, 'unpark'); +} + +function replaceDeployPath(deployUrl: string, suffix: string): string { const trimmed = deployUrl.trim().replace(/\/+$/, ''); if (/\/deploy$/i.test(trimmed)) { - return trimmed.replace(/\/deploy$/i, '/ssl'); + return trimmed.replace(/\/deploy$/i, `/${suffix}`); } - return `${trimmed}/ssl`; + return `${trimmed}/${suffix}`; }