diff --git a/packages/api/src/beacon/routes/lodestar.ts b/packages/api/src/beacon/routes/lodestar.ts index 5622c9949ef5..5236d3d40e38 100644 --- a/packages/api/src/beacon/routes/lodestar.ts +++ b/packages/api/src/beacon/routes/lodestar.ts @@ -54,6 +54,18 @@ export type GossipPeerScoreStat = { // + Other un-typed options }; +/** + * A multiaddr with peer ID or ENR string. + * + * Supported formats: + * - Multiaddr with peer ID: `/ip4/192.168.1.1/tcp/9000/p2p/16Uiu2HAmKLhW7...` + * - ENR: `enr:-IS4QHCYrYZbAKWCBRlAy5zzaDZXJBGkcnh4MHcBFZntXNFrdvJjX04jRzjzCBOo...` + * + * For multiaddrs, the string must contain a /p2p/ component with the peer ID. + * For ENRs, the TCP multiaddr and peer ID are extracted from the encoded record. + */ +export type DirectPeer = string; + export type RegenQueueItem = { key: string; args: unknown; @@ -240,6 +252,41 @@ export type Endpoints = { EmptyResponseData, EmptyMeta >; + + /** + * Add a direct peer at runtime. + * Direct peers maintain permanent mesh connections without GRAFT/PRUNE negotiation. + * Accepts either a multiaddr with peer ID or an ENR string. + */ + addDirectPeer: Endpoint< + // ⏎ + "POST", + {peer: DirectPeer}, + {query: {peer: string}}, + {peerId: string}, + EmptyMeta + >; + + /** Remove a peer from direct peers */ + removeDirectPeer: Endpoint< + // ⏎ + "DELETE", + {peerId: string}, + {query: {peerId: string}}, + {removed: boolean}, + EmptyMeta + >; + + /** Get list of direct peer IDs */ + getDirectPeers: Endpoint< + // ⏎ + "GET", + EmptyArgs, + EmptyRequest, + string[], + EmptyMeta + >; + /** Same to node api with new fields */ getPeers: Endpoint< "GET", @@ -443,6 +490,32 @@ export function getDefinitions(_config: ChainForkConfig): RouteDefinitions ({query: {peer}}), + parseReq: ({query}) => ({peer: query.peer}), + schema: {query: {peer: Schema.StringRequired}}, + }, + resp: JsonOnlyResponseCodec, + }, + removeDirectPeer: { + url: "/eth/v1/lodestar/direct_peers", + method: "DELETE", + req: { + writeReq: ({peerId}) => ({query: {peerId}}), + parseReq: ({query}) => ({peerId: query.peerId}), + schema: {query: {peerId: Schema.StringRequired}}, + }, + resp: JsonOnlyResponseCodec, + }, + getDirectPeers: { + url: "/eth/v1/lodestar/direct_peers", + method: "GET", + req: EmptyRequestCodec, + resp: JsonOnlyResponseCodec, + }, getPeers: { url: "/eth/v1/lodestar/peers", method: "GET", diff --git a/packages/beacon-node/src/api/impl/lodestar/index.ts b/packages/beacon-node/src/api/impl/lodestar/index.ts index ce5abcfc886e..9f94bdc1ad15 100644 --- a/packages/beacon-node/src/api/impl/lodestar/index.ts +++ b/packages/beacon-node/src/api/impl/lodestar/index.ts @@ -154,6 +154,23 @@ export function getLodestarApi({ await network.disconnectPeer(peerId); }, + async addDirectPeer({peer}) { + const peerId = await network.addDirectPeer(peer); + if (peerId === null) { + throw new ApiError(400, `Failed to add direct peer: invalid peer address or ENR "${peer}"`); + } + return {data: {peerId}}; + }, + + async removeDirectPeer({peerId}) { + const removed = await network.removeDirectPeer(peerId); + return {data: {removed}}; + }, + + async getDirectPeers() { + return {data: await network.getDirectPeers()}; + }, + async getPeers({state, direction}) { const peers = (await network.dumpPeers()).filter( (nodePeer) => diff --git a/packages/beacon-node/src/network/core/networkCore.ts b/packages/beacon-node/src/network/core/networkCore.ts index 06481890ca4e..d5ec1f6e9abe 100644 --- a/packages/beacon-node/src/network/core/networkCore.ts +++ b/packages/beacon-node/src/network/core/networkCore.ts @@ -454,6 +454,18 @@ export class NetworkCore implements INetworkCore { await this.libp2p.hangUp(peerIdFromString(peerIdStr)); } + async addDirectPeer(peer: routes.lodestar.DirectPeer): Promise { + return this.gossip.addDirectPeer(peer); + } + + async removeDirectPeer(peerIdStr: PeerIdStr): Promise { + return this.gossip.removeDirectPeer(peerIdStr); + } + + async getDirectPeers(): Promise { + return this.gossip.getDirectPeers(); + } + private _dumpPeer(peerIdStr: string, connections: Connection[]): routes.lodestar.LodestarNodePeer { const peerData = this.peersData.connectedPeers.get(peerIdStr); const fork = this.config.getForkName(this.clock.currentSlot); diff --git a/packages/beacon-node/src/network/core/networkCoreWorker.ts b/packages/beacon-node/src/network/core/networkCoreWorker.ts index e5cf1ba4dda8..8dd8229d5b70 100644 --- a/packages/beacon-node/src/network/core/networkCoreWorker.ts +++ b/packages/beacon-node/src/network/core/networkCoreWorker.ts @@ -153,6 +153,9 @@ const libp2pWorkerApi: NetworkWorkerApi = { getConnectedPeerCount: () => core.getConnectedPeerCount(), connectToPeer: (peer, multiaddr) => core.connectToPeer(peer, multiaddr), disconnectPeer: (peer) => core.disconnectPeer(peer), + addDirectPeer: (peer) => core.addDirectPeer(peer), + removeDirectPeer: (peerId) => core.removeDirectPeer(peerId), + getDirectPeers: () => core.getDirectPeers(), dumpPeers: () => core.dumpPeers(), dumpPeer: (peerIdStr) => core.dumpPeer(peerIdStr), dumpPeerScoreStats: () => core.dumpPeerScoreStats(), diff --git a/packages/beacon-node/src/network/core/networkCoreWorkerHandler.ts b/packages/beacon-node/src/network/core/networkCoreWorkerHandler.ts index 5ce810a30f6c..194a7a9ea73a 100644 --- a/packages/beacon-node/src/network/core/networkCoreWorkerHandler.ts +++ b/packages/beacon-node/src/network/core/networkCoreWorkerHandler.ts @@ -247,6 +247,15 @@ export class WorkerNetworkCore implements INetworkCore { disconnectPeer(peer: PeerIdStr): Promise { return this.getApi().disconnectPeer(peer); } + addDirectPeer(peer: routes.lodestar.DirectPeer): Promise { + return this.getApi().addDirectPeer(peer); + } + removeDirectPeer(peerId: PeerIdStr): Promise { + return this.getApi().removeDirectPeer(peerId); + } + getDirectPeers(): Promise { + return this.getApi().getDirectPeers(); + } dumpPeers(): Promise { return this.getApi().dumpPeers(); } diff --git a/packages/beacon-node/src/network/core/types.ts b/packages/beacon-node/src/network/core/types.ts index f372bc686778..1763b2e6bd61 100644 --- a/packages/beacon-node/src/network/core/types.ts +++ b/packages/beacon-node/src/network/core/types.ts @@ -30,6 +30,12 @@ export interface INetworkCorePublic { // Debug connectToPeer(peer: PeerIdStr, multiaddr: MultiaddrStr[]): Promise; disconnectPeer(peer: PeerIdStr): Promise; + + // Direct peers management + addDirectPeer(peer: routes.lodestar.DirectPeer): Promise; + removeDirectPeer(peerId: PeerIdStr): Promise; + getDirectPeers(): Promise; + dumpPeers(): Promise; dumpPeer(peerIdStr: PeerIdStr): Promise; dumpPeerScoreStats(): Promise; diff --git a/packages/beacon-node/src/network/gossip/gossipsub.ts b/packages/beacon-node/src/network/gossip/gossipsub.ts index bd38a93006e0..2018591f781e 100644 --- a/packages/beacon-node/src/network/gossip/gossipsub.ts +++ b/packages/beacon-node/src/network/gossip/gossipsub.ts @@ -5,6 +5,7 @@ import {GossipSub, GossipsubEvents} from "@chainsafe/libp2p-gossipsub"; import {MetricsRegister, TopicLabel, TopicStrToLabel} from "@chainsafe/libp2p-gossipsub/metrics"; import {PeerScoreParams} from "@chainsafe/libp2p-gossipsub/score"; import {AddrInfo, SignaturePolicy, TopicStr} from "@chainsafe/libp2p-gossipsub/types"; +import {routes} from "@lodestar/api"; import {BeaconConfig, ForkBoundary} from "@lodestar/config"; import {ATTESTATION_SUBNET_COUNT, SLOTS_PER_EPOCH, SYNC_COMMITTEE_SUBNET_COUNT} from "@lodestar/params"; import {SubnetID} from "@lodestar/types"; @@ -87,6 +88,7 @@ export class Eth2Gossipsub extends GossipSub { private readonly logger: Logger; private readonly peersData: PeersData; private readonly events: NetworkEventBus; + private readonly libp2p: Libp2p; // Internal caches private readonly gossipTopicCache: GossipTopicCache; @@ -159,6 +161,7 @@ export class Eth2Gossipsub extends GossipSub { this.logger = logger; this.peersData = peersData; this.events = events; + this.libp2p = modules.libp2p; this.gossipTopicCache = gossipTopicCache; this.addEventListener("gossipsub:message", this.onGossipsubMessage.bind(this)); @@ -341,6 +344,64 @@ export class Eth2Gossipsub extends GossipSub { this.reportMessageValidationResult(data.msgId, data.propagationSource, data.acceptance); }); } + + /** + * Add a peer as a direct peer at runtime. Accepts multiaddr with peer ID or ENR string. + * Direct peers maintain permanent mesh connections without GRAFT/PRUNE negotiation. + */ + async addDirectPeer(peerStr: routes.lodestar.DirectPeer): Promise { + const parsed = parseDirectPeers([peerStr], this.logger); + if (parsed.length === 0) { + return null; + } + + const {id: peerId, addrs} = parsed[0]; + const peerIdStr = peerId.toString(); + + // Prevent adding self as a direct peer + if (peerId.equals(this.libp2p.peerId)) { + this.logger.warn("Cannot add self as a direct peer", {peerId: peerIdStr}); + return null; + } + + // Direct peers need addresses to connect - reject if none provided + if (addrs.length === 0) { + this.logger.warn("Cannot add direct peer without addresses", {peerId: peerIdStr}); + return null; + } + + // Add addresses to peer store first so we can connect + try { + await this.libp2p.peerStore.merge(peerId, {multiaddrs: addrs}); + } catch (e) { + this.logger.warn("Failed to add direct peer addresses to peer store", {peerId: peerIdStr}, e as Error); + return null; + } + + // Add to direct peers set only after addresses are stored + this.direct.add(peerIdStr); + + this.logger.info("Added direct peer via API", {peerId: peerIdStr}); + return peerIdStr; + } + + /** + * Remove a peer from direct peers. + */ + removeDirectPeer(peerIdStr: string): boolean { + const removed = this.direct.delete(peerIdStr); + if (removed) { + this.logger.info("Removed direct peer via API", {peerId: peerIdStr}); + } + return removed; + } + + /** + * Get list of current direct peer IDs. + */ + getDirectPeers(): string[] { + return Array.from(this.direct); + } } /** @@ -406,7 +467,7 @@ function getForkBoundaryLabel(boundary: ForkBoundary): ForkBoundaryLabel { * For multiaddrs, the string must contain a /p2p/ component with the peer ID. * For ENRs, the TCP multiaddr and peer ID are extracted from the encoded record. */ -export function parseDirectPeers(directPeerStrs: string[], logger: Logger): AddrInfo[] { +export function parseDirectPeers(directPeerStrs: routes.lodestar.DirectPeer[], logger: Logger): AddrInfo[] { const directPeers: AddrInfo[] = []; for (const peerStr of directPeerStrs) { diff --git a/packages/beacon-node/src/network/network.ts b/packages/beacon-node/src/network/network.ts index 6169102af4d5..94e5b000909c 100644 --- a/packages/beacon-node/src/network/network.ts +++ b/packages/beacon-node/src/network/network.ts @@ -641,6 +641,18 @@ export class Network implements INetwork { return this.core.disconnectPeer(peer); } + addDirectPeer(peer: routes.lodestar.DirectPeer): Promise { + return this.core.addDirectPeer(peer); + } + + removeDirectPeer(peerId: string): Promise { + return this.core.removeDirectPeer(peerId); + } + + getDirectPeers(): Promise { + return this.core.getDirectPeers(); + } + dumpPeer(peerIdStr: string): Promise { return this.core.dumpPeer(peerIdStr); } diff --git a/packages/beacon-node/test/e2e/network/onWorker/dataSerialization.test.ts b/packages/beacon-node/test/e2e/network/onWorker/dataSerialization.test.ts index 35fdc57f4d56..cbef30901e57 100644 --- a/packages/beacon-node/test/e2e/network/onWorker/dataSerialization.test.ts +++ b/packages/beacon-node/test/e2e/network/onWorker/dataSerialization.test.ts @@ -118,6 +118,9 @@ describe("data serialization through worker boundary", () => { unsubscribeGossipCoreTopics: [], connectToPeer: [peerId, ["/ip4/1.2.3.4/tcp/13000"]], disconnectPeer: [peerId], + addDirectPeer: ["/ip4/1.2.3.4/tcp/13000/p2p/" + peerId], + removeDirectPeer: [peerId], + getDirectPeers: [], dumpPeers: [], dumpPeer: [peerId], dumpPeerScoreStats: [], @@ -207,6 +210,9 @@ describe("data serialization through worker boundary", () => { writeDiscv5Profile: "", setAdvertisedGroupCount: null, setTargetGroupCount: null, + addDirectPeer: peerId, + removeDirectPeer: true, + getDirectPeers: [peerId], }; type TestCase = {id: string; data: unknown; shouldFail?: boolean};