Relays: each relay's certificate #149
No files matched your search
@@ -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.')}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user