diff --git a/src/features/relays/RelaysPage.tsx b/src/features/relays/RelaysPage.tsx index aa5a40a..e3e7112 100644 --- a/src/features/relays/RelaysPage.tsx +++ b/src/features/relays/RelaysPage.tsx @@ -11,7 +11,8 @@ * when a relay needs attention. Each relay has its settings, which the * server pushes to it, and its queues, read over the server's link, where * held mail can be discarded and outbound mail cancelled. A relay older - * than this server can be handed a signed release to upgrade to. + * than this server can be handed a signed release to upgrade to. The link + * CA they all share can be replaced without a new join on each (RL-34). */ import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; @@ -23,6 +24,7 @@ import { Check, Copy, Inbox, + KeyRound, Loader2, Plus, RefreshCw, @@ -59,6 +61,7 @@ import { fetchRelays, rejoinRelay, RelaysUnavailable, + replaceLinkCa, RelayUnreachable, updateRelay, upgradeRelay, @@ -68,6 +71,7 @@ import { envelopeLine, joinCommand, latestStatus, + linkCaReplacement, readSignatureFile, settingsPatch, shutOut, @@ -193,9 +197,64 @@ function attentionText(t: TFunction, a: Attention): string { } case 'recipientsDiffer': return t('relays.att.recipients', 'Nodes sent it different recipient lists; one of them is out of date.'); + case 'linkCaOld': + return t( + 'relays.att.linkCaOld', + 'It only trusts a link CA this server has replaced, so no node can link to it. Start a new join.', + ); + case 'linkCaWaiting': + return t('relays.att.linkCaWaiting', 'It doesn’t trust the new link CA yet; the replacement waits for it.'); } } +type LinkCaChange = 'start' | 'finish' | 'cancel'; + +/** The replacement of the link CA under way, and what can be done about it. */ +function LinkCaBanner({ + since, + waiting, + canChange, + onChange, +}: { + since: string; + waiting: Relay[]; + canChange: boolean; + onChange: (change: LinkCaChange) => void; +}) { + const { t } = useTranslation(); + return ( +
+
+ +
+

+ {t('relays.linkCa.since', 'Replacing the link CA, since {{since}}', { since: when(since) })} +

+

+ {waiting.length > 0 + ? t( + 'relays.linkCa.waiting', + 'Waiting for {{names}} to trust the new one. Once every relay does, the nodes move to it and the old one stops working.', + { names: waiting.map((r) => r.name).join(', ') }, + ) + : t('relays.linkCa.ready', 'Every relay trusts the new one. The nodes move to it within a minute.')} +

