Relays: each relay's certificate #149

Merged
jcoffey-dev merged 1 commits from relay/certificate-error into main 2026-10-30 23:27:31 +00:00
5 changed files with 123 additions and 11 deletions

No files matched your search

+65
View File
@@ -154,6 +154,13 @@ function attentionText(t: TFunction, a: Attention): string {
return t('relays.att.certSoon', 'Its certificate runs out {{when}}.', { when: when(a.notAfter) });
case 'certificateExpired':
return t('relays.att.certGone', 'Its certificate ran out {{when}}.', { when: when(a.notAfter) });
case 'noCertificate':
return t(
'relays.att.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.',
);
case 'certificateFailed':
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 'recipientsDiffer':
@@ -323,6 +330,8 @@ function SettingsDialog({
const startForm = useMemo(() => toForm(relay.settings), [relay]);
const [g, setG] = useState(startGeneral);
const [form, setForm] = useState<SettingsForm>(startForm);
const [chain, setChain] = useState(relay.smtpCertificate ?? '');
const [key, setKey] = useState('');
const [reason, setReason] = useState('');
const [busy, setBusy] = useState(false);
const [invalid, setInvalid] = useState<string | null>(null);
@@ -363,6 +372,19 @@ function SettingsDialog({
}
patch.mxPreference = Number(g.mxPreference);
}
// A certificate uploaded by hand: the chain and its key go together
if (chain.trim() !== (relay.smtpCertificate ?? '').trim() || key.trim()) {
if (!chain.trim()) {
patch.smtpCertificate = null;
patch.smtpPrivateKey = null;
} else if (!key.trim() && !relay.smtpPrivateKey) {
setInvalid('smtpPrivateKey');
return;
} else {
patch.smtpCertificate = chain.trim();
if (key.trim()) patch.smtpPrivateKey = key.trim();
}
}
for (const flag of ['enabled', 'sendThrough', 'inboundOnly'] as const) {
if (g[flag] !== startGeneral[flag]) patch[flag] = g[flag];
}
@@ -536,6 +558,49 @@ function SettingsDialog({
</div>
</section>
<section className="space-y-3">
<h3 className="text-sm font-medium">{t('relays.s.certificate', 'Certificate')}</h3>
<p className="text-sm text-muted-foreground">
{relay.certificateError
? t('relays.cert.failed', 'The last order failed: {{error}}', { error: relay.certificateError })
: relay.certificateNotAfter
? t('relays.cert.until', 'Good until {{when}}.', { when: when(relay.certificateNotAfter) })
: t('relays.cert.none', 'None yet.')}{' '}
{t(
'relays.cert.how',
'Where a domain here manages its public name’s DNS, this server orders and renews it. Otherwise, paste one here.',
)}
</p>
<Field field="smtpCertificate" label={t('relays.f.smtpCertificate', 'Certificate chain (PEM)')}>
<Textarea
value={chain}
rows={4}
disabled={!canChange}
spellCheck={false}
className="font-mono text-xs"
placeholder="-----BEGIN CERTIFICATE-----"
onChange={(e) => setChain(e.target.value)}
/>
</Field>
<Field field="smtpPrivateKey" label={t('relays.f.smtpPrivateKey', 'Private key (PEM)')}>
<Textarea
value={key}
rows={3}
disabled={!canChange}
spellCheck={false}
autoComplete="off"
aria-invalid={invalid === 'smtpPrivateKey'}
className="font-mono text-xs"
placeholder={
relay.smtpPrivateKey
? t('relays.cert.keepKey', 'Leave empty to keep the stored key')
: '-----BEGIN PRIVATE KEY-----'
}
onChange={(e) => setKey(e.target.value)}
/>
</Field>
</section>
{invalid && (
<p className="text-sm text-destructive">
{t('relays.invalid', 'One value doesn’t read: check the field marked in red.')}
+17
View File
@@ -134,6 +134,23 @@ describe('attention', () => {
expect(expired).toEqual([{ kind: 'certificateExpired', notAfter: '2026-10-01T00:00:00Z' }]);
});
it('reads the certificate stored here before the one the relay reports', () => {
const status = link('1').relay!;
const linked = [link('1', { relay: { ...status, certificateNotAfter: '2027-01-01T00:00:00Z' } })];
expect(attention(relay({ links: linked, certificateNotAfter: '2026-11-05T00:00:00Z' }), NOW)).toEqual([
{ kind: 'certificateExpiring', notAfter: '2026-11-05T00:00:00Z' },
]);
// None stored, on a server that reads certificates
expect(attention(relay({ links: [link('1')], certificateNotAfter: null }), NOW)).toEqual([
{ kind: 'noCertificate' },
]);
// An older server sends neither: no line
expect(attention(relay({ links: [link('1')] }), NOW)).toEqual([]);
expect(
attention(relay({ links: [link('1')], certificateNotAfter: null, certificateError: 'rate limited' }), NOW),
).toEqual([{ kind: 'certificateFailed', error: 'rate limited' }]);
});
it('reads the status the relay sent last', () => {
const status = link('1').relay!;
const found = attention(
+21 -7
View File
@@ -66,6 +66,13 @@ export interface Relay {
/** Absent from servers before RL-25. */
mxPreference?: number;
settings: RelaySettings;
/** Its STARTTLS certificate chain, PEM; the key reads back as `****`. */
smtpCertificate?: string | null;
smtpPrivateKey?: string | null;
/** Absent from servers before RL-27: when the stored certificate runs out. */
certificateNotAfter?: string | null;
/** Why this server's last order for its certificate failed (RL-27). */
certificateError?: string | null;
joinState: 'waiting' | 'expired' | 'joined';
joinExpiresAt?: string | null;
joinedAt?: string | null;
@@ -120,6 +127,8 @@ export type Attention =
| { kind: 'spoolFilling'; percent: number }
| { kind: 'certificateExpiring'; notAfter: string }
| { kind: 'certificateExpired'; notAfter: string }
| { kind: 'noCertificate' }
| { kind: 'certificateFailed'; error: string }
| { kind: 'relayError'; error: string }
| { kind: 'recipientsDiffer' };
@@ -154,14 +163,19 @@ export function attention(relay: Relay, now: Date = new Date()): Attention[] {
if (max > 0 && status.spool.bytes / max >= SPOOL_WARN) {
out.push({ kind: 'spoolFilling', percent: Math.min(100, Math.round((status.spool.bytes / max) * 100)) });
}
if (status.certificateNotAfter) {
const left = new Date(status.certificateNotAfter).getTime() - now.getTime();
if (left <= 0) out.push({ kind: 'certificateExpired', notAfter: status.certificateNotAfter });
else if (left < CERTIFICATE_WARN_DAYS * 86_400_000)
out.push({ kind: 'certificateExpiring', notAfter: status.certificateNotAfter });
}
if (status.lastError) out.push({ kind: 'relayError', error: status.lastError });
}
// The certificate stored here, or else the one the relay reports
const notAfter = relay.certificateNotAfter ?? status?.certificateNotAfter;
if (notAfter) {
const left = new Date(notAfter).getTime() - now.getTime();
if (left <= 0) out.push({ kind: 'certificateExpired', notAfter });
else if (left < CERTIFICATE_WARN_DAYS * 86_400_000) out.push({ kind: 'certificateExpiring', notAfter });
} else if (relay.certificateNotAfter === null && !relay.certificateError) {
// A server that reads certificates says there's none
out.push({ kind: 'noCertificate' });
}
if (relay.certificateError) out.push({ kind: 'certificateFailed', error: relay.certificateError });
if (status?.lastError) out.push({ kind: 'relayError', error: status.lastError });
// 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),
+4
View File
@@ -32,6 +32,10 @@ export const FIELD_HELP: Record<string, string> = {
'The relay takes mail in but sends none, for instance while its address is new to other providers or not in your SPF record yet.',
'inbuxa:Relay.hostname':
'The relay’s public name: what it says when other servers connect, and the name on its certificate. Needs an A record and matching reverse DNS. Where this server publishes a domain’s DNS, it’s in the MX and SPF records.',
'inbuxa:Relay.smtpCertificate':
'The certificate the relay offers when other servers start TLS on port 25, with its chain. This server orders and renews it where a domain here manages the public name’s DNS; anything pasted is replaced only once it no longer does.',
'inbuxa:Relay.smtpPrivateKey':
'The key for the certificate. It’s never shown again: leave the box empty to keep the stored one.',
'inbuxa:Relay.mxPreference':
'Where this server publishes a domain’s DNS, the relay’s MX preference: senders try the lowest first. Give relays the same number to share the mail.',
'inbuxa:Relay.smtpListen': 'The addresses the relay takes mail on, usually [::]:25 for every address on port 25.',
+16 -4
View File
@@ -2356,7 +2356,9 @@
"certSoon": "Its certificate runs out {{when}}.",
"certGone": "Its certificate ran out {{when}}.",
"relayError": "The relay reports: {{error}}",
"recipients": "Nodes sent it different recipient lists; one of them is out of date."
"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}}"
},
"copied": "Copied",
"tokenTitle": "Join {{name}}",
@@ -2389,7 +2391,9 @@
"outboundRetry": "Waits between attempts",
"outboundExpiry": "Give up after",
"outboundTls": "Encryption",
"mxPreference": "MX preference"
"mxPreference": "MX preference",
"smtpCertificate": "Certificate chain (PEM)",
"smtpPrivateKey": "Private key (PEM)"
},
"cancel": "Cancel",
"addGo": "Add",
@@ -2400,7 +2404,8 @@
"s": {
"general": "The relay",
"receiving": "Taking mail in",
"sending": "Sending mail on"
"sending": "Sending mail on",
"certificate": "Certificate"
},
"tls": {
"opportunistic": "When offered",
@@ -2469,6 +2474,13 @@
"deleteTitle": "Remove {{name}}?",
"deleteBody": "This server stops linking to it. The relay keeps the mail it holds, and goes on taking mail in, until you run inbuxa-relay reset on its host. To stop it for a while, turn it off instead: it comes back without a new join.",
"deleteGo": "Remove",
"deleteFailed": "The relay wasn’t removed"
"deleteFailed": "The relay wasn’t removed",
"cert": {
"failed": "The last order failed: {{error}}",
"until": "Good until {{when}}.",
"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"
}
}
}