Guided setup: certificates, automatically #39
No files matched your search
@@ -18,6 +18,7 @@ import { DynamicViewPage } from '@/components/views/DynamicViewPage';
|
||||
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
||||
import { LegacyProtocolsBanner } from '@/features/hardening/LegacyProtocolsBanner';
|
||||
import { SendingSetupCard } from '@/features/sending/SendingSetupCard';
|
||||
import { CertificateSetupCard } from '@/features/certificates/CertificateSetupCard';
|
||||
import type { Schema } from '@/types/schema';
|
||||
|
||||
function lazyFeature<M, P>(load: () => Promise<M>, select: (module: M) => ComponentType<P>) {
|
||||
@@ -63,6 +64,10 @@ const DataInventoryPage = lazyFeature(
|
||||
(m) => m.DataInventoryPage,
|
||||
);
|
||||
// inbuxa: Settings › Overview (settings-reorg spec).
|
||||
const CertificateSetupPage = lazyFeature(
|
||||
() => import('@/features/certificates/CertificateSetupPage'),
|
||||
(m) => m.CertificateSetupPage,
|
||||
);
|
||||
const SendingSetupPage = lazyFeature(
|
||||
() => import('@/features/sending/SendingSetupPage'),
|
||||
(m) => m.SendingSetupPage,
|
||||
@@ -140,6 +145,10 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
|
||||
if (wizard === 'sending') {
|
||||
return <SendingSetupPage />;
|
||||
}
|
||||
// inbuxa: guided setup, automatic certificates (settings-reorg).
|
||||
if (wizard === 'certificates') {
|
||||
return <CertificateSetupPage />;
|
||||
}
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
|
||||
Unknown guide: {wizard}
|
||||
@@ -265,6 +274,14 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
|
||||
return page;
|
||||
}
|
||||
|
||||
if (viewName === 'x:Certificate' || viewName === 'x:AcmeProvider') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<CertificateSetupCard />
|
||||
<DynamicList viewName={viewName} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (viewName === 'x:MtaRoute') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ShieldCheck } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LaunchChoice } from '@/components/wizard/LaunchChoice';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export const CERTIFICATE_WIZARD_VIEW = 'Wizard/certificates';
|
||||
|
||||
/** "Guided or manual?" for automatic certificates. Manual opens the domains, where the setting lives. */
|
||||
export function CertificateLaunchChoice({
|
||||
open,
|
||||
onOpenChange,
|
||||
onManual,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onManual?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const viewToSection = useSchemaStore((s) => s.viewToSection);
|
||||
const section = viewToSection['x:Certificate'] ?? 'Settings';
|
||||
return (
|
||||
<LaunchChoice
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t('certCard.chooseTitle', 'Certificates, automatically')}
|
||||
guidedHint={t(
|
||||
'certCard.guidedHint',
|
||||
'Pick domains, check every name first, and watch Let’s Encrypt certificates arrive. About two minutes.',
|
||||
)}
|
||||
manualHint={t(
|
||||
'certCard.manualHint',
|
||||
'Add an ACME provider, then set each domain’s Certificate management yourself.',
|
||||
)}
|
||||
onGuided={() => navigate(`/${section}/${CERTIFICATE_WIZARD_VIEW}`)}
|
||||
onManual={onManual ?? (() => navigate(`/${viewToSection['x:Domain'] ?? 'Management'}/x:Domain`))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** inbuxa: at the top of Certificates and ACME providers (settings-reorg, guided setup 2). */
|
||||
export function CertificateSetupCard({ className }: { className?: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-wrap items-center gap-4 rounded-xl border border-primary/30 bg-primary/5 p-4',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ShieldCheck className="h-5 w-5 shrink-0 text-primary" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">{t('certCard.title', 'Free certificates that renew themselves')}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
'certCard.hint',
|
||||
'A guide checks your domains’ names and sets up Let’s Encrypt, so certificates are issued and renewed for you.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" onClick={() => setOpen(true)}>
|
||||
{t('certCard.start', 'Set it up')}
|
||||
</Button>
|
||||
<CertificateLaunchChoice open={open} onOpenChange={setOpen} onManual={() => undefined} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,800 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: guided setup, "Certificates, automatically" (settings-reorg, first
|
||||
* wave). Picks domains, checks every name the certificate will cover before
|
||||
* anything is ordered, chooses how Let's Encrypt checks them (through DNS
|
||||
* when the domain's DNS is connected, otherwise on port 443), switches the
|
||||
* domains to automatic, and watches the certificates arrive.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle, CheckCircle2, CircleDashed, Loader2, RefreshCw, XCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
||||
import { WizardNote, WizardShell, type WizardStep } from '@/components/wizard/WizardShell';
|
||||
import { getAccountId, jmapQueryAndGet, jmapRequest, jmapSet } from '@/services/jmap/client';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import type { JmapSetResponse } from '@/types/jmap';
|
||||
import { dohQuery, RESOLVER_NAME } from '@/features/dns/liveCheck';
|
||||
import { parseZone } from '@/features/dns/zone';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { classify, orderNames, type Method, type NameState } from './names';
|
||||
|
||||
const LE_PRODUCTION = 'https://acme-v02.api.letsencrypt.org/directory';
|
||||
const LE_STAGING = 'https://acme-staging-v02.api.letsencrypt.org/directory';
|
||||
const CHALLENGE: Record<Method, string> = { dns: 'Dns01', tls: 'TlsAlpn01' };
|
||||
const WATCH_MS = 5 * 60_000;
|
||||
|
||||
interface DomainRow {
|
||||
id: string;
|
||||
name: string;
|
||||
automatic: boolean;
|
||||
providerId?: string;
|
||||
dnsAutomatic: boolean;
|
||||
zone: ReturnType<typeof parseZone>;
|
||||
}
|
||||
|
||||
interface ProviderRow {
|
||||
id: string;
|
||||
directory: string;
|
||||
challengeType: string;
|
||||
contact: string[];
|
||||
}
|
||||
|
||||
interface CertRow {
|
||||
sans: string[];
|
||||
notValidBefore: string;
|
||||
notValidAfter: string;
|
||||
}
|
||||
|
||||
interface Loaded {
|
||||
domains: DomainRow[];
|
||||
providers: ProviderRow[];
|
||||
certs: CertRow[];
|
||||
serverName: string;
|
||||
}
|
||||
|
||||
type Check = { name: string; state: NameState | 'checking' | 'error'; addresses: string[] };
|
||||
|
||||
async function load(): Promise<Loaded> {
|
||||
const get = (obj: string, args: Record<string, unknown>, id: string) =>
|
||||
[`${obj}/get`, { accountId: getAccountId(obj), ...args }, id] as [string, Record<string, unknown>, string];
|
||||
const res = await jmapRequest([
|
||||
get('x:Domain', { ids: null, properties: ['name', 'certificateManagement', 'dnsManagement', 'dnsZoneFile'] }, 'd'),
|
||||
get('x:AcmeProvider', { ids: null, properties: ['directory', 'challengeType', 'contact'] }, 'p'),
|
||||
get(
|
||||
'x:Certificate',
|
||||
{ ids: null, properties: ['subjectAlternativeNames', 'notValidBefore', 'notValidAfter'] },
|
||||
'c',
|
||||
),
|
||||
get('x:SystemSettings', { ids: ['singleton'], properties: ['defaultHostname'] }, 's'),
|
||||
]);
|
||||
const list = (id: string) =>
|
||||
(res.find(([, , i]) => i === id)?.[1] as { list?: Record<string, unknown>[] } | undefined)?.list ?? [];
|
||||
const keys = (v: unknown) => (v && typeof v === 'object' ? Object.keys(v as object) : []);
|
||||
return {
|
||||
domains: list('d')
|
||||
.map((d) => {
|
||||
const cm = (d.certificateManagement ?? {}) as Record<string, unknown>;
|
||||
const dm = (d.dnsManagement ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
id: String(d.id),
|
||||
name: String(d.name),
|
||||
automatic: cm['@type'] === 'Automatic',
|
||||
providerId: cm.acmeProviderId as string | undefined,
|
||||
dnsAutomatic: dm['@type'] === 'Automatic',
|
||||
zone: parseZone(d.dnsZoneFile as string | undefined),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
providers: list('p').map((p) => ({
|
||||
id: String(p.id),
|
||||
directory: String(p.directory ?? ''),
|
||||
challengeType: String(p.challengeType ?? ''),
|
||||
contact: keys(p.contact).map((c) => c.replace(/^mailto:/, '')),
|
||||
})),
|
||||
certs: list('c').map((c) => ({
|
||||
sans: keys(c.subjectAlternativeNames).map((s) => s.toLowerCase()),
|
||||
notValidBefore: String(c.notValidBefore ?? ''),
|
||||
notValidAfter: String(c.notValidAfter ?? ''),
|
||||
})),
|
||||
serverName: String(list('s')[0]?.defaultHostname ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
/** When the switch happened, read in the click handler, for telling a new certificate from an old one. */
|
||||
async function switchedAt(): Promise<number> {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
/** The newest certificate covering every one of these names. */
|
||||
function coveringCert(certs: CertRow[], names: string[]): CertRow | undefined {
|
||||
return certs
|
||||
.filter((c) => names.every((n) => c.sans.includes(n.toLowerCase())))
|
||||
.sort((a, b) => b.notValidAfter.localeCompare(a.notValidAfter))[0];
|
||||
}
|
||||
|
||||
/** The newest certificate covering any of these names: what the domain uses today. */
|
||||
function currentCert(certs: CertRow[], names: string[]): CertRow | undefined {
|
||||
return certs
|
||||
.filter((c) => names.some((n) => c.sans.includes(n.toLowerCase())))
|
||||
.sort((a, b) => b.notValidAfter.localeCompare(a.notValidAfter))[0];
|
||||
}
|
||||
|
||||
async function resolveAddresses(name: string): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
for (const type of ['A', 'AAAA']) {
|
||||
const r = await dohQuery(name.replace(/^\*\./, ''), type);
|
||||
out.push(...r.answer.filter((a) => a.type === 1 || a.type === 28).map((a) => a.data));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? iso : d.toLocaleDateString(undefined, { dateStyle: 'medium' });
|
||||
}
|
||||
|
||||
function StateIcon({ state }: { state: Check['state'] }) {
|
||||
if (state === 'ok') return <CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-600" />;
|
||||
if (state === 'checking') return <Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />;
|
||||
if (state === 'error') return <CircleDashed className="h-4 w-4 shrink-0 text-muted-foreground" />;
|
||||
return <XCircle className="h-4 w-4 shrink-0 text-destructive" />;
|
||||
}
|
||||
|
||||
export function CertificateSetupPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const viewToSection = useSchemaStore((s) => s.viewToSection);
|
||||
const section = viewToSection['x:Certificate'] ?? 'Settings';
|
||||
|
||||
const [data, setData] = useState<Loaded | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [step, setStep] = useState(0);
|
||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||
const [checks, setChecks] = useState<Record<string, Check[]>>({});
|
||||
const [serverAddresses, setServerAddresses] = useState<string[] | null>(null);
|
||||
const [anyway, setAnyway] = useState<Set<string>>(new Set());
|
||||
const [staging, setStaging] = useState(false);
|
||||
const [contact, setContact] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [startedAt, setStartedAt] = useState<number | null>(null);
|
||||
const [watch, setWatch] = useState<{ certs: CertRow[]; tasks: Record<string, unknown>[]; at: number } | null>(null);
|
||||
const [watching, setWatching] = useState(false);
|
||||
const timer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
load()
|
||||
.then((d) => {
|
||||
if (!live) return;
|
||||
setData(d);
|
||||
setPicked(new Set(d.domains.filter((x) => !x.automatic).map((x) => x.id)));
|
||||
setContact(d.providers.find((p) => p.contact.length > 0)?.contact[0] ?? '');
|
||||
})
|
||||
.catch((e: unknown) => live && setLoadError(e instanceof Error ? e.message : String(e)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => void (timer.current && clearInterval(timer.current)), []);
|
||||
|
||||
const steps: WizardStep[] = useMemo(
|
||||
() => [
|
||||
{ id: 'domains', title: t('certSetup.stepDomains', 'Domains') },
|
||||
{ id: 'check', title: t('certSetup.stepCheck', 'Check names') },
|
||||
{ id: 'account', title: t('certSetup.stepAccount', 'Account') },
|
||||
{ id: 'review', title: t('certSetup.stepReview', 'Review') },
|
||||
{ id: 'watch', title: t('certSetup.stepWatch', 'Watch them arrive') },
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const chosen = useMemo(() => (data ? data.domains.filter((d) => picked.has(d.id)) : []), [data, picked]);
|
||||
const methodOf = (d: DomainRow): Method => (d.dnsAutomatic ? 'dns' : 'tls');
|
||||
const namesOf = useCallback(
|
||||
(d: DomainRow) => orderNames(d.name, methodOf(d), data?.serverName ?? '', d.zone),
|
||||
[data],
|
||||
);
|
||||
|
||||
const runChecks = useCallback(async () => {
|
||||
if (!data) return;
|
||||
const tlsDomains = chosen.filter((d) => methodOf(d) === 'tls');
|
||||
const initial: Record<string, Check[]> = {};
|
||||
for (const d of tlsDomains) initial[d.id] = namesOf(d).map((name) => ({ name, state: 'checking', addresses: [] }));
|
||||
setChecks(initial);
|
||||
// Where this server is: its own name, or the domain's mail host.
|
||||
const anchor =
|
||||
data.serverName ||
|
||||
tlsDomains[0]?.zone
|
||||
.find((r) => r.type === 'MX')
|
||||
?.value.split(/\s+/)
|
||||
.pop() ||
|
||||
'';
|
||||
const mine = anchor ? await resolveAddresses(anchor).catch(() => []) : [];
|
||||
setServerAddresses(mine);
|
||||
for (const d of tlsDomains) {
|
||||
const results = await Promise.all(
|
||||
namesOf(d).map(async (name): Promise<Check> => {
|
||||
try {
|
||||
const addresses = await resolveAddresses(name);
|
||||
return {
|
||||
name,
|
||||
state: mine.length > 0 ? classify(addresses, mine) : addresses.length ? 'ok' : 'missing',
|
||||
addresses,
|
||||
};
|
||||
} catch {
|
||||
return { name, state: 'error', addresses: [] };
|
||||
}
|
||||
}),
|
||||
);
|
||||
setChecks((c) => ({ ...c, [d.id]: results }));
|
||||
}
|
||||
}, [chosen, data, namesOf]);
|
||||
|
||||
const blocked = (d: DomainRow) =>
|
||||
methodOf(d) === 'tls' &&
|
||||
(checks[d.id] ?? []).some((c) => c.state !== 'ok' && c.state !== 'error') &&
|
||||
!anyway.has(d.id);
|
||||
const going = chosen.filter((d) => !blocked(d));
|
||||
|
||||
const directory = staging ? LE_STAGING : LE_PRODUCTION;
|
||||
const methodsNeeded = [...new Set(going.map(methodOf))];
|
||||
const providerFor = (m: Method) =>
|
||||
data?.providers.find((p) => p.directory === directory && p.challengeType === CHALLENGE[m]);
|
||||
const needsNewProvider = methodsNeeded.some((m) => !providerFor(m));
|
||||
|
||||
const poll = useCallback(async () => {
|
||||
const [certRes] = await jmapRequest([
|
||||
[
|
||||
'x:Certificate/get',
|
||||
{
|
||||
accountId: getAccountId('x:Certificate'),
|
||||
ids: null,
|
||||
properties: ['subjectAlternativeNames', 'notValidBefore', 'notValidAfter'],
|
||||
},
|
||||
'c',
|
||||
],
|
||||
]);
|
||||
const [, taskRes] = await jmapQueryAndGet(
|
||||
'x:Task',
|
||||
getAccountId('x:Task'),
|
||||
{ filter: { '@type': 'AcmeRenewal' } },
|
||||
['@type', 'domainId', 'status'],
|
||||
);
|
||||
const keys = (v: unknown) => (v && typeof v === 'object' ? Object.keys(v as object) : []);
|
||||
const certs = (
|
||||
((certRes?.[1] as { list?: Record<string, unknown>[] })?.list ?? []) as Record<string, unknown>[]
|
||||
).map((c) => ({
|
||||
sans: keys(c.subjectAlternativeNames).map((s) => s.toLowerCase()),
|
||||
notValidBefore: String(c.notValidBefore ?? ''),
|
||||
notValidAfter: String(c.notValidAfter ?? ''),
|
||||
}));
|
||||
const tasks = (
|
||||
((taskRes?.[1] as { list?: Record<string, unknown>[] })?.list ?? []) as Record<string, unknown>[]
|
||||
).filter((x) => x['@type'] === 'AcmeRenewal');
|
||||
setWatch({ certs, tasks, at: Date.now() });
|
||||
}, []);
|
||||
|
||||
const startWatching = useCallback(() => {
|
||||
if (timer.current) clearInterval(timer.current);
|
||||
const began = Date.now();
|
||||
setWatching(true);
|
||||
void poll().catch(() => undefined);
|
||||
timer.current = setInterval(() => {
|
||||
if (Date.now() - began > WATCH_MS) {
|
||||
clearInterval(timer.current!);
|
||||
timer.current = null;
|
||||
setWatching(false);
|
||||
return;
|
||||
}
|
||||
void poll().catch(() => undefined);
|
||||
}, 5000);
|
||||
}, [poll]);
|
||||
|
||||
const apply = async () => {
|
||||
if (!data) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const made: string[] = [];
|
||||
try {
|
||||
const ids: Partial<Record<Method, string>> = {};
|
||||
for (const m of methodsNeeded) {
|
||||
const existing = providerFor(m);
|
||||
if (existing) {
|
||||
ids[m] = existing.id;
|
||||
continue;
|
||||
}
|
||||
const [res] = await jmapSet('x:AcmeProvider', getAccountId('x:AcmeProvider'), {
|
||||
create: {
|
||||
acme: {
|
||||
directory,
|
||||
challengeType: CHALLENGE[m],
|
||||
contact: { [contact.trim()]: true },
|
||||
description: `${staging ? 'Let’s Encrypt (staging)' : 'Let’s Encrypt'}, ${m === 'dns' ? 'DNS-01' : 'TLS-ALPN-01'}, ${contact.trim()}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
const body = res?.[1] as unknown as JmapSetResponse | undefined;
|
||||
const id = (body?.created?.acme as { id?: string } | undefined)?.id;
|
||||
if (!id) {
|
||||
const e = body?.notCreated ? Object.values(body.notCreated)[0] : undefined;
|
||||
throw new Error(e?.description ?? e?.type ?? 'The Let’s Encrypt account could not be added.');
|
||||
}
|
||||
ids[m] = id;
|
||||
made.push(id);
|
||||
}
|
||||
const update = Object.fromEntries(
|
||||
going.map((d) => [
|
||||
d.id,
|
||||
{
|
||||
certificateManagement: {
|
||||
'@type': 'Automatic',
|
||||
acmeProviderId: ids[methodOf(d)],
|
||||
subjectAlternativeNames: {},
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
const [res] = await jmapSet('x:Domain', getAccountId('x:Domain'), { update });
|
||||
const body = res?.[1] as unknown as JmapSetResponse | undefined;
|
||||
const refused = Object.entries(body?.notUpdated ?? {});
|
||||
if (refused.length === going.length) {
|
||||
const e = refused[0]?.[1];
|
||||
throw new Error(e?.description ?? e?.type ?? 'The domains could not be switched to automatic certificates.');
|
||||
}
|
||||
if (refused.length > 0) {
|
||||
const names = refused.map(([id]) => data.domains.find((d) => d.id === id)?.name ?? id).join(', ');
|
||||
setError(t('certSetup.partial', 'These domains were not switched: {{names}}', { names }));
|
||||
}
|
||||
setStartedAt(await switchedAt());
|
||||
setStep(4);
|
||||
startWatching();
|
||||
} catch (e) {
|
||||
// Failsafe: an account added for this run is removed again when nothing uses it.
|
||||
for (const id of made) {
|
||||
await jmapSet('x:AcmeProvider', getAccountId('x:AcmeProvider'), { destroy: [id] }).catch(() => undefined);
|
||||
}
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loadError) return <div className="mx-auto max-w-3xl p-8 text-center text-destructive">{loadError}</div>;
|
||||
if (!data) return <LoadingFallback />;
|
||||
|
||||
const domainsView = `/${viewToSection['x:Domain'] ?? 'Management'}/x:Domain`;
|
||||
const common = {
|
||||
icon: 'shield-check',
|
||||
title: t('certSetup.title', 'Certificates, automatically'),
|
||||
subtitle: t(
|
||||
'certSetup.subtitle',
|
||||
'Free certificates from Let’s Encrypt for your domains, renewed by the server before they expire.',
|
||||
),
|
||||
steps,
|
||||
current: step,
|
||||
onCancel: () => navigate(`/${section}/x:Certificate`),
|
||||
};
|
||||
|
||||
// ── Step 1: domains ──
|
||||
if (step === 0) {
|
||||
return (
|
||||
<WizardShell
|
||||
{...common}
|
||||
canNext={chosen.length > 0}
|
||||
onNext={() => {
|
||||
setStep(1);
|
||||
void runChecks();
|
||||
}}
|
||||
aside={
|
||||
<WizardNote title={t('certSetup.howTitle', 'How the server proves it’s yours')}>
|
||||
<p>
|
||||
{t(
|
||||
'certSetup.howDns',
|
||||
'A domain whose DNS is connected is checked through DNS: the certificate covers the domain and everything under it (*.domain), and nothing needs to reach this server.',
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{t(
|
||||
'certSetup.howTls',
|
||||
'Otherwise Let’s Encrypt connects to this server on port 443 for each name. Those names must point straight at this server, not through a proxy.',
|
||||
)}
|
||||
</p>
|
||||
</WizardNote>
|
||||
}
|
||||
>
|
||||
{data.domains.length === 0 ? (
|
||||
<p className="text-sm">
|
||||
{t('certSetup.noDomains', 'There are no domains yet.')}{' '}
|
||||
<Link className="text-primary hover:underline" to={domainsView}>
|
||||
{t('certSetup.addDomain', 'Add one first')}
|
||||
</Link>
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y rounded-xl border">
|
||||
{data.domains.map((d) => {
|
||||
const cert = currentCert(data.certs, namesOf(d));
|
||||
return (
|
||||
<li key={d.id} className="flex items-center gap-3 px-4 py-3">
|
||||
<Checkbox
|
||||
id={`dom-${d.id}`}
|
||||
checked={picked.has(d.id)}
|
||||
onCheckedChange={(v) =>
|
||||
setPicked((s) => {
|
||||
const n = new Set(s);
|
||||
if (v) n.add(d.id);
|
||||
else n.delete(d.id);
|
||||
return n;
|
||||
})
|
||||
}
|
||||
/>
|
||||
<label htmlFor={`dom-${d.id}`} className="min-w-0 flex-1">
|
||||
<span className="font-medium">{d.name}</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{d.automatic
|
||||
? t('certSetup.alreadyAuto', 'Already automatic.')
|
||||
: t('certSetup.manual', 'Certificates managed by hand.')}{' '}
|
||||
{cert
|
||||
? t('certSetup.validUntil', 'Current certificate valid until {{date}}.', {
|
||||
date: fmtDate(cert.notValidAfter),
|
||||
})
|
||||
: t('certSetup.noCert', 'No certificate yet.')}{' '}
|
||||
{methodOf(d) === 'dns'
|
||||
? t('certSetup.viaDns', 'Checked through DNS.')
|
||||
: t('certSetup.viaTls', 'Checked on port 443.')}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</WizardShell>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step 2: check names ──
|
||||
if (step === 1) {
|
||||
const tlsDomains = chosen.filter((d) => methodOf(d) === 'tls');
|
||||
const pending = tlsDomains.some((d) => (checks[d.id] ?? []).some((c) => c.state === 'checking'));
|
||||
return (
|
||||
<WizardShell
|
||||
{...common}
|
||||
onBack={() => setStep(0)}
|
||||
onNext={() => setStep(2)}
|
||||
canNext={!pending && going.length > 0}
|
||||
aside={
|
||||
<>
|
||||
<WizardNote title={t('certSetup.checkedWith', 'How this is checked')}>
|
||||
<p>
|
||||
{t(
|
||||
'certSetup.checkedHow',
|
||||
'Each name is looked up in public DNS through {{resolver}}, and compared with where {{server}} points. Nothing is ordered yet.',
|
||||
{ resolver: RESOLVER_NAME, server: data.serverName || t('certSetup.thisServer', 'this server') },
|
||||
)}
|
||||
</p>
|
||||
</WizardNote>
|
||||
<WizardNote title={t('certSetup.whyAll', 'Why every name matters')}>
|
||||
<p>
|
||||
{t(
|
||||
'certSetup.whyAllText',
|
||||
'One certificate covers all of a domain’s names. If a single name fails, the whole order fails and keeps retrying.',
|
||||
)}
|
||||
</p>
|
||||
</WizardNote>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{chosen
|
||||
.filter((d) => methodOf(d) === 'dns')
|
||||
.map((d) => (
|
||||
<p key={d.id} className="flex items-center gap-2 text-sm">
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
|
||||
{t('certSetup.dnsReady', '{{domain}}: checked through its connected DNS, covering {{names}}.', {
|
||||
domain: d.name,
|
||||
names: namesOf(d).join(' and '),
|
||||
})}
|
||||
</p>
|
||||
))}
|
||||
{serverAddresses !== null && serverAddresses.length === 0 && tlsDomains.length > 0 && (
|
||||
<p className="flex items-start gap-2 text-sm text-muted-foreground">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
{t(
|
||||
'certSetup.noAnchor',
|
||||
'This server’s own address couldn’t be looked up, so names are only checked for existing.',
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{tlsDomains.map((d) => {
|
||||
const list = checks[d.id] ?? [];
|
||||
const problems = list.filter((c) => c.state !== 'ok' && c.state !== 'checking' && c.state !== 'error');
|
||||
return (
|
||||
<div key={d.id} className="space-y-2 rounded-xl border p-4">
|
||||
<p className="font-medium">{d.name}</p>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{list.map((c) => (
|
||||
<li key={c.name} className="flex items-start gap-2">
|
||||
<StateIcon state={c.state} />
|
||||
<span className="font-mono text-[13px]">{c.name}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{c.state === 'ok' && t('certSetup.ok', 'points here')}
|
||||
{c.state === 'missing' &&
|
||||
t('certSetup.missing', 'doesn’t exist: add a CNAME to {{server}}', {
|
||||
server: data.serverName || t('certSetup.thisServer', 'this server'),
|
||||
})}
|
||||
{c.state === 'proxied' &&
|
||||
t('certSetup.proxied', 'goes through the Cloudflare proxy: set it to DNS only (grey cloud)')}
|
||||
{c.state === 'elsewhere' &&
|
||||
t('certSetup.elsewhere', 'points somewhere else ({{ips}})', { ips: c.addresses.join(', ') })}
|
||||
{c.state === 'error' && t('certSetup.lookupFailed', 'couldn’t be looked up')}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{problems.length > 0 && (
|
||||
<div className="space-y-2 border-t pt-3 text-sm">
|
||||
<p>
|
||||
{t('certSetup.fixOr', 'Fix those names in DNS and check again, or')}{' '}
|
||||
<Link
|
||||
className="text-primary hover:underline"
|
||||
to={`/${viewToSection['x:Domain'] ?? 'Management'}/Wizard/dns/${d.id}`}
|
||||
>
|
||||
{t('certSetup.connectDns', 'connect this domain’s DNS')}
|
||||
</Link>{' '}
|
||||
{t('certSetup.connectDnsWhy', 'so it’s checked through DNS instead.')}
|
||||
</p>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={anyway.has(d.id)}
|
||||
onCheckedChange={(v) =>
|
||||
setAnyway((s) => {
|
||||
const n = new Set(s);
|
||||
if (v) n.add(d.id);
|
||||
else n.delete(d.id);
|
||||
return n;
|
||||
})
|
||||
}
|
||||
/>
|
||||
{t('certSetup.anyway', 'Order anyway (it will keep retrying until the names are fixed)')}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{tlsDomains.length > 0 && (
|
||||
<Button type="button" variant="outline" onClick={() => void runChecks()} disabled={pending}>
|
||||
<RefreshCw className={cn('h-4 w-4', pending && 'animate-spin')} />
|
||||
{t('certSetup.checkAgain', 'Check again')}
|
||||
</Button>
|
||||
)}
|
||||
</WizardShell>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step 3: account ──
|
||||
if (step === 2) {
|
||||
const validEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(contact.trim());
|
||||
return (
|
||||
<WizardShell
|
||||
{...common}
|
||||
onBack={() => setStep(1)}
|
||||
onNext={() => setStep(3)}
|
||||
canNext={!needsNewProvider || validEmail}
|
||||
aside={
|
||||
<WizardNote title={t('certSetup.stagingTitle', 'A practice run')}>
|
||||
<p>
|
||||
{t(
|
||||
'certSetup.stagingText',
|
||||
'Let’s Encrypt’s staging service issues certificates that browsers don’t trust, with far looser limits. Use it to see everything work, then run this guide again without it.',
|
||||
)}
|
||||
</p>
|
||||
</WizardNote>
|
||||
}
|
||||
>
|
||||
{needsNewProvider ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="acme-contact">{t('certSetup.contact', 'Your email address')}</Label>
|
||||
<Input
|
||||
id="acme-contact"
|
||||
type="email"
|
||||
value={contact}
|
||||
onChange={(e) => setContact(e.target.value)}
|
||||
className="max-w-md"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
'certSetup.contactHint',
|
||||
'Let’s Encrypt writes here only about problems with your account or certificates. Creating the account accepts their Subscriber Agreement.',
|
||||
)}{' '}
|
||||
<a
|
||||
className="text-primary hover:underline"
|
||||
href="https://letsencrypt.org/repository/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t('certSetup.agreement', 'Read it')}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm">
|
||||
{t('certSetup.reuse', 'The Let’s Encrypt account already set up on this server will be used.')}
|
||||
</p>
|
||||
)}
|
||||
<label className="flex items-center justify-between gap-4">
|
||||
<span>
|
||||
<span className="font-medium">{t('certSetup.staging', 'Practice run (staging)')}</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t('certSetup.stagingHint', 'Certificates browsers won’t trust. For trying this out.')}
|
||||
</span>
|
||||
</span>
|
||||
<Switch checked={staging} onCheckedChange={setStaging} />
|
||||
</label>
|
||||
</WizardShell>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step 4: review ──
|
||||
if (step === 3) {
|
||||
const skipped = chosen.filter(blocked);
|
||||
return (
|
||||
<WizardShell
|
||||
{...common}
|
||||
onBack={() => setStep(2)}
|
||||
onNext={() => void apply()}
|
||||
nextLabel={t('certSetup.apply', 'Switch them to automatic')}
|
||||
canNext={going.length > 0}
|
||||
busy={busy}
|
||||
aside={
|
||||
<WizardNote tone="undo" title={t('certSetup.undoTitle', 'Changing your mind')}>
|
||||
<p>
|
||||
{t(
|
||||
'certSetup.undo',
|
||||
'Set a domain’s Certificate management back to Manual. Certificates already issued stay until they expire.',
|
||||
)}
|
||||
</p>
|
||||
</WizardNote>
|
||||
}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">{t('certSetup.willChange', 'What will change')}</p>
|
||||
<ul className="list-disc space-y-1 pl-5 text-sm">
|
||||
{needsNewProvider && (
|
||||
<li>
|
||||
{t('certSetup.newAccount', 'A Let’s Encrypt {{kind}}account is added for {{email}}.', {
|
||||
kind: staging ? t('certSetup.stagingWord', 'staging ') : '',
|
||||
email: contact.trim(),
|
||||
})}
|
||||
</li>
|
||||
)}
|
||||
{going.map((d) => (
|
||||
<li key={d.id}>
|
||||
{t(
|
||||
'certSetup.domainAuto',
|
||||
'{{domain}} gets its certificate automatically, checked {{how}}: {{names}}.',
|
||||
{
|
||||
domain: d.name,
|
||||
how:
|
||||
methodOf(d) === 'dns'
|
||||
? t('certSetup.howDnsWord', 'through DNS')
|
||||
: t('certSetup.howTlsWord', 'on port 443'),
|
||||
names: namesOf(d).join(', '),
|
||||
},
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{skipped.length > 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('certSetup.skipped', 'Left as they are until their names are fixed: {{names}}.', {
|
||||
names: skipped.map((d) => d.name).join(', '),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="flex items-start gap-2 text-sm text-destructive">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</WizardShell>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step 5: watch ──
|
||||
const rows = going.map((d) => {
|
||||
const names = namesOf(d);
|
||||
const cert = watch ? coveringCert(watch.certs, names) : undefined;
|
||||
const fresh = cert && startedAt !== null && new Date(cert.notValidBefore).getTime() > startedAt - 2 * 3600_000;
|
||||
const task = watch?.tasks.find((x) => x.domainId === d.id);
|
||||
const status = (task?.status ?? {}) as Record<string, unknown>;
|
||||
const due = typeof status.due === 'string' ? new Date(status.due).getTime() : null;
|
||||
let state: { icon: Check['state']; text: string };
|
||||
if (fresh) {
|
||||
state = {
|
||||
icon: 'ok',
|
||||
text: t('certSetup.issued', 'Issued. Valid until {{date}}.', { date: fmtDate(cert.notValidAfter) }),
|
||||
};
|
||||
} else if (status['@type'] === 'Failed') {
|
||||
state = { icon: 'missing', text: String(status.failureReason ?? t('certSetup.failed', 'Failed.')) };
|
||||
} else if (status['@type'] === 'Retry') {
|
||||
state = {
|
||||
icon: 'checking',
|
||||
text: t('certSetup.retrying', 'Retrying: {{reason}}', { reason: String(status.failureReason ?? '') }),
|
||||
};
|
||||
} else if (cert && due !== null && watch && due > watch.at + 3600_000) {
|
||||
state = {
|
||||
icon: 'ok',
|
||||
text: t(
|
||||
'certSetup.coveredAlready',
|
||||
'Already covered by a certificate valid until {{date}}; it renews on {{due}}.',
|
||||
{
|
||||
date: fmtDate(cert.notValidAfter),
|
||||
due: fmtDate(String(status.due)),
|
||||
},
|
||||
),
|
||||
};
|
||||
} else {
|
||||
state = { icon: 'checking', text: t('certSetup.waiting', 'Ordering…') };
|
||||
}
|
||||
return { d, state };
|
||||
});
|
||||
const allDone = rows.length > 0 && rows.every((r) => r.state.icon !== 'checking');
|
||||
|
||||
return (
|
||||
<WizardShell
|
||||
{...common}
|
||||
onNext={() => navigate(`/${section}/x:Certificate`)}
|
||||
nextLabel={t('wizard.finish', 'Finish')}
|
||||
aside={
|
||||
<WizardNote title={t('certSetup.afterTitle', 'From now on')}>
|
||||
<p>
|
||||
{t(
|
||||
'certSetup.after',
|
||||
'The server renews each certificate well before it expires. Failed renewals show under Management › Tasks › Failed.',
|
||||
)}
|
||||
</p>
|
||||
</WizardNote>
|
||||
}
|
||||
>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{rows.map(({ d, state }) => (
|
||||
<li key={d.id} className="flex items-start gap-2">
|
||||
<StateIcon state={state.icon} />
|
||||
<span className="font-medium">{d.name}</span>
|
||||
<span className="text-muted-foreground">{state.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
{!allDone && !watching && (
|
||||
<Button type="button" variant="outline" onClick={startWatching}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
{t('certSetup.keepWatching', 'Keep watching')}
|
||||
</Button>
|
||||
)}
|
||||
{!allDone && watching && (
|
||||
<p className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
{t('certSetup.watchingHint', 'Checking every few seconds. Most certificates arrive within a minute.')}
|
||||
</p>
|
||||
)}
|
||||
</WizardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { classify, isCloudflareProxy, orderNames } from './names';
|
||||
|
||||
describe('orderNames', () => {
|
||||
it('asks for the domain and a wildcard with DNS validation', () => {
|
||||
expect(orderNames('Example.com', 'dns', 'mail.example.com')).toEqual(['*.example.com', 'example.com']);
|
||||
});
|
||||
|
||||
it('asks for the technical names, the server name and MX hosts in the domain otherwise', () => {
|
||||
const zone = [
|
||||
{ name: 'example.com.', type: 'MX', value: '10 mx.example.com.', kind: 'mx' as const },
|
||||
{ name: 'example.com.', type: 'MX', value: '20 mx.other.net.', kind: 'mx' as const },
|
||||
];
|
||||
expect(orderNames('example.com', 'tls', 'mail.example.com', zone)).toEqual([
|
||||
'autoconfig.example.com',
|
||||
'autodiscover.example.com',
|
||||
'mail.example.com',
|
||||
'mta-sts.example.com',
|
||||
'mx.example.com',
|
||||
'ua-auto-config.example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves out a server name in another domain', () => {
|
||||
expect(orderNames('example.com', 'tls', 'mail.other.net')).not.toContain('mail.other.net');
|
||||
});
|
||||
});
|
||||
|
||||
describe('classify', () => {
|
||||
it('knows a name that reaches this server', () => {
|
||||
expect(classify(['157.180.98.170'], ['157.180.98.170', '2a01::25'])).toBe('ok');
|
||||
});
|
||||
it('spots the Cloudflare proxy', () => {
|
||||
expect(isCloudflareProxy('104.21.3.4')).toBe(true);
|
||||
expect(isCloudflareProxy('172.67.1.1')).toBe(true);
|
||||
expect(isCloudflareProxy('2606:4700:3030::1')).toBe(true);
|
||||
expect(isCloudflareProxy('157.180.98.170')).toBe(false);
|
||||
expect(classify(['104.21.3.4'], ['157.180.98.170'])).toBe('proxied');
|
||||
});
|
||||
it('tells missing from elsewhere', () => {
|
||||
expect(classify([], ['1.2.3.4'])).toBe('missing');
|
||||
expect(classify(['5.6.7.8'], ['1.2.3.4'])).toBe('elsewhere');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: which names a certificate order asks for, and whether each one
|
||||
* reaches this server (settings-reorg, guided setup 2).
|
||||
*
|
||||
* The server decides the names (common/src/network/acme/order.rs): with DNS
|
||||
* validation it asks for the domain and a wildcard under it; otherwise for
|
||||
* mta-sts, ua-auto-config, autoconfig and autodiscover under the domain, plus
|
||||
* the server's own name when it's inside the domain. Every name must pass, or
|
||||
* the whole order fails and keeps retrying, so the guide checks them first.
|
||||
*/
|
||||
|
||||
import type { ZoneRecord } from '@/features/dns/zone';
|
||||
|
||||
export type Method = 'dns' | 'tls';
|
||||
|
||||
export const TECHNICAL_NAMES = ['mta-sts', 'ua-auto-config', 'autoconfig', 'autodiscover'];
|
||||
|
||||
function inZone(name: string, domain: string): boolean {
|
||||
return name === domain || name.endsWith(`.${domain}`);
|
||||
}
|
||||
|
||||
export function orderNames(domain: string, method: Method, serverName: string, zone: ZoneRecord[] = []): string[] {
|
||||
const d = domain.toLowerCase();
|
||||
if (method === 'dns') return [`*.${d}`, d];
|
||||
const names = new Set(TECHNICAL_NAMES.map((h) => `${h}.${d}`));
|
||||
const host = serverName.toLowerCase().replace(/\.$/, '');
|
||||
if (host && inZone(host, d)) names.add(host);
|
||||
for (const r of zone) {
|
||||
if (r.type !== 'MX') continue;
|
||||
const target = r.value.split(/\s+/).pop()?.toLowerCase().replace(/\.$/, '');
|
||||
if (target && inZone(target, d)) names.add(target);
|
||||
}
|
||||
return [...names].sort();
|
||||
}
|
||||
|
||||
// Cloudflare's published proxy ranges (cloudflare.com/ips). A name that
|
||||
// answers with one of these is orange-clouded: TLS-ALPN validation can't
|
||||
// reach this server through the proxy.
|
||||
const CF_V4 = [
|
||||
'173.245.48.0/20',
|
||||
'103.21.244.0/22',
|
||||
'103.22.200.0/22',
|
||||
'103.31.4.0/22',
|
||||
'141.101.64.0/18',
|
||||
'108.162.192.0/18',
|
||||
'190.93.240.0/20',
|
||||
'188.114.96.0/20',
|
||||
'197.234.240.0/22',
|
||||
'198.41.128.0/17',
|
||||
'162.158.0.0/15',
|
||||
'104.16.0.0/13',
|
||||
'104.24.0.0/14',
|
||||
'172.64.0.0/13',
|
||||
'131.0.72.0/22',
|
||||
];
|
||||
const CF_V6 = ['2400:cb00:', '2606:4700:', '2803:f800:', '2405:b500:', '2405:8100:', '2a06:98c', '2c0f:f248:'];
|
||||
|
||||
function v4ToInt(ip: string): number | null {
|
||||
const parts = ip.split('.').map(Number);
|
||||
if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255)) return null;
|
||||
return ((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3];
|
||||
}
|
||||
|
||||
export function isCloudflareProxy(ip: string): boolean {
|
||||
if (ip.includes(':')) return CF_V6.some((p) => ip.toLowerCase().startsWith(p));
|
||||
const n = v4ToInt(ip);
|
||||
if (n === null) return false;
|
||||
return CF_V4.some((cidr) => {
|
||||
const [base, bits] = cidr.split('/');
|
||||
const b = v4ToInt(base)!;
|
||||
const mask = bits === '0' ? 0 : (~0 << (32 - Number(bits))) >>> 0;
|
||||
return (n & mask) >>> 0 === (b & mask) >>> 0;
|
||||
});
|
||||
}
|
||||
|
||||
export type NameState = 'ok' | 'proxied' | 'elsewhere' | 'missing';
|
||||
|
||||
/** Where a name points, against where this server is. */
|
||||
export function classify(addresses: string[], serverAddresses: string[]): NameState {
|
||||
if (addresses.length === 0) return 'missing';
|
||||
const mine = new Set(serverAddresses.map((a) => a.toLowerCase()));
|
||||
if (addresses.some((a) => mine.has(a.toLowerCase()))) return 'ok';
|
||||
if (addresses.some(isCloudflareProxy)) return 'proxied';
|
||||
return 'elsewhere';
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { SETTINGS_CATEGORIES, SETTINGS_LAYOUT_NAME } from '@/lib/settingsLayout'
|
||||
import { resolveObject } from '@/lib/schemaResolver';
|
||||
import { countChanged, singletonPages, type SettingsPageRef } from './changedFromDefault';
|
||||
import { SENDING_WIZARD_VIEW, SendingLaunchChoice } from '@/features/sending/SendingSetupCard';
|
||||
import { CERTIFICATE_WIZARD_VIEW, CertificateLaunchChoice } from '@/features/certificates/CertificateSetupCard';
|
||||
|
||||
/** Calls per JMAP request; the protocol's own floor, so any server takes it. */
|
||||
const CALLS_PER_REQUEST = 16;
|
||||
@@ -49,6 +50,14 @@ async function anyExist(objectName: string): Promise<boolean> {
|
||||
|
||||
/** Each guided setup registers here as it lands. */
|
||||
const GUIDED_SETUPS: GuidedSetup[] = [
|
||||
{
|
||||
id: 'certificates',
|
||||
title: 'Certificates, automatically',
|
||||
blurb:
|
||||
'Free Let’s Encrypt certificates for your domains, with every name checked first and renewals handled for you.',
|
||||
to: { choice: (props) => <CertificateLaunchChoice {...props} /> },
|
||||
gate: CERTIFICATE_WIZARD_VIEW,
|
||||
},
|
||||
{
|
||||
id: 'sending',
|
||||
title: 'How this server sends mail',
|
||||
|
||||
@@ -100,6 +100,10 @@ function checkSpecialLink(
|
||||
}
|
||||
|
||||
// INBUXA: guided jobs are open to whoever may manage what they change.
|
||||
if (viewName === 'Wizard/certificates') {
|
||||
const ok = canGet ? canGet('sysDomain') && canGet('sysAcmeProvider') && canGet('sysCertificate') : true;
|
||||
return { visible: ok, enterprise: false };
|
||||
}
|
||||
if (viewName === 'Wizard/sending') {
|
||||
const ok = canGet ? canGet('sysMtaRoute') && canGet('sysMtaOutboundStrategy') : true;
|
||||
return { visible: ok, enterprise: false };
|
||||
|
||||
@@ -106,6 +106,7 @@ export default function AdminPanel() {
|
||||
if (!section) return t('dashboard.title', 'Dashboard');
|
||||
if (!viewName) return section;
|
||||
if (viewName.startsWith('Wizard/dns/')) return `${t('dnsWizard.tabTitle', 'Publish DNS')} · ${section}`;
|
||||
if (viewName === 'Wizard/certificates') return `${t('certSetup.tabTitle', 'Certificates')} · ${section}`;
|
||||
if (viewName === 'Wizard/sending') return `${t('sendingSetup.tabTitle', 'Sending mail')} · ${section}`;
|
||||
if (viewName.startsWith('Dashboard/')) {
|
||||
const board = schema?.dashboards?.find((d) => d.id === viewName.slice('Dashboard/'.length));
|
||||
|
||||
Reference in new issue
Block a user