+
+
+ {canChange && ( +
+ + +
+ )} +
+ ); +} + function CopyButton({ text, label }: { text: string; label: string }) { const { t } = useTranslation(); const [done, setDone] = useState(false); @@ -1152,7 +1211,11 @@ type Open = | { kind: 'queue'; relay: Relay } | { kind: 'rejoin'; relay: Relay } | { kind: 'upgrade'; relay: Relay } - | { kind: 'delete'; relay: Relay }; + | { kind: 'delete'; relay: Relay } + | { kind: 'linkCa'; change: LinkCaChange; relayId: string; waiting: Relay[] }; + +/** While the link CA is replaced, the page looks again this often, as the server finishes it by itself. */ +const REPLACING_REFRESH_MS = 15_000; export function RelaysPage() { const { t } = useTranslation(); @@ -1180,6 +1243,21 @@ export function RelaysPage() { return () => controller.abort(); }, [fetches, t]); + const relays = useMemo(() => (load.kind === 'ready' ? load.relays : []), [load]); + const replacement = useMemo(() => linkCaReplacement(relays), [relays]); + // A server from before RL-34 sends no linkCaReplacing at all + const canReplaceLinkCa = + canChange && !replacement && relays.some((r) => r.joinState === 'joined' && r.linkCaReplacing !== undefined); + + useEffect(() => { + if (!replacement || open) return; + const timer = setInterval(refetch, REPLACING_REFRESH_MS); + return () => clearInterval(timer); + }, [replacement, open, refetch]); + + const changeLinkCa = (change: LinkCaChange) => + relays[0] && setOpen({ kind: 'linkCa', change, relayId: relays[0].id, waiting: replacement?.waiting ?? [] }); + const close = () => setOpen(null); return ( @@ -1193,13 +1271,29 @@ export function RelaysPage() { )} actions={ canChange && ( - +
+ {canReplaceLinkCa && ( + + )} + +
) } /> + {replacement && ( + + )} {load.kind === 'loading' && } {load.kind === 'error' && (
{load.message}
@@ -1210,7 +1304,7 @@ export function RelaysPage() { )} {load.kind === 'ready' && - load.relays.map((relay) => ( + relays.map((relay) => ( )} + {open?.kind === 'linkCa' && ( + 0 + ? t( + 'relays.linkCa.finishBody', + 'The nodes move to the new link CA without waiting for {{names}}, which will each need a new join.', + { names: open.waiting.map((r) => r.name).join(', ') }, + ) + : t('relays.linkCa.finishReady', 'The nodes move to the new link CA now.') + : t( + 'relays.linkCa.cancelBody', + 'This server drops the new link CA, and the relays go back to trusting only the current one.', + ) + } + action={ + open.change === 'start' + ? t('relays.linkCa.startGo', 'Replace it') + : open.change === 'finish' + ? t('relays.linkCa.finishGo', 'Finish now') + : t('relays.linkCa.cancelGo', 'Cancel the replacement') + } + destructive={open.change === 'finish' && open.waiting.length > 0} + onClose={close} + onConfirm={async (reason) => { + try { + await replaceLinkCa(open.relayId, open.change, reason); + close(); + refetch(); + } catch (e) { + failed(t('relays.linkCa.failed', 'The link CA wasn’t changed'), e); + } + }} + /> + )} {open?.kind === 'delete' && ( { + await updateRelay(relayId, { replaceLinkCa: change }, reason); +} + export async function deleteRelay(id: string, reason?: string): Promise { const result = await call('inbuxa:Relay/set', { ...withReason(reason), destroy: [id] }); const failed = setFailure(result, 'notDestroyed', id); diff --git a/src/features/relays/model.test.ts b/src/features/relays/model.test.ts index 4629cd7..dc10699 100644 --- a/src/features/relays/model.test.ts +++ b/src/features/relays/model.test.ts @@ -11,6 +11,7 @@ import { envelopeLine, formatDuration, joinCommand, + linkCaReplacement, parseDuration, parseDurations, parseRange, @@ -83,6 +84,38 @@ function relay(extra: Partial = {}): Relay { }; } +describe('the link CA', () => { + it('asks for a new join from a relay on an old one, and nothing else', () => { + expect(attention(relay({ linkCa: 'old', links: [link('1', { state: 'down' })] }), NOW)).toEqual([ + { kind: 'linkCaOld' }, + ]); + expect(attention(relay({ linkCa: 'old', joinState: 'waiting', links: [] }), NOW)).toEqual([{ kind: 'linkCaOld' }]); + expect(attention(relay({ linkCa: 'old', enabled: false }), NOW)).toEqual([]); + }); + + it('names the relays a replacement waits for', () => { + const since = '2026-10-30T11:00:00Z'; + const relays = [ + relay({ id: 'a', linkCa: 'both', linkCaReplacing: since }), + relay({ id: 'b', linkCa: 'current', linkCaReplacing: since }), + relay({ id: 'c', linkCa: null, linkCaReplacing: since }), + relay({ id: 'd', linkCa: 'current', linkCaReplacing: since, enabled: false }), + relay({ id: 'e', linkCa: 'old', linkCaReplacing: since, joinState: 'waiting', links: [] }), + ]; + const found = linkCaReplacement(relays); + expect(found?.since).toBe(since); + expect(found?.waiting.map((r) => r.id)).toEqual(['b', 'c']); + expect(attention(relays[1], NOW)).toEqual([{ kind: 'linkCaWaiting' }]); + expect(attention(relays[0], NOW)).toEqual([]); + }); + + it('sees none under way, or none on a server before RL-34', () => { + expect(linkCaReplacement([relay({ linkCa: 'current', linkCaReplacing: null })])).toBeNull(); + expect(linkCaReplacement([relay()])).toBeNull(); + expect(attention(relay({ linkCa: 'current', linkCaReplacing: null }), NOW)).toEqual([]); + }); +}); + describe('attention', () => { it('says nothing when everything is fine, or the relay is off', () => { expect(attention(relay(), NOW)).toEqual([]); diff --git a/src/features/relays/model.ts b/src/features/relays/model.ts index 542c179..30bec54 100644 --- a/src/features/relays/model.ts +++ b/src/features/relays/model.ts @@ -11,6 +11,8 @@ * page edits its settings in. */ +export type LinkCa = 'current' | 'both' | 'old'; + export type LinkState = 'connecting' | 'joining' | 'linked' | 'down' | 'joinFailed'; /** What a relay last said of itself, over one node's link. */ @@ -79,6 +81,14 @@ export interface Relay { certificateError?: string | null; /** Absent from servers before RL-28: a link last heard an older release than the server's. */ olderThanServer?: boolean | null; + /** + * Absent from servers before RL-34: the link CA it trusts, `both` during a + * replacement, `old` when it only trusts one since replaced; null before + * it has said. + */ + linkCa?: LinkCa | null; + /** Absent from servers before RL-34: when the replacement of the link CA under way began. */ + linkCaReplacing?: string | null; joinState: 'waiting' | 'expired' | 'joined'; joinExpiresAt?: string | null; joinedAt?: string | null; @@ -137,7 +147,9 @@ export type Attention = | { kind: 'certificateFailed'; error: string } | { kind: 'relayError'; error: string } | { kind: 'olderVersion'; version: string | null; selfUpgrade: boolean } - | { kind: 'recipientsDiffer' }; + | { kind: 'recipientsDiffer' } + | { kind: 'linkCaOld' } + | { kind: 'linkCaWaiting' }; /** A spool this full is worth a line. */ export const SPOOL_WARN = 0.8; @@ -155,6 +167,8 @@ export function latestStatus(relay: Relay): RelayStatus | null { export function attention(relay: Relay, now: Date = new Date()): Attention[] { if (!relay.enabled) return []; + // It can't link until it joins again, so nothing else it says matters + if (relay.linkCa === 'old') return [{ kind: 'linkCaOld' }]; if (relay.joinState === 'waiting') return [{ kind: 'joinWaiting', until: relay.joinExpiresAt }]; if (relay.joinState === 'expired') return [{ kind: 'joinExpired' }]; const out: Attention[] = []; @@ -191,9 +205,21 @@ export function attention(relay: Relay, now: Date = new Date()): Attention[] { relay.links.filter((l) => l.state === 'linked' && l.recipients != null).map((l) => l.recipients), ); if (counts.size > 1) out.push({ kind: 'recipientsDiffer' }); + if (holdsUpReplacement(relay)) out.push({ kind: 'linkCaWaiting' }); return out; } +/** The server finishes a replacement once every enabled, joined relay trusts both CAs. */ +function holdsUpReplacement(relay: Relay): boolean { + return !!relay.linkCaReplacing && relay.enabled && relay.joinState === 'joined' && relay.linkCa !== 'both'; +} + +/** A replacement of the link CA under way (RL-34): when it began, and the relays it waits for. */ +export function linkCaReplacement(relays: Relay[]): { since: string; waiting: Relay[] } | null { + const since = relays.find((r) => r.linkCaReplacing)?.linkCaReplacing; + return since ? { since, waiting: relays.filter(holdsUpReplacement) } : null; +} + /** What a relay release's signature file says it is (RL-29). */ export interface ReleaseInfo { version: string; diff --git a/src/i18n/en.json b/src/i18n/en.json index d41f09c..642139e 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -2361,7 +2361,9 @@ "certFailed": "Its certificate couldn’t be ordered: {{error}}", "anOlderRelease": "an older release", "older": "It runs {{version}}, older than this server. Upgrade it to this server’s release.", - "olderImage": "It runs {{version}}, older than this server, from a container image: pull the image that matches this server and restart it." + "olderImage": "It runs {{version}}, older than this server, from a container image: pull the image that matches this server and restart it.", + "linkCaOld": "It only trusts a link CA this server has replaced, so no node can link to it. Start a new join.", + "linkCaWaiting": "It doesn’t trust the new link CA yet; the replacement waits for it." }, "copied": "Copied", "tokenTitle": "Join {{name}}", @@ -2499,6 +2501,25 @@ "upgrade": "Upgrade…", "anyone": "Anyone", "allowShutsOut": "This server links from {{addresses}}, which this list leaves out: it couldn’t link to the relay again. To undo that, run the relay with --link-allow-any.", - "allowLeavesOut": "Some of this server’s nodes link from {{addresses}}, which this list leaves out: they couldn’t link to the relay again." + "allowLeavesOut": "Some of this server’s nodes link from {{addresses}}, which this list leaves out: they couldn’t link to the relay again.", + "linkCa": { + "since": "Replacing the link CA, since {{since}}", + "waiting": "Waiting for {{names}} to trust the new one. Once every relay does, the nodes move to it and the old one stops working.", + "ready": "Every relay trusts the new one. The nodes move to it within a minute.", + "finish": "Finish now…", + "cancel": "Cancel it…", + "start": "Replace link CA…", + "startTitle": "Replace the link CA?", + "finishTitle": "Finish the replacement now?", + "cancelTitle": "Cancel the replacement?", + "startBody": "For when a node’s link certificate may have leaked. This server makes a new link CA, and every relay trusts it beside the old one. Once all of them do, the nodes move to it and a certificate from the old one stops working. A relay that is off or unreachable meanwhile will need a new join.", + "finishBody": "The nodes move to the new link CA without waiting for {{names}}, which will each need a new join.", + "finishReady": "The nodes move to the new link CA now.", + "cancelBody": "This server drops the new link CA, and the relays go back to trusting only the current one.", + "startGo": "Replace it", + "finishGo": "Finish now", + "cancelGo": "Cancel the replacement", + "failed": "The link CA wasn’t changed" + } } }