From 4bedcecc2e3adda1f1384e26b3e834dc7cb01212 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 28 Sep 2026 12:09:13 -0700 Subject: [PATCH] Guided setup: certificates, automatically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings › Overview, Certificates and ACME providers offer a guide ("Guided or manual?" each time) that: - lists the domains with how their certificates are managed today, the current certificate's expiry, and how each would be checked: through DNS when the domain's DNS is connected (the domain and *.domain), otherwise on port 443 (TLS-ALPN-01); - before anything is ordered, looks up every name the order will ask for (mta-sts, ua-auto-config, autoconfig, autodiscover, and the server or MX name when it's in the domain) in public DNS and compares it with where the server's own name points. Missing, Cloudflare-proxied and elsewhere names each say what to fix; a domain with problems is left out unless ordered anyway, or its DNS can be connected instead; - reuses the Let's Encrypt account for that check method, or adds one with the contact address; a staging switch gives a practice run; - switches the chosen domains to automatic, removing an account it added if that fails; - watches the renewal tasks and certificates: issued with its expiry, retrying with the reason, failed, or already covered by a valid certificate with the date it renews. --- src/components/layout/MainContent.tsx | 17 + .../certificates/CertificateSetupCard.tsx | 78 ++ .../certificates/CertificateSetupPage.tsx | 800 ++++++++++++++++++ src/features/certificates/names.test.ts | 50 ++ src/features/certificates/names.ts | 91 ++ .../settings/SettingsOverviewPage.tsx | 9 + src/lib/layout.ts | 4 + src/pages/AdminPanel.tsx | 1 + 8 files changed, 1050 insertions(+) create mode 100644 src/features/certificates/CertificateSetupCard.tsx create mode 100644 src/features/certificates/CertificateSetupPage.tsx create mode 100644 src/features/certificates/names.test.ts create mode 100644 src/features/certificates/names.ts diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index b0513f7..384304f 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -18,6 +18,7 @@ import { DynamicViewPage } from '@/components/views/DynamicViewPage'; import { LoadingFallback } from '@/components/common/LoadingFallback'; import { LegacyProtocolsBanner } from '@/features/hardening/LegacyProtocolsBanner'; import { SendingSetupCard } from '@/features/sending/SendingSetupCard'; +import { CertificateSetupCard } from '@/features/certificates/CertificateSetupCard'; import type { Schema } from '@/types/schema'; function lazyFeature(load: () => Promise, select: (module: M) => ComponentType

) { @@ -63,6 +64,10 @@ const DataInventoryPage = lazyFeature( (m) => m.DataInventoryPage, ); // inbuxa: Settings › Overview (settings-reorg spec). +const CertificateSetupPage = lazyFeature( + () => import('@/features/certificates/CertificateSetupPage'), + (m) => m.CertificateSetupPage, +); const SendingSetupPage = lazyFeature( () => import('@/features/sending/SendingSetupPage'), (m) => m.SendingSetupPage, @@ -140,6 +145,10 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti if (wizard === 'sending') { return ; } + // inbuxa: guided setup, automatic certificates (settings-reorg). + if (wizard === 'certificates') { + return ; + } return (

Unknown guide: {wizard} @@ -265,6 +274,14 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti return page; } + if (viewName === 'x:Certificate' || viewName === 'x:AcmeProvider') { + return ( +
+ + +
+ ); + } if (viewName === 'x:MtaRoute') { return (
diff --git a/src/features/certificates/CertificateSetupCard.tsx b/src/features/certificates/CertificateSetupCard.tsx new file mode 100644 index 0000000..7e64ee9 --- /dev/null +++ b/src/features/certificates/CertificateSetupCard.tsx @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { ShieldCheck } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { LaunchChoice } from '@/components/wizard/LaunchChoice'; +import { useSchemaStore } from '@/stores/schemaStore'; +import { cn } from '@/lib/utils'; + +export const CERTIFICATE_WIZARD_VIEW = 'Wizard/certificates'; + +/** "Guided or manual?" for automatic certificates. Manual opens the domains, where the setting lives. */ +export function CertificateLaunchChoice({ + open, + onOpenChange, + onManual, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onManual?: () => void; +}) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const viewToSection = useSchemaStore((s) => s.viewToSection); + const section = viewToSection['x:Certificate'] ?? 'Settings'; + return ( + navigate(`/${section}/${CERTIFICATE_WIZARD_VIEW}`)} + onManual={onManual ?? (() => navigate(`/${viewToSection['x:Domain'] ?? 'Management'}/x:Domain`))} + /> + ); +} + +/** inbuxa: at the top of Certificates and ACME providers (settings-reorg, guided setup 2). */ +export function CertificateSetupCard({ className }: { className?: string }) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + return ( +
+ +
+

{t('certCard.title', 'Free certificates that renew themselves')}

+

+ {t( + 'certCard.hint', + 'A guide checks your domains’ names and sets up Let’s Encrypt, so certificates are issued and renewed for you.', + )} +

+
+ + undefined} /> +
+ ); +} diff --git a/src/features/certificates/CertificateSetupPage.tsx b/src/features/certificates/CertificateSetupPage.tsx new file mode 100644 index 0000000..b3d3d57 --- /dev/null +++ b/src/features/certificates/CertificateSetupPage.tsx @@ -0,0 +1,800 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: guided setup, "Certificates, automatically" (settings-reorg, first + * wave). Picks domains, checks every name the certificate will cover before + * anything is ordered, chooses how Let's Encrypt checks them (through DNS + * when the domain's DNS is connected, otherwise on port 443), switches the + * domains to automatic, and watches the certificates arrive. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { AlertTriangle, CheckCircle2, CircleDashed, Loader2, RefreshCw, XCircle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { LoadingFallback } from '@/components/common/LoadingFallback'; +import { WizardNote, WizardShell, type WizardStep } from '@/components/wizard/WizardShell'; +import { getAccountId, jmapQueryAndGet, jmapRequest, jmapSet } from '@/services/jmap/client'; +import { useSchemaStore } from '@/stores/schemaStore'; +import type { JmapSetResponse } from '@/types/jmap'; +import { dohQuery, RESOLVER_NAME } from '@/features/dns/liveCheck'; +import { parseZone } from '@/features/dns/zone'; +import { cn } from '@/lib/utils'; +import { classify, orderNames, type Method, type NameState } from './names'; + +const LE_PRODUCTION = 'https://acme-v02.api.letsencrypt.org/directory'; +const LE_STAGING = 'https://acme-staging-v02.api.letsencrypt.org/directory'; +const CHALLENGE: Record = { dns: 'Dns01', tls: 'TlsAlpn01' }; +const WATCH_MS = 5 * 60_000; + +interface DomainRow { + id: string; + name: string; + automatic: boolean; + providerId?: string; + dnsAutomatic: boolean; + zone: ReturnType; +} + +interface ProviderRow { + id: string; + directory: string; + challengeType: string; + contact: string[]; +} + +interface CertRow { + sans: string[]; + notValidBefore: string; + notValidAfter: string; +} + +interface Loaded { + domains: DomainRow[]; + providers: ProviderRow[]; + certs: CertRow[]; + serverName: string; +} + +type Check = { name: string; state: NameState | 'checking' | 'error'; addresses: string[] }; + +async function load(): Promise { + const get = (obj: string, args: Record, id: string) => + [`${obj}/get`, { accountId: getAccountId(obj), ...args }, id] as [string, Record, string]; + const res = await jmapRequest([ + get('x:Domain', { ids: null, properties: ['name', 'certificateManagement', 'dnsManagement', 'dnsZoneFile'] }, 'd'), + get('x:AcmeProvider', { ids: null, properties: ['directory', 'challengeType', 'contact'] }, 'p'), + get( + 'x:Certificate', + { ids: null, properties: ['subjectAlternativeNames', 'notValidBefore', 'notValidAfter'] }, + 'c', + ), + get('x:SystemSettings', { ids: ['singleton'], properties: ['defaultHostname'] }, 's'), + ]); + const list = (id: string) => + (res.find(([, , i]) => i === id)?.[1] as { list?: Record[] } | undefined)?.list ?? []; + const keys = (v: unknown) => (v && typeof v === 'object' ? Object.keys(v as object) : []); + return { + domains: list('d') + .map((d) => { + const cm = (d.certificateManagement ?? {}) as Record; + const dm = (d.dnsManagement ?? {}) as Record; + return { + id: String(d.id), + name: String(d.name), + automatic: cm['@type'] === 'Automatic', + providerId: cm.acmeProviderId as string | undefined, + dnsAutomatic: dm['@type'] === 'Automatic', + zone: parseZone(d.dnsZoneFile as string | undefined), + }; + }) + .sort((a, b) => a.name.localeCompare(b.name)), + providers: list('p').map((p) => ({ + id: String(p.id), + directory: String(p.directory ?? ''), + challengeType: String(p.challengeType ?? ''), + contact: keys(p.contact).map((c) => c.replace(/^mailto:/, '')), + })), + certs: list('c').map((c) => ({ + sans: keys(c.subjectAlternativeNames).map((s) => s.toLowerCase()), + notValidBefore: String(c.notValidBefore ?? ''), + notValidAfter: String(c.notValidAfter ?? ''), + })), + serverName: String(list('s')[0]?.defaultHostname ?? ''), + }; +} + +/** 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(); +} + +/** The newest certificate covering every one of these names. */ +function coveringCert(certs: CertRow[], names: string[]): CertRow | undefined { + return certs + .filter((c) => names.every((n) => c.sans.includes(n.toLowerCase()))) + .sort((a, b) => b.notValidAfter.localeCompare(a.notValidAfter))[0]; +} + +/** The newest certificate covering any of these names: what the domain uses today. */ +function currentCert(certs: CertRow[], names: string[]): CertRow | undefined { + return certs + .filter((c) => names.some((n) => c.sans.includes(n.toLowerCase()))) + .sort((a, b) => b.notValidAfter.localeCompare(a.notValidAfter))[0]; +} + +async function resolveAddresses(name: string): Promise { + const out: string[] = []; + for (const type of ['A', 'AAAA']) { + const r = await dohQuery(name.replace(/^\*\./, ''), type); + out.push(...r.answer.filter((a) => a.type === 1 || a.type === 28).map((a) => a.data)); + } + return out; +} + +function fmtDate(iso: string): string { + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? iso : d.toLocaleDateString(undefined, { dateStyle: 'medium' }); +} + +function StateIcon({ state }: { state: Check['state'] }) { + if (state === 'ok') return ; + if (state === 'checking') return ; + if (state === 'error') return ; + return ; +} + +export function CertificateSetupPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const viewToSection = useSchemaStore((s) => s.viewToSection); + const section = viewToSection['x:Certificate'] ?? 'Settings'; + + const [data, setData] = useState(null); + const [loadError, setLoadError] = useState(null); + const [step, setStep] = useState(0); + const [picked, setPicked] = useState>(new Set()); + const [checks, setChecks] = useState>({}); + const [serverAddresses, setServerAddresses] = useState(null); + const [anyway, setAnyway] = useState>(new Set()); + const [staging, setStaging] = useState(false); + const [contact, setContact] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [startedAt, setStartedAt] = useState(null); + const [watch, setWatch] = useState<{ certs: CertRow[]; tasks: Record[]; at: number } | null>(null); + const [watching, setWatching] = useState(false); + const timer = useRef | null>(null); + + useEffect(() => { + let live = true; + load() + .then((d) => { + if (!live) return; + setData(d); + setPicked(new Set(d.domains.filter((x) => !x.automatic).map((x) => x.id))); + setContact(d.providers.find((p) => p.contact.length > 0)?.contact[0] ?? ''); + }) + .catch((e: unknown) => live && setLoadError(e instanceof Error ? e.message : String(e))); + return () => { + live = false; + }; + }, []); + + useEffect(() => () => void (timer.current && clearInterval(timer.current)), []); + + const steps: WizardStep[] = useMemo( + () => [ + { id: 'domains', title: t('certSetup.stepDomains', 'Domains') }, + { id: 'check', title: t('certSetup.stepCheck', 'Check names') }, + { id: 'account', title: t('certSetup.stepAccount', 'Account') }, + { id: 'review', title: t('certSetup.stepReview', 'Review') }, + { id: 'watch', title: t('certSetup.stepWatch', 'Watch them arrive') }, + ], + [t], + ); + + 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), + [data], + ); + + const runChecks = useCallback(async () => { + if (!data) return; + const tlsDomains = chosen.filter((d) => methodOf(d) === 'tls'); + const initial: Record = {}; + for (const d of tlsDomains) initial[d.id] = namesOf(d).map((name) => ({ name, state: 'checking', addresses: [] })); + setChecks(initial); + // Where this server is: its own name, or the domain's mail host. + const anchor = + data.serverName || + tlsDomains[0]?.zone + .find((r) => r.type === 'MX') + ?.value.split(/\s+/) + .pop() || + ''; + const mine = anchor ? await resolveAddresses(anchor).catch(() => []) : []; + setServerAddresses(mine); + for (const d of tlsDomains) { + const results = await Promise.all( + namesOf(d).map(async (name): Promise => { + try { + const addresses = await resolveAddresses(name); + return { + name, + state: mine.length > 0 ? classify(addresses, mine) : addresses.length ? 'ok' : 'missing', + addresses, + }; + } catch { + return { name, state: 'error', addresses: [] }; + } + }), + ); + setChecks((c) => ({ ...c, [d.id]: results })); + } + }, [chosen, data, namesOf]); + + const blocked = (d: DomainRow) => + methodOf(d) === 'tls' && + (checks[d.id] ?? []).some((c) => c.state !== 'ok' && c.state !== 'error') && + !anyway.has(d.id); + const going = chosen.filter((d) => !blocked(d)); + + const directory = staging ? LE_STAGING : LE_PRODUCTION; + const methodsNeeded = [...new Set(going.map(methodOf))]; + const providerFor = (m: Method) => + data?.providers.find((p) => p.directory === directory && p.challengeType === CHALLENGE[m]); + const needsNewProvider = methodsNeeded.some((m) => !providerFor(m)); + + const poll = useCallback(async () => { + const [certRes] = await jmapRequest([ + [ + 'x:Certificate/get', + { + accountId: getAccountId('x:Certificate'), + ids: null, + properties: ['subjectAlternativeNames', 'notValidBefore', 'notValidAfter'], + }, + 'c', + ], + ]); + const [, taskRes] = await jmapQueryAndGet( + 'x:Task', + getAccountId('x:Task'), + { filter: { '@type': 'AcmeRenewal' } }, + ['@type', 'domainId', 'status'], + ); + const keys = (v: unknown) => (v && typeof v === 'object' ? Object.keys(v as object) : []); + const certs = ( + ((certRes?.[1] as { list?: Record[] })?.list ?? []) as Record[] + ).map((c) => ({ + sans: keys(c.subjectAlternativeNames).map((s) => s.toLowerCase()), + notValidBefore: String(c.notValidBefore ?? ''), + notValidAfter: String(c.notValidAfter ?? ''), + })); + const tasks = ( + ((taskRes?.[1] as { list?: Record[] })?.list ?? []) as Record[] + ).filter((x) => x['@type'] === 'AcmeRenewal'); + setWatch({ certs, tasks, at: Date.now() }); + }, []); + + const startWatching = useCallback(() => { + if (timer.current) clearInterval(timer.current); + const began = Date.now(); + setWatching(true); + void poll().catch(() => undefined); + timer.current = setInterval(() => { + if (Date.now() - began > WATCH_MS) { + clearInterval(timer.current!); + timer.current = null; + setWatching(false); + return; + } + void poll().catch(() => undefined); + }, 5000); + }, [poll]); + + const apply = async () => { + if (!data) return; + setBusy(true); + setError(null); + const made: string[] = []; + try { + const ids: Partial> = {}; + for (const m of methodsNeeded) { + const existing = providerFor(m); + if (existing) { + ids[m] = existing.id; + continue; + } + const [res] = await jmapSet('x:AcmeProvider', getAccountId('x:AcmeProvider'), { + create: { + acme: { + directory, + challengeType: CHALLENGE[m], + contact: { [contact.trim()]: true }, + description: `${staging ? 'Let’s Encrypt (staging)' : 'Let’s Encrypt'}, ${m === 'dns' ? 'DNS-01' : 'TLS-ALPN-01'}, ${contact.trim()}`, + }, + }, + }); + const body = res?.[1] as unknown as JmapSetResponse | undefined; + const id = (body?.created?.acme as { id?: string } | undefined)?.id; + if (!id) { + const e = body?.notCreated ? Object.values(body.notCreated)[0] : undefined; + throw new Error(e?.description ?? e?.type ?? 'The Let’s Encrypt account could not be added.'); + } + ids[m] = id; + made.push(id); + } + const update = Object.fromEntries( + going.map((d) => [ + d.id, + { + certificateManagement: { + '@type': 'Automatic', + acmeProviderId: ids[methodOf(d)], + subjectAlternativeNames: {}, + }, + }, + ]), + ); + const [res] = await jmapSet('x:Domain', getAccountId('x:Domain'), { update }); + const body = res?.[1] as unknown as JmapSetResponse | undefined; + const refused = Object.entries(body?.notUpdated ?? {}); + if (refused.length === going.length) { + const e = refused[0]?.[1]; + throw new Error(e?.description ?? e?.type ?? 'The domains could not be switched to automatic certificates.'); + } + if (refused.length > 0) { + const names = refused.map(([id]) => data.domains.find((d) => d.id === id)?.name ?? id).join(', '); + setError(t('certSetup.partial', 'These domains were not switched: {{names}}', { names })); + } + setStartedAt(await switchedAt()); + setStep(4); + startWatching(); + } catch (e) { + // Failsafe: an account added for this run is removed again when nothing uses it. + for (const id of made) { + await jmapSet('x:AcmeProvider', getAccountId('x:AcmeProvider'), { destroy: [id] }).catch(() => undefined); + } + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + if (loadError) return
{loadError}
; + if (!data) return ; + + const domainsView = `/${viewToSection['x:Domain'] ?? 'Management'}/x:Domain`; + const common = { + icon: 'shield-check', + title: t('certSetup.title', 'Certificates, automatically'), + subtitle: t( + 'certSetup.subtitle', + 'Free certificates from Let’s Encrypt for your domains, renewed by the server before they expire.', + ), + steps, + current: step, + onCancel: () => navigate(`/${section}/x:Certificate`), + }; + + // ── Step 1: domains ── + if (step === 0) { + return ( + 0} + onNext={() => { + setStep(1); + void runChecks(); + }} + aside={ + +

+ {t( + 'certSetup.howDns', + 'A domain whose DNS is connected is checked through DNS: the certificate covers the domain and everything under it (*.domain), and nothing needs to reach this server.', + )} +

+

+ {t( + 'certSetup.howTls', + 'Otherwise Let’s Encrypt connects to this server on port 443 for each name. Those names must point straight at this server, not through a proxy.', + )} +

+
+ } + > + {data.domains.length === 0 ? ( +

+ {t('certSetup.noDomains', 'There are no domains yet.')}{' '} + + {t('certSetup.addDomain', 'Add one first')} + +

+ ) : ( +
    + {data.domains.map((d) => { + const cert = currentCert(data.certs, namesOf(d)); + return ( +
  • + + setPicked((s) => { + const n = new Set(s); + if (v) n.add(d.id); + else n.delete(d.id); + return n; + }) + } + /> + +
  • + ); + })} +
+ )} +
+ ); + } + + // ── Step 2: check names ── + if (step === 1) { + const tlsDomains = chosen.filter((d) => methodOf(d) === 'tls'); + const pending = tlsDomains.some((d) => (checks[d.id] ?? []).some((c) => c.state === 'checking')); + return ( + setStep(0)} + onNext={() => setStep(2)} + canNext={!pending && going.length > 0} + aside={ + <> + +

