diff --git a/src/features/relays/RelaysPage.tsx b/src/features/relays/RelaysPage.tsx
index 49b65b2..aa5a40a 100644
--- a/src/features/relays/RelaysPage.tsx
+++ b/src/features/relays/RelaysPage.tsx
@@ -70,6 +70,7 @@ import {
latestStatus,
readSignatureFile,
settingsPatch,
+ shutOut,
toForm,
waiting,
type Attention,
@@ -476,6 +477,34 @@ function SettingsDialog({
/>
+ {relay.settings.linkAllowFrom !== undefined && (
+
+ {input('linkAllowFrom', { placeholder: t('relays.anyone', 'Anyone') })}
+
+ )}
+ {(() => {
+ const { known, outside } = shutOut(form.linkAllowFrom, relay.links);
+ if (outside.length === 0) return null;
+ const all = outside.length === known.length;
+ return (
+
+
+
+ {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(', ') },
+ )}
+
+
+ );
+ })()}
{
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: {} });
+ });
+});
diff --git a/src/features/relays/model.ts b/src/features/relays/model.ts
index 438ff57..542c179 100644
--- a/src/features/relays/model.ts
+++ b/src/features/relays/model.ts
@@ -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)) };
+}
diff --git a/src/help/texts.ts b/src/help/texts.ts
index 95fb0f2..9156350 100644
--- a/src/help/texts.ts
+++ b/src/help/texts.ts
@@ -24,6 +24,8 @@ export const FIELD_HELP: Record = {
'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':
diff --git a/src/i18n/en.json b/src/i18n/en.json
index a43f948..d41f09c 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -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."
}
}