Certificates: order one with the names you choose, and order again #170
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
@@ -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' },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) } } };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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']],
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user