+ {t( + 'certSetup.checkedHow', + 'Each name is looked up in public DNS through {{resolver}}, and compared with where {{server}} points. Nothing is ordered yet.', + { resolver: RESOLVER_NAME, server: data.serverName || t('certSetup.thisServer', 'this server') }, + )} +

+
+ +

+ {t( + 'certSetup.whyAllText', + 'One certificate covers all of a domain’s names. If a single name fails, the whole order fails and keeps retrying.', + )} +

+
+ + } + > + {chosen + .filter((d) => methodOf(d) === 'dns') + .map((d) => ( +

+ + {t('certSetup.dnsReady', '{{domain}}: checked through its connected DNS, covering {{names}}.', { + domain: d.name, + names: namesOf(d).join(' and '), + })} +

+ ))} + {serverAddresses !== null && serverAddresses.length === 0 && tlsDomains.length > 0 && ( +

+ + {t( + 'certSetup.noAnchor', + 'This server’s own address couldn’t be looked up, so names are only checked for existing.', + )} +

+ )} + {tlsDomains.map((d) => { + const list = checks[d.id] ?? []; + const problems = list.filter((c) => c.state !== 'ok' && c.state !== 'checking' && c.state !== 'error'); + return ( +
+

{d.name}

+
    + {list.map((c) => ( +
  • + + {c.name} + + {c.state === 'ok' && t('certSetup.ok', 'points here')} + {c.state === 'missing' && + t('certSetup.missing', 'doesn’t exist: add a CNAME to {{server}}', { + server: data.serverName || t('certSetup.thisServer', 'this server'), + })} + {c.state === 'proxied' && + t('certSetup.proxied', 'goes through the Cloudflare proxy: set it to DNS only (grey cloud)')} + {c.state === 'elsewhere' && + t('certSetup.elsewhere', 'points somewhere else ({{ips}})', { ips: c.addresses.join(', ') })} + {c.state === 'error' && t('certSetup.lookupFailed', 'couldn’t be looked up')} + +
  • + ))} +
+ {problems.length > 0 && ( +
+

+ {t('certSetup.fixOr', 'Fix those names in DNS and check again, or')}{' '} + + {t('certSetup.connectDns', 'connect this domain’s DNS')} + {' '} + {t('certSetup.connectDnsWhy', 'so it’s checked through DNS instead.')} +

+ +
+ )} +
+ ); + })} + {tlsDomains.length > 0 && ( + + )} +
+ ); + } + + // ── Step 3: account ── + if (step === 2) { + const validEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(contact.trim()); + return ( + setStep(1)} + onNext={() => setStep(3)} + canNext={!needsNewProvider || validEmail} + aside={ + +

