Certificates: order one with the names you choose, and order again #170

Merged
jcoffey-dev merged 3 commits from feature/order-certificate into main 2026-11-07 01:17:38 +00:00
14 changed files with 1530 additions and 169 deletions

No files matched your search

+6 -1
View File
@@ -12,6 +12,7 @@ import { DynamicList } from '@/components/lists/DynamicList';
import { DynamicViewPage } from '@/components/views/DynamicViewPage';
import { AlertsPage } from '@/features/alerts/AlertsPage';
import { CertificateSetupCard } from '@/features/certificates/CertificateSetupCard';
import { CertificateOrdersPanel } from '@/features/certificates/CertificateOrdersPanel';
import { DirectorySetupCard } from '@/features/directory/DirectorySetupCard';
import { WebhooksPage } from '@/features/events/WebhooksPage';
import { WebhookTestCard } from '@/features/events/WebhookTestCard';
@@ -171,6 +172,10 @@ const WIZARDS: Record<string, ComponentType> = {
() => import('@/features/certificates/CertificateSetupPage'),
(m) => m.CertificateSetupPage,
),
'certificate-order': onDemand(
() => import('@/features/certificates/OrderCertificatePage'),
(m) => m.OrderCertificatePage,
),
};
const stacked = (...parts: ReactNode[]) => createElement('div', { className: 'space-y-4' }, ...parts);
@@ -206,7 +211,7 @@ const limitsList = (object: LimitObject) => () =>
/** Lists that open with a guide or a summary above them, or have a page of their own. */
const LIST_PAGES: Record<string, (viewName: string) => ReactNode> = {
'x:Certificate': listUnder(<CertificateSetupCard />),
'x:Certificate': listUnder(stacked(<CertificateSetupCard />, <CertificateOrdersPanel />)),
'x:AcmeProvider': listUnder(<CertificateSetupCard />),
// Received reports open with who sends as us and whether they pass.
'x:DmarcExternalReport': listUnder(<DmarcSummaryCard />),
@@ -0,0 +1,238 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: above the Certificates list, every certificate the server orders
* by itself (#167): which domain renews it, when, why the last order failed,
* and Order again.
*/
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { AlertTriangle, CheckCircle2, Clock, Loader2, Plus, RotateCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { getAccountId, jmapSet } from '@/services/jmap/client';
import { useSchemaStore } from '@/stores/schemaStore';
import { cn } from '@/lib/utils';
import { orderNames } from './names';
import { canOrderAgain, orderAgainSet, renewalState, type RenewalState } from './orders';
import { coveringCert, fmtDate, type CertRow, type Loaded, load, pollOrders, refusal } from './shared';
import { ORDER_WIZARD_VIEW } from './CertificateSetupCard';
interface State {
data: Loaded;
certs: CertRow[];
tasks: Record<string, unknown>[];
/** False when renewal tasks can't be read (no permission): then nothing is known about them. */
tasksKnown: boolean;
/** When this was read, for telling a renewal due soon from one due later. */
at: number;
}
export function CertificateOrdersPanel({ className }: { className?: string }) {
const { t } = useTranslation();
const navigate = useNavigate();
const viewToSection = useSchemaStore((s) => s.viewToSection);
const section = viewToSection['x:Certificate'] ?? 'Settings';
const domainSection = viewToSection['x:Domain'] ?? 'Management';
const [state, setState] = useState<State | null>(null);
const [failed, setFailed] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const [errors, setErrors] = useState<Record<string, string>>({});
const [following, setFollowing] = useState(false);
const timer = useRef<ReturnType<typeof setInterval> | null>(null);
const refresh = useCallback(async () => {
try {
const data = await load();
// Tasks need their own permission; without it the panel still lists the orders.
const orders = await pollOrders().catch(() => null);
setState({
data,
certs: orders?.certs ?? data.certs,
tasks: orders?.tasks ?? [],
tasksKnown: orders !== null,
at: Date.now(),
});
} catch {
setFailed(true);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => () => void (timer.current && clearInterval(timer.current)), []);
/** After Order again, reads again every few seconds for a minute, so the outcome shows. */
const follow = useCallback(() => {
if (timer.current) clearInterval(timer.current);
const began = Date.now();
setFollowing(true);
timer.current = setInterval(() => {
if (Date.now() - began > 60_000) {
clearInterval(timer.current!);
timer.current = null;
setFollowing(false);
return;
}
void refresh();
}, 5000);
}, [refresh]);
const orderAgain = async (domainId: string, taskId: string | undefined) => {
setBusy(domainId);
setErrors((e) => ({ ...e, [domainId]: '' }));
try {
const [res] = await jmapSet('x:Task', getAccountId('x:Task'), orderAgainSet(domainId, taskId));
const why = refusal(res?.[1], 'The order could not be started.');
if (why) throw new Error(why);
await refresh();
follow();
} catch (e) {
setErrors((s) => ({ ...s, [domainId]: e instanceof Error ? e.message : String(e) }));
} finally {
setBusy(null);
}
};
// Nothing to show when the server can't be read; the list below says why.
if (failed) return null;
const orderButton = (
<Button type="button" variant="outline" size="sm" onClick={() => navigate(`/${section}/${ORDER_WIZARD_VIEW}`)}>
<Plus className="h-4 w-4" />
{t('certOrders.order', 'Order a certificate')}
</Button>
);
if (!state) {
return (
<div className={cn('flex items-center gap-2 rounded-xl border p-4 text-sm text-muted-foreground', className)}>
<Loader2 className="h-4 w-4 animate-spin" />
{t('certOrders.loading', 'Reading automatic certificates…')}
</div>
);
}
const automatic = state.data.domains.filter((d) => d.automatic);
return (
<div className={cn('space-y-3 rounded-xl border p-4', className)}>
<div className="flex flex-wrap items-center gap-3">
<div className="min-w-0 flex-1">
<p className="font-medium">{t('certOrders.title', 'Ordered automatically')}</p>
<p className="text-sm text-muted-foreground">
{automatic.length === 0
? t('certOrders.none', 'No certificate is ordered automatically yet.')
: t(
'certOrders.hint',
'Each one belongs to a domain, which renews it before it expires. Connections that ask for one of its names get it.',
)}
</p>
</div>
{following && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" aria-hidden />}
{orderButton}
</div>
{automatic.length > 0 && (
<ul className="divide-y rounded-lg border text-sm">
{automatic.map((d) => {
const method = d.dnsAutomatic ? 'dns' : 'tls';
const names = orderNames(d.name, method, state.data.serverName, d.zone, d.hostnames);
const cert = coveringCert(state.certs, names);
const task = state.tasks.find((x) => x.domainId === d.id);
const renewal = renewalState(task);
const isDefault = !!cert && cert.id === state.data.defaultCertificateId;
return (
<li key={d.id} className="space-y-1 px-4 py-3">
<div className="flex flex-wrap items-start gap-3">
<div className="min-w-0 flex-1">
<p className="font-mono text-[13px]">{names.join(', ')}</p>
<p className="text-xs text-muted-foreground">
<Link className="text-primary hover:underline" to={`/${domainSection}/x:Domain/${d.id}`}>
{d.name}
</Link>
{' · '}
{cert
? t('certOrders.validUntil', 'valid until {{date}}', { date: fmtDate(cert.notValidAfter) })
: t('certOrders.noCert', 'not issued yet')}
{isDefault && ` · ${t('certOrders.default', 'default certificate')}`}
</p>
</div>
{state.tasksKnown && canOrderAgain(renewal) && (
<Button
type="button"
variant="outline"
size="sm"
disabled={busy !== null}
onClick={() => void orderAgain(d.id, task ? String(task.id) : undefined)}
>
{busy === d.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <RotateCw className="h-4 w-4" />}
{t('certOrders.again', 'Order again')}
</Button>
)}
</div>
{state.tasksKnown && <RenewalLine state={renewal} hasCert={!!cert} now={state.at} />}
{errors[d.id] && <p className="text-xs text-destructive">{errors[d.id]}</p>}
</li>
);
})}
</ul>
)}
</div>
);
}
function RenewalLine({ state, hasCert, now }: { state: RenewalState; hasCert: boolean; now: number }) {
const { t } = useTranslation();
const line = (icon: ReactNode, text: string, tone = 'text-muted-foreground') => (
<p className={cn('flex items-start gap-1.5 text-xs', tone)}>
{icon}
<span>{text}</span>
</p>
);
switch (state.kind) {
case 'failed':
return line(
<AlertTriangle className="mt-px h-3.5 w-3.5 shrink-0" />,
t('certOrders.failed', 'The last order failed{{at}}: {{reason}}', {
at: state.at ? ` ${t('certOrders.on', 'on {{date}}', { date: fmtDate(state.at) })}` : '',
reason: state.reason || t('certOrders.noReason', 'no reason given'),
}),
'text-destructive',
);
case 'retry':
return line(
<Clock className="mt-px h-3.5 w-3.5 shrink-0" />,
t('certOrders.retry', 'Failed, trying again {{when}}: {{reason}}', {
when: state.due
? t('certOrders.on', 'on {{date}}', { date: fmtDate(state.due) })
: t('certOrders.soon', 'soon'),
reason: state.reason || t('certOrders.noReason', 'no reason given'),
}),
'text-amber-700 dark:text-amber-500',
);
case 'pending': {
const later = !!state.due && new Date(state.due).getTime() > now + 3600_000;
return line(
<CheckCircle2 className="mt-px h-3.5 w-3.5 shrink-0 text-emerald-600" />,
!later
? t('certOrders.ordering', 'Ordering now.')
: hasCert
? t('certOrders.renews', 'Renews on {{date}}.', { date: fmtDate(state.due!) })
: t('certOrders.next', 'Next order on {{date}}.', { date: fmtDate(state.due!) }),
);
}
default:
return line(
<AlertTriangle className="mt-px h-3.5 w-3.5 shrink-0" />,
t('certOrders.nothingQueued', 'No renewal is queued. Order again queues one.'),
'text-amber-700 dark:text-amber-500',
);
}
}
@@ -14,6 +14,7 @@ import { useSchemaStore } from '@/stores/schemaStore';
import { cn } from '@/lib/utils';
export const CERTIFICATE_WIZARD_VIEW = 'Wizard/certificates';
export const ORDER_WIZARD_VIEW = 'Wizard/certificate-order';
/** "Guided or manual?" for automatic certificates. Manual opens the domains, where the setting lives. */
export function CertificateLaunchChoice({
@@ -15,7 +15,7 @@
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 { AlertTriangle, CheckCircle2, Loader2, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
@@ -23,99 +23,32 @@ 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 { getAccountId, 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 { RESOLVER_NAME } from '@/features/dns/liveCheck';
import { cn } from '@/lib/utils';
import { classify, orderNames, type Method, type NameState } from './names';
import { classify, orderNames, type Method } from './names';
import {
addProvider,
CHALLENGE,
type CertRow,
type Check,
coveringCert,
currentCert,
type DomainRow,
fmtDate,
LE_PRODUCTION,
LE_STAGING,
type Loaded,
load,
pollOrders,
resolveAddresses,
} from './shared';
import { StateIcon } from './StateIcon';
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;
/** The domain's own certificate hostnames, kept as they are when it's switched. */
hostnames: 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,
hostnames: cm['@type'] === 'Automatic' ? keys(cm.subjectAlternativeNames) : [],
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 ?? ''),
};
}
/** "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(', '));
@@ -124,41 +57,6 @@ 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();
@@ -264,35 +162,7 @@ export function CertificateSetupPage() {
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() });
setWatch(await pollOrders());
}, []);
const startWatching = useCallback(() => {
@@ -324,22 +194,7 @@ export function CertificateSetupPage() {
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.');
}
const id = await addProvider(m, contact, staging);
ids[m] = id;
made.push(id);
}
@@ -0,0 +1,748 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: "Order a certificate" (#167). Takes the names you want, finds the
* domain they belong to (or adds one just for certificates), checks the
* names, sets them as that domain's certificate hostnames, and watches the
* certificate arrive. The server orders certificates per domain, so this is
* the domain's automatic certificate with a name list of your choosing.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { AlertTriangle, CheckCircle2, Loader2, Plus, RefreshCw } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { LoadingFallback } from '@/components/common/LoadingFallback';
import { WizardNote, WizardShell, type WizardStep } from '@/components/wizard/WizardShell';
import { getAccountId, jmapSet } from '@/services/jmap/client';
import { useSchemaStore } from '@/stores/schemaStore';
import { RESOLVER_NAME } from '@/features/dns/liveCheck';
import { cn } from '@/lib/utils';
import { classify, orderNames, type Method } from './names';
import {
domainsFor,
hostnamesFor,
isValidName,
isWildcard,
nameInDomain,
parseNames,
renewalState,
sameNames,
suggestDomain,
} from './orders';
import {
addProvider,
CHALLENGE,
type CertRow,
type Check,
coveringCert,
type DomainRow,
fmtDate,
LE_PRODUCTION,
LE_STAGING,
type Loaded,
load,
pollOrders,
refusal,
resolveAddresses,
} from './shared';
import { StateIcon } from './StateIcon';
const WATCH_MS = 5 * 60_000;
export function OrderCertificatePage() {
const { t } = useTranslation();
const navigate = useNavigate();
const viewToSection = useSchemaStore((s) => s.viewToSection);
const section = viewToSection['x:Certificate'] ?? 'Settings';
const domainSection = viewToSection['x:Domain'] ?? 'Management';
const [data, setData] = useState<Loaded | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [step, setStep] = useState(0);
const [text, setText] = useState('');
const [domainId, setDomainId] = useState<string | null>(null);
const [newDomain, setNewDomain] = useState<string | null>(null);
const [checks, setChecks] = useState<Check[]>([]);
const [serverAddresses, setServerAddresses] = useState<string[] | null>(null);
const [anyway, setAnyway] = useState(false);
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 [madeDefault, setMadeDefault] = useState(false);
const timer = useRef<ReturnType<typeof setInterval> | null>(null);
const reload = useCallback(async () => {
const d = await load();
setData(d);
setContact((c) => c || (d.providers.find((p) => p.contact.length > 0)?.contact[0] ?? ''));
return d;
}, []);
useEffect(() => {
let live = true;
load()
.then((d) => {
if (!live) return;
setData(d);
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: 'names', title: t('certOrder.stepNames', 'Names') },
{ 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('certOrder.stepWatch', 'Watch it arrive') },
],
[t],
);
const names = useMemo(() => parseNames(text), [text]);
const invalid = names.filter((n) => !isValidName(n));
const candidates = useMemo(() => (data ? domainsFor(names, data.domains) : []), [data, names]);
// The closest domain unless one was picked and still fits.
const domain: DomainRow | undefined = candidates.find((d) => d.id === domainId) ?? candidates[0];
const method: Method = domain?.dnsAutomatic ? 'dns' : 'tls';
const wildcardBlocked = method === 'tls' && names.some(isWildcard);
const currentNames = domain?.automatic
? orderNames(domain.name, method, data?.serverName ?? '', domain.zone, domain.hostnames)
: [];
const unchanged = !!domain?.automatic && sameNames(currentNames, names);
const replaced = domain?.automatic && !unchanged ? currentNames.filter((n) => !names.includes(n)) : [];
const directory = staging ? LE_STAGING : LE_PRODUCTION;
const provider = data?.providers.find((p) => p.directory === directory && p.challengeType === CHALLENGE[method]);
const runChecks = useCallback(async () => {
if (!data || method !== 'tls') return;
setChecks(names.map((name) => ({ name, state: 'checking', addresses: [] })));
const anchor =
data.serverName ||
domain?.zone
.find((r) => r.type === 'MX')
?.value.split(/\s+/)
.pop() ||
'';
const mine = anchor ? await resolveAddresses(anchor).catch(() => []) : [];
setServerAddresses(mine);
const results = await Promise.all(
names.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(results);
}, [data, domain, method, names]);
const poll = useCallback(async () => {
setWatch(await pollOrders());
}, []);
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]);
/** Adds a domain that's only there for certificates: no DKIM keys, nothing published. */
const addDomain = async (name: string) => {
setBusy(true);
setError(null);
try {
const [res] = await jmapSet('x:Domain', getAccountId('x:Domain'), {
create: {
d: {
name,
description: t('certOrder.domainDescription', 'Added for certificates'),
dkimManagement: { '@type': 'Manual' },
dnsManagement: { '@type': 'Manual' },
certificateManagement: { '@type': 'Manual' },
},
},
});
const failed = refusal(res?.[1], 'The domain could not be added.');
if (failed) throw new Error(failed);
const id = (res?.[1] as { created?: Record<string, { id?: string }> })?.created?.d?.id;
await reload();
if (id) setDomainId(id);
setNewDomain(null);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const apply = async () => {
if (!data || !domain) return;
setBusy(true);
setError(null);
let made: string | null = null;
try {
let providerId = provider?.id;
if (!providerId) {
providerId = await addProvider(method, contact, staging);
made = providerId;
}
const [res] = await jmapSet('x:Domain', getAccountId('x:Domain'), {
update: {
[domain.id]: {
certificateManagement: {
'@type': 'Automatic',
acmeProviderId: providerId,
subjectAlternativeNames: hostnamesFor(names),
},
},
},
});
const failed = refusal(res?.[1], 'The certificate could not be ordered.');
if (failed) throw new Error(failed);
setStartedAt(Date.now());
setStep(4);
startWatching();
} catch (e) {
// Failsafe: an account added for this order is removed again when nothing uses it.
if (made)
await jmapSet('x:AcmeProvider', getAccountId('x:AcmeProvider'), { destroy: [made] }).catch(() => undefined);
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const makeDefault = async (certId: string) => {
setBusy(true);
setError(null);
try {
const [res] = await jmapSet('x:SystemSettings', getAccountId('x:SystemSettings'), {
update: { singleton: { defaultCertificateId: certId } },
});
const failed = refusal(res?.[1], 'The default certificate could not be changed.');
if (failed) throw new Error(failed);
setMadeDefault(true);
} catch (e) {
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 listView = `/${section}/x:Certificate`;
const common = {
icon: 'shield-check',
title: t('certOrder.title', 'Order a certificate'),
subtitle: t(
'certOrder.subtitle',
'A free Let’s Encrypt certificate for the names you choose, renewed by the server before it expires.',
),
steps,
current: domain ? step : 0,
onCancel: () => navigate(listView),
};
const errorLine = 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>
);
const how = method === 'dns' ? t('certSetup.howDnsWord', 'through DNS') : t('certSetup.howTlsWord', 'on port 443');
// ── Step 1: names (also where it goes back to if the domain stops fitting) ──
if (step === 0 || !domain) {
const ready = names.length > 0 && invalid.length === 0;
const suggestion = newDomain ?? suggestDomain(names);
return (
<WizardShell
{...common}
canNext={ready && !!domain && !wildcardBlocked && !unchanged && !busy}
onNext={() => {
setAnyway(false);
setStep(1);
void runChecks();
}}
aside={
<>
<WizardNote title={t('certOrder.oneDomainTitle', 'One certificate per domain')}>
<p>
{t(
'certOrder.oneDomain',
'The server orders each certificate for a domain, and renews it for as long as the domain says so. The names you enter become that domain’s certificate hostnames.',
)}
</p>
</WizardNote>
<WizardNote title={t('certSetup.howTitle', 'How the server proves it’s yours')}>
<p>
{t(
'certOrder.howDns',
'When the domain’s DNS is connected, the names are checked through DNS, and wildcards work. Otherwise Let’s Encrypt connects to this server on port 443 for each name.',
)}
</p>
</WizardNote>
</>
}
>
<div className="space-y-1.5">
<Label htmlFor="cert-names">{t('certOrder.namesLabel', 'Names the certificate covers')}</Label>
<Textarea
id="cert-names"
rows={4}
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={'mail.example.com\n*.example.com'}
className="max-w-md font-mono text-[13px]"
spellCheck={false}
autoComplete="off"
/>
<p className="text-xs text-muted-foreground">
{t('certOrder.namesHint', 'One per line. A wildcard (*.example.com) covers every name one level under it.')}
</p>
{invalid.length > 0 && (
<p className="text-sm text-destructive">
{t('certOrder.invalid', 'Not a name a certificate can cover: {{names}}', { names: invalid.join(', ') })}
</p>
)}
</div>
{ready && candidates.length > 0 && domain && (
<div className="space-y-3">
<div className="space-y-1.5">
<Label>{t('certOrder.domainLabel', 'Domain')}</Label>
{candidates.length > 1 ? (
<Select value={domain.id} onValueChange={setDomainId}>
<SelectTrigger className="w-72">
<SelectValue />
</SelectTrigger>
<SelectContent>
{candidates.map((d) => (
<SelectItem key={d.id} value={d.id}>
{d.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-sm font-medium">{domain.name}</p>
)}
<p className="text-xs text-muted-foreground">
{method === 'dns'
? t('certSetup.viaDns', 'Checked through DNS.')
: t('certSetup.viaTls', 'Checked on port 443.')}
</p>
</div>
{wildcardBlocked && (
<p className="flex items-start gap-2 text-sm">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" />
<span>
{t('certOrder.wildcardNeedsDns', 'A wildcard can only be checked through DNS.')}{' '}
<Link className="text-primary hover:underline" to={`/${domainSection}/Wizard/dns/${domain.id}`}>
{t('certOrder.connectDns', 'Connect {{domain}}’s DNS', { domain: domain.name })}
</Link>{' '}
{t('certOrder.connectDnsAfter', 'first, or leave the wildcard out.')}
</span>
</p>
)}
{unchanged && (
<p className="flex items-start gap-2 text-sm">
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-emerald-600" />
<span>
{t(
'certOrder.unchanged',
'{{domain}} already orders a certificate for exactly these names. To order it again after a failure, use Order again on the Certificates page.',
{ domain: domain.name },
)}
</span>
</p>
)}
{replaced.length > 0 && (
<div className="space-y-2 rounded-xl border border-amber-500/40 bg-amber-500/5 p-3 text-sm">
<p>
{t(
'certOrder.replaces',
'{{domain}} already gets a certificate for {{names}}. The new names take their place: that certificate isn’t renewed after this, and lasts until it expires.',
{ domain: domain.name, names: currentNames.join(', ') },
)}
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setText([...names, ...replaced].join('\n'))}
>
{t('certOrder.keepThem', 'Keep those names too')}
</Button>
</div>
)}
</div>
)}
{ready && candidates.length === 0 && (
<div className="space-y-2 rounded-xl border p-4 text-sm">
<p>
{t(
'certOrder.noDomain',
'None of your domains covers all of these names. Add the domain they belong to; it’s used only for certificates, with no DKIM keys and nothing published.',
)}
</p>
<div className="flex flex-wrap items-center gap-2">
<Input
aria-label={t('certOrder.domainLabel', 'Domain')}
value={suggestion}
onChange={(e) => setNewDomain(e.target.value.trim().toLowerCase())}
className="w-64"
/>
<Button
type="button"
variant="outline"
disabled={busy || !isValidName(suggestion) || !names.every((n) => nameInDomain(n, suggestion))}
onClick={() => void addDomain(suggestion)}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
{t('certOrder.addDomain', 'Add domain')}
</Button>
</div>
<p className="text-xs text-muted-foreground">
{t(
'certOrder.addDomainHint',
'To check the names through DNS (needed for wildcards), connect the new domain’s DNS from Domains afterwards.',
)}
</p>
</div>
)}
{errorLine}
</WizardShell>
);
}
// ── Step 2: check names ──
if (step === 1) {
const pending = checks.some((c) => c.state === 'checking');
const problems =
method === 'tls' && checks.some((c) => c.state !== 'ok' && c.state !== 'checking' && c.state !== 'error');
return (
<WizardShell
{...common}
onBack={() => setStep(0)}
onNext={() => setStep(2)}
canNext={!pending && (!problems || anyway)}
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(
'certOrder.whyAllText',
'If a single name fails, the whole order fails. It keeps retrying, and you can order it again once the name is fixed.',
)}
</p>
</WizardNote>
</>
}
>
{method === 'dns' ? (
<p className="flex items-center gap-2 text-sm">
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
{t('certOrder.dnsReady', 'Checked through {{domain}}’s connected DNS, covering {{names}}.', {
domain: domain.name,
names: names.join(', '),
})}
</p>
) : (
<>
{serverAddresses !== null && serverAddresses.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>
)}
<ul className="space-y-1 rounded-xl border p-4 text-sm">
{checks.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 && (
<div className="space-y-2 text-sm">
<p>
{t('certSetup.fixOr', 'Fix those names in DNS and check again, or')}{' '}
<Link className="text-primary hover:underline" to={`/${domainSection}/Wizard/dns/${domain.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} onCheckedChange={(v) => setAnyway(v === true)} />
{t('certSetup.anyway', 'Order anyway (it will keep retrying until the names are fixed)')}
</label>
</div>
)}
<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={!!provider || validEmail}
aside={
<WizardNote title={t('certSetup.stagingTitle', 'A practice run')}>
<p>
{t(
'certOrder.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 order again without it.',
)}
</p>
</WizardNote>
}
>
{provider ? (
<p className="text-sm">
{t('certSetup.reuse', 'The Let’s Encrypt account already set up on this server will be used.')}
</p>
) : (
<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>
)}
<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) {
return (
<WizardShell
{...common}
onBack={() => setStep(2)}
onNext={() => void apply()}
nextLabel={t('certOrder.apply', 'Order it')}
busy={busy}
aside={
<WizardNote tone="undo" title={t('certSetup.undoTitle', 'Changing your mind')}>
<p>
{t(
'certOrder.undo',
'Set the domain’s Certificate management back to Manual, or give it other names. A certificate already issued stays until it expires.',
)}
</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">
{!provider && (
<li>
{t('certSetup.newAccount', 'A Let’s Encrypt {{kind}}account is added for {{email}}.', {
kind: staging ? t('certSetup.stagingWord', 'staging ') : '',
email: contact.trim(),
})}
</li>
)}
<li>
{t('certOrder.domainGets', '{{domain}} gets a certificate for {{names}}, checked {{how}}.', {
domain: domain.name,
names: names.join(', '),
how,
})}
</li>
{replaced.length > 0 && (
<li>
{t('certOrder.noLonger', 'These names are no longer renewed: {{names}}.', {
names: replaced.join(', '),
})}
</li>
)}
</ul>
</div>
{errorLine}
</WizardShell>
);
}
// ── Step 5: watch ──
const cert = watch ? coveringCert(watch.certs, names) : undefined;
const fresh = cert && startedAt !== null && new Date(cert.notValidBefore).getTime() > startedAt - 2 * 3600_000;
const renewal = renewalState(watch?.tasks.find((x) => x.domainId === domain.id));
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 (renewal.kind === 'failed') {
state = { icon: 'missing', text: renewal.reason || t('certSetup.failed', 'Failed.') };
} else if (renewal.kind === 'retry') {
state = { icon: 'checking', text: t('certSetup.retrying', 'Retrying: {{reason}}', { reason: renewal.reason }) };
} else {
state = { icon: 'checking', text: t('certSetup.waiting', 'Ordering…') };
}
const done = state.icon !== 'checking';
const isDefault = madeDefault || (!!cert && data.defaultCertificateId === cert.id);
return (
<WizardShell
{...common}
onNext={() => navigate(listView)}
nextLabel={t('wizard.finish', 'Finish')}
aside={
<WizardNote title={t('certOrder.whereTitle', 'Where it’s used')}>
<p>
{t(
'certOrder.where',
'Every connection that asks for one of its names gets this certificate, with nothing to assign. The default certificate is the one sent when a connection asks for a name no certificate covers.',
)}
</p>
</WizardNote>
}
>
<p className="flex items-start gap-2 text-sm">
<StateIcon state={state.icon} />
<span className="font-medium">{names.join(', ')}</span>
<span className="text-muted-foreground">{state.text}</span>
</p>
{fresh && cert && (
<div className="flex flex-wrap items-center gap-3 text-sm">
{isDefault ? (
<span className="flex items-center gap-2 text-muted-foreground">
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
{t('certOrder.isDefault', 'This is the default certificate.')}
</span>
) : (
<Button type="button" variant="outline" disabled={busy} onClick={() => void makeDefault(cert.id)}>
{t('certOrder.makeDefault', 'Make it the default certificate')}
</Button>
)}
</div>
)}
{renewal.kind === 'failed' && (
<p className="text-sm text-muted-foreground">
{t('certOrder.failedNext', 'Fix what the reason says, then use Order again on the Certificates page.')}
</p>
)}
{errorLine}
{!done && !watching && (
<Button type="button" variant="outline" onClick={startWatching}>
<RefreshCw className="h-4 w-4" />
{t('certSetup.keepWatching', 'Keep watching')}
</Button>
)}
{!done && 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>
);
}
+15
View File
@@ -0,0 +1,15 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { CheckCircle2, CircleDashed, Loader2, XCircle } from 'lucide-react';
import type { Check } from './shared';
export 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" />;
}
+118
View File
@@ -0,0 +1,118 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, it } from 'vitest';
import {
canOrderAgain,
domainsFor,
isValidName,
nameInDomain,
orderAgainSet,
parseNames,
renewalState,
suggestDomain,
utcNow,
} from './orders';
describe('parseNames', () => {
it('splits on lines, commas and spaces, lowercases and drops repeats and trailing dots', () => {
expect(parseNames('Mail.Example.com\n*.example.com, mail.example.com. www.example.com')).toEqual([
'mail.example.com',
'*.example.com',
'www.example.com',
]);
});
});
describe('isValidName', () => {
it('takes host names and a leading wildcard', () => {
expect(isValidName('mail.example.com')).toBe(true);
expect(isValidName('*.example.com')).toBe(true);
});
it('refuses single labels, bare wildcards, inner wildcards and bad labels', () => {
expect(isValidName('localhost')).toBe(false);
expect(isValidName('*.com')).toBe(false);
expect(isValidName('a.*.example.com')).toBe(false);
expect(isValidName('-a.example.com')).toBe(false);
expect(isValidName('a_b.example.com')).toBe(false);
});
});
describe('domainsFor', () => {
const domains = [
{ id: 'a', name: 'example.com' },
{ id: 'b', name: 'lab.example.com' },
{ id: 'c', name: 'other.org' },
];
it('lists the domains covering every name, closest first', () => {
expect(domainsFor(['x.lab.example.com', '*.lab.example.com'], domains).map((d) => d.id)).toEqual(['b', 'a']);
});
it('is empty when the names span domains', () => {
expect(domainsFor(['x.example.com', 'y.other.org'], domains)).toEqual([]);
});
it('counts a wildcard by what follows it', () => {
expect(nameInDomain('*.example.com', 'example.com')).toBe(true);
expect(nameInDomain('notexample.com', 'example.com')).toBe(false);
});
});
describe('suggestDomain', () => {
it('uses the ending the names share', () => {
expect(suggestDomain(['mail.example.com', 'www.example.com'])).toBe('example.com');
expect(suggestDomain(['*.example.com', 'example.com'])).toBe('example.com');
});
it('suggests the parent of a single host', () => {
expect(suggestDomain(['mail.example.com'])).toBe('example.com');
expect(suggestDomain(['example.com'])).toBe('example.com');
});
it('gives up when the names share only a top-level domain', () => {
expect(suggestDomain(['a.example.com', 'b.other.com'])).toBe('');
});
});
describe('renewalState', () => {
it('reads each kind of status', () => {
expect(renewalState(undefined)).toEqual({ kind: 'none' });
expect(renewalState({ status: { '@type': 'Pending', due: '2026-12-01T00:00:00Z' } })).toEqual({
kind: 'pending',
due: '2026-12-01T00:00:00Z',
});
expect(
renewalState({ status: { '@type': 'Retry', due: '2026-11-07T00:00:00Z', failureReason: 'rate limited' } }),
).toEqual({ kind: 'retry', due: '2026-11-07T00:00:00Z', reason: 'rate limited' });
expect(
renewalState({ status: { '@type': 'Failed', failedAt: '2026-11-06T00:00:00Z', failureReason: 'NXDOMAIN' } }),
).toEqual({ kind: 'failed', at: '2026-11-06T00:00:00Z', reason: 'NXDOMAIN' });
});
it('offers Order again only when it would order something', () => {
expect(canOrderAgain({ kind: 'pending', due: null })).toBe(false);
expect(canOrderAgain({ kind: 'failed', at: null, reason: '' })).toBe(true);
expect(canOrderAgain({ kind: 'none' })).toBe(true);
});
});
describe('orderAgainSet', () => {
const now = new Date('2026-11-06T23:59:01.234Z');
it('runs the queued task now instead of adding another', () => {
expect(utcNow(now)).toBe('2026-11-06T23:59:01Z');
expect(orderAgainSet('d1', 't1', now)).toEqual({
update: {
t1: { status: { '@type': 'Pending', due: '2026-11-06T23:59:01Z' } },
},
});
});
it('creates one when nothing is queued', () => {
expect(orderAgainSet('d1', undefined, now)).toEqual({
create: {
renew: {
'@type': 'AcmeRenewal',
domainId: 'd1',
status: { '@type': 'Pending', due: '2026-11-06T23:59:01Z' },
},
},
});
});
});
+128
View File
@@ -0,0 +1,128 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: ordering a certificate with names you choose (#167).
*
* The server orders certificates for domains: each automatic domain gets one
* certificate, for its own hostname list when it has one
* (common/src/network/acme/order.rs). So a certificate for chosen names is
* that list on the domain the names belong to, and "order again" is the
* domain's renewal task told to run now.
*/
export interface DomainLike {
id: string;
name: string;
}
const LABEL = /^(?!-)[a-z0-9-]{1,63}(?<!-)$/;
/** Names typed one per line, or separated by commas or spaces, lowercased and without duplicates. */
export function parseNames(text: string): string[] {
const names = text
.split(/[\s,]+/)
.map((n) => n.trim().toLowerCase().replace(/\.$/, ''))
.filter(Boolean);
return [...new Set(names)];
}
/** A host name, or a wildcard in its first label only (`*.example.com`). */
export function isValidName(name: string): boolean {
const labels = name.split('.');
if (labels.length < 2 || name.length > 253) return false;
return labels.every((l, i) => (i === 0 && l === '*' ? labels.length > 2 : LABEL.test(l)));
}
export const isWildcard = (name: string) => name.startsWith('*.');
/** Whether a name is the domain itself or under it. A wildcard counts by what follows `*.`. */
export function nameInDomain(name: string, domain: string): boolean {
const n = name.toLowerCase().replace(/^\*\./, '');
const d = domain.toLowerCase();
return n === d || n.endsWith(`.${d}`);
}
/** The domains every one of these names belongs to, the closest (longest) first. */
export function domainsFor<T extends DomainLike>(names: string[], domains: T[]): T[] {
if (names.length === 0) return [];
return domains
.filter((d) => names.every((n) => nameInDomain(n, d.name)))
.sort((a, b) => b.name.length - a.name.length);
}
/**
* A domain to add when none of yours covers the names: the longest ending
* they all share, kept to at least two labels. A guess the admin can edit,
* since `example.co.uk` and `mail.example.com` look the same from here.
*/
export function suggestDomain(names: string[]): string {
const split = names.map((n) => n.replace(/^\*\./, '').split('.').reverse());
if (split.length === 0) return '';
const shared: string[] = [];
for (let i = 0; ; i++) {
const label = split[0][i];
if (label === undefined || split.some((s) => s[i] !== label)) break;
shared.push(label);
}
if (shared.length < 2) return '';
// One host on its own (mail.example.com) suggests the domain it's under.
const single = names.length === 1 && !isWildcard(names[0]) && shared.length > 2;
return (single ? shared.slice(0, -1) : shared).reverse().join('.');
}
/** The domain's own hostname list as the server stores it: names under it as written. */
export function hostnamesFor(names: string[]): Record<string, true> {
return Object.fromEntries(names.map((n) => [n, true as const]));
}
export const sameNames = (a: string[], b: string[]) =>
a.length === b.length && [...a].sort().join('\n') === [...b].sort().join('\n');
export type RenewalState =
| { kind: 'none' }
| { kind: 'pending'; due: string | null }
| { kind: 'retry'; due: string | null; reason: string }
| { kind: 'failed'; at: string | null; reason: string };
/** What a domain's renewal task says, from its `status`. */
export function renewalState(task: Record<string, unknown> | undefined): RenewalState {
if (!task) return { kind: 'none' };
const status = (task.status ?? {}) as Record<string, unknown>;
const str = (v: unknown) => (typeof v === 'string' && v ? v : null);
switch (status['@type']) {
case 'Failed':
return { kind: 'failed', at: str(status.failedAt), reason: String(status.failureReason ?? '') };
case 'Retry':
return { kind: 'retry', due: str(status.due), reason: String(status.failureReason ?? '') };
default:
return { kind: 'pending', due: str(status.due) ?? str(task.due) };
}
}
/** Order again is only offered when it would order something: after a failure, or with nothing queued. */
export const canOrderAgain = (s: RenewalState) => s.kind === 'failed' || s.kind === 'retry' || s.kind === 'none';
/** The server's date-time form, without the milliseconds `toISOString` adds. */
export function utcNow(now = new Date()): string {
return now.toISOString().replace(/\.\d{3}Z$/, 'Z');
}
/** A task status that runs it straight away. The server sets `createdAt` itself. */
export function runNowStatus(now = new Date()) {
return { '@type': 'Pending', due: utcNow(now) };
}
/**
* The `x:Task/set` arguments for Order again. A queued task is told to run
* now rather than a second one created: each renewal queues the next, so two
* would keep renewing for good (#290).
*/
export function orderAgainSet(domainId: string, taskId: string | undefined, now = new Date()) {
return taskId
? { update: { [taskId]: { status: runNowStatus(now) } } }
: { create: { renew: { '@type': 'AcmeRenewal', domainId, status: runNowStatus(now) } } };
}
+192
View File
@@ -0,0 +1,192 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/** inbuxa: what the certificate guides share: loading, polling and name lookups. */
import { getAccountId, jmapQueryAndGet, jmapRequest, jmapSet } from '@/services/jmap/client';
import type { JmapSetResponse } from '@/types/jmap';
import { dohQuery } from '@/features/dns/liveCheck';
import { parseZone } from '@/features/dns/zone';
import type { Method, NameState } from './names';
export const LE_PRODUCTION = 'https://acme-v02.api.letsencrypt.org/directory';
export const LE_STAGING = 'https://acme-staging-v02.api.letsencrypt.org/directory';
export const CHALLENGE: Record<Method, string> = { dns: 'Dns01', tls: 'TlsAlpn01' };
export interface DomainRow {
id: string;
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>;
}
export interface ProviderRow {
id: string;
directory: string;
challengeType: string;
contact: string[];
}
export interface CertRow {
id: string;
sans: string[];
notValidBefore: string;
notValidAfter: string;
}
export interface Loaded {
domains: DomainRow[];
providers: ProviderRow[];
certs: CertRow[];
serverName: string;
defaultCertificateId: string | null;
}
export type Check = { name: string; state: NameState | 'checking' | 'error'; addresses: string[] };
const keys = (v: unknown) => (v && typeof v === 'object' ? Object.keys(v as object) : []);
export const certRow = (c: Record<string, unknown>): CertRow => ({
id: String(c.id),
sans: keys(c.subjectAlternativeNames).map((s) => s.toLowerCase()),
notValidBefore: String(c.notValidBefore ?? ''),
notValidAfter: String(c.notValidAfter ?? ''),
});
export 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', 'defaultCertificateId'] }, 's'),
]);
const list = (id: string) =>
(res.find(([, , i]) => i === id)?.[1] as { list?: Record<string, unknown>[] } | undefined)?.list ?? [];
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,
hostnames: cm['@type'] === 'Automatic' ? keys(cm.subjectAlternativeNames) : [],
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(certRow),
serverName: String(list('s')[0]?.defaultHostname ?? ''),
defaultCertificateId: (list('s')[0]?.defaultCertificateId as string | undefined) ?? null,
};
}
/** The newest certificate covering every one of these names. */
export 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. */
export 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];
}
export 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;
}
export function fmtDate(iso: string): string {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleDateString(undefined, { dateStyle: 'medium' });
}
/** Every certificate and every certificate renewal task, for watching orders. */
export async function pollOrders(): Promise<{ certs: CertRow[]; tasks: Record<string, unknown>[]; at: number }> {
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',
'due',
]);
const certs = (((certRes?.[1] as { list?: Record<string, unknown>[] })?.list ?? []) as Record<string, unknown>[]).map(
certRow,
);
const tasks = (
((taskRes?.[1] as { list?: Record<string, unknown>[] })?.list ?? []) as Record<string, unknown>[]
).filter((x) => x['@type'] === 'AcmeRenewal');
return { certs, tasks, at: Date.now() };
}
/**
* Adds a Let’s Encrypt account for one way of checking names, and returns its
* id. The server writes the description itself, and refuses one from a client.
*/
export async function addProvider(method: Method, contact: string, staging: boolean): Promise<string> {
const [res] = await jmapSet('x:AcmeProvider', getAccountId('x:AcmeProvider'), {
create: {
acme: {
directory: staging ? LE_STAGING : LE_PRODUCTION,
challengeType: CHALLENGE[method],
contact: { [contact.trim()]: true },
},
},
});
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.');
}
return id;
}
/** The first refusal in a set response, as an error message, or null when nothing was refused. */
export function refusal(res: unknown, fallback: string): string | null {
const body = res as JmapSetResponse | undefined;
const e = [body?.notCreated, body?.notUpdated, body?.notDestroyed]
.flatMap((m) => (m ? Object.values(m) : []))
.find(Boolean);
return e ? (e.description ?? e.type ?? fallback) : null;
}
+56
View File
@@ -2673,5 +2673,61 @@
"guidedHint": "Five steps: where the mail is now, which accounts, where to, a preview, then the move.",
"manualHint": "Every answer in one form. Preview and move from the list.",
"newSecret": "New secret…"
},
"certOrders": {
"order": "Order a certificate",
"loading": "Reading automatic certificates…",
"title": "Ordered automatically",
"none": "No certificate is ordered automatically yet.",
"hint": "Each one belongs to a domain, which renews it before it expires. Connections that ask for one of its names get it.",
"validUntil": "valid until {{date}}",
"noCert": "not issued yet",
"default": "default certificate",
"again": "Order again",
"failed": "The last order failed{{at}}: {{reason}}",
"on": "on {{date}}",
"noReason": "no reason given",
"retry": "Failed, trying again {{when}}: {{reason}}",
"soon": "soon",
"ordering": "Ordering now.",
"renews": "Renews on {{date}}.",
"next": "Next order on {{date}}.",
"nothingQueued": "No renewal is queued. Order again queues one."
},
"certOrder": {
"stepNames": "Names",
"stepWatch": "Watch it arrive",
"domainDescription": "Added for certificates",
"title": "Order a certificate",
"subtitle": "A free Let’s Encrypt certificate for the names you choose, renewed by the server before it expires.",
"oneDomainTitle": "One certificate per domain",
"oneDomain": "The server orders each certificate for a domain, and renews it for as long as the domain says so. The names you enter become that domain’s certificate hostnames.",
"howDns": "When the domain’s DNS is connected, the names are checked through DNS, and wildcards work. Otherwise Let’s Encrypt connects to this server on port 443 for each name.",
"namesLabel": "Names the certificate covers",
"namesHint": "One per line. A wildcard (*.example.com) covers every name one level under it.",
"invalid": "Not a name a certificate can cover: {{names}}",
"domainLabel": "Domain",
"wildcardNeedsDns": "A wildcard can only be checked through DNS.",
"connectDns": "Connect {{domain}}’s DNS",
"connectDnsAfter": "first, or leave the wildcard out.",
"unchanged": "{{domain}} already orders a certificate for exactly these names. To order it again after a failure, use Order again on the Certificates page.",
"replaces": "{{domain}} already gets a certificate for {{names}}. The new names take their place: that certificate isn’t renewed after this, and lasts until it expires.",
"keepThem": "Keep those names too",
"noDomain": "None of your domains covers all of these names. Add the domain they belong to; it’s used only for certificates, with no DKIM keys and nothing published.",
"addDomain": "Add domain",
"addDomainHint": "To check the names through DNS (needed for wildcards), connect the new domain’s DNS from Domains afterwards.",
"whyAllText": "If a single name fails, the whole order fails. It keeps retrying, and you can order it again once the name is fixed.",
"dnsReady": "Checked through {{domain}}’s connected DNS, covering {{names}}.",
"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 order again without it.",
"apply": "Order it",
"undo": "Set the domain’s Certificate management back to Manual, or give it other names. A certificate already issued stays until it expires.",
"domainGets": "{{domain}} gets a certificate for {{names}}, checked {{how}}.",
"noLonger": "These names are no longer renewed: {{names}}.",
"whereTitle": "Where it’s used",
"where": "Every connection that asks for one of its names gets this certificate, with nothing to assign. The default certificate is the one sent when a connection asks for a name no certificate covers.",
"isDefault": "This is the default certificate.",
"makeDefault": "Make it the default certificate",
"failedNext": "Fix what the reason says, then use Order again on the Certificates page.",
"tabTitle": "Order a certificate"
}
}
+1
View File
@@ -67,6 +67,7 @@ const PAGES: Record<string, PageRule> = {
'CustomComponent/Migrations': { enterprise: false, visible: (c) => holds('sysMigrationRun', c) },
// Guided jobs are for whoever may read everything they change.
'Wizard/certificates': readOnly('sysDomain', 'sysAcmeProvider', 'sysCertificate'),
'Wizard/certificate-order': readOnly('sysDomain', 'sysAcmeProvider', 'sysCertificate'),
'Wizard/sending': readOnly('sysMtaRoute', 'sysMtaOutboundStrategy'),
'Wizard/directory': readOnly('sysDirectory', 'sysDomain', 'sysAuthentication'),
'Wizard/limits': readOnly('sysMtaInboundThrottle', 'sysMtaOutboundThrottle', 'sysMtaQueueQuota'),
+1
View File
@@ -114,6 +114,7 @@ describe('isLinkVisible for the console’s own pages', () => {
const wizards: [string, string[]][] = [
['Wizard/certificates', ['sysDomain', 'sysAcmeProvider', 'sysCertificate']],
['Wizard/certificate-order', ['sysDomain', 'sysAcmeProvider', 'sysCertificate']],
['Wizard/sending', ['sysMtaRoute', 'sysMtaOutboundStrategy']],
['Wizard/directory', ['sysDirectory', 'sysDomain', 'sysAuthentication']],
['Wizard/limits', ['sysMtaInboundThrottle', 'sysMtaOutboundThrottle', 'sysMtaQueueQuota']],
+1
View File
@@ -82,6 +82,7 @@ describe('adminPageTitle', () => {
expect(title('Settings', 'Wizard/limits')).toBe('Limits · Settings');
expect(title('Settings', 'Wizard/directory')).toBe('Sign-in directory · Settings');
expect(title('Settings', 'Wizard/certificates')).toBe('Certificates · Settings');
expect(title('Settings', 'Wizard/certificate-order')).toBe('Order a certificate · Settings');
expect(title('Settings', 'Wizard/sending')).toBe('Sending mail · Settings');
});
+2
View File
@@ -55,6 +55,8 @@ function wizardTitle(t: TFunction, viewName: string): string | null {
return t('dirSetup.tabTitle', 'Sign-in directory');
case 'Wizard/certificates':
return t('certSetup.tabTitle', 'Certificates');
case 'Wizard/certificate-order':
return t('certOrder.tabTitle', 'Order a certificate');
case 'Wizard/sending':
return t('sendingSetup.tabTitle', 'Sending mail');
}