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 && (
-
+
@@ -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"
+ }
}
}