Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19c0ea25e1 | ||
|
|
95d851f9dd |
@@ -18,7 +18,8 @@ type ArvanRecordType = 'aname' | 'cname' | 'a' | 'mx' | 'txt';
|
|||||||
type DesiredRecord = {
|
type DesiredRecord = {
|
||||||
type: ArvanRecordType;
|
type: ArvanRecordType;
|
||||||
name: string;
|
name: string;
|
||||||
value: Record<string, unknown>;
|
/** A records use an array of `{ ip }`; other types use a single object. */
|
||||||
|
value: Record<string, unknown> | Array<Record<string, unknown>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DnsMutationResult = {
|
type DnsMutationResult = {
|
||||||
@@ -34,7 +35,7 @@ type ArvanDnsRecord = {
|
|||||||
name: string;
|
name: string;
|
||||||
ttl?: number;
|
ttl?: number;
|
||||||
cloud?: boolean;
|
cloud?: boolean;
|
||||||
value?: Record<string, unknown> | null;
|
value?: Record<string, unknown> | Array<Record<string, unknown>> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -110,7 +111,8 @@ export class ArvanDnsService {
|
|||||||
await this.upsertTyped(host, auth, existing, {
|
await this.upsertTyped(host, auth, existing, {
|
||||||
type: 'a',
|
type: 'a',
|
||||||
name: 'mail',
|
name: 'mail',
|
||||||
value: { ip: MAIL_IPV4 },
|
// Arvan CDN 4.0 requires A `value` as an array of IP objects.
|
||||||
|
value: [{ ip: MAIL_IPV4 }],
|
||||||
}, created, updated, skipped);
|
}, created, updated, skipped);
|
||||||
|
|
||||||
await this.replaceExtraMx(host, auth, existing, removed);
|
await this.replaceExtraMx(host, auth, existing, removed);
|
||||||
@@ -447,7 +449,7 @@ export class ArvanDnsService {
|
|||||||
const mx = existing.filter((item) => item.type === 'mx');
|
const mx = existing.filter((item) => item.type === 'mx');
|
||||||
const keep = mx.find((item) => {
|
const keep = mx.find((item) => {
|
||||||
const name = this.relativeName(item.name, host);
|
const name = this.relativeName(item.name, host);
|
||||||
return name === '@' && this.fqdn(this.mxHost(item.value ?? {})) === MAIL_HOSTNAME;
|
return name === '@' && this.fqdn(this.mxHost(this.recordValueObject(item.value))) === MAIL_HOSTNAME;
|
||||||
});
|
});
|
||||||
for (const item of mx) {
|
for (const item of mx) {
|
||||||
if (keep && item.id === keep.id) continue;
|
if (keep && item.id === keep.id) continue;
|
||||||
@@ -462,7 +464,7 @@ export class ArvanDnsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const name = this.relativeName(item.name, host);
|
const name = this.relativeName(item.name, host);
|
||||||
removed.push(`MX ${name} ${this.mxHost(item.value ?? {}) || item.id}`);
|
removed.push(`MX ${name} ${this.mxHost(this.recordValueObject(item.value)) || item.id}`);
|
||||||
const index = existing.indexOf(item);
|
const index = existing.indexOf(item);
|
||||||
if (index >= 0) existing.splice(index, 1);
|
if (index >= 0) existing.splice(index, 1);
|
||||||
}
|
}
|
||||||
@@ -498,27 +500,51 @@ export class ArvanDnsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private sameTarget(desired: DesiredRecord, existing: ArvanDnsRecord): boolean {
|
private sameTarget(desired: DesiredRecord, existing: ArvanDnsRecord): boolean {
|
||||||
const current = existing.value ?? {};
|
const current = this.recordValueObject(existing.value);
|
||||||
|
const desiredObj = this.recordValueObject(desired.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(desiredObj.location ?? ''));
|
||||||
}
|
}
|
||||||
if (desired.type === 'a') {
|
if (desired.type === 'a') {
|
||||||
return String(current.ip ?? '') === String(desired.value.ip ?? '');
|
return this.aIps(existing.value).join(',') === this.aIps(desired.value).join(',');
|
||||||
}
|
}
|
||||||
if (desired.type === 'mx') {
|
if (desired.type === 'mx') {
|
||||||
return (
|
return (
|
||||||
this.fqdn(this.mxHost(current)) === this.fqdn(String(desired.value.host ?? '')) &&
|
this.fqdn(this.mxHost(current)) === this.fqdn(String(desiredObj.host ?? '')) &&
|
||||||
Number(current.priority ?? 0) === Number(desired.value.priority ?? 0)
|
Number(current.priority ?? 0) === Number(desiredObj.priority ?? 0)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (desired.type === 'txt') {
|
if (desired.type === 'txt') {
|
||||||
return this.txtValue(existing).replace(/\s+/g, '') === String(desired.value.text ?? '').replace(/\s+/g, '');
|
return this.txtValue(existing).replace(/\s+/g, '') === String(desiredObj.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(desiredObj.host ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Arvan A records return `value` as `[{ ip }]`; normalize to IP list for compare/send. */
|
||||||
|
private aIps(value: DesiredRecord['value'] | ArvanDnsRecord['value']): string[] {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.map((item) => String(item?.ip ?? '').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object' && 'ip' in value) {
|
||||||
|
const ip = String(value.ip ?? '').trim();
|
||||||
|
return ip ? [ip] : [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordValueObject(
|
||||||
|
value: DesiredRecord['value'] | ArvanDnsRecord['value'],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return (value[0] as Record<string, unknown> | undefined) ?? {};
|
||||||
|
}
|
||||||
|
return value ?? {};
|
||||||
}
|
}
|
||||||
|
|
||||||
private txtValue(record: ArvanDnsRecord): string {
|
private txtValue(record: ArvanDnsRecord): string {
|
||||||
const value = record.value ?? {};
|
const value = this.recordValueObject(record.value);
|
||||||
if (typeof value.text === 'string') return value.text;
|
if (typeof value.text === 'string') return value.text;
|
||||||
if (value.txt && typeof value.txt === 'object' && 'text' in value.txt) {
|
if (value.txt && typeof value.txt === 'object' && 'text' in value.txt) {
|
||||||
return String((value.txt as { text?: unknown }).text ?? '');
|
return String((value.txt as { text?: unknown }).text ?? '');
|
||||||
|
|||||||
@@ -939,8 +939,8 @@ export class DomainAdminService {
|
|||||||
let ed25519;
|
let ed25519;
|
||||||
let rsa;
|
let rsa;
|
||||||
try {
|
try {
|
||||||
ed25519 = parseDkimPaste(dto.ed25519Key, 'ed25519');
|
ed25519 = parseDkimPaste(dto.ed25519Key, 'ed25519', dto.ed25519Selector);
|
||||||
rsa = parseDkimPaste(dto.rsaKey, 'rsa');
|
rsa = parseDkimPaste(dto.rsaKey, 'rsa', dto.rsaSelector);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new BadRequestException(err instanceof Error ? err.message : 'Invalid DKIM key');
|
throw new BadRequestException(err instanceof Error ? err.message : 'Invalid DKIM key');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsIn, IsString, MaxLength, MinLength } from 'class-validator';
|
import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
import { PARKED_DNS_PROVIDERS } from '../parked-host.util';
|
import { PARKED_DNS_PROVIDERS } from '../parked-host.util';
|
||||||
|
|
||||||
export class ApplyMailDnsDto {
|
export class ApplyMailDnsDto {
|
||||||
@@ -14,4 +14,16 @@ export class ApplyMailDnsDto {
|
|||||||
@MinLength(20)
|
@MinLength(20)
|
||||||
@MaxLength(8000)
|
@MaxLength(8000)
|
||||||
rsaKey!: string;
|
rsaKey!: string;
|
||||||
|
|
||||||
|
/** Required when ed25519Key is a value-only `v=DKIM1; …` paste (no `…._domainkey`). */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(253)
|
||||||
|
ed25519Selector?: string;
|
||||||
|
|
||||||
|
/** Required when rsaKey is a value-only `v=DKIM1; …` paste (no `…._domainkey`). */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(253)
|
||||||
|
rsaSelector?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,29 @@ export type MailDkimKey = {
|
|||||||
text: string;
|
text: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Bare selector, or `name._domainkey` / `name._domainkey.example.com.` */
|
||||||
|
export function normalizeDkimSelector(raw: string | undefined | null): string | null {
|
||||||
|
if (raw == null) return null;
|
||||||
|
let input = raw.trim().replace(/^"+|"+$/g, '').trim();
|
||||||
|
if (!input) return null;
|
||||||
|
|
||||||
|
const hostMatch = input.match(/^([a-z0-9][a-z0-9-]*)\._domainkey(?:\.|$)/i);
|
||||||
|
if (hostMatch) return hostMatch[1].toLowerCase();
|
||||||
|
|
||||||
|
if (/^[a-z0-9][a-z0-9-]*$/i.test(input)) return input.toLowerCase();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseDkimPaste(
|
export function parseDkimPaste(
|
||||||
raw: string,
|
raw: string,
|
||||||
expectedKind: 'ed25519' | 'rsa',
|
expectedKind: 'ed25519' | 'rsa',
|
||||||
|
selectorHint?: string,
|
||||||
): MailDkimKey {
|
): MailDkimKey {
|
||||||
const input = raw.trim();
|
const input = raw.trim();
|
||||||
if (!input) {
|
if (!input) {
|
||||||
throw new Error(`Paste the ${expectedKind} DKIM line from Stalwart.`);
|
throw new Error(
|
||||||
|
`Paste the ${expectedKind} DKIM TXT value (v=DKIM1; …) or the full Stalwart zone line.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const quoted = [...input.matchAll(/"([^"]*)"/g)].map((match) => match[1]).join('');
|
const quoted = [...input.matchAll(/"([^"]*)"/g)].map((match) => match[1]).join('');
|
||||||
@@ -29,7 +45,7 @@ export function parseDkimPaste(
|
|||||||
text = text.replace(/^"+|"+$/g, '').replace(/\s+/g, ' ').replace(/[.\s]+$/g, '').trim();
|
text = text.replace(/^"+|"+$/g, '').replace(/\s+/g, ' ').replace(/[.\s]+$/g, '').trim();
|
||||||
if (!/^v=DKIM1/i.test(text)) {
|
if (!/^v=DKIM1/i.test(text)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Could not find a v=DKIM1 value in the ${expectedKind} field. Paste the Stalwart zone line.`,
|
`Could not find a v=DKIM1 value in the ${expectedKind} field. Paste the TXT value or Stalwart zone line.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,14 +61,16 @@ export function parseDkimPaste(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const nameMatch = compact.match(/([a-z0-9][a-z0-9-]*)\._domainkey/i);
|
const nameMatch = compact.match(/([a-z0-9][a-z0-9-]*)\._domainkey/i);
|
||||||
if (!nameMatch) {
|
const selector =
|
||||||
|
(nameMatch ? nameMatch[1].toLowerCase() : null) || normalizeDkimSelector(selectorHint);
|
||||||
|
if (!selector) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Include the selector name (for example v1-ed25519-20260831._domainkey) from the Stalwart zone file.',
|
'Could not find a DKIM selector. Enter the selector (for example v1-ed25519-20260901), or paste a zone line that includes …._domainkey.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
selector: nameMatch[1].toLowerCase(),
|
selector,
|
||||||
text,
|
text,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user