diff --git a/src/features/certificates/CertificateSetupPage.tsx b/src/features/certificates/CertificateSetupPage.tsx index 719b306..c7d86d3 100644 --- a/src/features/certificates/CertificateSetupPage.tsx +++ b/src/features/certificates/CertificateSetupPage.tsx @@ -41,6 +41,8 @@ interface DomainRow { name: string; automatic: boolean; providerId?: string; + /** The domain's own certificate hostnames, kept as they are when it's switched. */ + hostnames: string[]; dnsAutomatic: boolean; zone: ReturnType; } @@ -93,6 +95,7 @@ async function load(): Promise { name: String(d.name), automatic: cm['@type'] === 'Automatic', providerId: cm.acmeProviderId as string | undefined, + hostnames: cm['@type'] === 'Automatic' ? keys(cm.subjectAlternativeNames) : [], dnsAutomatic: dm['@type'] === 'Automatic', zone: parseZone(d.dnsZoneFile as string | undefined), }; @@ -113,6 +116,9 @@ async function load(): Promise { }; } +/** "a and b" for the default pair, "a, b, c" for a domain's own list. */ +const joinNames = (names: string[]) => (names.length === 2 ? names.join(' and ') : names.join(', ')); + /** When the switch happened, read in the click handler, for telling a new certificate from an old one. */ async function switchedAt(): Promise { return Date.now(); @@ -206,7 +212,7 @@ export function CertificateSetupPage() { const chosen = useMemo(() => (data ? data.domains.filter((d) => picked.has(d.id)) : []), [data, picked]); const methodOf = (d: DomainRow): Method => (d.dnsAutomatic ? 'dns' : 'tls'); const namesOf = useCallback( - (d: DomainRow) => orderNames(d.name, methodOf(d), data?.serverName ?? '', d.zone), + (d: DomainRow) => orderNames(d.name, methodOf(d), data?.serverName ?? '', d.zone, d.hostnames), [data], ); @@ -344,7 +350,7 @@ export function CertificateSetupPage() { certificateManagement: { '@type': 'Automatic', acmeProviderId: ids[methodOf(d)], - subjectAlternativeNames: {}, + subjectAlternativeNames: Object.fromEntries(d.hostnames.map((h) => [h, true])), }, }, ]), @@ -506,7 +512,7 @@ export function CertificateSetupPage() { {t('certSetup.dnsReady', '{{domain}}: checked through its connected DNS, covering {{names}}.', { domain: d.name, - names: namesOf(d).join(' and '), + names: joinNames(namesOf(d)), })}

))} diff --git a/src/features/certificates/names.test.ts b/src/features/certificates/names.test.ts index 90764d7..b3df69d 100644 --- a/src/features/certificates/names.test.ts +++ b/src/features/certificates/names.test.ts @@ -27,6 +27,13 @@ describe('orderNames', () => { ]); }); + it("uses a domain's own hostnames in place of the defaults, with either method", () => { + const own = ['mail', 'Example.com', 'smtp.example.com', 'mail']; + const want = ['mail.example.com', 'example.com', 'smtp.example.com']; + expect(orderNames('example.com', 'dns', 'mail.example.com', [], own)).toEqual(want); + expect(orderNames('example.com', 'tls', 'mail.example.com', [], own)).toEqual(want); + }); + it('leaves out a server name in another domain', () => { expect(orderNames('example.com', 'tls', 'mail.other.net')).not.toContain('mail.other.net'); }); diff --git a/src/features/certificates/names.ts b/src/features/certificates/names.ts index 1a8fd3f..156736b 100644 --- a/src/features/certificates/names.ts +++ b/src/features/certificates/names.ts @@ -11,8 +11,10 @@ * The server decides the names (common/src/network/acme/order.rs): with DNS * validation it asks for the domain and a wildcard under it; otherwise for * mta-sts, ua-auto-config, autoconfig and autodiscover under the domain, plus - * the server's own name when it's inside the domain. Every name must pass, or - * the whole order fails and keeps retrying, so the guide checks them first. + * the server's own name when it's inside the domain. A domain's own hostname + * list replaces all of that: a name without a dot gets the domain added, one + * with a dot is used as written. Every name must pass, or the whole order + * fails and keeps retrying, so the guide checks them first. */ import type { ZoneRecord } from '@/features/dns/zone'; @@ -25,8 +27,17 @@ function inZone(name: string, domain: string): boolean { return name === domain || name.endsWith(`.${domain}`); } -export function orderNames(domain: string, method: Method, serverName: string, zone: ZoneRecord[] = []): string[] { +export function orderNames( + domain: string, + method: Method, + serverName: string, + zone: ZoneRecord[] = [], + hostnames: string[] = [], +): string[] { const d = domain.toLowerCase(); + if (hostnames.length > 0) { + return [...new Set(hostnames.map((h) => (h.includes('.') ? h : `${h}.${d}`).toLowerCase()))]; + } if (method === 'dns') return [`*.${d}`, d]; const names = new Set(TECHNICAL_NAMES.map((h) => `${h}.${d}`)); const host = serverName.toLowerCase().replace(/\.$/, '');