Relays: upgrade a relay that's older than this server #150
No files matched your search
@@ -10,13 +10,26 @@
|
||||
* with the command to run on the relay host. The list says something only
|
||||
* 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.
|
||||
* held mail can be discarded and outbound mail cancelled. A relay older
|
||||
* than this server can be handed a signed release to upgrade to.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { AlertTriangle, Check, Copy, Inbox, Loader2, Plus, RefreshCw, Send, Settings2, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowUpCircle,
|
||||
Check,
|
||||
Copy,
|
||||
Inbox,
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Send,
|
||||
Settings2,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -48,12 +61,14 @@ import {
|
||||
RelaysUnavailable,
|
||||
RelayUnreachable,
|
||||
updateRelay,
|
||||
upgradeRelay,
|
||||
} from './api';
|
||||
import {
|
||||
attention,
|
||||
envelopeLine,
|
||||
joinCommand,
|
||||
latestStatus,
|
||||
readSignatureFile,
|
||||
settingsPatch,
|
||||
toForm,
|
||||
waiting,
|
||||
@@ -163,6 +178,18 @@ function attentionText(t: TFunction, a: Attention): string {
|
||||
return t('relays.att.certFailed', 'Its certificate couldn’t be ordered: {{error}}', { error: a.error });
|
||||
case 'relayError':
|
||||
return t('relays.att.relayError', 'The relay reports: {{error}}', { error: a.error });
|
||||
case 'olderVersion': {
|
||||
const version = a.version ?? t('relays.att.anOlderRelease', 'an older release');
|
||||
return a.selfUpgrade
|
||||
? t('relays.att.older', 'It runs {{version}}, older than this server. Upgrade it to this server’s release.', {
|
||||
version,
|
||||
})
|
||||
: t(
|
||||
'relays.att.olderImage',
|
||||
'It runs {{version}}, older than this server, from a container image: pull the image that matches this server and restart it.',
|
||||
{ version },
|
||||
);
|
||||
}
|
||||
case 'recipientsDiffer':
|
||||
return t('relays.att.recipients', 'Nodes sent it different recipient lists; one of them is out of date.');
|
||||
}
|
||||
@@ -879,6 +906,104 @@ function ConfirmDialog({
|
||||
);
|
||||
}
|
||||
|
||||
/** Hands a relay a signed release: the binary and its .sig file (RL-29). */
|
||||
function UpgradeDialog({ relay, onClose, onDone }: { relay: Relay; onClose: () => void; onDone: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [binary, setBinary] = useState<File | null>(null);
|
||||
const [signature, setSignature] = useState<{ text: string; name: string } | null>(null);
|
||||
const [reason, setReason] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const info = signature ? readSignatureFile(signature.text) : null;
|
||||
const running = latestStatus(relay)?.relayVersion;
|
||||
|
||||
const upgrade = async () => {
|
||||
if (!binary || !signature || !info) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await upgradeRelay(relay.id, binary, signature.text, reason);
|
||||
toast({
|
||||
title: t('relays.upgraded', '{{name}} is restarting with {{version}}.', {
|
||||
name: relay.name,
|
||||
version: info.version,
|
||||
}),
|
||||
});
|
||||
onDone();
|
||||
} catch (e) {
|
||||
failed(t('relays.upgradeFailed', 'The relay wasn’t upgraded'), e);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent onOpenAutoFocus={focusFirstField}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('relays.upgradeTitle', 'Upgrade {{name}}', { name: relay.name })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
'relays.upgradeBody',
|
||||
'Choose the inbuxa-relay binary for its host and the .sig file that comes with it, from the release that matches this server. The relay checks the signature, refuses an older release, and restarts on the new one.',
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 text-sm">
|
||||
{running && (
|
||||
<p className="text-muted-foreground">
|
||||
{t('relays.upgradeRunning', 'It runs {{version}} now.', { version: running })}
|
||||
</p>
|
||||
)}
|
||||
<label className="block space-y-1">
|
||||
<span className="text-muted-foreground">{t('relays.upgradeBinary', 'Release binary')}</span>
|
||||
<Input type="file" onChange={(e) => setBinary(e.target.files?.[0] ?? null)} disabled={busy} />
|
||||
</label>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-muted-foreground">{t('relays.upgradeSignature', 'Its signature file (.sig)')}</span>
|
||||
<Input
|
||||
type="file"
|
||||
accept=".sig"
|
||||
disabled={busy}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return setSignature(null);
|
||||
void file.text().then((text) => setSignature({ text, name: file.name }));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{signature &&
|
||||
(info ? (
|
||||
<p>
|
||||
{t('relays.upgradeRelease', 'Release {{version}}, for {{target}}.', {
|
||||
version: info.version,
|
||||
target: info.target,
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-destructive">
|
||||
{t('relays.upgradeNotSig', '{{file}} isn’t a relay release’s signature file.', {
|
||||
file: signature.name,
|
||||
})}
|
||||
</p>
|
||||
))}
|
||||
<label className="block space-y-1">
|
||||
<span className="text-muted-foreground">{t('relays.reason', 'Reason, for the audit log (optional)')}</span>
|
||||
<Input value={reason} maxLength={500} onChange={(e) => setReason(e.target.value)} disabled={busy} />
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose} disabled={busy}>
|
||||
{t('relays.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button onClick={() => void upgrade()} disabled={busy || !binary || !info}>
|
||||
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('relays.upgradeGo', 'Upgrade')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function RelayCard({
|
||||
relay,
|
||||
canChange,
|
||||
@@ -886,6 +1011,7 @@ function RelayCard({
|
||||
onSettings,
|
||||
onQueue,
|
||||
onRejoin,
|
||||
onUpgrade,
|
||||
onDelete,
|
||||
}: {
|
||||
relay: Relay;
|
||||
@@ -894,6 +1020,7 @@ function RelayCard({
|
||||
onSettings: () => void;
|
||||
onQueue: () => void;
|
||||
onRejoin: () => void;
|
||||
onUpgrade: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -956,6 +1083,12 @@ function RelayCard({
|
||||
{t('relays.queue', 'Queue…')}
|
||||
</Button>
|
||||
)}
|
||||
{canChange && relay.olderThanServer && status?.selfUpgrade !== false && (
|
||||
<Button variant="outline" size="sm" onClick={onUpgrade}>
|
||||
<ArrowUpCircle className="mr-2 h-4 w-4" />
|
||||
{t('relays.upgrade', 'Upgrade…')}
|
||||
</Button>
|
||||
)}
|
||||
{canChange && (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={onRejoin}>
|
||||
@@ -989,6 +1122,7 @@ type Open =
|
||||
| { kind: 'settings'; relay: Relay }
|
||||
| { kind: 'queue'; relay: Relay }
|
||||
| { kind: 'rejoin'; relay: Relay }
|
||||
| { kind: 'upgrade'; relay: Relay }
|
||||
| { kind: 'delete'; relay: Relay };
|
||||
|
||||
export function RelaysPage() {
|
||||
@@ -1056,6 +1190,7 @@ export function RelaysPage() {
|
||||
onSettings={() => setOpen({ kind: 'settings', relay })}
|
||||
onQueue={() => setOpen({ kind: 'queue', relay })}
|
||||
onRejoin={() => setOpen({ kind: 'rejoin', relay })}
|
||||
onUpgrade={() => setOpen({ kind: 'upgrade', relay })}
|
||||
onDelete={() => setOpen({ kind: 'delete', relay })}
|
||||
/>
|
||||
))}
|
||||
@@ -1102,6 +1237,16 @@ export function RelaysPage() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{open?.kind === 'upgrade' && (
|
||||
<UpgradeDialog
|
||||
relay={open.relay}
|
||||
onClose={close}
|
||||
onDone={() => {
|
||||
close();
|
||||
refetch();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{open?.kind === 'delete' && (
|
||||
<ConfirmDialog
|
||||
title={t('relays.deleteTitle', 'Remove {{name}}?', { name: open.relay.name })}
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
* pushes what is saved here, and reads a relay's queues over its own link.
|
||||
*/
|
||||
|
||||
import { getAccountId, jmapRequest } from '@/services/jmap/client';
|
||||
import { apiFetch } from '@/services/api';
|
||||
import { fetchSession, getAccountId, jmapRequest } from '@/services/jmap/client';
|
||||
import type { Relay, RelayMessage } from './model';
|
||||
|
||||
const CAPABILITY = 'urn:inbuxa:jmap';
|
||||
@@ -110,6 +111,36 @@ export async function rejoinRelay(id: string, reason?: string): Promise<string>
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a relay release binary to this account, for `upgrade`: its blob
|
||||
* id. The upload URL is the session's, as a path for apiFetch.
|
||||
*/
|
||||
async function uploadRelease(file: File): Promise<string> {
|
||||
const accountId = getAccountId('x:Account');
|
||||
const session = await fetchSession();
|
||||
const template = typeof session.uploadUrl === 'string' ? session.uploadUrl : '/jmap/upload/{accountId}/';
|
||||
const url = new URL(template.replace('{accountId}', encodeURIComponent(accountId)), 'http://placeholder');
|
||||
const response = await apiFetch(url.pathname + url.search, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
body: file,
|
||||
});
|
||||
if (!response.ok) throw new Error(`The upload failed (${response.status}).`);
|
||||
const blobId = ((await response.json()) as { blobId?: string }).blobId;
|
||||
if (!blobId) throw new Error('The server gave no blob id.');
|
||||
return blobId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands a relay a signed release (RL-29): the binary and its signature
|
||||
* file's text. The server checks the signature, the relay checks it again,
|
||||
* installs it and restarts.
|
||||
*/
|
||||
export async function upgradeRelay(id: string, binary: File, signature: string, reason?: string): Promise<void> {
|
||||
const blobId = await uploadRelease(binary);
|
||||
await updateRelay(id, { upgrade: { blobId, signature } }, 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);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
readSignatureFile,
|
||||
attention,
|
||||
envelopeLine,
|
||||
formatDuration,
|
||||
@@ -170,6 +171,40 @@ describe('attention', () => {
|
||||
{ kind: 'recipientsDiffer' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('says when a relay runs an older release than the server', () => {
|
||||
expect(attention(relay({ olderThanServer: false }), NOW)).toEqual([]);
|
||||
expect(attention(relay({ olderThanServer: null }), NOW)).toEqual([]);
|
||||
expect(attention(relay({ olderThanServer: true }), NOW)).toEqual([
|
||||
{ kind: 'olderVersion', version: '27.1.0', selfUpgrade: true },
|
||||
]);
|
||||
const status = link('1').relay!;
|
||||
const image = [link('1', { relay: { ...status, selfUpgrade: false } })];
|
||||
expect(attention(relay({ olderThanServer: true, links: image }), NOW)).toEqual([
|
||||
{ kind: 'olderVersion', version: '27.1.0', selfUpgrade: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('signature files', () => {
|
||||
const file =
|
||||
'inbuxa-relay release 1\nversion 27.1.0-rc.5\ntarget x86_64-linux-musl\nsha256 ' +
|
||||
'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad\nsignature zRj9\n';
|
||||
|
||||
it('reads what a release says it is', () => {
|
||||
expect(readSignatureFile(file)).toEqual({ version: '27.1.0-rc.5', target: 'x86_64-linux-musl' });
|
||||
expect(readSignatureFile(file.replace(/\n/g, '\r\n'))).toEqual({
|
||||
version: '27.1.0-rc.5',
|
||||
target: 'x86_64-linux-musl',
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses anything else', () => {
|
||||
expect(readSignatureFile('')).toBeNull();
|
||||
expect(readSignatureFile('\x7fELF')).toBeNull();
|
||||
expect(readSignatureFile(file.replace('signature zRj9', ''))).toBeNull();
|
||||
expect(readSignatureFile(file.replace('version 27.1.0-rc.5', 'version '))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('plain forms', () => {
|
||||
|
||||
@@ -73,6 +73,8 @@ export interface Relay {
|
||||
certificateNotAfter?: string | null;
|
||||
/** Why this server's last order for its certificate failed (RL-27). */
|
||||
certificateError?: string | null;
|
||||
/** Absent from servers before RL-28: a link last heard an older release than the server's. */
|
||||
olderThanServer?: boolean | null;
|
||||
joinState: 'waiting' | 'expired' | 'joined';
|
||||
joinExpiresAt?: string | null;
|
||||
joinedAt?: string | null;
|
||||
@@ -130,6 +132,7 @@ export type Attention =
|
||||
| { kind: 'noCertificate' }
|
||||
| { kind: 'certificateFailed'; error: string }
|
||||
| { kind: 'relayError'; error: string }
|
||||
| { kind: 'olderVersion'; version: string | null; selfUpgrade: boolean }
|
||||
| { kind: 'recipientsDiffer' };
|
||||
|
||||
/** A spool this full is worth a line. */
|
||||
@@ -176,6 +179,9 @@ export function attention(relay: Relay, now: Date = new Date()): Attention[] {
|
||||
}
|
||||
if (relay.certificateError) out.push({ kind: 'certificateFailed', error: relay.certificateError });
|
||||
if (status?.lastError) out.push({ kind: 'relayError', error: status.lastError });
|
||||
if (relay.olderThanServer) {
|
||||
out.push({ kind: 'olderVersion', version: status?.relayVersion ?? null, selfUpgrade: status?.selfUpgrade ?? true });
|
||||
}
|
||||
// Every node sends the same recipients; nodes that disagree have a stale set
|
||||
const counts = new Set(
|
||||
relay.links.filter((l) => l.state === 'linked' && l.recipients != null).map((l) => l.recipients),
|
||||
@@ -184,6 +190,28 @@ export function attention(relay: Relay, now: Date = new Date()): Attention[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** What a relay release's signature file says it is (RL-29). */
|
||||
export interface ReleaseInfo {
|
||||
version: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a signature file, `inbuxa-relay-<target>.sig`, for the upgrade
|
||||
* dialog to show; null if it isn't one. The server and the relay check the
|
||||
* signature: this only reads it.
|
||||
*/
|
||||
export function readSignatureFile(text: string): ReleaseInfo | null {
|
||||
const lines = text.replace(/\r\n/g, '\n').split('\n');
|
||||
if (lines[0] !== 'inbuxa-relay release 1') return null;
|
||||
const field = (line: string | undefined, name: string) =>
|
||||
line?.startsWith(`${name} `) ? line.slice(name.length + 1).trim() || null : null;
|
||||
const version = field(lines[1], 'version');
|
||||
const target = field(lines[2], 'target');
|
||||
if (!version || !target || !field(lines[3], 'sha256') || !field(lines[4], 'signature')) return null;
|
||||
return { version, target };
|
||||
}
|
||||
|
||||
/** The one command to run on the relay host, with the join token. */
|
||||
export function joinCommand(token: string): string {
|
||||
return `inbuxa-relay join ${token}`;
|
||||
|
||||
+16
-2
@@ -2358,7 +2358,10 @@
|
||||
"relayError": "The relay reports: {{error}}",
|
||||
"recipients": "Nodes sent it different recipient lists; one of them is out of date.",
|
||||
"noCertificate": "It has no certificate yet. This server orders one when a domain here manages the DNS of its public name; otherwise, upload one in its settings.",
|
||||
"certFailed": "Its certificate couldn’t be ordered: {{error}}"
|
||||
"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."
|
||||
},
|
||||
"copied": "Copied",
|
||||
"tokenTitle": "Join {{name}}",
|
||||
@@ -2481,6 +2484,17 @@
|
||||
"none": "None yet.",
|
||||
"how": "Where a domain here manages its public name’s DNS, this server orders and renews it. Otherwise, paste one here.",
|
||||
"keepKey": "Leave empty to keep the stored key"
|
||||
}
|
||||
},
|
||||
"upgraded": "{{name}} is restarting with {{version}}.",
|
||||
"upgradeFailed": "The relay wasn’t upgraded",
|
||||
"upgradeTitle": "Upgrade {{name}}",
|
||||
"upgradeBody": "Choose the inbuxa-relay binary for its host and the .sig file that comes with it, from the release that matches this server. The relay checks the signature, refuses an older release, and restarts on the new one.",
|
||||
"upgradeRunning": "It runs {{version}} now.",
|
||||
"upgradeBinary": "Release binary",
|
||||
"upgradeSignature": "Its signature file (.sig)",
|
||||
"upgradeRelease": "Release {{version}}, for {{target}}.",
|
||||
"upgradeNotSig": "{{file}} isn’t a relay release’s signature file.",
|
||||
"upgradeGo": "Upgrade",
|
||||
"upgrade": "Upgrade…"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user