+ {t( + 'certSetup.stagingText', + 'Let’s Encrypt’s staging service issues certificates that browsers don’t trust, with far looser limits. Use it to see everything work, then run this guide again without it.', + )} +

+
+ } + > + {needsNewProvider ? ( +
+ + setContact(e.target.value)} + className="max-w-md" + /> +

+ {t( + 'certSetup.contactHint', + 'Let’s Encrypt writes here only about problems with your account or certificates. Creating the account accepts their Subscriber Agreement.', + )}{' '} + + {t('certSetup.agreement', 'Read it')} + +

+
+ ) : ( +

+ {t('certSetup.reuse', 'The Let’s Encrypt account already set up on this server will be used.')} +

+ )} + +
+ ); + } + + // ── Step 4: review ── + if (step === 3) { + const skipped = chosen.filter(blocked); + return ( + setStep(2)} + onNext={() => void apply()} + nextLabel={t('certSetup.apply', 'Switch them to automatic')} + canNext={going.length > 0} + busy={busy} + aside={ + +

+ {t( + 'certSetup.undo', + 'Set a domain’s Certificate management back to Manual. Certificates already issued stay until they expire.', + )} +

+
+ } + > +
+

{t('certSetup.willChange', 'What will change')}

+
    + {needsNewProvider && ( +
  • + {t('certSetup.newAccount', 'A Let’s Encrypt {{kind}}account is added for {{email}}.', { + kind: staging ? t('certSetup.stagingWord', 'staging ') : '', + email: contact.trim(), + })} +
  • + )} + {going.map((d) => ( +
  • + {t( + 'certSetup.domainAuto', + '{{domain}} gets its certificate automatically, checked {{how}}: {{names}}.', + { + domain: d.name, + how: + methodOf(d) === 'dns' + ? t('certSetup.howDnsWord', 'through DNS') + : t('certSetup.howTlsWord', 'on port 443'), + names: namesOf(d).join(', '), + }, + )} +
  • + ))} +
