Certificates guide: keep a domain's own hostnames #164

Merged
jcoffey-dev merged 1 commits from fix/certificates-guide-keeps-names-160 into main 2026-11-06 07:34:40 +00:00
3 changed files with 30 additions and 6 deletions

No files matched your search

@@ -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<typeof parseZone>;
}
@@ -93,6 +95,7 @@ async function load(): Promise<Loaded> {
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<Loaded> {
};
}
/** "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<number> {
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() {
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
{t('certSetup.dnsReady', '{{domain}}: checked through its connected DNS, covering {{names}}.', {
domain: d.name,
names: namesOf(d).join(' and '),
names: joinNames(namesOf(d)),
})}
</p>
))}
+7
View File
@@ -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');
});
+14 -3
View File
@@ -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(/\.$/, '');