Guided setup: certificates, automatically #39

Merged
jcoffey-dev merged 1 commits from feature/certificate-setup into main 2026-09-28 21:53:19 +00:00
8 changed files with 1050 additions and 0 deletions

No files matched your search

+17
View File
@@ -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<M, P>(load: () => Promise<M>, select: (module: M) => ComponentType<P>) {
@@ -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 <SendingSetupPage />;
}
// inbuxa: guided setup, automatic certificates (settings-reorg).
if (wizard === 'certificates') {
return <CertificateSetupPage />;
}
return (
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
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 (
<div className="space-y-4">
<CertificateSetupCard />
<DynamicList viewName={viewName} />
</div>
);
}
if (viewName === 'x:MtaRoute') {
return (
<div className="space-y-4">
@@ -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 (
<LaunchChoice
open={open}
onOpenChange={onOpenChange}
title={t('certCard.chooseTitle', 'Certificates, automatically')}
guidedHint={t(
'certCard.guidedHint',
'Pick domains, check every name first, and watch Let’s Encrypt certificates arrive. About two minutes.',
)}
manualHint={t(
'certCard.manualHint',
'Add an ACME provider, then set each domain’s Certificate management yourself.',
)}
onGuided={() => 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 (
<div
className={cn(
'flex flex-wrap items-center gap-4 rounded-xl border border-primary/30 bg-primary/5 p-4',
className,
)}
>
<ShieldCheck className="h-5 w-5 shrink-0 text-primary" />
<div className="min-w-0 flex-1">
<p className="font-medium">{t('certCard.title', 'Free certificates that renew themselves')}</p>
<p className="text-sm text-muted-foreground">
{t(
'certCard.hint',
'A guide checks your domains’ names and sets up Let’s Encrypt, so certificates are issued and renewed for you.',
)}
</p>
</div>
<Button type="button" onClick={() => setOpen(true)}>
{t('certCard.start', 'Set it up')}
</Button>
<CertificateLaunchChoice open={open} onOpenChange={setOpen} onManual={() => undefined} />
</div>
);
}
@@ -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<Method, string> = { dns: 'Dns01', tls: 'TlsAlpn01' };
const WATCH_MS = 5 * 60_000;
interface DomainRow {
id: string;
name: string;
automatic: boolean;
providerId?: string;
dnsAutomatic: boolean;
zone: ReturnType<typeof parseZone>;
}
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<Loaded> {
const get = (obj: string, args: Record<string, unknown>, id: string) =>
[`${obj}/get`, { accountId: getAccountId(obj), ...args }, id] as [string, Record<string, unknown>, 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<string, unknown>[] } | 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<string, unknown>;
const dm = (d.dnsManagement ?? {}) as Record<string, unknown>;
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<number> {
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<string[]> {
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 <CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-600" />;
if (state === 'checking') return <Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />;
if (state === 'error') return <CircleDashed className="h-4 w-4 shrink-0 text-muted-foreground" />;
return <XCircle className="h-4 w-4 shrink-0 text-destructive" />;
}
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<Loaded | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [step, setStep] = useState(0);
const [picked, setPicked] = useState<Set<string>>(new Set());
const [checks, setChecks] = useState<Record<string, Check[]>>({});
const [serverAddresses, setServerAddresses] = useState<string[] | null>(null);
const [anyway, setAnyway] = useState<Set<string>>(new Set());
const [staging, setStaging] = useState(false);
const [contact, setContact] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [startedAt, setStartedAt] = useState<number | null>(null);
const [watch, setWatch] = useState<{ certs: CertRow[]; tasks: Record<string, unknown>[]; at: number } | null>(null);
const [watching, setWatching] = useState(false);
const timer = useRef<ReturnType<typeof setInterval> | 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<string, Check[]> = {};
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<Check> => {
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<string, unknown>[] })?.list ?? []) as Record<string, unknown>[]
).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<string, unknown>[] })?.list ?? []) as Record<string, unknown>[]
).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<Record<Method, string>> = {};
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 <div className="mx-auto max-w-3xl p-8 text-center text-destructive">{loadError}</div>;
if (!data) return <LoadingFallback />;
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 (
<WizardShell
{...common}
canNext={chosen.length > 0}
onNext={() => {
setStep(1);
void runChecks();
}}
aside={
<WizardNote title={t('certSetup.howTitle', 'How the server proves it’s yours')}>
<p>
{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.',
)}
</p>
<p>
{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.',
)}
</p>
</WizardNote>
}
>
{data.domains.length === 0 ? (
<p className="text-sm">
{t('certSetup.noDomains', 'There are no domains yet.')}{' '}
<Link className="text-primary hover:underline" to={domainsView}>
{t('certSetup.addDomain', 'Add one first')}
</Link>
</p>
) : (
<ul className="divide-y rounded-xl border">
{data.domains.map((d) => {
const cert = currentCert(data.certs, namesOf(d));
return (
<li key={d.id} className="flex items-center gap-3 px-4 py-3">
<Checkbox
id={`dom-${d.id}`}
checked={picked.has(d.id)}
onCheckedChange={(v) =>
setPicked((s) => {
const n = new Set(s);
if (v) n.add(d.id);
else n.delete(d.id);
return n;
})
}
/>
<label htmlFor={`dom-${d.id}`} className="min-w-0 flex-1">
<span className="font-medium">{d.name}</span>
<span className="block text-xs text-muted-foreground">
{d.automatic
? t('certSetup.alreadyAuto', 'Already automatic.')
: t('certSetup.manual', 'Certificates managed by hand.')}{' '}
{cert
? t('certSetup.validUntil', 'Current certificate valid until {{date}}.', {
date: fmtDate(cert.notValidAfter),
})
: t('certSetup.noCert', 'No certificate yet.')}{' '}
{methodOf(d) === 'dns'
? t('certSetup.viaDns', 'Checked through DNS.')
: t('certSetup.viaTls', 'Checked on port 443.')}
</span>
</label>
</li>
);
})}
</ul>
)}
</WizardShell>
);
}
// ── 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 (
<WizardShell
{...common}
onBack={() => setStep(0)}
onNext={() => setStep(2)}
canNext={!pending && going.length > 0}
aside={
<>
<WizardNote title={t('certSetup.checkedWith', 'How this is checked')}>
<p>
{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') },
)}
</p>
</WizardNote>
<WizardNote title={t('certSetup.whyAll', 'Why every name matters')}>
<p>
{t(
'certSetup.whyAllText',
'One certificate covers all of a domain’s names. If a single name fails, the whole order fails and keeps retrying.',
)}
</p>
</WizardNote>
</>
}
>
{chosen
.filter((d) => methodOf(d) === 'dns')
.map((d) => (
<p key={d.id} className="flex items-center gap-2 text-sm">
<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 '),
})}
</p>
))}
{serverAddresses !== null && serverAddresses.length === 0 && tlsDomains.length > 0 && (
<p className="flex items-start gap-2 text-sm text-muted-foreground">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
{t(
'certSetup.noAnchor',
'This server’s own address couldn’t be looked up, so names are only checked for existing.',
)}
</p>
)}
{tlsDomains.map((d) => {
const list = checks[d.id] ?? [];
const problems = list.filter((c) => c.state !== 'ok' && c.state !== 'checking' && c.state !== 'error');
return (
<div key={d.id} className="space-y-2 rounded-xl border p-4">
<p className="font-medium">{d.name}</p>
<ul className="space-y-1 text-sm">
{list.map((c) => (
<li key={c.name} className="flex items-start gap-2">
<StateIcon state={c.state} />
<span className="font-mono text-[13px]">{c.name}</span>
<span className="text-muted-foreground">
{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')}
</span>
</li>
))}
</ul>
{problems.length > 0 && (
<div className="space-y-2 border-t pt-3 text-sm">
<p>
{t('certSetup.fixOr', 'Fix those names in DNS and check again, or')}{' '}
<Link
className="text-primary hover:underline"
to={`/${viewToSection['x:Domain'] ?? 'Management'}/Wizard/dns/${d.id}`}
>
{t('certSetup.connectDns', 'connect this domain’s DNS')}
</Link>{' '}
{t('certSetup.connectDnsWhy', 'so it’s checked through DNS instead.')}
</p>
<label className="flex items-center gap-2">
<Checkbox
checked={anyway.has(d.id)}
onCheckedChange={(v) =>
setAnyway((s) => {
const n = new Set(s);
if (v) n.add(d.id);
else n.delete(d.id);
return n;
})
}
/>
{t('certSetup.anyway', 'Order anyway (it will keep retrying until the names are fixed)')}
</label>
</div>
)}
</div>
);
})}
{tlsDomains.length > 0 && (
<Button type="button" variant="outline" onClick={() => void runChecks()} disabled={pending}>
<RefreshCw className={cn('h-4 w-4', pending && 'animate-spin')} />
{t('certSetup.checkAgain', 'Check again')}
</Button>
)}
</WizardShell>
);
}
// ── Step 3: account ──
if (step === 2) {
const validEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(contact.trim());
return (
<WizardShell
{...common}
onBack={() => setStep(1)}
onNext={() => setStep(3)}
canNext={!needsNewProvider || validEmail}
aside={
<WizardNote title={t('certSetup.stagingTitle', 'A practice run')}>
<p>
{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.',
)}
</p>
</WizardNote>
}
>
{needsNewProvider ? (
<div className="space-y-1.5">
<Label htmlFor="acme-contact">{t('certSetup.contact', 'Your email address')}</Label>
<Input
id="acme-contact"
type="email"
value={contact}
onChange={(e) => setContact(e.target.value)}
className="max-w-md"
/>
<p className="text-xs text-muted-foreground">
{t(
'certSetup.contactHint',
'Let’s Encrypt writes here only about problems with your account or certificates. Creating the account accepts their Subscriber Agreement.',
)}{' '}
<a
className="text-primary hover:underline"
href="https://letsencrypt.org/repository/"
target="_blank"
rel="noreferrer"
>
{t('certSetup.agreement', 'Read it')}
</a>
</p>
</div>
) : (
<p className="text-sm">
{t('certSetup.reuse', 'The Let’s Encrypt account already set up on this server will be used.')}
</p>
)}
<label className="flex items-center justify-between gap-4">
<span>
<span className="font-medium">{t('certSetup.staging', 'Practice run (staging)')}</span>
<span className="block text-xs text-muted-foreground">
{t('certSetup.stagingHint', 'Certificates browsers won’t trust. For trying this out.')}
</span>
</span>
<Switch checked={staging} onCheckedChange={setStaging} />
</label>
</WizardShell>
);
}
// ── Step 4: review ──
if (step === 3) {
const skipped = chosen.filter(blocked);
return (
<WizardShell
{...common}
onBack={() => setStep(2)}
onNext={() => void apply()}
nextLabel={t('certSetup.apply', 'Switch them to automatic')}
canNext={going.length > 0}
busy={busy}
aside={
<WizardNote tone="undo" title={t('certSetup.undoTitle', 'Changing your mind')}>
<p>
{t(
'certSetup.undo',
'Set a domain’s Certificate management back to Manual. Certificates already issued stay until they expire.',
)}
</p>
</WizardNote>
}
>
<div className="space-y-2">
<p className="font-medium">{t('certSetup.willChange', 'What will change')}</p>
<ul className="list-disc space-y-1 pl-5 text-sm">
{needsNewProvider && (
<li>
{t('certSetup.newAccount', 'A Let’s Encrypt {{kind}}account is added for {{email}}.', {
kind: staging ? t('certSetup.stagingWord', 'staging ') : '',
email: contact.trim(),
})}
</li>
)}
{going.map((d) => (
<li key={d.id}>
{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(', '),
},
)}
</li>
))}
</ul>
</div>
{skipped.length > 0 && (
<p className="text-sm text-muted-foreground">
{t('certSetup.skipped', 'Left as they are until their names are fixed: {{names}}.', {
names: skipped.map((d) => d.name).join(', '),
})}
</p>
)}
{error && (
<p className="flex items-start gap-2 text-sm text-destructive">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
{error}
</p>
)}
</WizardShell>
);
}
// ── 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<string, unknown>;
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 (
<WizardShell
{...common}
onNext={() => navigate(`/${section}/x:Certificate`)}
nextLabel={t('wizard.finish', 'Finish')}
aside={
<WizardNote title={t('certSetup.afterTitle', 'From now on')}>
<p>
{t(
'certSetup.after',
'The server renews each certificate well before it expires. Failed renewals show under Management › Tasks › Failed.',
)}
</p>
</WizardNote>
}
>
<ul className="space-y-2 text-sm">
{rows.map(({ d, state }) => (
<li key={d.id} className="flex items-start gap-2">
<StateIcon state={state.icon} />
<span className="font-medium">{d.name}</span>
<span className="text-muted-foreground">{state.text}</span>
</li>
))}
</ul>
{error && <p className="text-sm text-destructive">{error}</p>}
{!allDone && !watching && (
<Button type="button" variant="outline" onClick={startWatching}>
<RefreshCw className="h-4 w-4" />
{t('certSetup.keepWatching', 'Keep watching')}
</Button>
)}
{!allDone && watching && (
<p className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t('certSetup.watchingHint', 'Checking every few seconds. Most certificates arrive within a minute.')}
</p>
)}
</WizardShell>
);
}
+50
View File
@@ -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');
});
});
+91
View File
@@ -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';
}
@@ -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<boolean> {
/** 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) => <CertificateLaunchChoice {...props} /> },
gate: CERTIFICATE_WIZARD_VIEW,
},
{
id: 'sending',
title: 'How this server sends mail',
+4
View File
@@ -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 };
+1
View File
@@ -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));