+
+ {skipped.length > 0 && ( +

+ {t('certSetup.skipped', 'Left as they are until their names are fixed: {{names}}.', { + names: skipped.map((d) => d.name).join(', '), + })} +

+ )} + {error && ( +

+ + {error} +

+ )} +
+ ); + } + + // ── Step 5: watch ── + const rows = going.map((d) => { + const names = namesOf(d); + const cert = watch ? coveringCert(watch.certs, names) : undefined; + const fresh = cert && startedAt !== null && new Date(cert.notValidBefore).getTime() > startedAt - 2 * 3600_000; + const task = watch?.tasks.find((x) => x.domainId === d.id); + const status = (task?.status ?? {}) as Record; + const due = typeof status.due === 'string' ? new Date(status.due).getTime() : null; + let state: { icon: Check['state']; text: string }; + if (fresh) { + state = { + icon: 'ok', + text: t('certSetup.issued', 'Issued. Valid until {{date}}.', { date: fmtDate(cert.notValidAfter) }), + }; + } else if (status['@type'] === 'Failed') { + state = { icon: 'missing', text: String(status.failureReason ?? t('certSetup.failed', 'Failed.')) }; + } else if (status['@type'] === 'Retry') { + state = { + icon: 'checking', + text: t('certSetup.retrying', 'Retrying: {{reason}}', { reason: String(status.failureReason ?? '') }), + }; + } else if (cert && due !== null && watch && due > watch.at + 3600_000) { + state = { + icon: 'ok', + text: t( + 'certSetup.coveredAlready', + 'Already covered by a certificate valid until {{date}}; it renews on {{due}}.', + { + date: fmtDate(cert.notValidAfter), + due: fmtDate(String(status.due)), + }, + ), + }; + } else { + state = { icon: 'checking', text: t('certSetup.waiting', 'Ordering…') }; + } + return { d, state }; + }); + const allDone = rows.length > 0 && rows.every((r) => r.state.icon !== 'checking'); + + return ( + navigate(`/${section}/x:Certificate`)} + nextLabel={t('wizard.finish', 'Finish')} + aside={ + +

