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
@@ -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_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
|
||||
|
||||
@@ -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);
|
||||
@@ -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 '{}';
|
||||
@@ -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[]
|
||||
|
||||
@@ -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):
|
||||
|
||||
Executable
+83
@@ -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 <parked-host> <canonical-host>" >&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" <<NGINX
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name ${HOST} www.${HOST};
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://${CANONICAL}\$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name ${HOST} www.${HOST};
|
||||
|
||||
ssl_certificate ${CERT};
|
||||
ssl_certificate_key ${KEY};
|
||||
|
||||
return 301 https://${CANONICAL}\$request_uri;
|
||||
}
|
||||
NGINX
|
||||
|
||||
ln -sfn "$NGINX_AVAILABLE" "$NGINX_ENABLED"
|
||||
nginx -t
|
||||
systemctl reload nginx
|
||||
|
||||
touch "$LIST_FILE"
|
||||
if ! grep -qxF "$HOST" "$LIST_FILE" 2>/dev/null; then
|
||||
echo "$HOST" >>"$LIST_FILE"
|
||||
fi
|
||||
|
||||
echo "park ok: $HOST → https://$CANONICAL (apex+www)"
|
||||
@@ -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' });
|
||||
});
|
||||
|
||||
|
||||
Executable
+31
@@ -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 <parked-host>" >&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"
|
||||
@@ -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') {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CloudflareDnsService } from './cloudflare-dns.service';
|
||||
|
||||
@Module({
|
||||
providers: [CloudflareDnsService],
|
||||
exports: [CloudflareDnsService],
|
||||
})
|
||||
export class CloudflareDnsModule {}
|
||||
@@ -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<string> {
|
||||
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<CfRecord[]> {
|
||||
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<string>('CLOUDFLARE_API_TOKEN')?.trim() || null;
|
||||
}
|
||||
|
||||
private baseUrl(): string {
|
||||
return (
|
||||
this.config.get<string>('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<string, unknown>;
|
||||
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<string, unknown> = {};
|
||||
try {
|
||||
parsed = text ? (JSON.parse(text) as Record<string, unknown>) : {};
|
||||
} 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,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],
|
||||
})
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user