nwaku/waku/discovery/waku_discv5.nim

463 lines
14 KiB
Nim

{.push raises: [].}
import
std/[sequtils, strutils, options, sets, net, json],
results,
chronos,
chronicles,
metrics,
libp2p/multiaddress,
eth/keys as eth_keys,
eth/p2p/discoveryv5/node,
eth/p2p/discoveryv5/protocol
import
../node/peer_manager/peer_manager,
../waku_core,
../waku_enr,
../factory/external_config
export protocol, waku_enr
declarePublicGauge waku_discv5_discovered, "number of nodes discovered"
declarePublicGauge waku_discv5_errors, "number of waku discv5 errors", ["type"]
logScope:
topics = "waku discv5"
## Config
type WakuDiscoveryV5Config* = object
discv5Config*: Option[DiscoveryConfig]
address*: IpAddress
port*: Port
privateKey*: eth_keys.PrivateKey
bootstrapRecords*: seq[waku_enr.Record]
autoupdateRecord*: bool
## Protocol
type WakuDiscv5Predicate* =
proc(record: waku_enr.Record): bool {.closure, gcsafe, raises: [].}
type WakuDiscoveryV5* = ref object
conf: WakuDiscoveryV5Config
protocol*: protocol.Protocol
listening*: bool
predicate: Option[WakuDiscv5Predicate]
peerManager: Option[PeerManager]
topicSubscriptionQueue: AsyncEventQueue[SubscriptionEvent]
proc shardingPredicate*(
record: Record, bootnodes: seq[Record] = @[]
): Option[WakuDiscv5Predicate] =
## Filter peers based on relay sharding information
let typedRecord = record.toTyped().valueOr:
debug "peer filtering failed", reason = error
return none(WakuDiscv5Predicate)
let nodeShard = typedRecord.relaySharding().valueOr:
debug "no relay sharding information, peer filtering disabled"
return none(WakuDiscv5Predicate)
debug "peer filtering updated"
let predicate = proc(record: waku_enr.Record): bool =
bootnodes.contains(record) or # Temp. Bootnode exception
(
record.getCapabilities().len > 0 and #RFC 31 requirement
nodeShard.shardIds.anyIt(record.containsShard(nodeShard.clusterId, it))
) #RFC 64 guideline
return some(predicate)
proc new*(
T: type WakuDiscoveryV5,
rng: ref HmacDrbgContext,
conf: WakuDiscoveryV5Config,
record: Option[waku_enr.Record],
peerManager: Option[PeerManager] = none(PeerManager),
queue: AsyncEventQueue[SubscriptionEvent] =
newAsyncEventQueue[SubscriptionEvent](30),
): T =
let protocol = newProtocol(
rng = rng,
config = conf.discv5Config.get(protocol.defaultDiscoveryConfig),
bindPort = conf.port,
bindIp = conf.address,
privKey = conf.privateKey,
bootstrapRecords = conf.bootstrapRecords,
enrAutoUpdate = conf.autoupdateRecord,
previousRecord = record,
enrIp = none(IpAddress),
enrTcpPort = none(Port),
enrUdpPort = none(Port),
)
let shardPredOp =
if record.isSome():
shardingPredicate(record.get(), conf.bootstrapRecords)
else:
none(WakuDiscv5Predicate)
WakuDiscoveryV5(
conf: conf,
protocol: protocol,
listening: false,
predicate: shardPredOp,
peerManager: peerManager,
topicSubscriptionQueue: queue,
)
proc updateAnnouncedMultiAddress*(
wd: WakuDiscoveryV5, addresses: seq[MultiAddress]
): Result[void, string] =
let encodedAddrs = multiaddr.encodeMultiaddrs(addresses)
wd.protocol.updateRecord([(MultiaddrEnrField, encodedAddrs)]).isOkOr:
return err("failed to update multiaddress in ENR: " & $error)
debug "ENR updated successfully with new multiaddress",
enrUri = wd.protocol.localNode.record.toUri(), enr = $(wd.protocol.localNode.record)
return ok()
proc updateENRShards(
wd: WakuDiscoveryV5, newTopics: seq[PubsubTopic], add: bool
): Result[void, string] =
## Add or remove shards from the Discv5 ENR
let newShardOp = topicsToRelayShards(newTopics).valueOr:
return err("ENR update failed: " & error)
let newShard = newShardOp.valueOr:
return ok()
let typedRecord = wd.protocol.localNode.record.toTyped().valueOr:
return err("ENR update failed: " & $error)
let currentShardsOp = typedRecord.relaySharding()
let resultShard =
if add and currentShardsOp.isSome():
let currentShard = currentShardsOp.get()
if currentShard.clusterId != newShard.clusterId:
return err("ENR update failed: clusterId id mismatch")
RelayShards.init(
currentShard.clusterId, currentShard.shardIds & newShard.shardIds
).valueOr:
return err("ENR update failed: " & error)
elif not add and currentShardsOp.isSome():
let currentShard = currentShardsOp.get()
if currentShard.clusterId != newShard.clusterId:
return err("ENR update failed: clusterId id mismatch")
let currentSet = toHashSet(currentShard.shardIds)
let newSet = toHashSet(newShard.shardIds)
let indices = toSeq(currentSet - newSet)
if indices.len == 0:
return err("ENR update failed: cannot remove all shards")
RelayShards.init(currentShard.clusterId, indices).valueOr:
return err("ENR update failed: " & error)
elif add and currentShardsOp.isNone():
newShard
else:
return ok()
let (field, value) =
if resultShard.shardIds.len >= ShardingIndicesListMaxLength:
(ShardingBitVectorEnrField, resultShard.toBitVector())
else:
let list = resultShard.toIndicesList().valueOr:
return err("ENR update failed: " & $error)
(ShardingIndicesListEnrField, list)
wd.protocol.updateRecord([(field, value)]).isOkOr:
return err("ENR update failed: " & $error)
return ok()
proc logDiscv5FoundPeers(discoveredRecords: seq[waku_enr.Record]) =
for record in discoveredRecords:
let recordUri = record.toURI()
let capabilities = record.getCapabilities()
let typedRecord = record.toTyped().valueOr:
warn "Could not parse to typed record", error = error, enr = recordUri
continue
let peerInfo = record.toRemotePeerInfo().valueOr:
warn "Could not generate remote peer info", error = error, enr = recordUri
continue
let addrs = peerInfo.constructMultiaddrStr()
let rs = typedRecord.relaySharding()
let shardsStr =
if rs.isSome():
$rs.get()
else:
"no shards found"
notice "Received discv5 node",
addrs = addrs, enr = recordUri, capabilities = capabilities, shards = shardsStr
proc findRandomPeers*(
wd: WakuDiscoveryV5, overridePred = none(WakuDiscv5Predicate)
): Future[seq[waku_enr.Record]] {.async.} =
## Find random peers to connect to using Discovery v5
let discoveredNodes = await wd.protocol.queryRandom()
var discoveredRecords = discoveredNodes.mapIt(it.record)
when defined(debugDiscv5):
logDiscv5FoundPeers(discoveredRecords)
# Filter out nodes that do not match the predicate
if overridePred.isSome():
discoveredRecords = discoveredRecords.filter(overridePred.get())
elif wd.predicate.isSome():
discoveredRecords = discoveredRecords.filter(wd.predicate.get())
waku_discv5_discovered.inc(discoveredRecords.len)
return discoveredRecords
proc searchLoop(wd: WakuDiscoveryV5) {.async.} =
## Continuously add newly discovered nodes
let peerManager = wd.peerManager.valueOr:
return
info "Starting discovery v5 search"
while wd.listening:
trace "running discv5 discovery loop"
let discoveredRecords = await wd.findRandomPeers()
var discoveredPeers: seq[RemotePeerInfo]
var wrongRecordsReasons: seq[tuple[record: string, errorDescription: string]]
## this is to store the reasons why certain records could not be converted to RemotePeerInfo
for record in discoveredRecords:
let peerInfo = record.toRemotePeerInfo().valueOr:
## in case of error, we keep track of it for debugging purposes
wrongRecordsReasons.add(($record, $error))
waku_discv5_errors.inc(labelValues = [$error])
continue
discoveredPeers.add(peerInfo)
trace "discv5 discovered peers",
num_discovered_peers = discoveredPeers.len,
peers = toSeq(discoveredPeers.mapIt(shortLog(it.peerId)))
trace "discv5 discarded wrong records",
wrong_records =
wrongRecordsReasons.mapIt("(" & it.record & "," & it.errorDescription & ")")
for peer in discoveredPeers:
# Peers added are filtered by the peer manager
peerManager.addPeer(peer, PeerOrigin.Discv5)
# Discovery `queryRandom` can have a synchronous fast path for example
# when no peers are in the routing table. Don't run it in continuous loop.
#
# Also, give some time to dial the discovered nodes and update stats, etc.
await sleepAsync(5.seconds)
proc subscriptionsListener(wd: WakuDiscoveryV5) {.async.} =
## Listen for pubsub topics subscriptions changes
let key = wd.topicSubscriptionQueue.register()
while wd.listening:
let events = await wd.topicSubscriptionQueue.waitEvents(key)
# Since we don't know the events we will receive we have to anticipate.
let subs = events.filterIt(it.kind == PubsubSub).mapIt(it.topic)
let unsubs = events.filterIt(it.kind == PubsubUnsub).mapIt(it.topic)
if subs.len == 0 and unsubs.len == 0:
continue
let unsubRes = wd.updateENRShards(unsubs, false)
let subRes = wd.updateENRShards(subs, true)
if subRes.isErr():
debug "ENR shard addition failed", reason = $subRes.error
if unsubRes.isErr():
debug "ENR shard removal failed", reason = $unsubRes.error
if subRes.isErr() and unsubRes.isErr():
continue
debug "ENR updated successfully",
enrUri = wd.protocol.localNode.record.toUri(),
enr = $(wd.protocol.localNode.record)
wd.predicate =
shardingPredicate(wd.protocol.localNode.record, wd.protocol.bootstrapRecords)
wd.topicSubscriptionQueue.unregister(key)
proc start*(wd: WakuDiscoveryV5): Future[Result[void, string]] {.async: (raises: []).} =
if wd.listening:
return err("already listening")
info "Starting discovery v5 service"
debug "start listening on udp port", address = $wd.conf.address, port = $wd.conf.port
try:
wd.protocol.open()
except CatchableError:
return err("failed to open udp port: " & getCurrentExceptionMsg())
wd.listening = true
trace "start discv5 service"
wd.protocol.start()
asyncSpawn wd.searchLoop()
asyncSpawn wd.subscriptionsListener()
debug "Successfully started discovery v5 service"
info "Discv5: discoverable ENR ",
enrUri = wd.protocol.localNode.record.toUri(), enr = $(wd.protocol.localNode.record)
ok()
proc stop*(wd: WakuDiscoveryV5): Future[void] {.async.} =
if not wd.listening:
return
info "Stopping discovery v5 service"
wd.listening = false
trace "Stop listening on discv5 port"
await wd.protocol.closeWait()
debug "Successfully stopped discovery v5 service"
## Helper functions
proc parseBootstrapAddress(address: string): Result[enr.Record, cstring] =
logScope:
address = address
if address[0] == '/':
return err("MultiAddress bootstrap addresses are not supported")
let lowerCaseAddress = toLowerAscii(address)
if lowerCaseAddress.startsWith("enr:"):
var enrRec: enr.Record
if not enrRec.fromURI(address):
return err("Invalid ENR bootstrap record")
return ok(enrRec)
elif lowerCaseAddress.startsWith("enode:"):
return err("ENode bootstrap addresses are not supported")
else:
return err("Ignoring unrecognized bootstrap address type")
proc addBootstrapNode*(bootstrapAddr: string, bootstrapEnrs: var seq[enr.Record]) =
# Ignore empty lines or lines starting with #
if bootstrapAddr.len == 0 or bootstrapAddr[0] == '#':
return
let enrRes = parseBootstrapAddress(bootstrapAddr)
if enrRes.isErr():
debug "ignoring invalid bootstrap address", reason = enrRes.error
return
bootstrapEnrs.add(enrRes.value)
proc setupDiscoveryV5*(
myENR: enr.Record,
nodePeerManager: PeerManager,
nodeTopicSubscriptionQueue: AsyncEventQueue[SubscriptionEvent],
conf: WakuNodeConf,
dynamicBootstrapNodes: seq[RemotePeerInfo],
rng: ref HmacDrbgContext,
key: crypto.PrivateKey,
): WakuDiscoveryV5 =
let dynamicBootstrapEnrs =
dynamicBootstrapNodes.filterIt(it.hasUdpPort()).mapIt(it.enr.get())
var discv5BootstrapEnrs: seq[enr.Record]
# parse enrURIs from the configuration and add the resulting ENRs to the discv5BootstrapEnrs seq
for enrUri in conf.discv5BootstrapNodes:
addBootstrapNode(enrUri, discv5BootstrapEnrs)
for enr in discv5BootstrapEnrs:
let peerInfoRes = enr.toRemotePeerInfo()
if peerInfoRes.isOk():
nodePeerManager.addPeer(peerInfoRes.get(), PeerOrigin.Discv5)
else:
debug "could not convert discv5 bootstrap node to peerInfo, not adding peer to Peer Store",
enr = enr.toUri(), error = peerInfoRes.error
discv5BootstrapEnrs.add(dynamicBootstrapEnrs)
let discv5Config = DiscoveryConfig.init(
conf.discv5TableIpLimit, conf.discv5BucketIpLimit, conf.discv5BitsPerHop
)
let discv5UdpPort = Port(uint16(conf.discv5UdpPort) + conf.portsShift)
let discv5Conf = WakuDiscoveryV5Config(
discv5Config: some(discv5Config),
address: conf.listenAddress,
port: discv5UdpPort,
privateKey: eth_keys.PrivateKey(key.skkey),
bootstrapRecords: discv5BootstrapEnrs,
autoupdateRecord: conf.discv5EnrAutoUpdate,
)
WakuDiscoveryV5.new(
rng, discv5Conf, some(myENR), some(nodePeerManager), nodeTopicSubscriptionQueue
)
proc updateBootstrapRecords*(
self: var WakuDiscoveryV5, newRecordsString: string
): Result[void, string] =
## newRecordsString - JSON array containing the bootnode ENRs i.e. `["enr:...", "enr:..."]`
var newRecords = newSeq[waku_enr.Record]()
var jsonNode: JsonNode
try:
jsonNode = parseJson(newRecordsString)
except Exception:
return err("exception parsing json enr records: " & getCurrentExceptionMsg())
if jsonNode.kind != JArray:
return err("updateBootstrapRecords should receive a json array containing ENRs")
for enr in jsonNode:
let enrWithoutQuotes = ($enr).replace("\"", "")
var bootstrapNodeEnr: waku_enr.Record
if not bootstrapNodeEnr.fromURI(enrWithoutQuotes):
return err("wrong enr given: " & enrWithoutQuotes)
self.protocol.bootstrapRecords = newRecords
self.protocol.seedTable()
return ok()
proc updateBootstrapRecords*(
self: var WakuDiscoveryV5, updatedRecords: seq[enr.Record]
): void =
self.protocol.bootstrapRecords = updatedRecords
# If we're updating the table with nodes that already existed, it will log an error when trying
# to add a bootstrap node that was already there. That's ok.
self.protocol.seedTable()