diff --git a/apps/portal/composables/useDomains.ts b/apps/portal/composables/useDomains.ts index afa989f..8505a91 100644 --- a/apps/portal/composables/useDomains.ts +++ b/apps/portal/composables/useDomains.ts @@ -6,7 +6,7 @@ export type RecordStatus = 'ok' | 'warn' | 'bad' | 'pending' export type DomainStatus = 'pending' | 'verifying' | 'active' | 'error' -export type RecordKind = 'ownership' | 'mx' | 'spf' | 'dkim' | 'dmarc' +export type RecordKind = 'ownership' | 'mx' | 'spf' | 'dkim' | 'dmarc' | 'autodiscovery' export type DmarcPolicy = 'none' | 'quarantine' | 'reject' export interface DomainRecordView { diff --git a/apps/portal/pages/admin/domains/index.vue b/apps/portal/pages/admin/domains/index.vue index c4b471c..973ec7e 100644 --- a/apps/portal/pages/admin/domains/index.vue +++ b/apps/portal/pages/admin/domains/index.vue @@ -14,7 +14,7 @@ const toast = useToast() const { domains, refresh, recheck, remove } = useDomains() type Tone = 'ok' | 'warn' | 'bad' -type RecordKey = 'mx' | 'spf' | 'dkim' | 'dmarc' +type RecordKey = 'mx' | 'spf' | 'dkim' | 'dmarc' | 'autodiscovery' // Static explanatory copy per record + status. Record VALUES are no longer here // — those come from the server (the real MX host, DKIM public key, etc.). We @@ -64,9 +64,23 @@ const DNS_FIX: Record>({}) const copied = ref(null) @@ -89,7 +103,7 @@ function copyValue(text: string) { } function issuesFor(d: DomainView): RecordKey[] { - return RECORD_KEYS.filter((k) => d.checks[k] !== 'ok') + return REQUIRED_KEYS.filter((k) => d.checks[k] !== 'ok') } function recordsOfKind(d: DomainView, k: RecordKey): DomainRecordView[] { return d.records.filter((r) => r.kind === k) diff --git a/services/platform-api/src/domains/dns-verifier.service.ts b/services/platform-api/src/domains/dns-verifier.service.ts index 274f850..0507936 100644 --- a/services/platform-api/src/domains/dns-verifier.service.ts +++ b/services/platform-api/src/domains/dns-verifier.service.ts @@ -42,11 +42,36 @@ export class DnsVerifierService { return this.checkDkim(record.fqdn, record.expected) case 'dmarc': return this.checkDmarc(domain) + case 'autodiscovery': + return this.checkSrv(record.fqdn, record.expected) default: return { status: 'pending' } } } + // Autodiscovery SRV (RFC 6186): the record must exist and point at our mail + // host on the expected port. expected is the zone's RDATA, e.g. + // "0 1 993 mail.dezky.eu." — we compare target + port and ignore + // priority/weight (any values route fine for a single host). + private async checkSrv(fqdn: string, expected: string): Promise { + const parts = expected.trim().split(/\s+/) + const expPort = Number(parts[2]) + const expTarget = (parts[3] ?? '').replace(/\.$/, '').toLowerCase() + let srvs: { name: string; port: number }[] + try { + srvs = await this.resolver.resolveSrv(fqdn) + } catch { + return { status: 'bad' } + } + if (!srvs.length) return { status: 'bad' } + const hit = srvs.find( + (r) => r.port === expPort && r.name.replace(/\.$/, '').toLowerCase() === expTarget, + ) + const observed = srvs.map((r) => `${r.port} ${r.name}`).join(', ') + // Present-but-wrong gets warn (client will try a bad endpoint), missing is bad. + return hit ? { observed, status: 'ok' } : { observed, status: 'warn' } + } + // Resolve TXT, joining each record's character-strings into one value. private async txt(fqdn: string): Promise { try { diff --git a/services/platform-api/src/domains/domains.service.ts b/services/platform-api/src/domains/domains.service.ts index a94b094..9ae35ed 100644 --- a/services/platform-api/src/domains/domains.service.ts +++ b/services/platform-api/src/domains/domains.service.ts @@ -28,8 +28,9 @@ import { Tenant, TenantDocument } from '../schemas/tenant.schema.js' import { User, UserDocument } from '../schemas/user.schema.js' import { DnsVerifierService } from './dns-verifier.service.js' -// The four status slots the customer-admin Domains page renders, plus ownership. -const CHECK_KINDS: RecordKind[] = ['ownership', 'mx', 'spf', 'dkim', 'dmarc'] +// The status slots the customer-admin Domains page renders: ownership + the +// four required mail kinds + the optional autodiscovery SRVs. +const CHECK_KINDS: RecordKind[] = ['ownership', 'mx', 'spf', 'dkim', 'dmarc', 'autodiscovery'] // Minimal tenant identity the service needs — the controller resolves the full // doc for its membership gate and hands us this. @@ -432,6 +433,12 @@ function classify(z: StalwartZoneRecord, domain: string): RecordKind | null { if (z.type === 'TXT' && z.fqdn === domain && /^v=spf1\b/i.test(z.value)) return 'spf' if (z.type === 'TXT' && z.fqdn.endsWith(`._domainkey.${domain}`)) return 'dkim' if (z.type === 'TXT' && z.fqdn === `_dmarc.${domain}` && /^v=DMARC1\b/i.test(z.value)) return 'dmarc' + // RFC 6186 client autodiscovery. Only the services that are actually + // reachable in production: IMAPS 993, SMTP submission 465, POP3S 995. + // The zone also offers _jmap/_caldavs/_carddavs SRVs targeting :443 — + // that port belongs to Traefik on the node, not Stalwart, so publishing + // them would advertise endpoints that 404. Revisit with the webmail story. + if (z.type === 'SRV' && /^_(imaps|submissions|pop3s)\._tcp\./.test(z.fqdn)) return 'autodiscovery' return null } diff --git a/services/platform-api/src/schemas/domain.schema.ts b/services/platform-api/src/schemas/domain.schema.ts index f8c1f68..0feffe2 100644 --- a/services/platform-api/src/schemas/domain.schema.ts +++ b/services/platform-api/src/schemas/domain.schema.ts @@ -15,8 +15,11 @@ export type DomainStatus = 'pending' | 'verifying' | 'active' | 'error' export type RecordStatus = 'ok' | 'warn' | 'bad' | 'pending' // Which DNS concern a record belongs to. `ownership` is the one-time TXT proving -// the customer controls the domain; the other four map to the UI's status slots. -export type RecordKind = 'ownership' | 'mx' | 'spf' | 'dkim' | 'dmarc' +// the customer controls the domain; mx/spf/dkim/dmarc map to the UI's required +// status slots. `autodiscovery` carries the optional SRV records (RFC 6186) +// that let mail clients configure themselves from just an email address — it +// never gates the domain's overall status. +export type RecordKind = 'ownership' | 'mx' | 'spf' | 'dkim' | 'dmarc' | 'autodiscovery' export type DmarcPolicy = 'none' | 'quarantine' | 'reject'