Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Use CIDR format for connection-manager allow/deny lists #2783

Merged
merged 9 commits into from
Dec 9, 2024
1 change: 1 addition & 0 deletions packages/libp2p/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 15 additions & 4 deletions packages/libp2p/src/connection-manager/connection-pruner.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { PeerMap } from '@libp2p/peer-collections'
import { safelyCloseConnectionIfUnused } from '@libp2p/utils/close'
import { convertToIpNet } from '@multiformats/multiaddr/convert'
import { MAX_CONNECTIONS } from './constants.js'
acul71 marked this conversation as resolved.
Show resolved Hide resolved
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'
Expand Down Expand Up @@ -29,13 +31,22 @@ 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<Libp2pEvents>
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) => {
try {
if (!ma.protoNames().includes('ipcidr')) {
ma = ma.encapsulate('/ipcidr/32') // Encapsulate with /ipcidr/32 if missing
}
return convertToIpNet(ma)
} catch (error) {
throw new Error(`Invalid multiaddr format in allow list: ${ma}`)
}
})
acul71 marked this conversation as resolved.
Show resolved Hide resolved
this.connectionManager = components.connectionManager
this.peerStore = components.peerStore
this.events = components.events
Expand Down Expand Up @@ -107,8 +118,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
Expand Down
42 changes: 34 additions & 8 deletions packages/libp2p/src/connection-manager/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -176,8 +178,8 @@ export interface DefaultConnectionManagerComponents {
export class DefaultConnectionManager implements ConnectionManager, Startable {
private started: boolean
private readonly connections: PeerMap<Connection[]>
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
Expand Down Expand Up @@ -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 != null ? this.parseIpNetList(init.allow) : []
this.deny = init.deny != null ? this.parseIpNetList(init.deny) : []
achingbrain marked this conversation as resolved.
Show resolved Hide resolved

this.incomingPendingConnections = 0
this.maxIncomingPendingConnections = init.maxIncomingPendingConnections ?? defaultOptions.maxIncomingPendingConnections
Expand All @@ -237,7 +239,7 @@ export class DefaultConnectionManager implements ConnectionManager, Startable {
logger: components.logger
}, {
maxConnections: this.maxConnections,
allow: this.allow
allow: init.allow != null ? init.allow.map(a => multiaddr(a)) : undefined
acul71 marked this conversation as resolved.
Show resolved Hide resolved
})

this.dialQueue = new DialQueue(components, {
Expand Down Expand Up @@ -265,6 +267,30 @@ export class DefaultConnectionManager implements ConnectionManager, Startable {
})
}

/**
* Parses a list of IP addresses or CIDR notation strings into an array of IpNet objects.
*
* @param {string[]} list - The list of IP addresses or CIDR strings to parse.
* @returns {IpNet[]} An array of IpNet objects derived from the provided list.
* @throws {Error} Throws an error if any string in the list is not a valid multiaddr format.
*/
private parseIpNetList (list: string[]): IpNet[] {
return list.map((a) => {
try {
// Attempt to parse `a` with the required /ipcidr/32 if missing
let ma
if (a.includes('/ipcidr')) {
ma = multiaddr(a) // Parse directly if it already includes /ipcidr
} else {
ma = multiaddr(a).encapsulate('/ipcidr/32') // Encapsulate with /ipcidr/32 if missing
}
return multiaddrToIpNet(ma)
} catch (error) {
throw new Error(`Invalid multiaddr format in list: ${a}`)
}
})
}

acul71 marked this conversation as resolved.
Show resolved Hide resolved
readonly [Symbol.toStringTag] = '@libp2p/connection-manager'

/**
Expand Down Expand Up @@ -575,7 +601,7 @@ export class DefaultConnectionManager implements ConnectionManager, Startable {
async acceptIncomingConnection (maConn: MultiaddrConnection): Promise<boolean> {
acul71 marked this conversation as resolved.
Show resolved Hide resolved
// check deny list
const denyConnection = this.deny.some(ma => {
return maConn.remoteAddr.toString().startsWith(ma.toString())
return ma.contains(maConn.remoteAddr.nodeAddress().address)
})
acul71 marked this conversation as resolved.
Show resolved Hide resolved

if (denyConnection) {
Expand All @@ -584,8 +610,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) {
Expand Down
37 changes: 35 additions & 2 deletions packages/libp2p/src/connection-manager/utils.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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'
acul71 marked this conversation as resolved.
Show resolved Hide resolved
parsedMa = parsedMa.encapsulate(cidr)
}

return convertToIpNet(parsedMa)
} catch (error) {
throw new Error(`Can't convert to IpNet, Invalid multiaddr format: ${ma}`)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,39 @@ 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')
]
}

// 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])
}
]

// 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)
})
acul71 marked this conversation as resolved.
Show resolved Hide resolved

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')
Expand All @@ -241,6 +274,7 @@ describe('connection-pruner', () => {
for (let i = 0; i < max; i++) {
const connection = stubInterface<Connection>({
remotePeer: peerIdFromPrivateKey(await generateKeyPair('Ed25519')),
remoteAddr: multiaddr('/ip4/127.0.0.1/tcp/12345'),
streams: []
})
const spy = connection.close
Expand Down Expand Up @@ -269,7 +303,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<Peer>({
tags: new Map([['test-tag', { value }]])
Expand Down
Loading
Loading