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
@@ -1,10 +1,18 @@
|
||||
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
||||
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 TTL = 120;
|
||||
|
||||
type ArvanRecordType = 'aname' | 'cname';
|
||||
type ArvanRecordType = 'aname' | 'cname' | 'a' | 'mx' | 'txt';
|
||||
|
||||
type DesiredRecord = {
|
||||
type: ArvanRecordType;
|
||||
@@ -12,6 +20,13 @@ type DesiredRecord = {
|
||||
value: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type DnsMutationResult = {
|
||||
created: string[];
|
||||
updated: string[];
|
||||
skipped: string[];
|
||||
removed: string[];
|
||||
};
|
||||
|
||||
type ArvanDnsRecord = {
|
||||
id: string;
|
||||
type: string;
|
||||
@@ -51,6 +66,89 @@ export class ArvanDnsService {
|
||||
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(
|
||||
hostRaw: string,
|
||||
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 {
|
||||
const current = existing.value ?? {};
|
||||
if (desired.type === 'aname') {
|
||||
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 ?? ''));
|
||||
}
|
||||
|
||||
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 {
|
||||
return value.trim().replace(/\.$/, '').toLowerCase();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
||||
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 TTL = 120;
|
||||
@@ -12,6 +20,7 @@ type CfRecord = {
|
||||
content: string;
|
||||
proxied?: boolean;
|
||||
ttl?: number;
|
||||
priority?: number;
|
||||
};
|
||||
|
||||
@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(
|
||||
hostRaw: 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 {
|
||||
const n = name.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 { UpdateDomainAdminDto } from './dto/update-domain-admin.dto';
|
||||
import { UpdateParkedHostsDto } from './dto/update-parked-hosts.dto';
|
||||
import { ApplyMailDnsDto } from './dto/apply-mail-dns.dto';
|
||||
import { DomainAdminService } from './domain-admin.service';
|
||||
|
||||
@Controller('domains')
|
||||
@@ -71,6 +72,16 @@ export class DomainAdminController {
|
||||
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')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
update(
|
||||
|
||||
@@ -30,7 +30,14 @@ 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 { 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';
|
||||
|
||||
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(
|
||||
host: string,
|
||||
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