Relays: replace the link CA (RL-34) #152

Merged
jcoffey-dev merged 1 commits from relay/link-ca-replace into main 2026-10-31 02:36:52 +00:00
5 changed files with 244 additions and 10 deletions

No files matched your search

+149 -7
View File
@@ -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 (
<section className="flex flex-wrap items-start justify-between gap-3 rounded-xl border p-4">
<div className="flex min-w-0 items-start gap-3">
<KeyRound className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
<div className="space-y-1 text-sm">
<p className="font-medium">
{t('relays.linkCa.since', 'Replacing the link CA, since {{since}}', { since: when(since) })}
</p>
<p className="text-muted-foreground">
{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.')}
</p>
</div>
</div>
{canChange && (
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => onChange('finish')}>
{t('relays.linkCa.finish', 'Finish now…')}
</Button>
<Button variant="ghost" size="sm" onClick={() => onChange('cancel')}>
{t('relays.linkCa.cancel', 'Cancel it…')}
</Button>
</div>
)}
</section>
);
}
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 && (
<Button onClick={() => setOpen({ kind: 'add' })}>
<Plus className="mr-2 h-4 w-4" />
{t('relays.add', 'Add a relay…')}
</Button>
<div className="flex flex-wrap gap-2">
{canReplaceLinkCa && (
<Button variant="outline" onClick={() => changeLinkCa('start')}>
<KeyRound className="mr-2 h-4 w-4" />
{t('relays.linkCa.start', 'Replace link CA…')}
</Button>
)}
<Button onClick={() => setOpen({ kind: 'add' })}>
<Plus className="mr-2 h-4 w-4" />
{t('relays.add', 'Add a relay…')}
</Button>
</div>
)
}
/>
{replacement && (
<LinkCaBanner
since={replacement.since}
waiting={replacement.waiting}
canChange={canChange}
onChange={changeLinkCa}
/>
)}
{load.kind === 'loading' && <LoadingFallback />}
{load.kind === 'error' && (
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">{load.message}</div>
@@ -1210,7 +1304,7 @@ export function RelaysPage() {
</div>
)}
{load.kind === 'ready' &&
load.relays.map((relay) => (
relays.map((relay) => (
<RelayCard
key={relay.id}
relay={relay}
@@ -1276,6 +1370,54 @@ export function RelaysPage() {
}}
/>
)}
{open?.kind === 'linkCa' && (
<ConfirmDialog
title={
open.change === 'start'
? t('relays.linkCa.startTitle', 'Replace the link CA?')
: open.change === 'finish'
? t('relays.linkCa.finishTitle', 'Finish the replacement now?')
: t('relays.linkCa.cancelTitle', 'Cancel the replacement?')
}
body={
open.change === 'start'
? t(
'relays.linkCa.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.',
)
: open.change === 'finish'
? open.waiting.length > 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' && (
<ConfirmDialog
title={t('relays.deleteTitle', 'Remove {{name}}?', { name: open.relay.name })}
+12
View File
@@ -141,6 +141,18 @@ export async function upgradeRelay(id: string, binary: File, signature: string,
await updateRelay(id, { upgrade: { blobId, signature } }, reason);
}
/**
* Starts, finishes or cancels a replacement of the link CA every relay
* shares (RL-34). It goes as an update of any one relay, on its own.
*/
export async function replaceLinkCa(
relayId: string,
change: 'start' | 'finish' | 'cancel',
reason?: string,
): Promise<void> {
await updateRelay(relayId, { replaceLinkCa: change }, reason);
}
export async function deleteRelay(id: string, reason?: string): Promise<void> {
const result = await call('inbuxa:Relay/set', { ...withReason(reason), destroy: [id] });
const failed = setFailure(result, 'notDestroyed', id);
+33
View File
@@ -11,6 +11,7 @@ import {
envelopeLine,
formatDuration,
joinCommand,
linkCaReplacement,
parseDuration,
parseDurations,
parseRange,
@@ -83,6 +84,38 @@ function relay(extra: Partial<Relay> = {}): 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([]);
+27 -1
View File
@@ -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;
+23 -2
View File
@@ -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"
}
}
}