+ {t( + 'certSetup.after', + 'The server renews each certificate well before it expires. Failed renewals show under Management › Tasks › Failed.', + )} +

+
+ } + > +
    + {rows.map(({ d, state }) => ( +
  • + + {d.name} + {state.text} +
  • + ))} +
+ {error &&

{error}

} + {!allDone && !watching && ( + + )} + {!allDone && watching && ( +

+ + {t('certSetup.watchingHint', 'Checking every few seconds. Most certificates arrive within a minute.')} +

+ )} +
+ ); +} diff --git a/src/features/certificates/names.test.ts b/src/features/certificates/names.test.ts new file mode 100644 index 0000000..d94f418 --- /dev/null +++ b/src/features/certificates/names.test.ts @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, it, expect } from 'vitest'; +import { classify, isCloudflareProxy, orderNames } from './names'; + +describe('orderNames', () => { + it('asks for the domain and a wildcard with DNS validation', () => { + expect(orderNames('Example.com', 'dns', 'mail.example.com')).toEqual(['*.example.com', 'example.com']); + }); + + it('asks for the technical names, the server name and MX hosts in the domain otherwise', () => { + const zone = [ + { name: 'example.com.', type: 'MX', value: '10 mx.example.com.', kind: 'mx' as const }, + { name: 'example.com.', type: 'MX', value: '20 mx.other.net.', kind: 'mx' as const }, + ]; + expect(orderNames('example.com', 'tls', 'mail.example.com', zone)).toEqual([ + 'autoconfig.example.com', + 'autodiscover.example.com', + 'mail.example.com', + 'mta-sts.example.com', + 'mx.example.com', + 'ua-auto-config.example.com', + ]); + }); + + it('leaves out a server name in another domain', () => { + expect(orderNames('example.com', 'tls', 'mail.other.net')).not.toContain('mail.other.net'); + }); +}); + +describe('classify', () => { + it('knows a name that reaches this server', () => { + expect(classify(['157.180.98.170'], ['157.180.98.170', '2a01::25'])).toBe('ok'); + }); + it('spots the Cloudflare proxy', () => { + expect(isCloudflareProxy('104.21.3.4')).toBe(true); + expect(isCloudflareProxy('172.67.1.1')).toBe(true); + expect(isCloudflareProxy('2606:4700:3030::1')).toBe(true); + expect(isCloudflareProxy('157.180.98.170')).toBe(false); + expect(classify(['104.21.3.4'], ['157.180.98.170'])).toBe('proxied'); + }); + it('tells missing from elsewhere', () => { + expect(classify([], ['1.2.3.4'])).toBe('missing'); + expect(classify(['5.6.7.8'], ['1.2.3.4'])).toBe('elsewhere'); + }); +}); diff --git a/src/features/certificates/names.ts b/src/features/certificates/names.ts new file mode 100644 index 0000000..227740b --- /dev/null +++ b/src/features/certificates/names.ts @@ -0,0 +1,91 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: which names a certificate order asks for, and whether each one + * reaches this server (settings-reorg, guided setup 2). + * + * 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. + */ + +import type { ZoneRecord } from '@/features/dns/zone'; + +export type Method = 'dns' | 'tls'; + +export const TECHNICAL_NAMES = ['mta-sts', 'ua-auto-config', 'autoconfig', 'autodiscover']; + +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[] { + const d = domain.toLowerCase(); + if (method === 'dns') return [`*.${d}`, d]; + const names = new Set(TECHNICAL_NAMES.map((h) => `${h}.${d}`)); + const host = serverName.toLowerCase().replace(/\.$/, ''); + if (host && inZone(host, d)) names.add(host); + for (const r of zone) { + if (r.type !== 'MX') continue; + const target = r.value.split(/\s+/).pop()?.toLowerCase().replace(/\.$/, ''); + if (target && inZone(target, d)) names.add(target); + } + return [...names].sort(); +} + +// Cloudflare's published proxy ranges (cloudflare.com/ips). A name that +// answers with one of these is orange-clouded: TLS-ALPN validation can't +// reach this server through the proxy. +const CF_V4 = [ + '173.245.48.0/20', + '103.21.244.0/22', + '103.22.200.0/22', + '103.31.4.0/22', + '141.101.64.0/18', + '108.162.192.0/18', + '190.93.240.0/20', + '188.114.96.0/20', + '197.234.240.0/22', + '198.41.128.0/17', + '162.158.0.0/15', + '104.16.0.0/13', + '104.24.0.0/14', + '172.64.0.0/13', + '131.0.72.0/22', +]; +const CF_V6 = ['2400:cb00:', '2606:4700:', '2803:f800:', '2405:b500:', '2405:8100:', '2a06:98c', '2c0f:f248:']; + +function v4ToInt(ip: string): number | null { + const parts = ip.split('.').map(Number); + if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255)) return null; + return ((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]; +} + +export function isCloudflareProxy(ip: string): boolean { + if (ip.includes(':')) return CF_V6.some((p) => ip.toLowerCase().startsWith(p)); + const n = v4ToInt(ip); + if (n === null) return false; + return CF_V4.some((cidr) => { + const [base, bits] = cidr.split('/'); + const b = v4ToInt(base)!; + const mask = bits === '0' ? 0 : (~0 << (32 - Number(bits))) >>> 0; + return (n & mask) >>> 0 === (b & mask) >>> 0; + }); +} + +export type NameState = 'ok' | 'proxied' | 'elsewhere' | 'missing'; + +/** Where a name points, against where this server is. */ +export function classify(addresses: string[], serverAddresses: string[]): NameState { + if (addresses.length === 0) return 'missing'; + const mine = new Set(serverAddresses.map((a) => a.toLowerCase())); + if (addresses.some((a) => mine.has(a.toLowerCase()))) return 'ok'; + if (addresses.some(isCloudflareProxy)) return 'proxied'; + return 'elsewhere'; +} diff --git a/src/features/settings/SettingsOverviewPage.tsx b/src/features/settings/SettingsOverviewPage.tsx index 33abea8..b681143 100644 --- a/src/features/settings/SettingsOverviewPage.tsx +++ b/src/features/settings/SettingsOverviewPage.tsx @@ -23,6 +23,7 @@ import { SETTINGS_CATEGORIES, SETTINGS_LAYOUT_NAME } from '@/lib/settingsLayout' import { resolveObject } from '@/lib/schemaResolver'; import { countChanged, singletonPages, type SettingsPageRef } from './changedFromDefault'; import { SENDING_WIZARD_VIEW, SendingLaunchChoice } from '@/features/sending/SendingSetupCard'; +import { CERTIFICATE_WIZARD_VIEW, CertificateLaunchChoice } from '@/features/certificates/CertificateSetupCard'; /** Calls per JMAP request; the protocol's own floor, so any server takes it. */ const CALLS_PER_REQUEST = 16; @@ -49,6 +50,14 @@ async function anyExist(objectName: string): Promise { /** Each guided setup registers here as it lands. */ const GUIDED_SETUPS: GuidedSetup[] = [ + { + id: 'certificates', + title: 'Certificates, automatically', + blurb: + 'Free Let’s Encrypt certificates for your domains, with every name checked first and renewals handled for you.', + to: { choice: (props) => }, + gate: CERTIFICATE_WIZARD_VIEW, + }, { id: 'sending', title: 'How this server sends mail', diff --git a/src/lib/layout.ts b/src/lib/layout.ts index 32a935d..124daa5 100644 --- a/src/lib/layout.ts +++ b/src/lib/layout.ts @@ -100,6 +100,10 @@ function checkSpecialLink( } // INBUXA: guided jobs are open to whoever may manage what they change. + if (viewName === 'Wizard/certificates') { + const ok = canGet ? canGet('sysDomain') && canGet('sysAcmeProvider') && canGet('sysCertificate') : true; + return { visible: ok, enterprise: false }; + } if (viewName === 'Wizard/sending') { const ok = canGet ? canGet('sysMtaRoute') && canGet('sysMtaOutboundStrategy') : true; return { visible: ok, enterprise: false }; diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index 383721d..c533f51 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -106,6 +106,7 @@ export default function AdminPanel() { if (!section) return t('dashboard.title', 'Dashboard'); if (!viewName) return section; if (viewName.startsWith('Wizard/dns/')) return `${t('dnsWizard.tabTitle', 'Publish DNS')} · ${section}`; + if (viewName === 'Wizard/certificates') return `${t('certSetup.tabTitle', 'Certificates')} · ${section}`; if (viewName === 'Wizard/sending') return `${t('sendingSetup.tabTitle', 'Sending mail')} · ${section}`; if (viewName.startsWith('Dashboard/')) { const board = schema?.dashboards?.find((d) => d.id === viewName.slice('Dashboard/'.length)); -- 2.54.0