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." } }