From 04945159fbbe5e02ebb172c35afe9505e0ecfb62 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Fri, 30 Oct 2026 19:10:58 -0700 Subject: [PATCH] Relays: replace the link CA (RL-34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace link CA… on the Relays page starts a replacement of the link CA every relay shares, sent as replaceLinkCa on one relay. While it's under way a line above the relays says since when and which relays it waits for, with Finish now… and Cancel it…, and the page looks again every 15 seconds, as the server finishes it by itself. A relay that doesn't trust the new CA yet says so on its card; one that only trusts a replaced CA asks for a new join, and nothing else. The action shows only for a server that sends linkCaReplacing. --- src/features/relays/RelaysPage.tsx | 156 +++++++++++++++++++++++++++-- src/features/relays/api.ts | 12 +++ src/features/relays/model.test.ts | 33 ++++++ src/features/relays/model.ts | 28 +++++- src/i18n/en.json | 25 ++++- 5 files changed, 244 insertions(+), 10 deletions(-) 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" + } } } -- 2.54.0