Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions src/lib/ip-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
isIpInCidr,
isAnyIpAllowlisted,
isAnyIpBlocked,
expandIpv6,
ipFaultDomain,
} from './ip-utils.js';

describe('IP Utilities', () => {
Expand Down Expand Up @@ -389,4 +391,85 @@ describe('IP Utilities', () => {
);
});
});

describe('expandIpv6', () => {
it('expands full and compressed forms to 8 padded groups', () => {
assert.deepEqual(expandIpv6('2001:db8::1'), [
'2001',
'0db8',
'0000',
'0000',
'0000',
'0000',
'0000',
'0001',
]);
assert.deepEqual(expandIpv6('::'), Array(8).fill('0000'));
assert.deepEqual(expandIpv6('::1'), [...Array(7).fill('0000'), '0001']);
});

it('returns undefined for non-IPv6 / malformed input', () => {
assert.equal(expandIpv6('1.2.3.4'), undefined);
assert.equal(expandIpv6('2001::db8::1'), undefined); // two `::`
assert.equal(expandIpv6('2001:db8:zz::1'), undefined); // bad hex
});
});

describe('ipFaultDomain', () => {
it('collapses all five tip nodes into one /24', () => {
const tips = [
'38.29.227.74',
'38.29.227.75',
'38.29.227.76',
'38.29.227.69',
'38.29.227.70',
];
const domains = new Set(tips.map((ip) => ipFaultDomain(ip)));
assert.equal(domains.size, 1);
assert.equal([...domains][0], '38.29.227.0/24');
});

it('keeps distinct /24s distinct', () => {
assert.notEqual(
ipFaultDomain('38.29.227.74'),
ipFaultDomain('38.29.228.74'),
);
});

it('normalizes IPv4-mapped IPv6 to the IPv4 /24', () => {
assert.equal(ipFaultDomain('::ffff:38.29.227.74'), '38.29.227.0/24');
});

it('buckets IPv6 to /48 by default', () => {
assert.equal(
ipFaultDomain('2001:db8:abcd:1234::1'),
'2001:0db8:abcd::/48',
);
// same /48, different lower bits -> same bucket
assert.equal(
ipFaultDomain('2001:db8:abcd:9999::abcd'),
ipFaultDomain('2001:db8:abcd:1234::1'),
);
// different /48 -> different bucket
assert.notEqual(
ipFaultDomain('2001:db8:abce::1'),
ipFaultDomain('2001:db8:abcd::1'),
);
});

it('honors custom prefix widths', () => {
assert.equal(
ipFaultDomain('38.29.227.74', { v4Bits: 16 }),
'38.29.0.0/16',
);
});

it('treats an unresolved hostname as its own domain', () => {
assert.equal(ipFaultDomain('tip-1.arweave.xyz'), 'tip-1.arweave.xyz');
assert.notEqual(
ipFaultDomain('tip-1.arweave.xyz'),
ipFaultDomain('tip-2.arweave.xyz'),
);
});
});
});
96 changes: 96 additions & 0 deletions src/lib/ip-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,102 @@ export function isIpInCidr(ip: string, cidr: string): boolean {
}
}

/**
* Expand an IPv6 address to its 8 zero-padded 16-bit hex groups, or undefined if
* it cannot be parsed. Handles `::` zero-compression. ip-utils otherwise only
* does basic IPv6 validation; this is the minimum needed for prefix bucketing
* (see {@link ipFaultDomain}).
*/
export function expandIpv6(ip: string): string[] | undefined {
if (!ip.includes(':')) return undefined;
const halves = ip.split('::');
if (halves.length > 2) return undefined; // at most one `::`

const head = halves[0] ? halves[0].split(':') : [];
const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];

let groups: string[];
if (halves.length === 2) {
const missing = 8 - (head.length + tail.length);
if (missing < 0) return undefined;
groups = [...head, ...Array(missing).fill('0'), ...tail];
} else {
groups = head;
}
if (groups.length !== 8) return undefined;

const out: string[] = [];
for (const g of groups) {
if (!/^[0-9a-fA-F]{1,4}$/.test(g)) return undefined;
out.push(parseInt(g, 16).toString(16).padStart(4, '0'));
}
return out;
}

/**
* Reduce a peer host to a stable "fault domain" bucket key — the network block
* an address belongs to — for seeding-diversity accounting. Two peers in the
* same bucket count as one fault domain (e.g. the five `tip-*.arweave.xyz` nodes
* all resolve into `38.29.227.0/24`).
*
* - IPv4 (incl. IPv4-mapped IPv6): masked to /`v4Bits` (default 24) → `"a.b.c.0/24"`.
* - IPv6: expanded + truncated to the first `v6Bits` (default 48) → `"2001:db8:abcd::/48"`.
* - Not a valid IP (e.g. an unresolved hostname): returned verbatim, so it counts
* as its own domain rather than silently collapsing distinct hosts.
*
* Pure and hot-path cheap; performs no DNS. Callers pass the already-resolved
* peer host (the chunk-POST peer list is IP-literal after DNS resolution).
*/
export function ipFaultDomain(
host: string,
{ v4Bits = 24, v6Bits = 48 }: { v4Bits?: number; v6Bits?: number } = {},
): string {
const normalized = normalizeIpv4MappedIpv6(host.trim());

// IPv4
const ipv4Segment = '(?:25[0-5]|2[0-4]\\d|1?\\d?\\d)';
const ipv4Regex = new RegExp(
`^${ipv4Segment}\\.${ipv4Segment}\\.${ipv4Segment}\\.${ipv4Segment}$`,
);
if (ipv4Regex.test(normalized)) {
const bits = Math.max(0, Math.min(32, v4Bits));
const ipInt =
normalized
.split('.')
.reduce((acc, oct) => (acc << 8) + parseInt(oct, 10), 0) >>> 0;
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
const net = (ipInt & mask) >>> 0;
const octets = [
(net >>> 24) & 0xff,
(net >>> 16) & 0xff,
(net >>> 8) & 0xff,
net & 0xff,
].join('.');
return `${octets}/${bits}`;
}

// IPv6
if (normalized.includes(':')) {
const groups = expandIpv6(normalized);
if (groups !== undefined) {
const bits = Math.max(0, Math.min(128, v6Bits));
const keptGroups = Math.ceil(bits / 16);
const masked = groups.slice(0, keptGroups).map((g, i) => {
const groupHigh = (i + 1) * 16;
if (groupHigh <= bits) return g; // group fully inside the prefix
const groupBits = bits - i * 16; // partial group: 1..15 bits kept
const m = groupBits === 0 ? 0 : (0xffff << (16 - groupBits)) & 0xffff;
return ((parseInt(g, 16) & m) >>> 0).toString(16).padStart(4, '0');
});
const prefix = masked.join(':');
return keptGroups < 8 ? `${prefix}::/${bits}` : `${prefix}/${bits}`;
}
}

// Not a parseable IP (unresolved hostname, garbage): its own domain.
return host.trim();
}

/**
* Check if any IP in a list matches any entry in an allowlist (supports CIDR)
* @param clientIps - Array of client IP addresses to check
Expand Down
Loading