Relays: replace the link CA (RL-34) #152
No files matched your search
@@ -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 })}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user