diff --git a/packages/libp2p/package.json b/packages/libp2p/package.json index 17b6f4f5b0..2bcc25d698 100644 --- a/packages/libp2p/package.json +++ b/packages/libp2p/package.json @@ -86,6 +86,7 @@ }, "dependencies": { "@chainsafe/is-ip": "^2.0.2", + "@chainsafe/netmask": "^2.0.0", "@libp2p/crypto": "^5.0.7", "@libp2p/interface": "^2.2.1", "@libp2p/interface-internal": "^2.1.1", diff --git a/packages/libp2p/src/connection-manager/connection-pruner.ts b/packages/libp2p/src/connection-manager/connection-pruner.ts index c780e6a03d..7550bb4140 100644 --- a/packages/libp2p/src/connection-manager/connection-pruner.ts +++ b/packages/libp2p/src/connection-manager/connection-pruner.ts @@ -1,6 +1,8 @@ import { PeerMap } from '@libp2p/peer-collections' import { safelyCloseConnectionIfUnused } from '@libp2p/utils/close' import { MAX_CONNECTIONS } from './constants.js' +import { multiaddrToIpNet } from './utils.js' +import type { IpNet } from '@chainsafe/netmask' import type { Libp2pEvents, Logger, ComponentLogger, TypedEventTarget, PeerStore, Connection } from '@libp2p/interface' import type { ConnectionManager } from '@libp2p/interface-internal' import type { Multiaddr } from '@multiformats/multiaddr' @@ -29,13 +31,13 @@ export class ConnectionPruner { private readonly maxConnections: number private readonly connectionManager: ConnectionManager private readonly peerStore: PeerStore - private readonly allow: Multiaddr[] + private readonly allow: IpNet[] private readonly events: TypedEventTarget private readonly log: Logger constructor (components: ConnectionPrunerComponents, init: ConnectionPrunerInit = {}) { this.maxConnections = init.maxConnections ?? defaultOptions.maxConnections - this.allow = init.allow ?? defaultOptions.allow + this.allow = (init.allow ?? []).map(ma => multiaddrToIpNet(ma)) this.connectionManager = components.connectionManager this.peerStore = components.peerStore this.events = components.events @@ -107,8 +109,8 @@ export class ConnectionPruner { for (const connection of sortedConnections) { this.log('too many connections open - closing a connection to %p', connection.remotePeer) // check allow list - const connectionInAllowList = this.allow.some((ma) => { - return connection.remoteAddr.toString().startsWith(ma.toString()) + const connectionInAllowList = this.allow.some((ipNet) => { + return ipNet.contains(connection.remoteAddr.nodeAddress().address) }) // Connections in the allow list should be excluded from pruning diff --git a/packages/libp2p/src/connection-manager/index.ts b/packages/libp2p/src/connection-manager/index.ts index 1968ddbbbb..43ec0ffba6 100644 --- a/packages/libp2p/src/connection-manager/index.ts +++ b/packages/libp2p/src/connection-manager/index.ts @@ -9,6 +9,8 @@ import { ConnectionPruner } from './connection-pruner.js' import { DIAL_TIMEOUT, INBOUND_CONNECTION_THRESHOLD, MAX_CONNECTIONS, MAX_DIAL_QUEUE_LENGTH, MAX_INCOMING_PENDING_CONNECTIONS, MAX_PARALLEL_DIALS, MAX_PEER_ADDRS_TO_DIAL } from './constants.js' import { DialQueue } from './dial-queue.js' import { ReconnectQueue } from './reconnect-queue.js' +import { multiaddrToIpNet } from './utils.js' +import type { IpNet } from '@chainsafe/netmask' import type { PendingDial, AddressSorter, Libp2pEvents, AbortOptions, ComponentLogger, Logger, Connection, MultiaddrConnection, ConnectionGater, TypedEventTarget, Metrics, PeerId, PeerStore, Startable, PendingDialStatus, PeerRouting, IsDialableOptions } from '@libp2p/interface' import type { ConnectionManager, OpenConnectionOptions, TransportManager } from '@libp2p/interface-internal' import type { JobStatus } from '@libp2p/utils/queue' @@ -176,8 +178,8 @@ export interface DefaultConnectionManagerComponents { export class DefaultConnectionManager implements ConnectionManager, Startable { private started: boolean private readonly connections: PeerMap - private readonly allow: Multiaddr[] - private readonly deny: Multiaddr[] + private readonly allow: IpNet[] + private readonly deny: IpNet[] private readonly maxIncomingPendingConnections: number private incomingPendingConnections: number private outboundPendingConnections: number @@ -216,8 +218,8 @@ export class DefaultConnectionManager implements ConnectionManager, Startable { this.onDisconnect = this.onDisconnect.bind(this) // allow/deny lists - this.allow = (init.allow ?? []).map(ma => multiaddr(ma)) - this.deny = (init.deny ?? []).map(ma => multiaddr(ma)) + this.allow = (init.allow ?? []).map(str => multiaddrToIpNet(str)) + this.deny = (init.deny ?? []).map(str => multiaddrToIpNet(str)) this.incomingPendingConnections = 0 this.maxIncomingPendingConnections = init.maxIncomingPendingConnections ?? defaultOptions.maxIncomingPendingConnections @@ -237,7 +239,7 @@ export class DefaultConnectionManager implements ConnectionManager, Startable { logger: components.logger }, { maxConnections: this.maxConnections, - allow: this.allow + allow: init.allow?.map(a => multiaddr(a)) }) this.dialQueue = new DialQueue(components, { @@ -575,7 +577,7 @@ export class DefaultConnectionManager implements ConnectionManager, Startable { async acceptIncomingConnection (maConn: MultiaddrConnection): Promise { // check deny list const denyConnection = this.deny.some(ma => { - return maConn.remoteAddr.toString().startsWith(ma.toString()) + return ma.contains(maConn.remoteAddr.nodeAddress().address) }) if (denyConnection) { @@ -584,8 +586,8 @@ export class DefaultConnectionManager implements ConnectionManager, Startable { } // check allow list - const allowConnection = this.allow.some(ma => { - return maConn.remoteAddr.toString().startsWith(ma.toString()) + const allowConnection = this.allow.some(ipNet => { + return ipNet.contains(maConn.remoteAddr.nodeAddress().address) }) if (allowConnection) { diff --git a/packages/libp2p/src/connection-manager/utils.ts b/packages/libp2p/src/connection-manager/utils.ts index b2f657d355..b45bb0fbb3 100644 --- a/packages/libp2p/src/connection-manager/utils.ts +++ b/packages/libp2p/src/connection-manager/utils.ts @@ -1,6 +1,7 @@ -import { resolvers } from '@multiformats/multiaddr' +import { multiaddr, resolvers, type Multiaddr, type ResolveOptions } from '@multiformats/multiaddr' +import { convertToIpNet } from '@multiformats/multiaddr/convert' +import type { IpNet } from '@chainsafe/netmask' import type { LoggerOptions } from '@libp2p/interface' -import type { Multiaddr, ResolveOptions } from '@multiformats/multiaddr' /** * Recursively resolve DNSADDR multiaddrs @@ -28,3 +29,35 @@ export async function resolveMultiaddrs (ma: Multiaddr, options: ResolveOptions return output } + +/** + * Converts a multiaddr string or object to an IpNet object. + * If the multiaddr doesn't include /ipcidr, it will encapsulate with the appropriate CIDR: + * - /ipcidr/32 for IPv4 + * - /ipcidr/128 for IPv6 + * + * @param {string | Multiaddr} ma - The multiaddr string or object to convert. + * @returns {IpNet} The converted IpNet object. + * @throws {Error} Throws an error if the multiaddr is not valid. + */ +export function multiaddrToIpNet (ma: string | Multiaddr): IpNet { + try { + let parsedMa: Multiaddr + if (typeof ma === 'string') { + parsedMa = multiaddr(ma) + } else { + parsedMa = ma + } + + // Check if /ipcidr is already present + if (!parsedMa.protoNames().includes('ipcidr')) { + const isIPv6 = parsedMa.protoNames().includes('ip6') + const cidr = isIPv6 ? '/ipcidr/128' : '/ipcidr/32' + parsedMa = parsedMa.encapsulate(cidr) + } + + return convertToIpNet(parsedMa) + } catch (error) { + throw new Error(`Can't convert to IpNet, Invalid multiaddr format: ${ma}`) + } +} diff --git a/packages/libp2p/test/connection-manager/connection-pruner.spec.ts b/packages/libp2p/test/connection-manager/connection-pruner.spec.ts index 8f93fae1f4..8d41fe4ac3 100644 --- a/packages/libp2p/test/connection-manager/connection-pruner.spec.ts +++ b/packages/libp2p/test/connection-manager/connection-pruner.spec.ts @@ -219,6 +219,44 @@ describe('connection-pruner', () => { expect(shortestLivedWithLowestTagSpy).to.have.property('callCount', 1) }) + it('should correctly parse and store allow list as IpNet objects in ConnectionPruner', () => { + const mockInit = { + allow: [ + multiaddr('/ip4/83.13.55.32/ipcidr/32'), + multiaddr('/ip4/83.13.55.32'), + multiaddr('/ip4/192.168.1.1/ipcidr/24'), + multiaddr('/ip6/2001::0/ipcidr/64') + ] + } + + // Create a ConnectionPruner instance + const pruner = new ConnectionPruner(components, mockInit) + + // Expected IpNet objects for comparison + const expectedAllowList = [ + { + mask: new Uint8Array([255, 255, 255, 255]), + network: new Uint8Array([83, 13, 55, 32]) + }, + { + mask: new Uint8Array([255, 255, 255, 255]), + network: new Uint8Array([83, 13, 55, 32]) + }, + { + mask: new Uint8Array([255, 255, 255, 0]), + network: new Uint8Array([192, 168, 1, 0]) + }, + { + mask: new Uint8Array([255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, 0, 0, 0, 0]), + network: new Uint8Array([32, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]) + } + ] + + // Verify that the allow list in the pruner matches the expected IpNet objects + // eslint-disable-next-line @typescript-eslint/dot-notation + expect(pruner['allow']).to.deep.equal(expectedAllowList) + }) + it('should not close connection that is on the allowlist when pruning', async () => { const max = 2 const remoteAddr = multiaddr('/ip4/83.13.55.32/tcp/59283') @@ -241,6 +279,7 @@ describe('connection-pruner', () => { for (let i = 0; i < max; i++) { const connection = stubInterface({ remotePeer: peerIdFromPrivateKey(await generateKeyPair('Ed25519')), + remoteAddr: multiaddr('/ip4/127.0.0.1/tcp/12345'), streams: [] }) const spy = connection.close @@ -269,7 +308,6 @@ describe('connection-pruner', () => { const value = 0 const spy = connection.close spies.set(value, spy) - // Tag that allowed peer with lowest value components.peerStore.get.withArgs(connection.remotePeer).resolves(stubInterface({ tags: new Map([['test-tag', { value }]]) diff --git a/packages/libp2p/test/connection-manager/index.spec.ts b/packages/libp2p/test/connection-manager/index.spec.ts index ba884092db..d7de4ef0e9 100644 --- a/packages/libp2p/test/connection-manager/index.spec.ts +++ b/packages/libp2p/test/connection-manager/index.spec.ts @@ -33,6 +33,48 @@ describe('Connection Manager', () => { await stop(connectionManager, libp2p) }) + it('should correctly parse and store allow and deny lists as IpNet objects in ConnectionManager', () => { + // Define common IPs and CIDRs for reuse + const ipAllowDeny = [ + '/ip4/83.13.55.32', // Single IP address + '/ip4/83.13.55.32/ipcidr/32', // CIDR notation for a single IP + '/ip4/192.168.1.1/ipcidr/24' // CIDR notation for a network + ] + + // Initialize mock input for the allow and deny lists + const mockInit = { + allow: [...ipAllowDeny], + deny: [...ipAllowDeny] + } + + // Create an instance of the DefaultConnectionManager with the mock initialization + const connectionManager = new DefaultConnectionManager(components, mockInit) + + // Define the expected IpNet objects that should result from parsing the allow and deny lists + const expectedIpNets = [ + { + mask: new Uint8Array([255, 255, 255, 255]), // Netmask for a single IP address + network: new Uint8Array([83, 13, 55, 32]) // Network address for '83.13.55.32' + }, + { + mask: new Uint8Array([255, 255, 255, 255]), // Netmask for a single IP address + network: new Uint8Array([83, 13, 55, 32]) // Network address for '83.13.55.32' + }, + { + mask: new Uint8Array([255, 255, 255, 0]), // Netmask for a /24 CIDR block + network: new Uint8Array([192, 168, 1, 0]) // Network address for '192.168.1.0' + } + ] + + // Test that the 'allow' list is correctly parsed and stored as IpNet objects + // eslint-disable-next-line @typescript-eslint/dot-notation + expect(connectionManager['allow']).to.deep.equal(expectedIpNets) + + // Test that the 'deny' list is correctly parsed and stored as IpNet objects + // eslint-disable-next-line @typescript-eslint/dot-notation + expect(connectionManager['deny']).to.deep.equal(expectedIpNets) + }) + it('should fail if the connection manager has mismatched connection limit options', async () => { await expect( createLibp2p({ @@ -83,7 +125,7 @@ describe('Connection Manager', () => { expect(connectionManagerOpenConnectionSpy.getCall(0).args[0].toString()).to.equal(peerId.toString(), 'Attempted to connect to the wrong peer') }) - it('should deny connections from denylist multiaddrs', async () => { + it('should deny connections from denylist multiaddrs (IPv4)', async () => { const remoteAddr = multiaddr('/ip4/83.13.55.32/tcp/59283') connectionManager = new DefaultConnectionManager(components, { ...defaultOptions, @@ -101,6 +143,53 @@ describe('Connection Manager', () => { .to.eventually.be.false() }) + it('should allow connections from allowlist multiaddrs (IPv6)', async () => { + const remoteAddr = multiaddr('/ip6/2001:db8::1/tcp/59283') + const connectionManager = new DefaultConnectionManager(components, { + ...defaultOptions, + maxConnections: 1, + allow: [ + '/ip6/2001:db8::1' + ] + }) + await connectionManager.start() + + sinon.stub(connectionManager.dialQueue, 'dial').resolves(stubInterface({ + remotePeer: peerIdFromPrivateKey(await generateKeyPair('Ed25519')), + status: 'open' + })) + + // max out the connection limit + await connectionManager.openConnection(peerIdFromPrivateKey(await generateKeyPair('Ed25519'))) + expect(connectionManager.getConnections()).to.have.lengthOf(1) + + // an inbound connection is opened from an address in the allow list + const maConn = stubInterface({ + remoteAddr + }) + + await expect(connectionManager.acceptIncomingConnection(maConn)) + .to.eventually.be.true() + }) + + it('should deny connections from denylist multiaddrs (IPv6)', async () => { + const remoteAddr = multiaddr('/ip6/2001:db8::1/tcp/59283') + const connectionManager = new DefaultConnectionManager(components, { + ...defaultOptions, + deny: [ + '/ip6/2001:db8::1' + ] + }) + await connectionManager.start() + + const maConn = stubInterface({ + remoteAddr + }) + + await expect(connectionManager.acceptIncomingConnection(maConn)) + .to.eventually.be.false() + }) + it('should deny connections when maxConnections is exceeded', async () => { connectionManager = new DefaultConnectionManager(components, { ...defaultOptions, @@ -152,7 +241,7 @@ describe('Connection Manager', () => { .to.eventually.be.false() }) - it('should allow connections from allowlist multiaddrs', async () => { + it('should allow connections from allowlist multiaddrs (IPv4)', async () => { const remoteAddr = multiaddr('/ip4/83.13.55.32/tcp/59283') connectionManager = new DefaultConnectionManager(components, { ...defaultOptions, @@ -181,6 +270,80 @@ describe('Connection Manager', () => { .to.eventually.be.true() }) + it('should allow connections from allowlist subnet (IPv4)', async () => { + const remoteAddr = multiaddr('/ip4/83.13.55.32/tcp/59283') + const connectionManager = new DefaultConnectionManager(components, { + ...defaultOptions, + maxConnections: 1, + allow: [ + '/ip4/83.13.55.0/ipcidr/24' // Allow IPv4 subnet /24 + ] + }) + await connectionManager.start() + + const maConn = stubInterface({ + remoteAddr + }) + + await expect(connectionManager.acceptIncomingConnection(maConn)) + .to.eventually.be.true() + }) + + it('should deny connections from denylist subnet (IPv4)', async () => { + const remoteAddr = multiaddr('/ip4/83.13.55.32/tcp/59283') + const connectionManager = new DefaultConnectionManager(components, { + ...defaultOptions, + deny: [ + '/ip4/83.13.55.0/ipcidr/24' // Deny IPv4 subnet /24 + ] + }) + await connectionManager.start() + + const maConn = stubInterface({ + remoteAddr + }) + + await expect(connectionManager.acceptIncomingConnection(maConn)) + .to.eventually.be.false() + }) + + it('should allow connections from allowlist subnet (IPv6)', async () => { + const remoteAddr = multiaddr('/ip6/2001:db8::1/tcp/59283') + const connectionManager = new DefaultConnectionManager(components, { + ...defaultOptions, + maxConnections: 1, + allow: [ + '/ip6/2001:db8::/ipcidr/64' // Allow an IPv6 subnet + ] + }) + await connectionManager.start() + + const maConn = stubInterface({ + remoteAddr + }) + + await expect(connectionManager.acceptIncomingConnection(maConn)) + .to.eventually.be.true() + }) + + it('should deny connections from denylist subnet (IPv6)', async () => { + const remoteAddr = multiaddr('/ip6/2001:db8::1/tcp/59283') + const connectionManager = new DefaultConnectionManager(components, { + ...defaultOptions, + deny: [ + '/ip6/2001:db8::/ipcidr/64' // Deny an IPv6 subnet + ] + }) + await connectionManager.start() + + const maConn = stubInterface({ + remoteAddr + }) + + await expect(connectionManager.acceptIncomingConnection(maConn)) + .to.eventually.be.false() + }) + it('should limit the number of inbound pending connections', async () => { connectionManager = new DefaultConnectionManager(components, { ...defaultOptions, diff --git a/packages/libp2p/test/connection-manager/multiaddr-to-ipnet.spec.ts b/packages/libp2p/test/connection-manager/multiaddr-to-ipnet.spec.ts new file mode 100644 index 0000000000..1590d0d7b6 --- /dev/null +++ b/packages/libp2p/test/connection-manager/multiaddr-to-ipnet.spec.ts @@ -0,0 +1,62 @@ +/* eslint-env mocha */ + +import { expect } from 'aegir/chai' +import { multiaddrToIpNet } from '../../src/connection-manager/utils.js' + +describe('multiaddrToIpNet', () => { + it('should convert a simple IPv4 multiaddr to an IpNet', () => { + const ma = '/ip4/127.0.0.1' + const ipNet = multiaddrToIpNet(ma) + expect(ipNet.toString()).to.equal('127.0.0.1/32') + }) + + it('should convert a multiaddr with an IPv4 ipcidr to an IpNet', () => { + const ma = '/ip4/127.0.0.1/ipcidr/32' + const ipNet = multiaddrToIpNet(ma) + expect(ipNet.toString()).to.equal('127.0.0.1/32') + }) + + it('should convert a simple IPv6 multiaddr to an IpNet', () => { + const ma = '/ip6/::1/ipcidr/128' + const ipNet = multiaddrToIpNet(ma) + expect(ipNet.toString()).to.equal('0000:0000:0000:0000:0000:0000:0000:0001/128') + }) + + it('should convert a multiaddr with an IPv6 ipcidr to an IpNet', () => { + const ma = '/ip6/2001:db8::/ipcidr/64' + const ipNet = multiaddrToIpNet(ma) + expect(ipNet.toString()).to.equal('2001:0db8:0000:0000:0000:0000:0000:0000/64') + }) + + it('should throw an error for invalid multiaddr', () => { + const ma = '/ip6/invalid::address' + expect(() => multiaddrToIpNet(ma)).to.throw(Error, 'Invalid multiaddr') + }) + + it('should expand shorthand IPv6 addresses to full form', () => { + const ma = '/ip6/2001:db8::1/ipcidr/128' + const ipNet = multiaddrToIpNet(ma) + expect(ipNet.toString()).to.equal('2001:0db8:0000:0000:0000:0000:0000:0001/128') + }) + + it('should throw an error for invalid CIDR value in IPv4 and IPv6 multiaddr', () => { + const invalidIPv6 = '/ip6/2001:db8::1/ipcidr/256' + const invalidIPv4 = '/ip4/192.168.1.1/ipcidr/33' + + expect(() => multiaddrToIpNet(invalidIPv6)).to.throw(Error, 'Invalid multiaddr format') + expect(() => multiaddrToIpNet(invalidIPv4)).to.throw(Error, 'Invalid multiaddr format') + }) + + it('should handle IPv6 address with different prefix lengths (e.g., /0, /48, /128)', () => { + const testCases = [ + { ma: '/ip6/2001:db8::/ipcidr/0', expected: '0000:0000:0000:0000:0000:0000:0000:0000/0' }, + { ma: '/ip6/2001:db8:abcd:0000::1/ipcidr/48', expected: '2001:0db8:abcd:0000:0000:0000:0000:0000/48' }, + { ma: '/ip6/2001:db8:abcd:1234::1/ipcidr/128', expected: '2001:0db8:abcd:1234:0000:0000:0000:0001/128' } + ] + + testCases.forEach(({ ma, expected }) => { + const ipNet = multiaddrToIpNet(ma) + expect(ipNet.toString()).to.equal(expected) + }) + }) +})