Relays: who may link (RL-33) #151

Merged
jcoffey-dev merged 1 commits from relay/link-allow-from into main 2026-10-31 01:42:39 +00:00
5 changed files with 214 additions and 2 deletions

No files matched your search

+29
View File
@@ -70,6 +70,7 @@ import {
latestStatus,
readSignatureFile,
settingsPatch,
shutOut,
toForm,
waiting,
type Attention,
@@ -476,6 +477,34 @@ function SettingsDialog({
/>
</Field>
</div>
{relay.settings.linkAllowFrom !== undefined && (
<Field field="linkAllowFrom" label={t('relays.f.linkAllowFrom', 'Who may link')}>
{input('linkAllowFrom', { placeholder: t('relays.anyone', 'Anyone') })}
</Field>
)}
{(() => {
const { known, outside } = shutOut(form.linkAllowFrom, relay.links);
if (outside.length === 0) return null;
const all = outside.length === known.length;
return (
<p className="flex items-start gap-2 text-sm text-amber-700 dark:text-amber-400">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span className="break-words">
{all
? t(
'relays.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.',
{ addresses: outside.join(', ') },
)
: t(
'relays.allowLeavesOut',
'Some of this server’s nodes link from {{addresses}}, which this list leaves out: they couldn’t link to the relay again.',
{ addresses: outside.join(', ') },
)}
</span>
</p>
);
})()}
<ToggleField
field="enabled"
label={t('relays.f.enabled', 'Linked')}
+62
View File
@@ -13,7 +13,9 @@ import {
joinCommand,
parseDuration,
parseDurations,
parseRange,
settingsPatch,
shutOut,
toForm,
waiting,
type Relay,
@@ -291,3 +293,63 @@ describe('queues', () => {
expect(joinCommand('abc')).toBe('inbuxa-relay join abc');
});
});
describe('who may link (RL-33)', () => {
const from = (address: string) => link('n', { relay: { ...link('n').relay!, linkFrom: address } });
it('reads addresses and ranges as the relay does', () => {
for (const ok of [
'192.0.2.7',
'192.0.2.0/24',
'0.0.0.0/0',
'2001:db8::/32',
'::1',
'::ffff:192.0.2.1',
'fd42:1::2',
]) {
expect(parseRange(ok), ok).not.toBeNull();
}
for (const bad of [
'',
'relay.example',
'192.0.2.0/33',
'2001:db8::/129',
'192.0.2.0/',
'192.0.2',
'1::2::3',
'192.0.2.0/24/1',
'256.0.0.1',
]) {
expect(parseRange(bad), bad).toBeNull();
}
});
it('says which addresses this server links from a list leaves out', () => {
const links = [from('203.0.113.5'), from('2001:db8:5::9'), link('m')];
expect(shutOut('', links)).toEqual({ known: ['203.0.113.5', '2001:db8:5::9'], outside: [] });
expect(shutOut('203.0.113.0/24, 2001:db8:5::/48', links).outside).toEqual([]);
expect(shutOut('203.0.113.0/24', links).outside).toEqual(['2001:db8:5::9']);
expect(shutOut('198.51.100.1', links).outside).toEqual(['203.0.113.5', '2001:db8:5::9']);
// An IPv4 node behind a [::] listener is matched as IPv4, both ways
expect(shutOut('::ffff:203.0.113.5', [from('203.0.113.5')]).outside).toEqual([]);
expect(shutOut('203.0.113.5', [from('::ffff:203.0.113.5')]).outside).toEqual([]);
// Nothing to check against, or a list that doesn't read yet
expect(shutOut('198.51.100.1', [link('m')])).toEqual({ known: [], outside: [] });
expect(shutOut('198.51.100.1, nope', links).outside).toEqual([]);
});
it('sends the list when it changes, and refuses one that does not read', () => {
const settings = { ...SETTINGS, linkAllowFrom: [] };
const before = toForm(settings);
expect(before.linkAllowFrom).toBe('');
expect(settingsPatch(before, { ...before, linkAllowFrom: '192.0.2.0/24\n2001:db8::/48' }, settings)).toEqual({
patch: { 'settings/linkAllowFrom': ['192.0.2.0/24', '2001:db8::/48'] },
});
expect(settingsPatch(before, { ...before, linkAllowFrom: '192.0.2.0/24, relay.example' }, settings)).toEqual({
invalid: 'linkAllowFrom',
});
// A server before RL-33 has no list, and none is sent
expect(toForm(SETTINGS).linkAllowFrom).toBe('');
expect(settingsPatch(toForm(SETTINGS), toForm(SETTINGS), SETTINGS)).toEqual({ patch: {} });
});
});
+115
View File
@@ -24,6 +24,8 @@ export interface RelayStatus {
certificateNotAfter?: string | null;
lastError?: string | null;
selfUpgrade: boolean;
/** Absent from relays before RL-33: where the relay sees this node's link from. */
linkFrom?: string | null;
}
/** One node's link to a relay. */
@@ -52,6 +54,8 @@ export interface RelaySettings {
outboundRetry: number[];
outboundExpiry: number;
outboundTls: 'opportunistic' | 'required';
/** Absent from servers before RL-33: who may dial the link; empty is anyone. */
linkAllowFrom?: string[];
}
export interface Relay {
@@ -281,6 +285,7 @@ export interface SettingsForm {
outboundRetry: string;
outboundExpiry: string;
outboundTls: 'opportunistic' | 'required';
linkAllowFrom: string;
}
function trimmedNumber(value: number): string {
@@ -303,6 +308,7 @@ export function toForm(s: RelaySettings): SettingsForm {
outboundRetry: s.outboundRetry.map(formatDuration).join(', '),
outboundExpiry: formatDuration(s.outboundExpiry),
outboundTls: s.outboundTls,
linkAllowFrom: (s.linkAllowFrom ?? []).join(', '),
};
}
@@ -358,6 +364,11 @@ export function settingsPatch(
if (changed('outboundExpiry'))
patch['settings/outboundExpiry'] = read('outboundExpiry', parseDuration(after.outboundExpiry));
if (changed('outboundTls')) patch['settings/outboundTls'] = after.outboundTls;
if (changed('linkAllowFrom')) {
const list = parseList(after.linkAllowFrom);
if (list.length > MAX_ALLOW || !list.every((entry) => parseRange(entry))) throw 'linkAllowFrom';
patch['settings/linkAllowFrom'] = list;
}
} catch (field) {
return { invalid: field as keyof SettingsForm };
}
@@ -378,3 +389,107 @@ export function waiting(message: RelayMessage): { to: string; reply: string }[]
.filter(([, status]) => status.delivered === 'queued')
.map(([to, status]) => ({ to, reply: status.smtpReply }));
}
// ---- who may dial the link (RL-33) ----
/** The most entries the relay takes in `linkAllowFrom`. */
export const MAX_ALLOW = 64;
interface Range {
v6: boolean;
addr: bigint;
bits: number;
}
function parseIPv4(text: string): bigint | null {
const parts = text.split('.');
if (parts.length !== 4) return null;
let out = 0n;
for (const part of parts) {
if (!/^\d{1,3}$/.test(part) || Number(part) > 255) return null;
out = (out << 8n) | BigInt(Number(part));
}
return out;
}
function parseIPv6(text: string): bigint | null {
let groups: string[];
// A trailing IPv4 part, as in ::ffff:192.0.2.1
const v4 = /^(.*:)(\d+\.\d+\.\d+\.\d+)$/.exec(text);
let tail: bigint | null = null;
if (v4) {
tail = parseIPv4(v4[2]);
if (tail === null) return null;
text = v4[1] + '0:0';
}
const halves = text.split('::');
if (halves.length > 2) return null;
const split = (h: string) => (h === '' ? [] : h.split(':'));
if (halves.length === 2) {
const head = split(halves[0]);
const rest = split(halves[1]);
if (head.length + rest.length > 7) return null;
groups = [...head, ...Array(8 - head.length - rest.length).fill('0'), ...rest];
} else {
groups = split(text);
}
if (groups.length !== 8) return null;
let out = 0n;
for (const group of groups) {
if (!/^[0-9a-fA-F]{1,4}$/.test(group)) return null;
out = (out << 16n) | BigInt(parseInt(group, 16));
}
if (tail !== null) out = (out & ~0xffffffffn) | tail;
return out;
}
const MAPPED = 0xffff00000000n;
/** An address, IPv4-mapped IPv6 taken as the IPv4 it carries, as the relay takes it. */
function parseAddress(text: string): { v6: boolean; addr: bigint } | null {
const v4 = parseIPv4(text);
if (v4 !== null) return { v6: false, addr: v4 };
const v6 = parseIPv6(text);
if (v6 === null) return null;
if (v6 >> 32n === MAPPED >> 32n) return { v6: false, addr: v6 & 0xffffffffn };
return { v6: true, addr: v6 };
}
function masked(addr: bigint, bits: number, width: number): bigint {
const shift = BigInt(width - bits);
return (addr >> shift) << shift;
}
/** `192.0.2.7`, `192.0.2.0/24` or `2001:db8::/48`, as the relay reads them. */
export function parseRange(text: string): Range | null {
const [addrText, bitsText, extra] = text.trim().split('/');
if (extra !== undefined) return null;
const parsed = parseAddress(addrText);
if (!parsed) return null;
const width = parsed.v6 ? 128 : 32;
let bits = width;
if (bitsText !== undefined) {
if (!/^\d+$/.test(bitsText) || Number(bitsText) > width) return null;
bits = Number(bitsText);
}
return { v6: parsed.v6, addr: masked(parsed.addr, bits, width), bits };
}
function allows(ranges: Range[], address: string): boolean {
const parsed = parseAddress(address);
if (!parsed) return false;
return ranges.some((r) => r.v6 === parsed.v6 && masked(parsed.addr, r.bits, r.v6 ? 128 : 32) === r.addr);
}
/**
* The addresses the relay last saw this server's nodes link from that a
* `linkAllowFrom` list would turn away, and whether any were seen at all:
* a list that leaves them all out locks this server out (RL-33).
*/
export function shutOut(list: string, links: RelayLink[]): { known: string[]; outside: string[] } {
const known = [...new Set(links.map((l) => l.relay?.linkFrom).filter((a): a is string => !!a))];
const entries = parseList(list);
const ranges = entries.map(parseRange);
if (entries.length === 0 || ranges.some((r) => !r)) return { known, outside: [] };
return { known, outside: known.filter((a) => !allows(ranges as Range[], a)) };
}
+2
View File
@@ -24,6 +24,8 @@ export const FIELD_HELP: Record<string, string> = {
'inbuxa:Relay.address':
'The relay’s host name or IP address, as this server dials it for the link. A WireGuard address keeps the link off the internet.',
'inbuxa:Relay.linkPort': 'The port the relay listens on for this server’s link. 443 unless you chose another.',
'inbuxa:Relay.linkAllowFrom':
'The addresses and ranges, such as 192.0.2.0/24, the relay takes this server’s link from. Others get no answer. Empty takes it from anyone, so keep the port closed with a firewall instead.',
'inbuxa:Relay.enabled':
'Turn off to stop this server linking to it. The relay keeps the mail it holds and goes on taking mail in, held until it’s turned back on.',
'inbuxa:Relay.sendThrough':
+6 -2
View File
@@ -2396,7 +2396,8 @@
"outboundTls": "Encryption",
"mxPreference": "MX preference",
"smtpCertificate": "Certificate chain (PEM)",
"smtpPrivateKey": "Private key (PEM)"
"smtpPrivateKey": "Private key (PEM)",
"linkAllowFrom": "Who may link"
},
"cancel": "Cancel",
"addGo": "Add",
@@ -2495,6 +2496,9 @@
"upgradeRelease": "Release {{version}}, for {{target}}.",
"upgradeNotSig": "{{file}} isn’t a relay release’s signature file.",
"upgradeGo": "Upgrade",
"upgrade": "Upgrade…"
"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."
}
}