Relays: who may link (RL-33) #151
No files matched your search
@@ -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')}
|
||||
|
||||
@@ -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: {} });
|
||||
});
|
||||
});
|
||||
@@ -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)) };
|
||||
}
|
||||
@@ -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
@@ -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."
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user