Add Email DNS upsert for Stalwart DKIM, MX, and SPF.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
6b8073f998
commit
6f1b13b6dc
@@ -643,6 +643,7 @@ See `.env.example` for the full list. Key groups:
|
|||||||
- Multi-tenant auth (register, login, passwordless OTP login, reset password via SMS, profile)
|
- Multi-tenant auth (register, login, passwordless OTP login, reset password via SMS, profile)
|
||||||
- Super admin: users, businesses, domains, system business categories
|
- Super admin: users, businesses, domains, system business categories
|
||||||
- Super admin add/update domain upserts tenant DNS via ArvanCloud or Cloudflare (`@` ANAME/CNAME, `www`/`business`/`customer`/`api` CNAMEs; Cloudflare DNS-only)
|
- Super admin add/update domain upserts tenant DNS via ArvanCloud or Cloudflare (`@` ANAME/CNAME, `www`/`business`/`customer`/`api` CNAMEs; Cloudflare DNS-only)
|
||||||
|
- Super admin Websites ⋯ **Email DNS** upserts Stalwart mail records (mail A `185.214.101.41`, MX `mail.meshkee.com`, SPF, two DKIM TXT, autoconfig/autodiscover) on Arvan or Cloudflare
|
||||||
- Super admin: selective migrate-from-old + purge-data (portfolio categories + portfolios; oversized images resized to max 1280×1280; purge removes portfolios + images)
|
- 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
|
- Business team management
|
||||||
- Media upload (S3 + Sharp)
|
- Media upload (S3 + Sharp)
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import {
|
||||||
|
MAIL_DMARC,
|
||||||
|
MAIL_HOSTNAME,
|
||||||
|
MAIL_IPV4,
|
||||||
|
MAIL_MX_PRIORITY,
|
||||||
|
MAIL_SPF,
|
||||||
|
type MailDkimKey,
|
||||||
|
} from '../mail-dns/mail-dns';
|
||||||
|
|
||||||
const DEFAULT_BASE_URL = 'https://napi.arvancloud.ir/cdn/4.0';
|
const DEFAULT_BASE_URL = 'https://napi.arvancloud.ir/cdn/4.0';
|
||||||
const TTL = 120;
|
const TTL = 120;
|
||||||
|
|
||||||
type ArvanRecordType = 'aname' | 'cname';
|
type ArvanRecordType = 'aname' | 'cname' | 'a' | 'mx' | 'txt';
|
||||||
|
|
||||||
type DesiredRecord = {
|
type DesiredRecord = {
|
||||||
type: ArvanRecordType;
|
type: ArvanRecordType;
|
||||||
@@ -12,6 +20,13 @@ type DesiredRecord = {
|
|||||||
value: Record<string, unknown>;
|
value: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type DnsMutationResult = {
|
||||||
|
created: string[];
|
||||||
|
updated: string[];
|
||||||
|
skipped: string[];
|
||||||
|
removed: string[];
|
||||||
|
};
|
||||||
|
|
||||||
type ArvanDnsRecord = {
|
type ArvanDnsRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
@@ -51,6 +66,89 @@ export class ArvanDnsService {
|
|||||||
return this.ensureRecords(hostRaw, this.desiredParkedRecords);
|
return this.ensureRecords(hostRaw, this.desiredParkedRecords);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stalwart mail for a Meshkee website: mail A, MX, SPF, DKIM, autoconfig.
|
||||||
|
* DNS-only (not orange-cloud). Does not touch website ANAME/CNAME records.
|
||||||
|
*/
|
||||||
|
async ensureMailRecords(hostRaw: string, keys: MailDkimKey[]): Promise<DnsMutationResult> {
|
||||||
|
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[] = [];
|
||||||
|
const removed: string[] = [];
|
||||||
|
|
||||||
|
await this.replaceConflicting(
|
||||||
|
host,
|
||||||
|
auth,
|
||||||
|
existing,
|
||||||
|
'mail',
|
||||||
|
['cname', 'aname'],
|
||||||
|
removed,
|
||||||
|
);
|
||||||
|
await this.upsertTyped(host, auth, existing, {
|
||||||
|
type: 'a',
|
||||||
|
name: 'mail',
|
||||||
|
value: { ip: MAIL_IPV4 },
|
||||||
|
}, created, updated, skipped);
|
||||||
|
|
||||||
|
await this.replaceExtraMx(host, auth, existing, removed);
|
||||||
|
await this.upsertTyped(host, auth, existing, {
|
||||||
|
type: 'mx',
|
||||||
|
name: '@',
|
||||||
|
value: { host: `${MAIL_HOSTNAME}.`, priority: MAIL_MX_PRIORITY },
|
||||||
|
}, created, updated, skipped);
|
||||||
|
|
||||||
|
await this.upsertSpf(host, auth, existing, created, updated, skipped);
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
await this.upsertTyped(host, auth, existing, {
|
||||||
|
type: 'txt',
|
||||||
|
name: `${key.selector}._domainkey`,
|
||||||
|
value: { text: key.text },
|
||||||
|
}, created, updated, skipped);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.upsertTyped(host, auth, existing, {
|
||||||
|
type: 'cname',
|
||||||
|
name: 'autoconfig',
|
||||||
|
value: { host: `${MAIL_HOSTNAME}.`, host_header: 'source' },
|
||||||
|
}, created, updated, skipped);
|
||||||
|
await this.upsertTyped(host, auth, existing, {
|
||||||
|
type: 'cname',
|
||||||
|
name: 'autodiscover',
|
||||||
|
value: { host: `${MAIL_HOSTNAME}.`, host_header: 'source' },
|
||||||
|
}, created, updated, skipped);
|
||||||
|
|
||||||
|
await this.ensureDmarcIfMissing(host, auth, existing, created, skipped);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Arvan mail DNS ${host}: created=${created.join(',') || '-'} updated=${updated.join(',') || '-'} skipped=${skipped.join(',') || '-'} removed=${removed.join(',') || '-'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { created, updated, skipped, removed };
|
||||||
|
}
|
||||||
|
|
||||||
private async ensureRecords(
|
private async ensureRecords(
|
||||||
hostRaw: string,
|
hostRaw: string,
|
||||||
desiredFor: (apex: string) => DesiredRecord[],
|
desiredFor: (apex: string) => DesiredRecord[],
|
||||||
@@ -198,14 +296,270 @@ export class ArvanDnsService {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async upsertTyped(
|
||||||
|
host: string,
|
||||||
|
auth: string,
|
||||||
|
existing: ArvanDnsRecord[],
|
||||||
|
desired: DesiredRecord,
|
||||||
|
created: string[],
|
||||||
|
updated: string[],
|
||||||
|
skipped: string[],
|
||||||
|
) {
|
||||||
|
const match = existing.find(
|
||||||
|
(item) => this.relativeName(item.name, host) === desired.name && item.type === desired.type,
|
||||||
|
);
|
||||||
|
const label = `${desired.type.toUpperCase()} ${desired.name}`;
|
||||||
|
|
||||||
|
if (match && this.sameTarget(desired, match)) {
|
||||||
|
skipped.push(label);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
match.value = desired.value;
|
||||||
|
updated.push(label);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const createdId =
|
||||||
|
post.body && typeof post.body === 'object' && 'data' in post.body
|
||||||
|
? String((post.body.data as { id?: string } | undefined)?.id ?? '')
|
||||||
|
: '';
|
||||||
|
existing.push({
|
||||||
|
id: createdId || `new-${desired.type}-${desired.name}`,
|
||||||
|
type: desired.type,
|
||||||
|
name: desired.name,
|
||||||
|
value: desired.value,
|
||||||
|
});
|
||||||
|
created.push(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async upsertSpf(
|
||||||
|
host: string,
|
||||||
|
auth: string,
|
||||||
|
existing: ArvanDnsRecord[],
|
||||||
|
created: string[],
|
||||||
|
updated: string[],
|
||||||
|
skipped: string[],
|
||||||
|
) {
|
||||||
|
const match = existing.find(
|
||||||
|
(item) =>
|
||||||
|
this.relativeName(item.name, host) === '@' &&
|
||||||
|
item.type === 'txt' &&
|
||||||
|
this.txtValue(item).toLowerCase().startsWith('v=spf1'),
|
||||||
|
);
|
||||||
|
const desired: DesiredRecord = {
|
||||||
|
type: 'txt',
|
||||||
|
name: '@',
|
||||||
|
value: { text: MAIL_SPF },
|
||||||
|
};
|
||||||
|
if (match) {
|
||||||
|
if (this.sameTarget(desired, match)) {
|
||||||
|
skipped.push('TXT @ SPF');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const put = await this.request(
|
||||||
|
'PUT',
|
||||||
|
`/domains/${encodeURIComponent(host)}/dns-records/${match.id}`,
|
||||||
|
auth,
|
||||||
|
{
|
||||||
|
type: 'txt',
|
||||||
|
name: '@',
|
||||||
|
ttl: match.ttl || TTL,
|
||||||
|
cloud: false,
|
||||||
|
value: desired.value,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!put.ok) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
`Arvan failed to update TXT @ SPF (${put.status})${put.message ? `: ${put.message}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
match.value = desired.value;
|
||||||
|
updated.push('TXT @ SPF');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = await this.request('POST', `/domains/${encodeURIComponent(host)}/dns-records`, auth, {
|
||||||
|
type: 'txt',
|
||||||
|
name: '@',
|
||||||
|
ttl: TTL,
|
||||||
|
cloud: false,
|
||||||
|
value: desired.value,
|
||||||
|
});
|
||||||
|
if (!post.ok) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
`Arvan failed to create TXT @ SPF (${post.status})${post.message ? `: ${post.message}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
existing.push({
|
||||||
|
id: 'new-spf',
|
||||||
|
type: 'txt',
|
||||||
|
name: '@',
|
||||||
|
value: desired.value,
|
||||||
|
});
|
||||||
|
created.push('TXT @ SPF');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureDmarcIfMissing(
|
||||||
|
host: string,
|
||||||
|
auth: string,
|
||||||
|
existing: ArvanDnsRecord[],
|
||||||
|
created: string[],
|
||||||
|
skipped: string[],
|
||||||
|
) {
|
||||||
|
const match = existing.find(
|
||||||
|
(item) => this.relativeName(item.name, host) === '_dmarc' && item.type === 'txt',
|
||||||
|
);
|
||||||
|
if (match) {
|
||||||
|
skipped.push('TXT _dmarc');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.upsertTyped(
|
||||||
|
host,
|
||||||
|
auth,
|
||||||
|
existing,
|
||||||
|
{ type: 'txt', name: '_dmarc', value: { text: MAIL_DMARC } },
|
||||||
|
created,
|
||||||
|
[],
|
||||||
|
skipped,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async replaceExtraMx(
|
||||||
|
host: string,
|
||||||
|
auth: string,
|
||||||
|
existing: ArvanDnsRecord[],
|
||||||
|
removed: string[],
|
||||||
|
) {
|
||||||
|
const mx = existing.filter(
|
||||||
|
(item) => this.relativeName(item.name, host) === '@' && item.type === 'mx',
|
||||||
|
);
|
||||||
|
const keep = mx.find((item) => {
|
||||||
|
const current = item.value ?? {};
|
||||||
|
return this.fqdn(this.mxHost(current)) === MAIL_HOSTNAME;
|
||||||
|
});
|
||||||
|
for (const item of mx) {
|
||||||
|
if (keep && item.id === keep.id) continue;
|
||||||
|
const del = await this.request(
|
||||||
|
'DELETE',
|
||||||
|
`/domains/${encodeURIComponent(host)}/dns-records/${item.id}`,
|
||||||
|
auth,
|
||||||
|
);
|
||||||
|
if (!del.ok) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
`Arvan failed to remove extra MX (${del.status})${del.message ? `: ${del.message}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
removed.push(`MX ${this.mxHost(item.value ?? {}) || item.id}`);
|
||||||
|
const index = existing.indexOf(item);
|
||||||
|
if (index >= 0) existing.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async replaceConflicting(
|
||||||
|
host: string,
|
||||||
|
auth: string,
|
||||||
|
existing: ArvanDnsRecord[],
|
||||||
|
name: string,
|
||||||
|
types: string[],
|
||||||
|
removed: string[],
|
||||||
|
) {
|
||||||
|
const conflicts = existing.filter(
|
||||||
|
(item) =>
|
||||||
|
this.relativeName(item.name, host) === name && types.includes(item.type),
|
||||||
|
);
|
||||||
|
for (const item of conflicts) {
|
||||||
|
const del = await this.request(
|
||||||
|
'DELETE',
|
||||||
|
`/domains/${encodeURIComponent(host)}/dns-records/${item.id}`,
|
||||||
|
auth,
|
||||||
|
);
|
||||||
|
if (!del.ok) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
`Arvan failed to replace ${item.type} ${name} (${del.status})${del.message ? `: ${del.message}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
removed.push(`${item.type.toUpperCase()} ${name}`);
|
||||||
|
const index = existing.indexOf(item);
|
||||||
|
if (index >= 0) existing.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private sameTarget(desired: DesiredRecord, existing: ArvanDnsRecord): boolean {
|
private sameTarget(desired: DesiredRecord, existing: ArvanDnsRecord): boolean {
|
||||||
const current = existing.value ?? {};
|
const current = existing.value ?? {};
|
||||||
if (desired.type === 'aname') {
|
if (desired.type === 'aname') {
|
||||||
return this.fqdn(String(current.location ?? '')) === this.fqdn(String(desired.value.location ?? ''));
|
return this.fqdn(String(current.location ?? '')) === this.fqdn(String(desired.value.location ?? ''));
|
||||||
}
|
}
|
||||||
|
if (desired.type === 'a') {
|
||||||
|
return String(current.ip ?? '') === String(desired.value.ip ?? '');
|
||||||
|
}
|
||||||
|
if (desired.type === 'mx') {
|
||||||
|
return (
|
||||||
|
this.fqdn(this.mxHost(current)) === this.fqdn(String(desired.value.host ?? '')) &&
|
||||||
|
Number(current.priority ?? 0) === Number(desired.value.priority ?? 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (desired.type === 'txt') {
|
||||||
|
return this.txtValue(existing).replace(/\s+/g, '') === String(desired.value.text ?? '').replace(/\s+/g, '');
|
||||||
|
}
|
||||||
return this.fqdn(String(current.host ?? '')) === this.fqdn(String(desired.value.host ?? ''));
|
return this.fqdn(String(current.host ?? '')) === this.fqdn(String(desired.value.host ?? ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private txtValue(record: ArvanDnsRecord): string {
|
||||||
|
const value = record.value ?? {};
|
||||||
|
if (typeof value.text === 'string') return value.text;
|
||||||
|
if (value.txt && typeof value.txt === 'object' && 'text' in value.txt) {
|
||||||
|
return String((value.txt as { text?: unknown }).text ?? '');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private mxHost(value: Record<string, unknown>): string {
|
||||||
|
if (typeof value.host === 'string') return value.host;
|
||||||
|
if (value.mx && typeof value.mx === 'object' && value.mx && 'host' in value.mx) {
|
||||||
|
return String((value.mx as { host?: unknown }).host ?? '');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 fqdn(value: string): string {
|
private fqdn(value: string): string {
|
||||||
return value.trim().replace(/\.$/, '').toLowerCase();
|
return value.trim().replace(/\.$/, '').toLowerCase();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import {
|
||||||
|
MAIL_DMARC,
|
||||||
|
MAIL_HOSTNAME,
|
||||||
|
MAIL_IPV4,
|
||||||
|
MAIL_MX_PRIORITY,
|
||||||
|
MAIL_SPF,
|
||||||
|
type MailDkimKey,
|
||||||
|
} from '../mail-dns/mail-dns';
|
||||||
|
|
||||||
const DEFAULT_BASE_URL = 'https://api.cloudflare.com/client/v4';
|
const DEFAULT_BASE_URL = 'https://api.cloudflare.com/client/v4';
|
||||||
const TTL = 120;
|
const TTL = 120;
|
||||||
@@ -12,6 +20,7 @@ type CfRecord = {
|
|||||||
content: string;
|
content: string;
|
||||||
proxied?: boolean;
|
proxied?: boolean;
|
||||||
ttl?: number;
|
ttl?: number;
|
||||||
|
priority?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -52,6 +61,139 @@ export class CloudflareDnsService {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stalwart mail for a Meshkee website. All records DNS-only (not proxied).
|
||||||
|
*/
|
||||||
|
async ensureMailRecords(hostRaw: string, keys: MailDkimKey[]): Promise<{
|
||||||
|
created: string[];
|
||||||
|
updated: string[];
|
||||||
|
skipped: string[];
|
||||||
|
removed: 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 removed: string[] = [];
|
||||||
|
|
||||||
|
await this.deleteRecordsOfTypes(zoneId, token, existing, host, 'mail', ['CNAME'], removed);
|
||||||
|
await this.upsertCf(
|
||||||
|
zoneId,
|
||||||
|
token,
|
||||||
|
existing,
|
||||||
|
host,
|
||||||
|
{ type: 'A', name: 'mail', content: MAIL_IPV4, proxied: false },
|
||||||
|
created,
|
||||||
|
updated,
|
||||||
|
skipped,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.replaceExtraMx(zoneId, token, existing, host, removed);
|
||||||
|
await this.upsertCf(
|
||||||
|
zoneId,
|
||||||
|
token,
|
||||||
|
existing,
|
||||||
|
host,
|
||||||
|
{
|
||||||
|
type: 'MX',
|
||||||
|
name: '@',
|
||||||
|
content: MAIL_HOSTNAME,
|
||||||
|
priority: MAIL_MX_PRIORITY,
|
||||||
|
proxied: false,
|
||||||
|
},
|
||||||
|
created,
|
||||||
|
updated,
|
||||||
|
skipped,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.upsertSpf(zoneId, token, existing, host, created, updated, skipped);
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
await this.upsertCf(
|
||||||
|
zoneId,
|
||||||
|
token,
|
||||||
|
existing,
|
||||||
|
host,
|
||||||
|
{
|
||||||
|
type: 'TXT',
|
||||||
|
name: `${key.selector}._domainkey`,
|
||||||
|
content: key.text,
|
||||||
|
proxied: false,
|
||||||
|
},
|
||||||
|
created,
|
||||||
|
updated,
|
||||||
|
skipped,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.upsertCf(
|
||||||
|
zoneId,
|
||||||
|
token,
|
||||||
|
existing,
|
||||||
|
host,
|
||||||
|
{
|
||||||
|
type: 'CNAME',
|
||||||
|
name: 'autoconfig',
|
||||||
|
content: MAIL_HOSTNAME,
|
||||||
|
proxied: false,
|
||||||
|
},
|
||||||
|
created,
|
||||||
|
updated,
|
||||||
|
skipped,
|
||||||
|
);
|
||||||
|
await this.upsertCf(
|
||||||
|
zoneId,
|
||||||
|
token,
|
||||||
|
existing,
|
||||||
|
host,
|
||||||
|
{
|
||||||
|
type: 'CNAME',
|
||||||
|
name: 'autodiscover',
|
||||||
|
content: MAIL_HOSTNAME,
|
||||||
|
proxied: false,
|
||||||
|
},
|
||||||
|
created,
|
||||||
|
updated,
|
||||||
|
skipped,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dmarc = existing.find(
|
||||||
|
(rec) => rec.type === 'TXT' && this.relativeName(rec.name, host) === '_dmarc',
|
||||||
|
);
|
||||||
|
if (dmarc) {
|
||||||
|
skipped.push('TXT _dmarc');
|
||||||
|
} else {
|
||||||
|
await this.upsertCf(
|
||||||
|
zoneId,
|
||||||
|
token,
|
||||||
|
existing,
|
||||||
|
host,
|
||||||
|
{ type: 'TXT', name: '_dmarc', content: MAIL_DMARC, proxied: false },
|
||||||
|
created,
|
||||||
|
updated,
|
||||||
|
skipped,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Cloudflare mail DNS ${host}: created=${created.join(',') || '-'} updated=${updated.join(',') || '-'} skipped=${skipped.join(',') || '-'} removed=${removed.join(',') || '-'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { created, updated, skipped, removed };
|
||||||
|
}
|
||||||
|
|
||||||
private async ensureCnameRecords(
|
private async ensureCnameRecords(
|
||||||
hostRaw: string,
|
hostRaw: string,
|
||||||
desiredFor: (apex: string) => Array<{ relative: string; content: string }>,
|
desiredFor: (apex: string) => Array<{ relative: string; content: string }>,
|
||||||
@@ -208,6 +350,198 @@ export class CloudflareDnsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async upsertCf(
|
||||||
|
zoneId: string,
|
||||||
|
token: string,
|
||||||
|
existing: CfRecord[],
|
||||||
|
zone: string,
|
||||||
|
desired: {
|
||||||
|
type: 'A' | 'MX' | 'TXT' | 'CNAME';
|
||||||
|
name: string;
|
||||||
|
content: string;
|
||||||
|
priority?: number;
|
||||||
|
proxied: boolean;
|
||||||
|
},
|
||||||
|
created: string[],
|
||||||
|
updated: string[],
|
||||||
|
skipped: string[],
|
||||||
|
) {
|
||||||
|
const label = `${desired.type} ${desired.name}`;
|
||||||
|
const match = existing.find(
|
||||||
|
(rec) => rec.type === desired.type && this.relativeName(rec.name, zone) === desired.name,
|
||||||
|
);
|
||||||
|
const same =
|
||||||
|
match &&
|
||||||
|
this.sameContent(match, desired.content) &&
|
||||||
|
(desired.priority === undefined || this.mxPriority(match) === desired.priority) &&
|
||||||
|
(desired.proxied === false ? match.proxied !== true : true);
|
||||||
|
|
||||||
|
if (match && same && match.proxied === false) {
|
||||||
|
skipped.push(label);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
type: desired.type,
|
||||||
|
name: desired.name,
|
||||||
|
content: desired.content,
|
||||||
|
ttl: TTL,
|
||||||
|
proxied: desired.type === 'A' || desired.type === 'CNAME' ? desired.proxied : undefined,
|
||||||
|
};
|
||||||
|
if (desired.priority !== undefined) body.priority = desired.priority;
|
||||||
|
if (desired.type === 'MX' || desired.type === 'TXT') delete body.proxied;
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
const patch = await this.request(
|
||||||
|
'PATCH',
|
||||||
|
`/zones/${zoneId}/dns_records/${match.id}`,
|
||||||
|
token,
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
if (!patch.ok) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
`Cloudflare failed to update ${label} (${patch.status})${patch.message ? `: ${patch.message}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
match.content = desired.content;
|
||||||
|
match.proxied = desired.proxied;
|
||||||
|
updated.push(label);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = await this.request('POST', `/zones/${zoneId}/dns_records`, token, body);
|
||||||
|
if (!post.ok) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
`Cloudflare failed to create ${label} (${post.status})${post.message ? `: ${post.message}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
existing.push({
|
||||||
|
id: String((post.body.result as { id?: string } | undefined)?.id ?? `new-${label}`),
|
||||||
|
type: desired.type,
|
||||||
|
name: desired.name,
|
||||||
|
content: desired.content,
|
||||||
|
proxied: desired.proxied,
|
||||||
|
});
|
||||||
|
created.push(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async upsertSpf(
|
||||||
|
zoneId: string,
|
||||||
|
token: string,
|
||||||
|
existing: CfRecord[],
|
||||||
|
zone: string,
|
||||||
|
created: string[],
|
||||||
|
updated: string[],
|
||||||
|
skipped: string[],
|
||||||
|
) {
|
||||||
|
const match = existing.find(
|
||||||
|
(rec) =>
|
||||||
|
rec.type === 'TXT' &&
|
||||||
|
this.relativeName(rec.name, zone) === '@' &&
|
||||||
|
rec.content.replace(/^"|"$/g, '').toLowerCase().startsWith('v=spf1'),
|
||||||
|
);
|
||||||
|
if (match) {
|
||||||
|
if (this.sameContent(match, MAIL_SPF)) {
|
||||||
|
skipped.push('TXT @ SPF');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const patch = await this.request(
|
||||||
|
'PATCH',
|
||||||
|
`/zones/${zoneId}/dns_records/${match.id}`,
|
||||||
|
token,
|
||||||
|
{ type: 'TXT', name: '@', content: MAIL_SPF, ttl: TTL },
|
||||||
|
);
|
||||||
|
if (!patch.ok) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
`Cloudflare failed to update TXT @ SPF (${patch.status})${patch.message ? `: ${patch.message}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
match.content = MAIL_SPF;
|
||||||
|
updated.push('TXT @ SPF');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = await this.request('POST', `/zones/${zoneId}/dns_records`, token, {
|
||||||
|
type: 'TXT',
|
||||||
|
name: '@',
|
||||||
|
content: MAIL_SPF,
|
||||||
|
ttl: TTL,
|
||||||
|
});
|
||||||
|
if (!post.ok) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
`Cloudflare failed to create TXT @ SPF (${post.status})${post.message ? `: ${post.message}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
existing.push({
|
||||||
|
id: String((post.body.result as { id?: string } | undefined)?.id ?? 'new-spf'),
|
||||||
|
type: 'TXT',
|
||||||
|
name: '@',
|
||||||
|
content: MAIL_SPF,
|
||||||
|
});
|
||||||
|
created.push('TXT @ SPF');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async replaceExtraMx(
|
||||||
|
zoneId: string,
|
||||||
|
token: string,
|
||||||
|
existing: CfRecord[],
|
||||||
|
zone: string,
|
||||||
|
removed: string[],
|
||||||
|
) {
|
||||||
|
const mx = existing.filter(
|
||||||
|
(rec) => rec.type === 'MX' && this.relativeName(rec.name, zone) === '@',
|
||||||
|
);
|
||||||
|
const keep = mx.find(
|
||||||
|
(rec) => this.sameContent(rec, MAIL_HOSTNAME) && this.mxPriority(rec) === MAIL_MX_PRIORITY,
|
||||||
|
);
|
||||||
|
for (const rec of mx) {
|
||||||
|
if (keep && rec.id === keep.id) continue;
|
||||||
|
const del = await this.request('DELETE', `/zones/${zoneId}/dns_records/${rec.id}`, token);
|
||||||
|
if (!del.ok) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
`Cloudflare failed to remove extra MX (${del.status})${del.message ? `: ${del.message}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
removed.push(`MX ${rec.content}`);
|
||||||
|
const index = existing.indexOf(rec);
|
||||||
|
if (index >= 0) existing.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deleteRecordsOfTypes(
|
||||||
|
zoneId: string,
|
||||||
|
token: string,
|
||||||
|
existing: CfRecord[],
|
||||||
|
zone: string,
|
||||||
|
name: string,
|
||||||
|
types: string[],
|
||||||
|
removed: string[],
|
||||||
|
) {
|
||||||
|
const conflicts = existing.filter(
|
||||||
|
(rec) => types.includes(rec.type) && this.relativeName(rec.name, zone) === name,
|
||||||
|
);
|
||||||
|
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} ${name} (${del.status})${del.message ? `: ${del.message}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
removed.push(`${rec.type} ${name}`);
|
||||||
|
const index = existing.indexOf(rec);
|
||||||
|
if (index >= 0) existing.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sameContent(rec: CfRecord, content: string): boolean {
|
||||||
|
return rec.content.replace(/\.$/, '').replace(/^"|"$/g, '').toLowerCase() ===
|
||||||
|
content.replace(/\.$/, '').replace(/^"|"$/g, '').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private mxPriority(rec: CfRecord): number {
|
||||||
|
return Number(rec.priority ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
private relativeName(name: string, zone: string): string {
|
private relativeName(name: string, zone: string): string {
|
||||||
const n = name.replace(/\.$/, '').toLowerCase();
|
const n = name.replace(/\.$/, '').toLowerCase();
|
||||||
const z = zone.replace(/\.$/, '').toLowerCase();
|
const z = zone.replace(/\.$/, '').toLowerCase();
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { ListDomainsDto } from './dto/list-domains.dto';
|
|||||||
import { ToggleSslDto } from './dto/toggle-ssl.dto';
|
import { ToggleSslDto } from './dto/toggle-ssl.dto';
|
||||||
import { UpdateDomainAdminDto } from './dto/update-domain-admin.dto';
|
import { UpdateDomainAdminDto } from './dto/update-domain-admin.dto';
|
||||||
import { UpdateParkedHostsDto } from './dto/update-parked-hosts.dto';
|
import { UpdateParkedHostsDto } from './dto/update-parked-hosts.dto';
|
||||||
|
import { ApplyMailDnsDto } from './dto/apply-mail-dns.dto';
|
||||||
import { DomainAdminService } from './domain-admin.service';
|
import { DomainAdminService } from './domain-admin.service';
|
||||||
|
|
||||||
@Controller('domains')
|
@Controller('domains')
|
||||||
@@ -71,6 +72,16 @@ export class DomainAdminController {
|
|||||||
return this.service.setParkedHosts(domainId, dto, user);
|
return this.service.setParkedHosts(domainId, dto, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':domainId/mail-dns')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
applyMailDns(
|
||||||
|
@Param('domainId') domainId: string,
|
||||||
|
@Body() dto: ApplyMailDnsDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.applyMailDns(domainId, dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch(':domainId')
|
@Patch(':domainId')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
update(
|
update(
|
||||||
|
|||||||
@@ -30,7 +30,14 @@ import { ListDomainsDto } from './dto/list-domains.dto';
|
|||||||
import { ToggleSslDto } from './dto/toggle-ssl.dto';
|
import { ToggleSslDto } from './dto/toggle-ssl.dto';
|
||||||
import { UpdateDomainAdminDto } from './dto/update-domain-admin.dto';
|
import { UpdateDomainAdminDto } from './dto/update-domain-admin.dto';
|
||||||
import { UpdateParkedHostsDto } from './dto/update-parked-hosts.dto';
|
import { UpdateParkedHostsDto } from './dto/update-parked-hosts.dto';
|
||||||
import { isValidParkedHost, parseParkedDnsMap, uniqueParkedAliases, uniqueParkedHosts } from './parked-host.util';
|
import { ApplyMailDnsDto } from './dto/apply-mail-dns.dto';
|
||||||
|
import { parseDkimPaste } from '../mail-dns/mail-dns';
|
||||||
|
import {
|
||||||
|
isValidParkedHost,
|
||||||
|
parseParkedDnsMap,
|
||||||
|
uniqueParkedAliases,
|
||||||
|
uniqueParkedHosts,
|
||||||
|
} from './parked-host.util';
|
||||||
import type { ParkedDnsProvider } from './parked-host.util';
|
import type { ParkedDnsProvider } from './parked-host.util';
|
||||||
|
|
||||||
type DomainRow = {
|
type DomainRow = {
|
||||||
@@ -919,6 +926,65 @@ export class DomainAdminService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async applyMailDns(domainIdRaw: string, dto: ApplyMailDnsDto, actor: AuthUser) {
|
||||||
|
await this.assertSuperAdmin(actor);
|
||||||
|
|
||||||
|
const domainId = BigInt(domainIdRaw);
|
||||||
|
const domain = await this.prisma.domain.findUnique({
|
||||||
|
where: { id: domainId },
|
||||||
|
select: { id: true, host: true },
|
||||||
|
});
|
||||||
|
if (!domain) throw new NotFoundException('Website not found');
|
||||||
|
|
||||||
|
let ed25519;
|
||||||
|
let rsa;
|
||||||
|
try {
|
||||||
|
ed25519 = parseDkimPaste(dto.ed25519Key, 'ed25519');
|
||||||
|
rsa = parseDkimPaste(dto.rsaKey, 'rsa');
|
||||||
|
} catch (err) {
|
||||||
|
throw new BadRequestException(err instanceof Error ? err.message : 'Invalid DKIM key');
|
||||||
|
}
|
||||||
|
if (ed25519.selector === rsa.selector) {
|
||||||
|
throw new BadRequestException('Ed25519 and RSA DKIM selectors must be different.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = dto.dnsProvider === 'cloudflare' ? 'cloudflare' : 'arvan';
|
||||||
|
const host = domain.host.trim().toLowerCase();
|
||||||
|
try {
|
||||||
|
const result =
|
||||||
|
provider === 'cloudflare'
|
||||||
|
? await this.cloudflareDns.ensureMailRecords(host, [ed25519, rsa])
|
||||||
|
: await this.arvanDns.ensureMailRecords(host, [ed25519, rsa]);
|
||||||
|
|
||||||
|
const parts = [
|
||||||
|
result.created.length ? `created ${result.created.join(', ')}` : '',
|
||||||
|
result.updated.length ? `updated ${result.updated.join(', ')}` : '',
|
||||||
|
result.removed.length ? `removed ${result.removed.join(', ')}` : '',
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return {
|
||||||
|
host,
|
||||||
|
dnsProvider: provider,
|
||||||
|
created: result.created,
|
||||||
|
updated: result.updated,
|
||||||
|
skipped: result.skipped,
|
||||||
|
removed: result.removed,
|
||||||
|
message: parts.length
|
||||||
|
? `Mail DNS updated on ${provider === 'cloudflare' ? 'Cloudflare' : 'Arvan'} (${parts.join('; ')}).`
|
||||||
|
: `Mail DNS already up to date on ${provider === 'cloudflare' ? 'Cloudflare' : 'Arvan'}.`,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
this.httpErrorMessage(
|
||||||
|
err,
|
||||||
|
provider === 'cloudflare'
|
||||||
|
? 'Cloudflare mail DNS update failed'
|
||||||
|
: 'Arvan mail DNS update failed',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async applyParkedDns(
|
private async applyParkedDns(
|
||||||
host: string,
|
host: string,
|
||||||
provider: ParkedDnsProvider,
|
provider: ParkedDnsProvider,
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { IsIn, IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
import { PARKED_DNS_PROVIDERS } from '../parked-host.util';
|
||||||
|
|
||||||
|
export class ApplyMailDnsDto {
|
||||||
|
@IsIn(PARKED_DNS_PROVIDERS)
|
||||||
|
dnsProvider!: (typeof PARKED_DNS_PROVIDERS)[number];
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(20)
|
||||||
|
@MaxLength(8000)
|
||||||
|
ed25519Key!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(20)
|
||||||
|
@MaxLength(8000)
|
||||||
|
rsaKey!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
export const MAIL_IPV4 = '185.214.101.41';
|
||||||
|
export const MAIL_HOSTNAME = 'mail.meshkee.com';
|
||||||
|
export const MAIL_MX_PRIORITY = 10;
|
||||||
|
export const MAIL_SPF = `v=spf1 ip4:${MAIL_IPV4} -all`;
|
||||||
|
export const MAIL_DMARC = 'v=DMARC1; p=none';
|
||||||
|
|
||||||
|
export type MailDkimKey = {
|
||||||
|
selector: string;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseDkimPaste(
|
||||||
|
raw: string,
|
||||||
|
expectedKind: 'ed25519' | 'rsa',
|
||||||
|
): MailDkimKey {
|
||||||
|
const input = raw.trim();
|
||||||
|
if (!input) {
|
||||||
|
throw new Error(`Paste the ${expectedKind} DKIM line from Stalwart.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const quoted = [...input.matchAll(/"([^"]*)"/g)].map((match) => match[1]).join('');
|
||||||
|
const compact = input.replace(/[()\n\r\t]+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
const blob = (quoted || compact).replace(/"\s+"/g, '');
|
||||||
|
|
||||||
|
const txtMatch = blob.match(/v=DKIM1[\s\S]*/i);
|
||||||
|
let text = (txtMatch ? txtMatch[0] : blob).trim();
|
||||||
|
text = text.replace(/^"+|"+$/g, '').replace(/\s+/g, ' ').replace(/[.\s]+$/g, '').trim();
|
||||||
|
if (!/^v=DKIM1/i.test(text)) {
|
||||||
|
throw new Error(
|
||||||
|
`Could not find a v=DKIM1 value in the ${expectedKind} field. Paste the Stalwart zone line.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const kind = /k=ed25519/i.test(text)
|
||||||
|
? 'ed25519'
|
||||||
|
: /k=rsa/i.test(text)
|
||||||
|
? 'rsa'
|
||||||
|
: null;
|
||||||
|
if (kind && kind !== expectedKind) {
|
||||||
|
throw new Error(
|
||||||
|
`That paste is ${kind} DKIM. Put it in the ${kind === 'ed25519' ? 'Ed25519' : 'RSA'} field.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameMatch = compact.match(/([a-z0-9][a-z0-9-]*)\._domainkey/i);
|
||||||
|
if (!nameMatch) {
|
||||||
|
throw new Error(
|
||||||
|
'Include the selector name (for example v1-ed25519-20260831._domainkey) from the Stalwart zone file.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
selector: nameMatch[1].toLowerCase(),
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user