feat(domains): surface autodiscovery SRV records (RFC 6186)
ci / changes (push) Successful in 4s
ci / tc_booking (push) Has been skipped
ci / tc_operator (push) Has been skipped
ci / tc_website (push) Has been skipped
ci / tc_platform_api (push) Successful in 20s
ci / tc_portal (push) Failing after 27s
ci / build_booking (push) Has been skipped
ci / build_operator (push) Has been skipped
ci / build_portal (push) Has been skipped
ci / test_platform_api (push) Successful in 33s
ci / build_platform_api (push) Successful in 15s
ci / deploy (push) Failing after 3m5s
ci / changes (push) Successful in 4s
ci / tc_booking (push) Has been skipped
ci / tc_operator (push) Has been skipped
ci / tc_website (push) Has been skipped
ci / tc_platform_api (push) Successful in 20s
ci / tc_portal (push) Failing after 27s
ci / build_booking (push) Has been skipped
ci / build_operator (push) Has been skipped
ci / build_portal (push) Has been skipped
ci / test_platform_api (push) Successful in 33s
ci / build_platform_api (push) Successful in 15s
ci / deploy (push) Failing after 3m5s
Mail clients could never autoconfigure: Stalwart's zone file contains the _imaps/_submissions/_pop3s SRV records but classify() dropped everything except mx/spf/dkim/dmarc, so customers never saw them and every client needed manual server entry. New 'autodiscovery' record kind: classified from the zone (only the services actually reachable in prod — the _jmap/_caldavs SRVs target :443 which Traefik owns, deferred to the webmail story), verified via resolveSrv (missing=bad, wrong target=warn), shown as an OPTIONAL slot on the portal Domains page that never gates the domain status or the records-to-fix nag. Also fixed on the live server via management JMAP (x:SystemSettings): hostname was the machine name node1.dezky.eu from the v0.16 auto-bootstrap — MX/SRV targets and the SMTP banner now say mail.dezky.eu, and the LE x:Certificate is set as defaultCertificateId.
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
export type RecordStatus = 'ok' | 'warn' | 'bad' | 'pending'
|
export type RecordStatus = 'ok' | 'warn' | 'bad' | 'pending'
|
||||||
export type DomainStatus = 'pending' | 'verifying' | 'active' | 'error'
|
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 type DmarcPolicy = 'none' | 'quarantine' | 'reject'
|
||||||
|
|
||||||
export interface DomainRecordView {
|
export interface DomainRecordView {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const toast = useToast()
|
|||||||
const { domains, refresh, recheck, remove } = useDomains()
|
const { domains, refresh, recheck, remove } = useDomains()
|
||||||
|
|
||||||
type Tone = 'ok' | 'warn' | 'bad'
|
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
|
// 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
|
// — those come from the server (the real MX host, DKIM public key, etc.). We
|
||||||
@@ -64,9 +64,23 @@ const DNS_FIX: Record<RecordKey, {
|
|||||||
pending: { headline: 'Not checked yet', body: 'Add the record below, then re-check.' },
|
pending: { headline: 'Not checked yet', body: 'Add the record below, then re-check.' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
autodiscovery: {
|
||||||
|
label: 'SRV · autodiscovery',
|
||||||
|
purpose: 'Lets mail apps set themselves up from just the email address — no manual server fields.',
|
||||||
|
states: {
|
||||||
|
ok: { headline: 'Autodiscovery active', body: 'Mail clients can configure IMAP/SMTP automatically.' },
|
||||||
|
warn: { headline: 'SRV points elsewhere', body: 'An SRV record exists but targets the wrong host or port — clients may try a dead endpoint. Update it to the values below.' },
|
||||||
|
bad: { headline: 'No SRV records', body: 'Optional but recommended: without these, users type the mail server manually. Add the records below.' },
|
||||||
|
pending: { headline: 'Not checked yet', body: 'Add the records below, then re-check.' },
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const RECORD_KEYS: RecordKey[] = ['mx', 'spf', 'dkim', 'dmarc']
|
const RECORD_KEYS: RecordKey[] = ['mx', 'spf', 'dkim', 'dmarc', 'autodiscovery']
|
||||||
|
|
||||||
|
// Required kinds drive the "X records to fix" nag; autodiscovery is optional
|
||||||
|
// (clients fall back to manual server entry) so it never counts as an issue.
|
||||||
|
const REQUIRED_KEYS: RecordKey[] = ['mx', 'spf', 'dkim', 'dmarc']
|
||||||
|
|
||||||
const expanded = reactive<Record<string, RecordKey | null>>({})
|
const expanded = reactive<Record<string, RecordKey | null>>({})
|
||||||
const copied = ref<string | null>(null)
|
const copied = ref<string | null>(null)
|
||||||
@@ -89,7 +103,7 @@ function copyValue(text: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function issuesFor(d: DomainView): RecordKey[] {
|
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[] {
|
function recordsOfKind(d: DomainView, k: RecordKey): DomainRecordView[] {
|
||||||
return d.records.filter((r) => r.kind === k)
|
return d.records.filter((r) => r.kind === k)
|
||||||
|
|||||||
@@ -42,11 +42,36 @@ export class DnsVerifierService {
|
|||||||
return this.checkDkim(record.fqdn, record.expected)
|
return this.checkDkim(record.fqdn, record.expected)
|
||||||
case 'dmarc':
|
case 'dmarc':
|
||||||
return this.checkDmarc(domain)
|
return this.checkDmarc(domain)
|
||||||
|
case 'autodiscovery':
|
||||||
|
return this.checkSrv(record.fqdn, record.expected)
|
||||||
default:
|
default:
|
||||||
return { status: 'pending' }
|
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<CheckResult> {
|
||||||
|
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.
|
// Resolve TXT, joining each record's character-strings into one value.
|
||||||
private async txt(fqdn: string): Promise<string[]> {
|
private async txt(fqdn: string): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
|
|||||||
import { User, UserDocument } from '../schemas/user.schema.js'
|
import { User, UserDocument } from '../schemas/user.schema.js'
|
||||||
import { DnsVerifierService } from './dns-verifier.service.js'
|
import { DnsVerifierService } from './dns-verifier.service.js'
|
||||||
|
|
||||||
// The four status slots the customer-admin Domains page renders, plus ownership.
|
// The status slots the customer-admin Domains page renders: ownership + the
|
||||||
const CHECK_KINDS: RecordKind[] = ['ownership', 'mx', 'spf', 'dkim', 'dmarc']
|
// 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
|
// Minimal tenant identity the service needs — the controller resolves the full
|
||||||
// doc for its membership gate and hands us this.
|
// 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 === 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.endsWith(`._domainkey.${domain}`)) return 'dkim'
|
||||||
if (z.type === 'TXT' && z.fqdn === `_dmarc.${domain}` && /^v=DMARC1\b/i.test(z.value)) return 'dmarc'
|
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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,11 @@ export type DomainStatus = 'pending' | 'verifying' | 'active' | 'error'
|
|||||||
export type RecordStatus = 'ok' | 'warn' | 'bad' | 'pending'
|
export type RecordStatus = 'ok' | 'warn' | 'bad' | 'pending'
|
||||||
|
|
||||||
// Which DNS concern a record belongs to. `ownership` is the one-time TXT proving
|
// 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.
|
// the customer controls the domain; mx/spf/dkim/dmarc map to the UI's required
|
||||||
export type RecordKind = 'ownership' | 'mx' | 'spf' | 'dkim' | 'dmarc'
|
// 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'
|
export type DmarcPolicy = 'none' | 'quarantine' | 'reject'
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user