Skip to content

Commit

Permalink
feat: check service dependencies on startup (#2586)
Browse files Browse the repository at this point in the history
Allows services to optionally define the capabilities they provide to the
rest of libp2p and also the capabilities they require from other services.

This allows, for example, the `WebRTC` transport to require the `CircuitRelay`
transport to be present, or `KAD-DHT` (or anything that uses a topology)
to require the identify protocol.

Fixes #2263
Refs #2135
  • Loading branch information
achingbrain committed Jun 13, 2024
1 parent 0447913 commit d1f1c2b
Show file tree
Hide file tree
Showing 53 changed files with 423 additions and 77 deletions.
8 changes: 7 additions & 1 deletion packages/connection-encrypter-plaintext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
* ```
*/

import { UnexpectedPeerError, InvalidCryptoExchangeError } from '@libp2p/interface'
import { UnexpectedPeerError, InvalidCryptoExchangeError, serviceCapabilities } from '@libp2p/interface'
import { peerIdFromBytes, peerIdFromKeys } from '@libp2p/peer-id'
import { pbStream } from 'it-protobuf-stream'
import { Exchange, KeyType } from './pb/proto.js'
Expand Down Expand Up @@ -52,6 +52,12 @@ class Plaintext implements ConnectionEncrypter {
this.timeout = init.timeout ?? 1000
}

readonly [Symbol.toStringTag] = '@libp2p/plaintext'

readonly [serviceCapabilities]: string[] = [
'@libp2p/connection-encryption'
]

async secureInbound <Stream extends Duplex<AsyncGenerator<Uint8Array | Uint8ArrayList>> = MultiaddrConnection> (localId: PeerId, conn: Stream, remoteId?: PeerId): Promise<SecuredConnection<Stream>> {
return this._encrypt(localId, conn, remoteId)
}
Expand Down
8 changes: 7 additions & 1 deletion packages/connection-encrypter-tls/src/tls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
*/

import { TLSSocket, type TLSSocketOptions, connect } from 'node:tls'
import { CodeError } from '@libp2p/interface'
import { CodeError, serviceCapabilities } from '@libp2p/interface'
import { generateCertificate, verifyPeerCertificate, itToStream, streamToIt } from './utils.js'
import { PROTOCOL } from './index.js'
import type { TLSComponents, TLSInit } from './index.js'
Expand All @@ -37,6 +37,12 @@ export class TLS implements ConnectionEncrypter {
this.timeout = init.timeout ?? 1000
}

readonly [Symbol.toStringTag] = '@libp2p/tls'

readonly [serviceCapabilities]: string[] = [
'@libp2p/connection-encryption'
]

async secureInbound <Stream extends Duplex<AsyncGenerator<Uint8Array | Uint8ArrayList>> = MultiaddrConnection> (localId: PeerId, conn: Stream, remoteId?: PeerId): Promise<SecuredConnection<Stream>> {
return this._encrypt(localId, conn, true, remoteId)
}
Expand Down
26 changes: 20 additions & 6 deletions packages/integration-tests/test/circuit-relay.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -758,15 +758,21 @@ describe('circuit-relay', () => {
circuitRelayTransport({
discoverRelays: 1
})
]
],
services: {
identify: identify()
}
}),
createClient({
transports: [
tcp(),
circuitRelayTransport({
discoverRelays: 1
})
]
],
services: {
identify: identify()
}
}),
createRelay({
services: {
Expand Down Expand Up @@ -846,15 +852,21 @@ describe('circuit-relay', () => {
circuitRelayTransport({
discoverRelays: 1
})
]
],
services: {
identify: identify()
}
}),
createClient({
transports: [
tcp(),
circuitRelayTransport({
discoverRelays: 1
})
]
],
services: {
identify: identify()
}
}),
createRelay({
services: {
Expand Down Expand Up @@ -1010,7 +1022,8 @@ describe('circuit-relay', () => {
defaultDurationLimit,
applyDefaultLimit: false
}
})
}),
identify: identify()
}
})

Expand All @@ -1023,7 +1036,8 @@ describe('circuit-relay', () => {
]
},
services: {
echoService
echoService,
identify: identify()
}
})
])
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { yamux } from '@chainsafe/libp2p-yamux'
import { circuitRelayTransport } from '@libp2p/circuit-relay-v2'
import { identify } from '@libp2p/identify'
import { mockConnectionGater } from '@libp2p/interface-compliance-tests/mocks'
import { mplex } from '@libp2p/mplex'
import { plaintext } from '@libp2p/plaintext'
Expand All @@ -24,12 +26,16 @@ export function createBaseOptions <T extends ServiceMap = Record<string, unknown
circuitRelayTransport()
],
streamMuxers: [
yamux(),
mplex()
],
connectionEncryption: [
plaintext()
],
connectionGater: mockConnectionGater()
connectionGater: mockConnectionGater(),
services: {
identify: identify()
}
}

// WebWorkers cannot do WebRTC so only add support if we are not in a worker
Expand Down
8 changes: 7 additions & 1 deletion packages/integration-tests/test/fixtures/base-options.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { yamux } from '@chainsafe/libp2p-yamux'
import { circuitRelayTransport } from '@libp2p/circuit-relay-v2'
import { identify } from '@libp2p/identify'
import { mplex } from '@libp2p/mplex'
import { plaintext } from '@libp2p/plaintext'
import { tcp } from '@libp2p/tcp'
Expand Down Expand Up @@ -27,11 +29,15 @@ export function createBaseOptions <T extends ServiceMap = Record<string, unknown
circuitRelayTransport()
],
streamMuxers: [
yamux(),
mplex()
],
connectionEncryption: [
plaintext()
]
],
services: {
identify: identify()
}
}

return mergeOptions(options, ...overrides)
Expand Down
18 changes: 18 additions & 0 deletions packages/interface/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,24 @@ export interface RoutingOptions extends AbortOptions, ProgressOptions {
useCache?: boolean
}

/**
* This symbol is used by libp2p services to define the capabilities they can
* provide to other libp2p services.
*
* The service should define a property with this symbol as the key and the
* value should be a string array of provided capabilities.
*/
export const serviceCapabilities = Symbol.for('@libp2p/service-capabilities')

/**
* This symbol is used by libp2p services to define the capabilities they
* require from other libp2p services.
*
* The service should define a property with this symbol as the key and the
* value should be a string array of required capabilities.
*/
export const serviceDependencies = Symbol.for('@libp2p/service-dependencies')

export * from './connection/index.js'
export * from './connection-encrypter/index.js'
export * from './connection-gater/index.js'
Expand Down
14 changes: 13 additions & 1 deletion packages/kad-dht/src/kad-dht.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CodeError, CustomEvent, TypedEventEmitter, contentRoutingSymbol, peerDiscoverySymbol, peerRoutingSymbol, start, stop } from '@libp2p/interface'
import { CodeError, CustomEvent, TypedEventEmitter, contentRoutingSymbol, peerDiscoverySymbol, peerRoutingSymbol, serviceCapabilities, serviceDependencies, start, stop } from '@libp2p/interface'
import drain from 'it-drain'
import pDefer from 'p-defer'
import { PROTOCOL } from './constants.js'
Expand Down Expand Up @@ -308,6 +308,18 @@ export class KadDHT extends TypedEventEmitter<PeerDiscoveryEvents> implements Ka
}
}

readonly [Symbol.toStringTag] = '@libp2p/kad-dht'

readonly [serviceCapabilities]: string[] = [
'@libp2p/content-routing',
'@libp2p/peer-routing',
'@libp2p/peer-discovery'
]

readonly [serviceDependencies]: string[] = [
'@libp2p/identify'
]

get [contentRoutingSymbol] (): ContentRouting {
return this.dhtContentRouting
}
Expand Down
8 changes: 7 additions & 1 deletion packages/keychain/src/keychain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { pbkdf2, randomBytes } from '@libp2p/crypto'
import { generateKeyPair, importKey, unmarshalPrivateKey } from '@libp2p/crypto/keys'
import { CodeError } from '@libp2p/interface'
import { CodeError, serviceCapabilities } from '@libp2p/interface'
import { peerIdFromKeys } from '@libp2p/peer-id'
import { Key } from 'interface-datastore/key'
import mergeOptions from 'merge-options'
Expand Down Expand Up @@ -119,6 +119,12 @@ export class DefaultKeychain implements Keychain {
privates.set(this, { dek })
}

readonly [Symbol.toStringTag] = '@libp2p/keychain'

readonly [serviceCapabilities]: string[] = [
'@libp2p/keychain'
]

/**
* Generates the options for a keychain. A random salt is produced.
*
Expand Down
2 changes: 2 additions & 0 deletions packages/libp2p/src/address-manager/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ export class DefaultAddressManager {
})
}

readonly [Symbol.toStringTag] = '@libp2p/address-manager'

_updatePeerStoreAddresses (): void {
// if announce addresses have been configured, ensure they make it into our peer
// record for things like identify
Expand Down
40 changes: 39 additions & 1 deletion packages/libp2p/src/components.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CodeError } from '@libp2p/interface'
import { CodeError, serviceCapabilities, serviceDependencies } from '@libp2p/interface'
import { isStartable, type Startable, type Libp2pEvents, type ComponentLogger, type NodeInfo, type ConnectionProtector, type ConnectionGater, type ContentRouting, type TypedEventTarget, type Metrics, type PeerId, type PeerRouting, type PeerStore, type PrivateKey, type Upgrader } from '@libp2p/interface'
import { defaultLogger } from '@libp2p/logger'
import type { AddressManager, ConnectionManager, RandomWalk, Registrar, TransportManager } from '@libp2p/interface-internal'
Expand Down Expand Up @@ -157,3 +157,41 @@ export function defaultComponents (init: ComponentsInit = {}): Components {
// @ts-expect-error component keys are proxied
return proxy
}

export function checkServiceDependencies (components: Components): void {
const serviceCapabilities: Record<string, ConstrainBoolean> = {}

for (const service of Object.values(components.components)) {
for (const capability of getServiceCapabilities(service)) {
serviceCapabilities[capability] = true
}
}

for (const service of Object.values(components.components)) {
for (const capability of getServiceDependencies(service)) {
if (serviceCapabilities[capability] !== true) {
throw new CodeError(`Service "${getServiceName(service)}" required capability "${capability}" but it was not provided by any component, you may need to add additional configuration when creating your node.`, 'ERR_UNMET_SERVICE_DEPENDENCIES')
}
}
}
}

function getServiceCapabilities (service: any): string[] {
if (Array.isArray(service?.[serviceCapabilities])) {
return service[serviceCapabilities]
}

return []
}

function getServiceDependencies (service: any): string[] {
if (Array.isArray(service?.[serviceDependencies])) {
return service[serviceDependencies]
}

return []
}

function getServiceName (service: any): string {
return service?.[Symbol.toStringTag] ?? service?.toString() ?? 'unknown'
}
2 changes: 2 additions & 0 deletions packages/libp2p/src/connection-manager/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,8 @@ export class DefaultConnectionManager implements ConnectionManager, Startable {
})
}

readonly [Symbol.toStringTag] = '@libp2p/connection-manager'

isStarted (): boolean {
return this.started
}
Expand Down
2 changes: 2 additions & 0 deletions packages/libp2p/src/content-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export class CompoundContentRouting implements ContentRouting, Startable {
this.components = components
}

readonly [Symbol.toStringTag] = '@libp2p/content-routing'

isStarted (): boolean {
return this.started
}
Expand Down
8 changes: 4 additions & 4 deletions packages/libp2p/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@ import type { PersistentPeerStoreInit } from '@libp2p/peer-store'
import type { DNS } from '@multiformats/dns'
import type { Datastore } from 'interface-datastore'

export type ServiceFactoryMap<T extends Record<string, unknown> = Record<string, unknown>> = {
export type ServiceFactoryMap<T extends ServiceMap = ServiceMap> = {
[Property in keyof T]: (components: Components & T) => T[Property]
}

/**
* For Libp2p configurations and modules details read the [Configuration Document](https://github.com/libp2p/js-libp2p/tree/main/doc/CONFIGURATION.md).
*/
export interface Libp2pInit<T extends ServiceMap = { x: Record<string, unknown> }> {
export interface Libp2pInit<T extends ServiceMap = ServiceMap> {
/**
* peerId instance (it will be created if not provided)
*/
Expand Down Expand Up @@ -135,7 +135,7 @@ export interface Libp2pInit<T extends ServiceMap = { x: Record<string, unknown>

export type { Libp2p }

export type Libp2pOptions<T extends ServiceMap = Record<string, unknown>> = Libp2pInit<T> & { start?: boolean }
export type Libp2pOptions<T extends ServiceMap = ServiceMap> = Libp2pInit<T> & { start?: boolean }

/**
* Returns a new instance of the Libp2p interface, generating a new PeerId
Expand Down Expand Up @@ -163,7 +163,7 @@ export type Libp2pOptions<T extends ServiceMap = Record<string, unknown>> = Libp
* const libp2p = await createLibp2p(options)
* ```
*/
export async function createLibp2p <T extends ServiceMap = { x: Record<string, unknown> }> (options: Libp2pOptions<T> = {}): Promise<Libp2p<T>> {
export async function createLibp2p <T extends ServiceMap = ServiceMap> (options: Libp2pOptions<T> = {}): Promise<Libp2p<T>> {
const node = await createLibp2pNode(options)

if (options.start !== false) {
Expand Down
12 changes: 7 additions & 5 deletions packages/libp2p/src/libp2p.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { MemoryDatastore } from 'datastore-core/memory'
import { concat as uint8ArrayConcat } from 'uint8arrays/concat'
import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
import { DefaultAddressManager } from './address-manager/index.js'
import { defaultComponents } from './components.js'
import { checkServiceDependencies, defaultComponents } from './components.js'
import { connectionGater } from './config/connection-gater.js'
import { validateConfig } from './config.js'
import { DefaultConnectionManager } from './connection-manager/index.js'
Expand All @@ -27,7 +27,7 @@ import type { Libp2p, Libp2pInit, Libp2pOptions } from './index.js'
import type { PeerRouting, ContentRouting, Libp2pEvents, PendingDial, ServiceMap, AbortOptions, ComponentLogger, Logger, Connection, NewStreamOptions, Stream, Metrics, PeerId, PeerInfo, PeerStore, Topology, Libp2pStatus, IsDialableOptions } from '@libp2p/interface'
import type { StreamHandler, StreamHandlerOptions } from '@libp2p/interface-internal'

export class Libp2pNode<T extends ServiceMap = Record<string, unknown>> extends TypedEventEmitter<Libp2pEvents> implements Libp2p<T> {
export class Libp2pNode<T extends ServiceMap = ServiceMap> extends TypedEventEmitter<Libp2pEvents> implements Libp2p<T> {
public peerId: PeerId
public peerStore: PeerStore
public contentRouting: ContentRouting
Expand All @@ -37,7 +37,7 @@ export class Libp2pNode<T extends ServiceMap = Record<string, unknown>> extends
public logger: ComponentLogger
public status: Libp2pStatus

public components: Components & T[keyof T]
public components: Components & T
private readonly log: Logger

constructor (init: Libp2pInit<T> & Required<Pick<Libp2pInit<T>, 'peerId'>>) {
Expand Down Expand Up @@ -160,7 +160,6 @@ export class Libp2pNode<T extends ServiceMap = Record<string, unknown>> extends
if (init.services != null) {
for (const name of Object.keys(init.services)) {
const createService = init.services[name]
// @ts-expect-error components type is not fully formed yet
const service: any = createService(this.components)

if (service == null) {
Expand Down Expand Up @@ -189,6 +188,9 @@ export class Libp2pNode<T extends ServiceMap = Record<string, unknown>> extends
}
}
}

// Ensure all services have their required dependencies
checkServiceDependencies(components)
}

private configureComponent <T> (name: string, component: T): T {
Expand Down Expand Up @@ -409,7 +411,7 @@ export class Libp2pNode<T extends ServiceMap = Record<string, unknown>> extends
* Returns a new Libp2pNode instance - this exposes more of the internals than the
* libp2p interface and is useful for testing and debugging.
*/
export async function createLibp2pNode <T extends ServiceMap = Record<string, unknown>> (options: Libp2pOptions<T> = {}): Promise<Libp2pNode<T>> {
export async function createLibp2pNode <T extends ServiceMap = ServiceMap> (options: Libp2pOptions<T> = {}): Promise<Libp2pNode<T>> {
const peerId = options.peerId ??= await createEd25519PeerId()

if (peerId.privateKey == null) {
Expand Down
Loading

0 comments on commit d1f1c2b

Please sign in to comment.