Upsert ArvanCloud DNS records when a super-admin adds or updates a domain.

Tenant zones get an apex ANAME and CNAMEs for www, business, customer, and api without storing origin IPs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-14 11:16:35 +03:30
co-authored by Cursor
parent d08a85cee5
commit 30119e6d9e
6 changed files with 301 additions and 2 deletions
+6
View File
@@ -95,6 +95,12 @@ WEBSITE_DEPLOY_TOKEN=
SSL_SYNC_AGENT_URL=http://45.149.76.52:9051/ssl-sync
SSL_SYNC_AGENT_TOKEN=
# ArvanCloud CDN DNS (Machine User key from Arvan panel)
# Header sent as: Authorization: Apikey <ARVAN_API_KEY>
ARVAN_API_KEY=
# Optional override; default https://napi.arvancloud.ir/cdn/4.0
# ARVAN_API_BASE_URL=https://napi.arvancloud.ir/cdn/4.0
# 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
+3 -1
View File
@@ -1,7 +1,7 @@
# Meshkee CMS API — Project Context
> Living reference for developers and AI assistants working on this codebase.
> Last updated: August 11, 2026
> Last updated: August 14, 2026
## What This Project Is
@@ -597,6 +597,7 @@ See `.env.example` for the full list. Key groups:
| JWT | `JWT_ACCESS_SECRET`, `JWT_REFRESH_SECRET`, `JWT_*_EXPIRES_IN` |
| SMS | `SMS_ENABLED`, `SMS_GAMA_BASE_URL`, `SMS_GAMA_USERNAME`, `SMS_GAMA_PASSWORD`, `SMS_GAMA_SOURCE_SERVICE`, `SMS_PARTNERS` |
| S3 | `S3_ENDPOINT`, `S3_BUCKET`, `S3_PUBLIC_URL`, `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` |
| ArvanCloud DNS | `ARVAN_API_KEY`, optional `ARVAN_API_BASE_URL` |
| Legacy MySQL (WillaEngine migrate) | `OLD_MYSQL_HOST`, `OLD_MYSQL_PORT`, `OLD_MYSQL_USER`, `OLD_MYSQL_PASSWORD`, `OLD_MYSQL_DATABASE` |
| Legacy S3 source (media copy) | `OLD_S3_ENDPOINT`, `OLD_S3_BUCKET`, `OLD_S3_PUBLIC_URL`, `OLD_S3_ACCESS_KEY_ID`, `OLD_S3_SECRET_ACCESS_KEY` |
| Media | `MEDIA_MAX_FILE_SIZE_MB` |
@@ -609,6 +610,7 @@ See `.env.example` for the full list. Key groups:
- Multi-tenant auth (register, login, passwordless OTP login, reset password via SMS, profile)
- Super admin: users, businesses, domains, system business categories
- Super admin add/update domain upserts ArvanCloud DNS (`@` ANAME, `www`/`business`/`customer`/`api` CNAMEs)
- Super admin: selective migrate-from-old + purge-data (portfolio categories + portfolios; oversized images resized to max 1280×1280; purge removes portfolios + images)
- Business team management
- Media upload (S3 + Sharp)
+8
View File
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { ArvanDnsService } from './arvan-dns.service';
@Module({
providers: [ArvanDnsService],
exports: [ArvanDnsService],
})
export class ArvanDnsModule {}
+253
View File
@@ -0,0 +1,253 @@
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
const DEFAULT_BASE_URL = 'https://napi.arvancloud.ir/cdn/4.0';
const TTL = 120;
type ArvanRecordType = 'aname' | 'cname';
type DesiredRecord = {
type: ArvanRecordType;
name: string;
value: Record<string, unknown>;
};
type ArvanDnsRecord = {
id: string;
type: string;
name: string;
ttl?: number;
cloud?: boolean;
value?: Record<string, unknown> | null;
};
@Injectable()
export class ArvanDnsService {
private readonly logger = new Logger(ArvanDnsService.name);
constructor(private readonly config: ConfigService) {}
/**
* Ensure Meshkee tenant records on an existing Arvan zone.
* Does not register the domain or delete unrelated records.
*/
async ensureTenantRecords(hostRaw: string): Promise<{
created: string[];
updated: string[];
skipped: string[];
}> {
const host = hostRaw.trim().toLowerCase();
if (!host) {
throw new ServiceUnavailableException('Domain host is required for Arvan DNS');
}
const auth = this.authHeader();
if (!auth) {
throw new ServiceUnavailableException('Arvan DNS is not configured');
}
const zone = await this.request('GET', `/domains/${encodeURIComponent(host)}`, auth);
if (zone.status === 404) {
throw new ServiceUnavailableException(
`Arvan zone "${host}" was not found. Add the domain in Arvan first.`,
);
}
if (!zone.ok) {
throw new ServiceUnavailableException(
`Arvan could not load zone "${host}" (${zone.status})${zone.message ? `: ${zone.message}` : ''}`,
);
}
const existing = await this.listRecords(host, auth);
const created: string[] = [];
const updated: string[] = [];
const skipped: string[] = [];
for (const desired of this.desiredRecords(host)) {
const match = existing.find(
(item) => item.name === desired.name && item.type === desired.type,
);
const label = `${desired.type} ${desired.name}`;
if (match && this.sameTarget(desired, match)) {
skipped.push(label);
continue;
}
if (match) {
const put = await this.request('PUT', `/domains/${encodeURIComponent(host)}/dns-records/${match.id}`, auth, {
type: desired.type,
name: desired.name,
ttl: match.ttl || TTL,
cloud: false,
value: desired.value,
});
if (!put.ok) {
throw new ServiceUnavailableException(
`Arvan failed to update ${label} (${put.status})${put.message ? `: ${put.message}` : ''}`,
);
}
updated.push(label);
continue;
}
const sameNameOtherType = existing.find(
(item) => item.name === desired.name && item.type !== desired.type && item.type !== 'ns',
);
if (sameNameOtherType) {
const del = await this.request(
'DELETE',
`/domains/${encodeURIComponent(host)}/dns-records/${sameNameOtherType.id}`,
auth,
);
if (!del.ok) {
throw new ServiceUnavailableException(
`Arvan failed to replace ${sameNameOtherType.type} ${desired.name} (${del.status})${del.message ? `: ${del.message}` : ''}`,
);
}
}
const post = await this.request('POST', `/domains/${encodeURIComponent(host)}/dns-records`, auth, {
type: desired.type,
name: desired.name,
ttl: TTL,
cloud: false,
value: desired.value,
});
if (!post.ok) {
throw new ServiceUnavailableException(
`Arvan failed to create ${label} (${post.status})${post.message ? `: ${post.message}` : ''}`,
);
}
created.push(label);
}
this.logger.log(
`Arvan DNS ${host}: created=${created.join(',') || '-'} updated=${updated.join(',') || '-'} skipped=${skipped.join(',') || '-'}`,
);
return { created, updated, skipped };
}
private desiredRecords(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' },
},
{
type: 'cname',
name: 'business',
value: { host: 'business.meshkee.com.', host_header: 'source' },
},
{
type: 'cname',
name: 'customer',
value: { host: 'customer.meshkee.com.', host_header: 'source' },
},
{
type: 'cname',
name: 'api',
value: { host: 'api.meshkee.com.', host_header: 'source' },
},
];
}
private sameTarget(desired: DesiredRecord, existing: ArvanDnsRecord): boolean {
const current = existing.value ?? {};
if (desired.type === 'aname') {
return this.fqdn(String(current.location ?? '')) === this.fqdn(String(desired.value.location ?? ''));
}
return this.fqdn(String(current.host ?? '')) === this.fqdn(String(desired.value.host ?? ''));
}
private fqdn(value: string): string {
return value.trim().replace(/\.$/, '').toLowerCase();
}
private async listRecords(domain: string, auth: string): Promise<ArvanDnsRecord[]> {
const items: ArvanDnsRecord[] = [];
let page = 1;
for (;;) {
const res = await this.request(
'GET',
`/domains/${encodeURIComponent(domain)}/dns-records?per_page=50&page=${page}`,
auth,
);
if (!res.ok) {
throw new ServiceUnavailableException(
`Arvan could not list DNS records (${res.status})${res.message ? `: ${res.message}` : ''}`,
);
}
const payload = (res.body ?? {}) as {
data?: ArvanDnsRecord[];
meta?: { last_page?: number };
};
const data = Array.isArray(payload.data) ? payload.data : [];
items.push(...data);
const lastPage = payload.meta?.last_page ?? 1;
if (page >= lastPage) break;
page += 1;
}
return items;
}
private authHeader(): string | null {
const raw = this.config.get<string>('ARVAN_API_KEY')?.trim();
if (!raw) return null;
return raw.toLowerCase().startsWith('apikey ') ? raw : `Apikey ${raw}`;
}
private baseUrl(): string {
return (
this.config.get<string>('ARVAN_API_BASE_URL')?.trim().replace(/\/$/, '') || DEFAULT_BASE_URL
);
}
private async request(
method: string,
path: string,
auth: string,
body?: unknown,
): Promise<{ ok: boolean; status: number; body: Record<string, unknown> | null; message: string }> {
let response: Response;
try {
response = await fetch(`${this.baseUrl()}${path}`, {
method,
headers: {
Accept: 'application/json',
Authorization: auth,
'Content-Type': 'application/json',
},
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch {
throw new ServiceUnavailableException('Could not reach ArvanCloud 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 message =
(typeof parsed.message === 'string' && parsed.message) ||
(text && !response.ok ? text.slice(0, 300) : '');
return {
ok: response.ok,
status: response.status,
body: parsed,
message,
};
}
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { ArvanDnsModule } from '../arvan-dns/arvan-dns.module';
import { WebsiteDeployModule } from '../website-deploy/website-deploy.module';
import { BusinessCategoriesController } from './business-categories.controller';
import { BusinessCategoriesService } from './business-categories.service';
@@ -9,7 +10,7 @@ import { LegacyMigrateService } from './legacy-migrate.service';
import { LegacyPurgeService } from './legacy-purge.service';
@Module({
imports: [AuthModule, WebsiteDeployModule],
imports: [AuthModule, WebsiteDeployModule, ArvanDnsModule],
controllers: [BusinessAdminController, BusinessCategoriesController],
providers: [
BusinessAdminService,
@@ -2,6 +2,7 @@ import {
BadRequestException,
ConflictException,
ForbiddenException,
HttpException,
Injectable,
NotFoundException,
} from '@nestjs/common';
@@ -40,6 +41,7 @@ import {
DEFAULT_ENABLED_BUSINESS_MODULES,
DEFAULT_HOME_CHARTS,
} from '../business-settings/business-settings.types';
import { ArvanDnsService } from '../arvan-dns/arvan-dns.service';
import { WebsiteDeployAgentService } from '../website-deploy/website-deploy-agent.service';
import {
deploySlugFromHost,
@@ -88,6 +90,7 @@ export class BusinessAdminService {
private readonly legacyMigrate: LegacyMigrateService,
private readonly legacyPurge: LegacyPurgeService,
private readonly websiteDeployAgent: WebsiteDeployAgentService,
private readonly arvanDns: ArvanDnsService,
) {}
private async assertSuperAdmin(actor: AuthUser) {
@@ -545,9 +548,12 @@ export class BusinessAdminService {
},
});
const dnsError = await this.applyArvanDns(host);
return {
...domain,
provisionError,
dnsError,
};
}
@@ -642,12 +648,35 @@ export class BusinessAdminService {
},
});
const dnsError = await this.applyArvanDns(host);
return {
...updated,
provisionError,
dnsError,
};
}
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';
}
}
async disable(businessIdRaw: string, dto: DisableBusinessDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);