From 1d1242e07a44bb1f52aa5ed374dab8949e89b668 Mon Sep 17 00:00:00 2001 From: Giuliano Mega Date: Wed, 17 Jun 2026 15:41:32 -0300 Subject: [PATCH] feat: run DHT queries over Mix (#1452) Signed-off-by: Chrysostomos Nanakos Co-authored-by: Chrysostomos Nanakos --- .gitmodules | 3 + Makefile | 4 + build.nims | 16 + config.nims | 2 +- storage.nim | 5 +- storage/blockexchange/network/network.nim | 10 + storage/conf.nim | 34 ++ storage/dht_proxy/client.nim | 119 ++++ storage/dht_proxy/handler.nim | 118 ++++ storage/dht_proxy/protocol.nim | 133 +++++ storage/discovery.nim | 65 ++- storage/rest/api.nim | 5 + storage/storage.nim | 47 ++ storage/utils/mixidentity.nim | 204 +++++++ tests/storage/utils/testmixidentity.nim | 74 +++ tools/mix/config.nims | 2 + tools/mix/mix_pool.nim | 437 +++++++++++++++ tools/mix/mix_relay_dht.nim | 627 ++++++++++++++++++++++ vendor/nim-libp2p-mix | 1 + 19 files changed, 1888 insertions(+), 18 deletions(-) create mode 100644 storage/dht_proxy/client.nim create mode 100644 storage/dht_proxy/handler.nim create mode 100644 storage/dht_proxy/protocol.nim create mode 100644 storage/utils/mixidentity.nim create mode 100644 tests/storage/utils/testmixidentity.nim create mode 100644 tools/mix/config.nims create mode 100644 tools/mix/mix_pool.nim create mode 100644 tools/mix/mix_relay_dht.nim create mode 160000 vendor/nim-libp2p-mix diff --git a/.gitmodules b/.gitmodules index 8538670b..40792355 100644 --- a/.gitmodules +++ b/.gitmodules @@ -196,3 +196,6 @@ url = https://github.com/vacp2p/nim-lsquic.git ignore = untracked branch = main +[submodule "vendor/nim-libp2p-mix"] + path = vendor/nim-libp2p-mix + url = https://github.com/logos-co/nim-libp2p-mix diff --git a/Makefile b/Makefile index f7945253..22dc70fc 100644 --- a/Makefile +++ b/Makefile @@ -110,6 +110,10 @@ all: | build deps echo -e $(BUILD_MSG) "build/$@" && \ $(ENV_SCRIPT) nim storage $(NIM_PARAMS) build.nims +mix-tools: | build deps + echo -e $(BUILD_MSG) "build/mix_pool build/mix_relay_dht" && \ + $(ENV_SCRIPT) nim mixTools $(NIM_PARAMS) build.nims + # must be included after the default target -include $(BUILD_SYSTEM_DIR)/makefiles/targets.mk diff --git a/build.nims b/build.nims index b74a931f..081e3912 100644 --- a/build.nims +++ b/build.nims @@ -66,6 +66,22 @@ task storage, "build logos storage binary": outname = "storage", params = "-d:chronicles_runtime_filtering -d:chronicles_log_level=TRACE" +task mixTools, "build mix tools (mix_pool, mix_relay_dht)": + let (desc, ec) = gorgeEx("git describe --always --dirty") + let mixVersion = + if ec == 0 and desc.strip().len > 0: desc.strip() else: "unknown" + let mixParams = + "-d:chronicles_runtime_filtering -d:chronicles_log_level=TRACE " & + "-d:mixVersion:" & mixVersion + buildBinary "mix_pool", + outName = "mix_pool", + srcDir = "tools/mix/", + params = mixParams + buildBinary "mix_relay_dht", + outName = "mix_relay_dht", + srcDir = "tools/mix/", + params = mixParams + task testStorage, "Build & run Logos Storage tests": test "testStorage", outName = "testStorage" diff --git a/config.nims b/config.nims index 5b1ecb00..ed49cfb2 100644 --- a/config.nims +++ b/config.nims @@ -114,7 +114,7 @@ when (NimMajor, NimMinor, NimPatch) >= (1, 6, 11): "BareExcept:off" when (NimMajor, NimMinor) >= (2, 0): --mm: - orc + refc switch("define", "withoutPCRE") diff --git a/storage.nim b/storage.nim index f1f4372a..aaff21ad 100644 --- a/storage.nim +++ b/storage.nim @@ -94,7 +94,10 @@ when isMainModule: else: config.dataDir / config.netPrivKeyFile - privateKey = setupKey(keyPath).expect("Should setup private key!") + privateKey = setupKey(keyPath).valueOr: + fatal "Failed to set up the network private key", + path = keyPath, err = error.msg + quit QuitFailure server = try: diff --git a/storage/blockexchange/network/network.nim b/storage/blockexchange/network/network.nim index 1d7ebafb..2a5e326b 100644 --- a/storage/blockexchange/network/network.nim +++ b/storage/blockexchange/network/network.nim @@ -9,6 +9,7 @@ import std/tables import std/sequtils +import std/sets import pkg/chronos @@ -72,6 +73,7 @@ type BlockExcNetwork* = ref object of LPProtocol peers*: Table[PeerId, NetworkPeer] + excludedPeers: HashSet[PeerId] switch*: Switch handlers*: BlockExcHandlers request*: BlockExcRequest @@ -258,9 +260,15 @@ proc dropPeer*( except CatchableError as error: warn "Error attempting to disconnect from peer", peer = peer, error = error.msg +proc excludeRelays*(self: BlockExcNetwork, peers: openArray[PeerId]) = + for p in peers: + self.excludedPeers.incl(p) + proc handlePeerJoined*( self: BlockExcNetwork, peer: PeerId ) {.async: (raises: [CancelledError]).} = + if peer in self.excludedPeers: + return discard self.getOrCreatePeer(peer) if not self.handlers.onPeerJoined.isNil: await self.handlers.onPeerJoined(peer) @@ -271,6 +279,8 @@ proc handlePeerDeparted*( ## Cleanup disconnected peer ## + if peer in self.excludedPeers: + return trace "Cleaning up departed peer", peer self.peers.del(peer) if not self.handlers.onPeerDeparted.isNil: diff --git a/storage/conf.nim b/storage/conf.nim index 2937bad4..ec95dd7d 100644 --- a/storage/conf.nim +++ b/storage/conf.nim @@ -45,6 +45,7 @@ import ./presets import ./utils/natutils from ./blockexchange/engine/downloadmanager import DefaultBlockRetries +from ./dht_proxy/protocol import DefaultMaxInFlightLookups export units, net, storagetypes, logutils, presets, completeCmdArg, parseCmdArg, NatConfig @@ -200,6 +201,39 @@ type defaultValue: DefaultNetworkPreset .}: NetworkPreset + dhtMixProxies* {. + desc: "Peers used as dht-proxy destinations when Mix is enabled", + name: "dht-mix-proxy" + .}: seq[SignedPeerRecord] + + mixEnabled* {. + desc: + "Route DHT provider lookups through the Mix protocol via the " & + "dht-mix-proxy. Hides the requester's identity from the proxy", + defaultValue: false, + name: "mix-enabled" + .}: bool + + mixPool* {. + desc: "Path to the Mix relay pool JSON file", defaultValue: "", name: "mix-pool" + .}: string + + mixPoolJson* {. + desc: + "Inline JSON content of the Mix relay pool." & + "Takes precedence over --mix-pool when non-empty", + defaultValue: "", + name: "mix-pool-json" + .}: string + + dhtProxyMaxInFlight* {. + desc: + "Max concurrent DHT proxy lookups handled by this node " & + "(omit to use the protocol default: " & $DefaultMaxInFlightLookups & ")", + defaultValue: int.none, + name: "dht-proxy-max-inflight" + .}: Option[int] + maxPeers* {. desc: "The maximum number of peers to connect to", defaultValue: 160, diff --git a/storage/dht_proxy/client.nim b/storage/dht_proxy/client.nim new file mode 100644 index 00000000..676a42f4 --- /dev/null +++ b/storage/dht_proxy/client.nim @@ -0,0 +1,119 @@ +## Logos Storage +## Copyright (c) 2026 Status Research & Development GmbH +## Licensed under either of +## * Apache License, version 2.0, ([LICENSE-APACHE](LICENSE-APACHE)) +## * MIT license ([LICENSE-MIT](LICENSE-MIT)) +## at your option. +## This file may not be copied, modified, or distributed except according to +## those terms. + +{.push raises: [].} + +import std/sequtils +import std/strutils + +import pkg/chronos +import pkg/libp2p +import pkg/libp2p/cid +import pkg/libp2p/routing_record +import pkg/libp2p_mix + +import ../errors +import ../logutils +import ../utils/mixidentity +import ./protocol + +const DefaultLookupTimeout* = 30.seconds + +logScope: + topics = "storage dht-proxy client" + +type LookupResult = object + status: ResponseStatus + errorKind: ErrorKind + providers: seq[SignedPeerRecord] + +proc requestLookup( + conn: Connection, request: LookupRequest +): Future[?!LookupResult] {.async: (raises: [CancelledError]).} = + try: + let encoded = request.encode() + if encoded.len > MaxLookupRequestBytes: + return failure( + "Request exceeds " & $MaxLookupRequestBytes & " bytes (got " & $encoded.len & ")" + ) + await conn.writeLp(encoded) + + let + respBytes = await conn.readLp(MaxLookupResponseBytes) + resp = LookupResponse.decode(respBytes).valueOr: + return + failure("Failed to decode response (bytes=" & $respBytes.len & "): " & $error) + + var providers = newSeqOfCap[SignedPeerRecord](resp.providers.len) + for sprBytes in resp.providers: + let res = SignedPeerRecord.decode(sprBytes) + if res.isOk: + providers.add(res.get) + else: + warn "Failed to decode SignedPeerRecord from response", err = $res.error + + return success LookupResult( + status: resp.status, errorKind: resp.errorKind, providers: providers + ) + except LPStreamError as exc: + return failure("Stream error: " & exc.msg) + except CatchableError as exc: + return failure("Client error: " & exc.msg) + +proc lookupProviders*( + mixProto: MixProtocol, proxy: PeerRecord, cid: Cid +): Future[?!seq[SignedPeerRecord]] {.async: (raises: [CancelledError]).} = + if proxy.addresses.len == 0: + return failure("Proxy has no addresses") + + let mixAddr = pickMixCompatibleMultiAddr(proxy.addresses.mapIt(it.address)).valueOr: + let dump = proxy.addresses.mapIt($it.address).join(",") + return failure( + "No Mix-compatible address on proxy " & $proxy.peerId & " (advertised: [" & dump & + "])" + ) + + let + destination = MixDestination.init(proxy.peerId, mixAddr) + request = + LookupRequest(queryType: QueryType.FindProviders, queryBytes: cid.data.buffer) + + var conn: Connection + try: + conn = mixProto.toConnection( + destination, + DhtProxyCodec, + MixParameters(expectReply: Opt.some(true), numSurbs: Opt.some(1'u8)), + ).valueOr: + return failure("Failed to obtain Mix connection: " & error) + + let lookupFut = requestLookup(conn, request) + if not (await lookupFut.withTimeout(DefaultLookupTimeout)): + lookupFut.cancelSoon() + return failure("Mix lookup timed out after " & $DefaultLookupTimeout) + + let lookupRes = lookupFut.read() + if lookupRes.isErr: + return failure(lookupRes.error) + let lookup = lookupRes.get() + + case lookup.status + of ResponseStatus.Ok: + return success lookup.providers + of ResponseStatus.NotFound: + return success newSeq[SignedPeerRecord]() + of ResponseStatus.Error: + return failure("Remote returned error: " & $lookup.errorKind) + except CancelledError as exc: + raise exc + except CatchableError as exc: + return failure("Mix lookup failed: " & exc.msg) + finally: + if not conn.isNil: + await noCancel conn.close() diff --git a/storage/dht_proxy/handler.nim b/storage/dht_proxy/handler.nim new file mode 100644 index 00000000..c8d2543e --- /dev/null +++ b/storage/dht_proxy/handler.nim @@ -0,0 +1,118 @@ +## Logos Storage +## Copyright (c) 2026 Status Research & Development GmbH +## Licensed under either of +## * Apache License, version 2.0, ([LICENSE-APACHE](LICENSE-APACHE)) +## * MIT license ([LICENSE-MIT](LICENSE-MIT)) +## at your option. +## This file may not be copied, modified, or distributed except according to +## those terms. + +{.push raises: [].} + +import pkg/chronos +import pkg/libp2p +import pkg/libp2p/cid +import pkg/libp2p/routing_record + +import ../discovery +import ../logutils +import ./protocol + +export protocol + +logScope: + topics = "storage dht-proxy server" + +type DhtProxyProtocol* = ref object of LPProtocol + discovery*: Discovery + inFlight: int + maxInFlight: int + +proc handleFindProviders( + self: DhtProxyProtocol, queryBytes: seq[byte] +): Future[LookupResponse] {.async: (raises: [CancelledError]).} = + let + cid = Cid.init(queryBytes).valueOr: + warn "Invalid CID in lookup request" + return + LookupResponse(status: ResponseStatus.Error, errorKind: ErrorKind.InvalidCid) + providers = (await self.discovery.findDirect(cid)).valueOr: + warn "Direct lookup failed", cid, err = error.msg + return LookupResponse(status: ResponseStatus.Error, errorKind: ErrorKind.Internal) + + if providers.len == 0: + return LookupResponse(status: ResponseStatus.NotFound) + + var encoded = newSeqOfCap[seq[byte]](providers.len) + for spr in providers: + let bytes = spr.encode().valueOr: + warn "Failed to encode SignedPeerRecord", err = error + continue + encoded.add(bytes) + + if encoded.len == 0: + return LookupResponse(status: ResponseStatus.Error, errorKind: ErrorKind.Internal) + + let packed = packProviders(encoded, MaxLookupResponseBytes).valueOr: + return LookupResponse(status: ResponseStatus.Error, errorKind: error) + + LookupResponse(status: ResponseStatus.Ok, providers: packed) + +proc handleLookupRequest( + self: DhtProxyProtocol, conn: Connection +) {.async: (raises: [CancelledError]).} = + try: + if self.inFlight >= self.maxInFlight: + debug "DHT proxy at capacity, replying TooBusy", + inFlight = self.inFlight, max = self.maxInFlight + await conn.writeLp( + LookupResponse(status: ResponseStatus.Error, errorKind: ErrorKind.TooBusy).encode() + ) + return + + inc self.inFlight + defer: + dec self.inFlight + + let + reqBytes = await conn.readLp(MaxLookupRequestBytes) + req = LookupRequest.decode(reqBytes).valueOr: + warn "Failed to decode lookup request" + await conn.writeLp( + LookupResponse( + status: ResponseStatus.Error, errorKind: ErrorKind.DecodeFailed + ).encode() + ) + return + + let resp = + case req.queryType + of FindProviders: + await self.handleFindProviders(req.queryBytes) + + await conn.writeLp(resp.encode()) + except CancelledError as exc: + raise exc + except LPStreamError as exc: + warn "Stream error", err = exc.msg + except CatchableError as exc: + warn "Handler error", err = exc.msg + +proc new*( + T: type DhtProxyProtocol, + discovery: Discovery, + maxInFlight: int = DefaultMaxInFlightLookups, +): DhtProxyProtocol = + let self = DhtProxyProtocol(discovery: discovery, maxInFlight: maxInFlight) + + proc handler( + conn: Connection, proto: string + ): Future[void] {.async: (raises: [CancelledError]).} = + try: + await self.handleLookupRequest(conn) + finally: + await noCancel conn.close() + + self.handler = handler + self.codec = DhtProxyCodec + return self diff --git a/storage/dht_proxy/protocol.nim b/storage/dht_proxy/protocol.nim new file mode 100644 index 00000000..e98abe6c --- /dev/null +++ b/storage/dht_proxy/protocol.nim @@ -0,0 +1,133 @@ +## Logos Storage +## Copyright (c) 2026 Status Research & Development GmbH +## Licensed under either of +## * Apache License, version 2.0, ([LICENSE-APACHE](LICENSE-APACHE)) +## * MIT license ([LICENSE-MIT](LICENSE-MIT)) +## at your option. +## This file may not be copied, modified, or distributed except according to +## those terms. + +{.push raises: [].} + +import pkg/libp2p/protobuf/minprotobuf +import pkg/libp2p_mix +import pkg/libp2p/routing_record + +import ../logutils + +const DhtProxyCodec* = "/storage/dht-proxy/1.0.0" + +const DefaultMaxInFlightLookups* = 100 + +let MaxLookupRequestBytes* = getMaxMessageSizeForCodec(DhtProxyCodec, 1).expect( + "DhtProxyCodec framing leaves no room for a Sphinx forward payload" + ) + +let MaxLookupResponseBytes* = getMaxMessageSizeForCodec(DhtProxyCodec, 0).expect( + "DhtProxyCodec framing leaves no room for a Sphinx reply payload" + ) + +type + QueryType* {.pure.} = enum + FindProviders = 0 + + ResponseStatus* {.pure.} = enum + Ok = 0 + NotFound = 1 + Error = 2 + + ErrorKind* {.pure.} = enum + DecodeFailed = 0 + InvalidCid = 1 + Internal = 2 + ResponseTooLarge = 3 + TooBusy = 4 + + LookupRequest* = object + queryType*: QueryType + queryBytes*: seq[byte] + + LookupResponse* = object + status*: ResponseStatus + errorKind*: ErrorKind + providers*: seq[seq[byte]] + +proc encode*(req: LookupRequest): seq[byte] = + var pb = initProtoBuffer() + pb.write(1, req.queryType.uint32) + pb.write(2, req.queryBytes) + pb.finish() + pb.buffer + +proc encode*(resp: LookupResponse): seq[byte] = + var pb = initProtoBuffer() + pb.write(1, resp.status.uint32) + if resp.status == ResponseStatus.Error: + pb.write(2, resp.errorKind.uint32) + for spr in resp.providers: + pb.write(3, spr) + pb.finish() + pb.buffer + +proc decode*(_: type LookupRequest, data: openArray[byte]): ProtoResult[LookupRequest] = + let pb = initProtoBuffer(data) + var + req = LookupRequest() + qt: uint32 + + if ?pb.getField(1, qt): + if qt > QueryType.high.uint32: + return err(ProtoError.IncorrectBlob) + req.queryType = QueryType(qt) + + discard ?pb.getField(2, req.queryBytes) + ok(req) + +proc decode*( + _: type LookupResponse, data: openArray[byte] +): ProtoResult[LookupResponse] = + let pb = initProtoBuffer(data) + var + resp = LookupResponse() + status: uint32 + + if ?pb.getField(1, status): + if status > ResponseStatus.high.uint32: + return err(ProtoError.IncorrectBlob) + resp.status = ResponseStatus(status) + + if resp.status == ResponseStatus.Error: + var ek: uint32 + if ?pb.getField(2, ek): + if ek > ErrorKind.high.uint32: + return err(ProtoError.IncorrectBlob) + resp.errorKind = ErrorKind(ek) + + discard ?pb.getRepeatedField(3, resp.providers) + + ok(resp) + +proc packProviders*( + providers: seq[seq[byte]], budget_bytes: int +): Result[seq[seq[byte]], ErrorKind] = + if providers.len == 0: + error "packProviders called with no providers" + return err(ErrorKind.Internal) + + let single = LookupResponse(status: ResponseStatus.Ok, providers: providers[0 ..< 1]) + if single.encode().len > budget_bytes: + return err(ErrorKind.ResponseTooLarge) + + var + lo = 1 + hi = providers.len + while lo < hi: + let + mid = (lo + hi + 1) div 2 + test = LookupResponse(status: ResponseStatus.Ok, providers: providers[0 ..< mid]) + if test.encode().len <= budget_bytes: + lo = mid + else: + hi = mid - 1 + + ok(providers[0 ..< lo]) diff --git a/storage/discovery.nim b/storage/discovery.nim index c5943d88..a973b6d3 100644 --- a/storage/discovery.nim +++ b/storage/discovery.nim @@ -11,10 +11,12 @@ import std/algorithm import std/net +import std/random import std/sequtils import pkg/chronos import pkg/libp2p/[cid, multicodec, routing_record, signed_envelope] +import pkg/libp2p_mix import pkg/questionable import pkg/questionable/results import pkg/contractabi/address as ca @@ -24,6 +26,7 @@ from pkg/nimcrypto import keccak256 import ./rng as storage_rng import ./errors import ./logutils +import ./dht_proxy/client as dht_proxy_client export discv5 @@ -45,6 +48,8 @@ type Discovery* = ref object of RootObj dhtRecord*: ?SignedPeerRecord # record to advertice DHT connection information isStarted: bool store: Datastore + mixProto*: MixProtocol + dhtMixProxies*: seq[SignedPeerRecord] proc toNodeId*(cid: Cid): NodeId = ## Cid to discovery id @@ -81,23 +86,45 @@ proc findPeer*( return PeerRecord.none +proc findViaMix( + d: Discovery, cid: Cid +): Future[?!seq[SignedPeerRecord]] {.async: (raises: [CancelledError]).} = + var candidates = d.dhtMixProxies + shuffle(candidates) + + for record in candidates: + let proxy = record.data + let res = await dht_proxy_client.lookupProviders(d.mixProto, proxy, cid) + if res.isErr: + warn "Mix lookup proxy failed", cid, proxy = proxy.peerId, err = res.error.msg + continue + return success res.get + + failure("All Mix lookup proxies failed (candidates=" & $candidates.len & ")") + +proc findDirect*( + d: Discovery, cid: Cid +): Future[?!seq[SignedPeerRecord]] {.async: (raises: [CancelledError]).} = + try: + return (await d.protocol.getProviders(cid.toNodeId())).mapFailure + except CancelledError as exc: + raise exc + except CatchableError as exc: + return failure("Error finding providers for block " & $cid & ": " & exc.msg) + method find*( d: Discovery, cid: Cid ): Future[seq[SignedPeerRecord]] {.async: (raises: [CancelledError]), base.} = - ## Find block providers - ## - - try: - without providers =? (await d.protocol.getProviders(cid.toNodeId())).mapFailure, - error: - warn "Error finding providers for block", cid, error = error.msg - - return providers.filterIt(not (it.data.peerId == d.peerId)) - except CancelledError as exc: - warn "Error finding providers for block", cid, exc = exc.msg - raise exc - except CatchableError as exc: - warn "Error finding providers for block", cid, exc = exc.msg + let providers = + if not d.mixProto.isNil and d.dhtMixProxies.len > 0: + (await d.findViaMix(cid)).valueOr: + warn "Mix lookup failed", cid, err = error.msg + return @[] + else: + (await d.findDirect(cid)).valueOr: + warn "Direct lookup failed", cid, err = error.msg + return @[] + providers.filterIt(not (it.data.peerId == d.peerId)) method provide*(d: Discovery, cid: Cid) {.async: (raises: [CancelledError]), base.} = ## Provide a block Cid @@ -181,11 +208,13 @@ proc updateAnnounceRecord*(d: Discovery, addrs: openArray[MultiAddress]) = d.announceAddrs = @addrs - info "Updating announce record", addrs = d.announceAddrs d.providerRecord = SignedPeerRecord .init(d.key, PeerRecord.init(d.peerId, d.announceAddrs)) .expect("Should construct signed record").some + info "Updating announce record", + addrs = d.announceAddrs, spr = d.providerRecord.get.toURI + if not d.protocol.isNil: d.protocol.updateRecord(d.providerRecord).expect("Should update SPR") @@ -239,6 +268,7 @@ proc new*( bindPort = 0.Port, announceAddrs: openArray[MultiAddress], bootstrapNodes: openArray[SignedPeerRecord] = [], + dhtMixProxies: openArray[SignedPeerRecord] = [], store: Datastore = SQLiteDatastore.new(Memory).expect("Should not fail!"), tableIpLimits: TableIpLimits = DefaultTableIpLimits, ): Discovery = @@ -246,7 +276,10 @@ proc new*( ## var self = Discovery( - key: key, peerId: PeerId.init(key).expect("Should construct PeerId"), store: store + key: key, + peerId: PeerId.init(key).expect("Should construct PeerId"), + store: store, + dhtMixProxies: @dhtMixProxies, ) self.updateAnnounceRecord(announceAddrs) diff --git a/storage/rest/api.nim b/storage/rest/api.nim index 865591fc..af13d71f 100644 --- a/storage/rest/api.nim +++ b/storage/rest/api.nim @@ -574,6 +574,11 @@ proc initDebugApi(node: StorageNodeRef, conf: StorageConf, router: var RestRoute "repo": $conf.dataDir, "spr": if node.discovery.dhtRecord.isSome: node.discovery.dhtRecord.get.toURI else: "", + "providerRecord": + if node.discovery.providerRecord.isSome: + node.discovery.providerRecord.get.toURI + else: + "", "announceAddresses": node.discovery.announceAddrs, "table": table, "storage": {"version": $storageVersion, "revision": $storageRevision}, diff --git a/storage/storage.nim b/storage/storage.nim index 678f5f87..cb6aa629 100644 --- a/storage/storage.nim +++ b/storage/storage.nim @@ -17,6 +17,7 @@ import pkg/chronos import pkg/taskpools import pkg/presto import pkg/libp2p +import pkg/libp2p_mix import pkg/confutils import pkg/confutils/defs import pkg/stew/io2 @@ -30,7 +31,9 @@ import ./rng as random import ./rest/api import ./stores import ./blockexchange +import ./dht_proxy/handler import ./utils/fileutils +import ./utils/mixidentity import ./discovery import ./utils/addrutils import ./utils/natutils @@ -76,6 +79,49 @@ proc start*(s: StorageServer) {.async.} = await s.storageNode.switch.start() + if s.config.mixEnabled: + let + switch = s.storageNode.switch + (mixPub, mixPriv) = loadOrGenerateMixKeys( + string(s.config.dataDir) / "mix-identity" + ).valueOr: + raise newException( + StorageError, "Failed to load or generate Mix keys: " & error.msg + ) + mixAddr = pickMixCompatibleMultiAddr(switch.peerInfo.addrs).valueOr: + raise newException(StorageError, "No Mix-compatible address among listen addrs") + mixNodeInfo = buildMixNodeInfo( + mixPub, mixPriv, switch.peerInfo.peerId, mixAddr, switch.peerInfo.privateKey + ).valueOr: + raise newException(StorageError, "Failed to build Mix node info: " & error.msg) + relayPool = ( + if s.config.mixPoolJson.len > 0: + loadRelayPubInfoTableFromJson(s.config.mixPoolJson) + else: + loadRelayPubInfoTableFromFile(s.config.mixPool) + ).valueOr: + raise newException(StorageError, "Failed to load Mix relay pool: " & error.msg) + mixProto = MixProtocol.new(mixNodeInfo, switch) + + for info in relayPool.values: + mixProto.nodePool.add(info) + + mixProto.registerDestReadBehavior(DhtProxyCodec, readLp(MaxLookupResponseBytes)) + await mixProto.start() + switch.mount(mixProto) + + let dhtProxyProto = + if cap =? s.config.dhtProxyMaxInFlight: + DhtProxyProtocol.new(s.storageNode.discovery, maxInFlight = cap) + else: + DhtProxyProtocol.new(s.storageNode.discovery) + await dhtProxyProto.start() + switch.mount(dhtProxyProto) + + s.storageNode.discovery.mixProto = mixProto + + s.storageNode.engine.network.excludeRelays(relayPool.keys.toSeq) + let (announceAddrs, discoveryAddrs) = nattedAddress( s.config.nat, s.storageNode.switch.peerInfo.addrs, s.config.discoveryPort ) @@ -239,6 +285,7 @@ proc new*( announceAddrs = @[listenMultiAddr], bindPort = config.discoveryPort, bootstrapNodes = bootstrapNodes, + dhtMixProxies = config.dhtMixProxies, store = discoveryStore, ) diff --git a/storage/utils/mixidentity.nim b/storage/utils/mixidentity.nim new file mode 100644 index 00000000..21422c55 --- /dev/null +++ b/storage/utils/mixidentity.nim @@ -0,0 +1,204 @@ +## Logos Storage +## Copyright (c) 2026 Status Research & Development GmbH +## Licensed under either of +## * Apache License, version 2.0, ([LICENSE-APACHE](LICENSE-APACHE)) +## * MIT license ([LICENSE-MIT](LICENSE-MIT)) +## at your option. +## This file may not be copied, modified, or distributed except according to +## those terms. + +{.push raises: [].} + +import std/[json, os, tables] + +import pkg/libp2p +import pkg/libp2p/crypto/crypto +import pkg/libp2p/crypto/secp +import pkg/libp2p_mix +import pkg/libp2p_mix/[curve25519, mix_node] +import pkg/libp2p/crypto/curve25519 as libp2p_curve25519 +import pkg/questionable/results +import pkg/stew/byteutils + +import ../errors + +const PoolFormatVersion = 1 + +const MixIdentityFileSize = 2 * FieldElementSize + +proc pickMixCompatibleMultiAddr*(addrs: openArray[MultiAddress]): Opt[MultiAddress] = + ## Mix only supports /ip4/*/tcp/* or /ip4/*/udp/*/quic-v1 multiaddrs. + for ma in addrs: + if TCP_IP.match(ma) or QUIC_V1_IP.match(ma): + return Opt.some(ma) + Opt.none(MultiAddress) + +proc loadOrGenerateMixKeys*( + path: string +): ?!tuple[mixPub: FieldElement, mixPriv: FieldElement] = + if fileExists(path): + let raw = + try: + readFile(path) + except IOError as exc: + return failure("Failed to read mix-identity from " & path & ": " & exc.msg) + + if raw.len != MixIdentityFileSize: + return failure( + "Invalid mix-identity file size at " & path & " (expected " & + $MixIdentityFileSize & ", got " & $raw.len & ")" + ) + + let + pub = bytesToFieldElement(raw.toOpenArrayByte(0, FieldElementSize - 1)).valueOr: + return failure("Bad mix pub key in " & path & ": " & error) + priv = bytesToFieldElement( + raw.toOpenArrayByte(FieldElementSize, 2 * FieldElementSize - 1) + ).valueOr: + return failure("Bad mix priv key in " & path & ": " & error) + if libp2p_curve25519.public(priv) != pub: + return + failure("Mix identity in " & path & " is inconsistent: pub does not match priv") + return success((mixPub: pub, mixPriv: priv)) + + let (priv, pub) = generateKeyPair().valueOr: + return failure("Failed to generate Mix keypair: " & error) + + let dir = parentDir(path) + if dir.len > 0 and not dirExists(dir): + try: + createDir(dir) + except OSError as exc: + return failure("Failed to create directory " & dir & ": " & exc.msg) + except IOError as exc: + return failure("Failed to create directory " & dir & ": " & exc.msg) + + let blob = fieldElementToBytes(pub) & fieldElementToBytes(priv) + + try: + writeFile(path, string.fromBytes(blob)) + setFilePermissions(path, {fpUserRead, fpUserWrite}) + except IOError as exc: + return failure("Failed to write mix-identity to " & path & ": " & exc.msg) + except OSError as exc: + return failure("Failed to set permissions on " & path & ": " & exc.msg) + + success((mixPub: pub, mixPriv: priv)) + +proc buildMixNodeInfo*( + mixPub, mixPriv: FieldElement, + peerId: PeerId, + multiAddr: MultiAddress, + libp2pPriv: PrivateKey, +): ?!MixNodeInfo = + if libp2pPriv.scheme != Secp256k1: + return failure("Mix requires a Secp256k1 libp2p key; got " & $libp2pPriv.scheme) + + let libp2pPub = libp2pPriv.getPublicKey().valueOr: + return failure("Failed to derive libp2p pub key: " & $error) + + if libp2pPub.scheme != Secp256k1: + return failure("Unexpected libp2p pub key scheme: " & $libp2pPub.scheme) + + success initMixNodeInfo( + peerId = peerId, + multiAddr = multiAddr, + mixPubKey = mixPub, + mixPrivKey = mixPriv, + libp2pPubKey = libp2pPub.skkey, + libp2pPrivKey = libp2pPriv.skkey, + ) + +proc pubInfoFromJson(node: JsonNode): ?!MixPubInfo = + if node.kind != JObject: + return failure("pool entry is not a JSON object") + + let + peerIdNode = node.getOrDefault("peerId") + multiAddrNode = node.getOrDefault("multiAddr") + mixPubKeyNode = node.getOrDefault("mixPubKey") + libp2pPubKeyNode = node.getOrDefault("libp2pPubKey") + + if peerIdNode.isNil: + return failure("pool entry missing field 'peerId'") + if multiAddrNode.isNil: + return failure("pool entry missing field 'multiAddr'") + if mixPubKeyNode.isNil: + return failure("pool entry missing field 'mixPubKey'") + if libp2pPubKeyNode.isNil: + return failure("pool entry missing field 'libp2pPubKey'") + + let + peerIdStr = peerIdNode.getStr() + multiAddrStr = multiAddrNode.getStr() + mixPubKeyHex = mixPubKeyNode.getStr() + libp2pPubKeyHex = libp2pPubKeyNode.getStr() + + let peerId = PeerId.init(peerIdStr).valueOr: + return failure("Invalid peerId in pool entry: " & peerIdStr & " (" & $error & ")") + + let multiAddr = MultiAddress.init(multiAddrStr).valueOr: + return + failure("Invalid multiAddr in pool entry: " & multiAddrStr & " (" & $error & ")") + + let mixPubKeyBytes = + try: + hexToSeqByte(mixPubKeyHex) + except ValueError as exc: + return failure("Invalid mixPubKey hex in pool entry: " & exc.msg) + let mixPubKey = bytesToFieldElement(mixPubKeyBytes).valueOr: + return failure("Invalid mixPubKey in pool entry: " & error) + + let libp2pPubKeyBytes = + try: + hexToSeqByte(libp2pPubKeyHex) + except ValueError as exc: + return failure("Invalid libp2pPubKey hex in pool entry: " & exc.msg) + let libp2pPubKey = SkPublicKey.init(libp2pPubKeyBytes).valueOr: + return failure("Invalid libp2pPubKey in pool entry: " & $error) + + success MixPubInfo.init(peerId, multiAddr, mixPubKey, libp2pPubKey) + +proc loadRelayPubInfoTableFromJson*(poolJson: string): ?!Table[PeerId, MixPubInfo] = + ## Expected format: + ## { "version": 1, "relays": [ { peerId, multiAddr, mixPubKey, libp2pPubKey }, ... ] } + if poolJson.len == 0: + return success initTable[PeerId, MixPubInfo]() + + let parsed = + try: + parseJson(poolJson) + except CatchableError as exc: + return failure("Failed to parse pool JSON: " & exc.msg) + + let versionNode = parsed.getOrDefault("version") + if versionNode.isNil or versionNode.getInt() != PoolFormatVersion: + return failure("Unsupported pool version (expected " & $PoolFormatVersion & ")") + + let relaysNode = parsed.getOrDefault("relays") + if relaysNode.isNil or relaysNode.kind != JArray: + return failure("Pool JSON missing 'relays' array") + + var t = initTable[PeerId, MixPubInfo]() + for entry in relaysNode: + let info = ?pubInfoFromJson(entry) + t[info.peerId] = info + + success t + +proc loadRelayPubInfoTableFromFile*(poolPath: string): ?!Table[PeerId, MixPubInfo] = + if poolPath.len == 0: + return success initTable[PeerId, MixPubInfo]() + + if not fileExists(poolPath): + return failure("Mix pool file does not exist: " & poolPath) + + let poolJson = + try: + readFile(poolPath) + except IOError as exc: + return failure("Failed to read pool " & poolPath & ": " & exc.msg) + + loadRelayPubInfoTableFromJson(poolJson) + +{.pop.} diff --git a/tests/storage/utils/testmixidentity.nim b/tests/storage/utils/testmixidentity.nim new file mode 100644 index 00000000..0f04c8b0 --- /dev/null +++ b/tests/storage/utils/testmixidentity.nim @@ -0,0 +1,74 @@ +import std/tables + +import pkg/unittest2 +import pkg/libp2p/peerid +import pkg/storage/utils/mixidentity {.all.} + +const SamplePoolJson = """ +{ + "version": 1, + "relays": [ + { + "peerId": "16Uiu2HAmNNzXL3wnW64pPFJDwrSJnNaX4CNLeWPbzdPcVJhRTGwP", + "multiAddr": "/ip4/127.0.0.1/tcp/4242", + "mixPubKey": "8a6571e8665fb1c894215f97d6a244591b655b1f5fd5ff7f928ef8b74aa66c5f", + "libp2pPubKey": "03907bc5a41bec7c5ba11f8dfe6c7f779328d2d5bb48c9a978a11e09f3fbf61b3e" + }, + { + "peerId": "16Uiu2HAmM6CDJa9HJQ76cRubcpAmrHfMcUCvYncA9M4BfFFEszQn", + "multiAddr": "/ip4/127.0.0.1/tcp/4243", + "mixPubKey": "f268d04a1a0903ecf63a3441b986eae414579aa47ff22b071370e6fcd9d3b45c", + "libp2pPubKey": "037d526dab2572c2336f721813964011899ba7d11a3ebebed1d22d1dea2b74e547" + } + ] +} +""" + +suite "mixidentity / loadRelayPubInfoTableFromJson": + test "empty string yields an empty table": + let res = loadRelayPubInfoTableFromJson("") + check res.isOk + check res.get.len == 0 + + test "parses a well-formed pool": + let res = loadRelayPubInfoTableFromJson(SamplePoolJson) + check res.isOk + let t = res.get + check t.len == 2 + let + p0 = PeerId.init("16Uiu2HAmNNzXL3wnW64pPFJDwrSJnNaX4CNLeWPbzdPcVJhRTGwP").get + p1 = PeerId.init("16Uiu2HAmM6CDJa9HJQ76cRubcpAmrHfMcUCvYncA9M4BfFFEszQn").get + check p0 in t + check p1 in t + + test "rejects malformed JSON": + let res = loadRelayPubInfoTableFromJson("{not json") + check res.isErr + + test "rejects unsupported version": + let + json = """{"version": 99, "relays": []}""" + res = loadRelayPubInfoTableFromJson(json) + check res.isErr + + test "rejects missing relays array": + let + json = """{"version": 1}""" + res = loadRelayPubInfoTableFromJson(json) + check res.isErr + + test "rejects entry missing required field": + let json = """ +{ + "version": 1, + "relays": [ + { + "peerId": "16Uiu2HAmNNzXL3wnW64pPFJDwrSJnNaX4CNLeWPbzdPcVJhRTGwP", + "multiAddr": "/ip4/127.0.0.1/tcp/4242", + "mixPubKey": "8a6571e8665fb1c894215f97d6a244591b655b1f5fd5ff7f928ef8b74aa66c5f" + } + ] +} +""" + let res = loadRelayPubInfoTableFromJson(json) + check res.isErr diff --git a/tools/mix/config.nims b/tools/mix/config.nims new file mode 100644 index 00000000..e215f09a --- /dev/null +++ b/tools/mix/config.nims @@ -0,0 +1,2 @@ +--path: + "../.." diff --git a/tools/mix/mix_pool.nim b/tools/mix/mix_pool.nim new file mode 100644 index 00000000..4203952b --- /dev/null +++ b/tools/mix/mix_pool.nim @@ -0,0 +1,437 @@ +## Copyright (c) 2026 Status Research & Development GmbH +## Licensed under either of +## * Apache License, version 2.0, ([LICENSE-APACHE](LICENSE-APACHE)) +## * MIT license ([LICENSE-MIT](LICENSE-MIT)) +## at your option. +## This file may not be copied, modified, or distributed except according to +## those terms. + +import std/[json, os, parseopt, strformat, strutils] + +import pkg/libp2p/crypto/crypto +import pkg/libp2p/crypto/secp +import pkg/libp2p/multiaddress +import pkg/libp2p/peerid +import pkg/libp2p_mix/curve25519 +import pkg/libp2p_mix/mix_node +import pkg/stew/byteutils +import pkg/results + +const PoolFormatVersion = 1 +const MixIdentityFileSize = 2 * FieldElementSize + +when not defined(mixVersion): + {.error: "mixVersion must be set at build time via -d:mixVersion:".} +const mixVersion* {.strdefine.} = "" + +proc fail(msg: string) {.noreturn.} = + stderr.writeLine msg + quit(1) + +proc readBin(path: string): seq[byte] = + if not fileExists(path): + fail "File not found: " & path + try: + cast[seq[byte]](readFile(path)) + except IOError as exc: + fail "Failed to read " & path & ": " & exc.msg + +proc writeBin(path: string, data: openArray[byte]) = + let parent = parentDir(path) + if parent.len > 0 and not dirExists(parent): + createDir(parent) + try: + writeFile(path, cast[string](@data)) + setFilePermissions(path, {fpUserRead, fpUserWrite}) + except IOError as exc: + fail "Failed to write " & path & ": " & exc.msg + except OSError as exc: + fail "Failed to set permissions on " & path & ": " & exc.msg + +proc pubInfoToJson(info: MixPubInfo): JsonNode = + let (peerId, multiAddr, mixPubKey, libp2pPubKey) = info.get() + %*{ + "peerId": $peerId, + "multiAddr": $multiAddr, + "mixPubKey": byteutils.toHex(fieldElementToBytes(mixPubKey)), + "libp2pPubKey": byteutils.toHex(libp2pPubKey.getBytes()), + } + +proc pubInfoFromJson(node: JsonNode): MixPubInfo = + let + peerIdStr = node["peerId"].getStr() + multiAddrStr = node["multiAddr"].getStr() + mixPubKeyHex = node["mixPubKey"].getStr() + libp2pPubKeyHex = node["libp2pPubKey"].getStr() + + let peerId = PeerId.init(peerIdStr).valueOr: + fail "Invalid peerId in pool entry: " & peerIdStr & " (" & $error & ")" + + let multiAddr = MultiAddress.init(multiAddrStr).valueOr: + fail "Invalid multiAddr in pool entry: " & multiAddrStr & " (" & $error & ")" + + let mixPubKey = bytesToFieldElement(hexToSeqByte(mixPubKeyHex)).valueOr: + fail "Invalid mixPubKey in pool entry: " & error + + let libp2pPubKey = SkPublicKey.init(hexToSeqByte(libp2pPubKeyHex)).valueOr: + fail "Invalid libp2pPubKey in pool entry: " & $error + + MixPubInfo.init(peerId, multiAddr, mixPubKey, libp2pPubKey) + +proc readPool(path: string): JsonNode = + if not fileExists(path): + return %*{"version": PoolFormatVersion, "relays": newJArray()} + + let jsonPool = + try: + readFile(path) + except IOError as exc: + fail "Failed to read pool " & path & ": " & exc.msg + + let parsed = + try: + parseJson(jsonPool) + except JsonParsingError as exc: + fail "Pool file is not valid JSON: " & exc.msg + + if not parsed.hasKey("version") or parsed["version"].getInt() != PoolFormatVersion: + fail( + "Unsupported pool version (expected " & $PoolFormatVersion & " in " & path & ")" + ) + if not parsed.hasKey("relays") or parsed["relays"].kind != JArray: + fail "Pool file missing 'relays' array: " & path + parsed + +proc writePool(path: string, pool: JsonNode) = + let parent = parentDir(path) + if parent.len > 0 and not dirExists(parent): + createDir(parent) + try: + writeFile(path, pool.pretty() & "\n") + except IOError as exc: + fail "Failed to write pool " & path & ": " & exc.msg + +proc appendOrReplace(pool: JsonNode, entry: JsonNode) = + let + peerId = entry["peerId"].getStr() + relays = pool["relays"] + for i in 0 ..< relays.len: + if relays[i]["peerId"].getStr() == peerId: + relays.elems[i] = entry + return + relays.add(entry) + +proc writeMixIdentity(path: string, mixPub, mixPriv: FieldElement) = + let + pubBytes = fieldElementToBytes(mixPub) + privBytes = fieldElementToBytes(mixPriv) + doAssert pubBytes.len == FieldElementSize and privBytes.len == FieldElementSize + writeBin(path, pubBytes & privBytes) + +proc readMixIdentity(path: string): tuple[pub: FieldElement, priv: FieldElement] = + let raw = readBin(path) + if raw.len != MixIdentityFileSize: + fail( + "Invalid mix-identity size at " & path & " (expected " & $MixIdentityFileSize & + ", got " & $raw.len & ")" + ) + let + pub = bytesToFieldElement(raw.toOpenArray(0, FieldElementSize - 1)).valueOr: + fail "Failed to parse mix pub key in " & path & ": " & error + priv = bytesToFieldElement( + raw.toOpenArray(FieldElementSize, MixIdentityFileSize - 1) + ).valueOr: + fail "Failed to parse mix priv key in " & path & ": " & error + (pub: pub, priv: priv) + +proc writeLibp2pKey(path: string, priv: PrivateKey) = + let bytes = priv.getBytes().valueOr: + fail "Failed to serialize libp2p key: " & $error + writeBin(path, bytes) + +proc readLibp2pKey(path: string): PrivateKey = + let bytes = readBin(path) + PrivateKey.init(bytes).valueOr: + fail "Failed to parse libp2p key in " & path & ": " & $error + +type + InitArgs = object + pool, outDir, ip: string + count, basePort: int + + ExportArgs = object + pool, dataDir, listenIp: string + listenPort: int + + ListArgs = object + pool: string + + RemoveArgs = object + pool, peerId: string + +proc cmdInit(args: InitArgs) = + let rng = newRng() + if rng.isNil: + fail "Failed to create RNG" + + var pool = %*{"version": PoolFormatVersion, "relays": newJArray()} + + for i in 0 ..< args.count: + let port = args.basePort + i + var nodeInfo = MixNodeInfo.generateRandom(port, rng) + + let ma = MultiAddress.init(fmt"/ip4/{args.ip}/tcp/{port}").valueOr: + fail "Failed to construct multiaddr: " & $error + nodeInfo.multiAddr = ma + let libp2pPubProto = PublicKey(scheme: Secp256k1, skkey: nodeInfo.libp2pPubKey) + nodeInfo.peerId = PeerId.init(libp2pPubProto).valueOr: + fail "Failed to derive peerId: " & $error + + let nodeDir = args.outDir / fmt"relay_{i}" + writeMixIdentity(nodeDir / "mix-identity", nodeInfo.mixPubKey, nodeInfo.mixPrivKey) + let libp2pPriv = PrivateKey(scheme: Secp256k1, skkey: nodeInfo.libp2pPrivKey) + writeLibp2pKey(nodeDir / "key", libp2pPriv) + + pool["relays"].add(pubInfoToJson(nodeInfo.toMixPubInfo())) + + writePool(args.pool, pool) + stdout.writeLine "Wrote pool with " & $args.count & " relays to " & args.pool + stdout.writeLine "Per-node identity files under " & args.outDir & "/relay_/" + +proc cmdExport(args: ExportArgs) = + if args.listenPort < 1 or args.listenPort > 65535: + fail "--listen-port must be 1..65535" + + let + (mixPub, mixPriv) = readMixIdentity(args.dataDir / "mix-identity") + libp2pPriv = readLibp2pKey(args.dataDir / "key") + + if libp2pPriv.scheme != Secp256k1: + fail "Mix requires a Secp256k1 libp2p key; got " & $libp2pPriv.scheme + + let libp2pPub = libp2pPriv.getPublicKey().valueOr: + fail "Failed to derive libp2p public key: " & $error + + let peerId = PeerId.init(libp2pPub).valueOr: + fail "Failed to derive peerId: " & $error + + let multiAddr = MultiAddress.init(fmt"/ip4/{args.listenIp}/tcp/{args.listenPort}").valueOr: + fail "Failed to construct multiaddr: " & $error + + let + pubInfo = MixPubInfo.init(peerId, multiAddr, mixPub, libp2pPub.skkey) + pool = readPool(args.pool) + pool.appendOrReplace(pubInfoToJson(pubInfo)) + writePool(args.pool, pool) + stdout.writeLine "Added/updated relay " & $peerId & " (" & $multiAddr & ") in " & + args.pool + +proc cmdList(args: ListArgs) = + let + pool = readPool(args.pool) + relays = pool["relays"] + stdout.writeLine "Pool version " & $pool["version"].getInt() & ", " & $relays.len & + " relays:" + for entry in relays: + stdout.writeLine " " & entry["peerId"].getStr() & " " & entry["multiAddr"].getStr() + +proc cmdRemove(args: RemoveArgs) = + let pool = readPool(args.pool) + var + relays = pool["relays"] + filtered = newJArray() + removed = 0 + for entry in relays: + if entry["peerId"].getStr() == args.peerId: + inc removed + else: + filtered.add(entry) + pool["relays"] = filtered + writePool(args.pool, pool) + if removed == 0: + stdout.writeLine "No matching peerId in pool; nothing changed." + else: + stdout.writeLine "Removed " & $removed & " entry(ies) for peerId " & args.peerId + +proc usage(): string = + """ +mix_pool — manage a Mix relay pool stored as JSON. + +Usage: + mix_pool init --pool= --count=N [--ip=] [--base-port=] [--outdir=] + mix_pool export --pool= --data-dir= --listen-ip= --listen-port= + mix_pool list --pool= + mix_pool remove --pool= --peer-id= + +Options (common): + --pool= Path to pool.json (created if absent). + -h, --help Show this help. + -v, --version Show version and revision. + +init: + --count=N Number of relays to generate. + --ip= Public IPv4 set into each relay's multiaddr. (default 127.0.0.1) + --base-port= First TCP port; relay i uses base-port+i. (default 4242) + --outdir= Where to write each relay's identity files. (default ./relays) + +export: + --data-dir= Existing storage data-dir (contains mix-identity and key). + --listen-ip= Public IPv4 to embed in the pool entry's multiaddr. + --listen-port= Public TCP port (1..65535). + +remove: + --peer-id= Base58 PeerId of the entry to drop. +""" + +proc parseSubcommand(): string = + let params = commandLineParams() + if params.len == 0: + stdout.writeLine usage() + quit(1) + let first = params[0] + if first in ["-h", "--help", "help"]: + stdout.writeLine usage() + quit(0) + if first in ["-v", "--version", "version"]: + stdout.writeLine mixVersion + quit(0) + if first.startsWith("-"): + fail "Expected a subcommand as first argument; got: " & first & "\n" & usage() + return first + +proc dispatch() = + let sub = parseSubcommand() + var args = commandLineParams() + args.delete(0) + var p = initOptParser(args) + + case sub + of "init": + var a = + InitArgs(pool: "", outDir: "./relays", ip: "127.0.0.1", count: 0, basePort: 4242) + while true: + p.next() + case p.kind + of cmdEnd: + break + of cmdShortOption, cmdLongOption: + case p.key + of "help", "h": + stdout.writeLine usage() + quit(0) + of "pool": + a.pool = expandTilde(p.val) + of "count": + try: + a.count = parseInt(p.val) + except ValueError: + fail "init: --count must be an integer, got: " & p.val + of "ip": + a.ip = p.val + of "base-port": + try: + a.basePort = parseInt(p.val) + except ValueError: + fail "init: --base-port must be an integer, got: " & p.val + of "outdir": + a.outDir = expandTilde(p.val) + else: + fail "init: unknown flag --" & p.key + of cmdArgument: + stderr.writeLine usage() + quit(1) + if a.pool.len == 0: + fail "init: --pool= is required" + if a.count < 1: + fail "init: --count= must be >= 1" + cmdInit(a) + of "export": + var a = ExportArgs(pool: "", dataDir: "", listenIp: "", listenPort: 0) + while true: + p.next() + case p.kind + of cmdEnd: + break + of cmdShortOption, cmdLongOption: + case p.key + of "help", "h": + stdout.writeLine usage() + quit(0) + of "pool": + a.pool = expandTilde(p.val) + of "data-dir": + a.dataDir = expandTilde(p.val) + of "listen-ip": + a.listenIp = p.val + of "listen-port": + try: + a.listenPort = parseInt(p.val) + except ValueError: + fail "export: --listen-port must be an integer, got: " & p.val + else: + fail "export: unknown flag --" & p.key + of cmdArgument: + stderr.writeLine usage() + quit(1) + if a.pool.len == 0: + fail "export: --pool= is required" + if a.dataDir.len == 0: + fail "export: --data-dir= is required" + if a.listenIp.len == 0: + fail "export: --listen-ip= is required" + if a.listenPort == 0: + fail "export: --listen-port= is required" + cmdExport(a) + of "list": + var a = ListArgs(pool: "") + while true: + p.next() + case p.kind + of cmdEnd: + break + of cmdShortOption, cmdLongOption: + case p.key + of "help", "h": + stdout.writeLine usage() + quit(0) + of "pool": + a.pool = expandTilde(p.val) + else: + fail "list: unknown flag --" & p.key + of cmdArgument: + stderr.writeLine usage() + quit(1) + if a.pool.len == 0: + fail "list: --pool= is required" + cmdList(a) + of "remove": + var a = RemoveArgs(pool: "", peerId: "") + while true: + p.next() + case p.kind + of cmdEnd: + break + of cmdShortOption, cmdLongOption: + case p.key + of "help", "h": + stdout.writeLine usage() + quit(0) + of "pool": + a.pool = expandTilde(p.val) + of "peer-id": + a.peerId = p.val + else: + fail "remove: unknown flag --" & p.key + of cmdArgument: + stderr.writeLine usage() + quit(1) + if a.pool.len == 0: + fail "remove: --pool= is required" + if a.peerId.len == 0: + fail "remove: --peer-id= is required" + cmdRemove(a) + else: + fail "Unknown subcommand: " & sub & "\n" & usage() + +when isMainModule: + dispatch() diff --git a/tools/mix/mix_relay_dht.nim b/tools/mix/mix_relay_dht.nim new file mode 100644 index 00000000..f9fc493e --- /dev/null +++ b/tools/mix/mix_relay_dht.nim @@ -0,0 +1,627 @@ +## Copyright (c) 2026 Status Research & Development GmbH +## Licensed under either of +## * Apache License, version 2.0, ([LICENSE-APACHE](LICENSE-APACHE)) +## * MIT license ([LICENSE-MIT](LICENSE-MIT)) +## at your option. +## This file may not be copied, modified, or distributed except according to +## those terms. + +import std/[net, os, parseopt, strformat, strutils] + +import pkg/chronos +import pkg/chronicles +import + pkg/libp2p/ + [builders, cid, multiaddress, peerid, routing_record, signed_envelope, switch] +import pkg/libp2p/crypto/crypto +import pkg/libp2p/crypto/secp +import pkg/libp2p/protocols/protocol +import pkg/libp2p/stream/connection +import pkg/libp2p_mix +import pkg/libp2p_mix/[curve25519, mix_node] +import pkg/libp2p/crypto/curve25519 as libp2p_curve25519 +import pkg/results +import pkg/codexdht/discv5/[protocol as discv5, routing_table] +from pkg/nimcrypto import keccak256 + +import pkg/storage/dht_proxy/protocol + +when defined(posix): + import std/posix + +const MixIdentityFileSize = 2 * FieldElementSize + +when not defined(mixVersion): + {.error: "mixVersion must be set at build time via -d:mixVersion:".} +const mixVersion* {.strdefine.} = "" + +logScope: + topics = "mix relay dht" + +proc fail(msg: string) {.noreturn.} = + stderr.writeLine msg + quit(1) + +proc readBin(path: string): seq[byte] = + if not fileExists(path): + fail "File not found: " & path + try: + cast[seq[byte]](readFile(path)) + except IOError as exc: + fail "Failed to read " & path & ": " & exc.msg + +proc loadMixKeys(path: string): tuple[pub, priv: FieldElement] = + let raw = readBin(path) + if raw.len != MixIdentityFileSize: + fail( + "Invalid mix-identity size at " & path & " (expected " & $MixIdentityFileSize & + ", got " & $raw.len & ")" + ) + let + pub = bytesToFieldElement(raw.toOpenArray(0, FieldElementSize - 1)).valueOr: + fail "Failed to parse mix pub key in " & path & ": " & error + priv = bytesToFieldElement( + raw.toOpenArray(FieldElementSize, MixIdentityFileSize - 1) + ).valueOr: + fail "Failed to parse mix priv key in " & path & ": " & error + if libp2p_curve25519.public(priv) != pub: + fail "Mix identity in " & path & " is inconsistent: pub does not match priv" + (pub: pub, priv: priv) + +proc loadLibp2pKey(path: string): PrivateKey = + let bytes = readBin(path) + PrivateKey.init(bytes).valueOr: + fail "Failed to parse libp2p key in " & path & ": " & $error + +proc writeBin(path: string, data: openArray[byte]) = + let parent = parentDir(path) + if parent.len > 0 and not dirExists(parent): + createDir(parent) + try: + writeFile(path, cast[string](@data)) + setFilePermissions(path, {fpUserRead, fpUserWrite}) + except IOError as exc: + fail "Failed to write " & path & ": " & exc.msg + except OSError as exc: + fail "Failed to set permissions on " & path & ": " & exc.msg + +proc generateKeys(dataDir: string) = + let + mixIdentityPath = dataDir / "mix-identity" + libp2pKeyPath = dataDir / "key" + + if not dirExists(dataDir): + try: + createDir(dataDir) + except OSError as exc: + fail "Failed to create --data-dir " & dataDir & ": " & exc.msg + + let rng = newRng() + if rng.isNil: + fail "Failed to create RNG" + + let (mixPriv, mixPub) = generateKeyPair().valueOr: + fail "Failed to generate mix keypair: " & error + writeBin(mixIdentityPath, fieldElementToBytes(mixPub) & fieldElementToBytes(mixPriv)) + + let libp2pPair = SkKeyPair.random(rng) + let libp2pPriv = PrivateKey(scheme: Secp256k1, skkey: libp2pPair.seckey) + let libp2pBytes = libp2pPriv.getBytes().valueOr: + fail "Failed to serialize libp2p key: " & $error + writeBin(libp2pKeyPath, libp2pBytes) + + notice "Generated fresh identity", + dataDir = dataDir, mixIdentity = mixIdentityPath, libp2pKey = libp2pKeyPath + +proc toNodeId(c: Cid): NodeId = + readUintBE[256](keccak256.digest(c.data.buffer).data) + +type DhtProxyProtocol = ref object of LPProtocol + dht: discv5.Protocol + inFlight: int + maxInFlight: int + +proc handleFindProviders( + self: DhtProxyProtocol, queryBytes: seq[byte] +): Future[LookupResponse] {.async: (raises: [CancelledError]).} = + let c = Cid.init(queryBytes).valueOr: + warn "Invalid CID in lookup request" + return LookupResponse(status: ResponseStatus.Error, errorKind: ErrorKind.InvalidCid) + + let providers = + try: + (await self.dht.getProviders(c.toNodeId())).valueOr: + warn "discv5 getProviders failed", err = $error + return + LookupResponse(status: ResponseStatus.Error, errorKind: ErrorKind.Internal) + except CancelledError as exc: + raise exc + except CatchableError as exc: + warn "discv5 getProviders raised", err = exc.msg + return LookupResponse(status: ResponseStatus.Error, errorKind: ErrorKind.Internal) + + if providers.len == 0: + return LookupResponse(status: ResponseStatus.NotFound) + + var encoded = newSeqOfCap[seq[byte]](providers.len) + for rec in providers: + let bytes = rec.encode().valueOr: + warn "Failed to encode SignedPeerRecord", err = error + continue + encoded.add(bytes) + + if encoded.len == 0: + return LookupResponse(status: ResponseStatus.Error, errorKind: ErrorKind.Internal) + + let packed = packProviders(encoded, MaxLookupResponseBytes).valueOr: + return LookupResponse(status: ResponseStatus.Error, errorKind: error) + + LookupResponse(status: ResponseStatus.Ok, providers: packed) + +proc handleLookupRequest( + self: DhtProxyProtocol, conn: Connection +) {.async: (raises: [CancelledError]).} = + try: + if self.inFlight >= self.maxInFlight: + debug "DHT proxy at capacity, replying TooBusy", + inFlight = self.inFlight, max = self.maxInFlight + await conn.writeLp( + LookupResponse(status: ResponseStatus.Error, errorKind: ErrorKind.TooBusy).encode() + ) + return + + inc self.inFlight + defer: + dec self.inFlight + + let + reqBytes = await conn.readLp(MaxLookupRequestBytes) + req = LookupRequest.decode(reqBytes).valueOr: + warn "Failed to decode lookup request" + await conn.writeLp( + LookupResponse( + status: ResponseStatus.Error, errorKind: ErrorKind.DecodeFailed + ).encode() + ) + return + + let resp = + case req.queryType + of FindProviders: + await self.handleFindProviders(req.queryBytes) + + await conn.writeLp(resp.encode()) + except CancelledError as exc: + raise exc + except LPStreamError as exc: + warn "Stream error", err = exc.msg + except CatchableError as exc: + warn "Handler error", err = exc.msg + +proc new( + T: type DhtProxyProtocol, + dht: discv5.Protocol, + maxInFlight: int = DefaultMaxInFlightLookups, +): DhtProxyProtocol = + let self = DhtProxyProtocol(dht: dht, maxInFlight: maxInFlight) + + proc handler( + conn: Connection, proto: string + ): Future[void] {.async: (raises: [CancelledError]).} = + try: + await self.handleLookupRequest(conn) + finally: + await noCancel conn.close() + + self.handler = handler + self.codec = DhtProxyCodec + self + +type Conf = object + dataDir: string + listenIp: string + listenPort: int + discPort: int + bootstrapNodes: seq[SignedPeerRecord] + logLevel: string + logFile: string + generate: bool + noDhtProxy: bool + maxInFlight: int + +proc usage(): string = + """ +mix_relay_dht — standalone Mix relay + DHT proxy daemon. + +Usage: + mix_relay_dht --data-dir= --listen-ip= --listen-port= + --disc-port= + [--bootstrap-node= ...] [--log-level=] [--generate] + +Options: + --data-dir= Directory holding identity files (key + mix-identity). + --listen-ip= Public IPv4 to bind/announce for libp2p TCP. + --listen-port= libp2p TCP port (Mix relay + DHT proxy share this). + --disc-port= discv5 UDP port. + --bootstrap-node= Repeatable. SPR of a discv5 bootstrap peer. + --log-level= TRACE | DEBUG | INFO | NOTICE | WARN | ERROR | FATAL | NONE + (default: INFO) + --log-file= Write logs to instead of stdout. + --generate Generate fresh identity files if data-dir is empty. + --no-dht-proxy Run as a pure Mix relay. + Conflicts with --disc-port and --bootstrap-node. + --max-inflight= Max concurrent DHT proxy lookups (default: 100). + -h, --help Show this help. + -v, --version Show version and revision. +""" + +proc parseSpr(raw: string): SignedPeerRecord = + var spr: SignedPeerRecord + if not spr.fromURI(raw): + fail "Invalid --bootstrap-node SPR: " & raw + spr + +proc parseConf(): Conf = + result = Conf( + dataDir: "", + listenIp: "", + listenPort: 0, + discPort: 0, + bootstrapNodes: @[], + logLevel: "INFO", + logFile: "", + generate: false, + noDhtProxy: false, + maxInFlight: DefaultMaxInFlightLookups, + ) + var p = initOptParser(commandLineParams()) + while true: + p.next() + case p.kind + of cmdEnd: + break + of cmdShortOption, cmdLongOption: + case p.key + of "help", "h": + stdout.writeLine usage() + quit(0) + of "version", "v": + stdout.writeLine mixVersion + quit(0) + of "data-dir": + result.dataDir = expandTilde(p.val) + of "listen-ip": + result.listenIp = p.val + of "listen-port": + try: + result.listenPort = parseInt(p.val) + except ValueError: + fail "--listen-port must be an integer, got: " & p.val + of "disc-port": + try: + result.discPort = parseInt(p.val) + except ValueError: + fail "--disc-port must be an integer, got: " & p.val + of "bootstrap-node": + result.bootstrapNodes.add(parseSpr(p.val)) + of "log-level": + try: + discard parseEnum[LogLevel](p.val) + except ValueError: + fail "Invalid --log-level: " & p.val & + " (use TRACE|DEBUG|INFO|NOTICE|WARN|ERROR|FATAL|NONE)" + result.logLevel = p.val + of "log-file": + result.logFile = expandTilde(p.val) + of "generate": + result.generate = true + of "no-dht-proxy": + result.noDhtProxy = true + of "max-inflight": + try: + result.maxInFlight = parseInt(p.val) + except ValueError: + fail "--max-inflight must be an integer, got: " & p.val + if result.maxInFlight < 1: + fail "--max-inflight must be >= 1, got: " & $result.maxInFlight + else: + fail "Unknown flag: --" & p.key + of cmdArgument: + stderr.writeLine usage() + quit(1) + + if result.dataDir.len == 0: + fail "--data-dir= is required" + if result.listenIp.len == 0: + fail "--listen-ip= is required" + if result.listenPort == 0: + fail "--listen-port= is required" + if result.listenPort < 1 or result.listenPort > 65535: + fail "--listen-port out of range: " & $result.listenPort & " (must be 1..65535)" + + if result.noDhtProxy: + if result.discPort != 0: + fail "--no-dht-proxy conflicts with --disc-port" + if result.bootstrapNodes.len > 0: + fail "--no-dht-proxy conflicts with --bootstrap-node" + else: + if result.discPort == 0: + fail "--disc-port= is required" + if result.discPort < 1 or result.discPort > 65535: + fail "--disc-port out of range: " & $result.discPort & " (must be 1..65535)" + +var shutdownRequested = false + +proc requestShutdown() = + shutdownRequested = true + +proc controlCHandler() {.noconv.} = + requestShutdown() + +when defined(posix): + proc sigtermHandler(signal: cint) {.noconv.} = + requestShutdown() + +proc runRelayOnly( + conf: Conf, + switch: Switch, + mixProto: MixProtocol, + peerId: PeerId, + tcpAddr: MultiAddress, +) {.async: (raises: [CatchableError]).} = + try: + await mixProto.start() + except CatchableError as exc: + raise newException(CatchableError, "MixProtocol start failed: " & exc.msg) + switch.mount(mixProto) + + try: + await switch.start() + except CatchableError as exc: + raise newException(CatchableError, "libp2p switch start failed: " & exc.msg) + + notice "Mix relay started (no DHT proxy)", + peerId = peerId, tcp = $tcpAddr, dataDir = conf.dataDir + + try: + while not shutdownRequested: + await sleepAsync(200.milliseconds) + finally: + notice "Stopping" + await switch.stop() + notice "Stopped" + +proc runWithDhtProxy( + conf: Conf, + switch: Switch, + mixProto: MixProtocol, + libp2pPriv: PrivateKey, + peerId: PeerId, + listenIp: IpAddress, + tcpAddr: MultiAddress, +) {.async: (raises: [CatchableError]).} = + let udpAddr = MultiAddress.init(fmt"/ip4/{$listenIp}/udp/{conf.discPort}").valueOr: + raise newException(ValueError, "Invalid discv5 multiaddr: " & $error) + + let dhtRecord = SignedPeerRecord.init(libp2pPriv, PeerRecord.init(peerId, @[udpAddr])).valueOr: + raise newException(ValueError, "Failed to build DHT SPR: " & $error) + + let discoveryConfig = + DiscoveryConfig(tableIpLimits: DefaultTableIpLimits, bitsPerHop: DefaultBitsPerHop) + + let dht = newProtocol( + libp2pPriv, + bindIp = listenIp, + bindPort = Port(conf.discPort), + record = dhtRecord, + bootstrapRecords = conf.bootstrapNodes, + rng = newRng(), + providers = + ProvidersManager.new(SQLiteDatastore.new(Memory).expect("Should not fail")), + config = discoveryConfig, + ) + + let maxReplyBytes = getMaxMessageSizeForCodec(DhtProxyCodec, 0).valueOr: + raise + newException(ValueError, "DhtProxyCodec does not fit Sphinx payload: " & error) + mixProto.registerDestReadBehavior(DhtProxyCodec, readLp(maxReplyBytes)) + + let proxyProto = DhtProxyProtocol.new(dht, maxInFlight = conf.maxInFlight) + + try: + await mixProto.start() + except CatchableError as exc: + raise newException(CatchableError, "MixProtocol start failed: " & exc.msg) + switch.mount(mixProto) + + try: + await proxyProto.start() + except CatchableError as exc: + raise newException(CatchableError, "DhtProxyProtocol start failed: " & exc.msg) + switch.mount(proxyProto) + + try: + dht.open() + await dht.start() + except CatchableError as exc: + raise newException(CatchableError, "discv5 start failed: " & exc.msg) + + try: + await switch.start() + except CatchableError as exc: + raise newException(CatchableError, "libp2p switch start failed: " & exc.msg) + + let mixNodeRecord = SignedPeerRecord.init( + libp2pPriv, PeerRecord.init(peerId, @[tcpAddr]) + ).valueOr: + raise newException(ValueError, "Failed to build mix node SPR: " & $error) + + let + mixNodeSprStr = mixNodeRecord.toURI() + dhtSprStr = dht.localNode.record.toURI() + mixNodeSprPath = conf.dataDir / "mix_node.spr" + dhtSprPath = conf.dataDir / "dht.spr" + + try: + writeFile(mixNodeSprPath, mixNodeSprStr) + except IOError as exc: + raise newException( + CatchableError, + "Failed to write mix node SPR file " & mixNodeSprPath & ": " & exc.msg, + ) + + try: + writeFile(dhtSprPath, dhtSprStr) + except IOError as exc: + raise newException( + CatchableError, "Failed to write DHT SPR file " & dhtSprPath & ": " & exc.msg + ) + + notice "Mix relay and DHT proxy started", + peerId = peerId, tcp = $tcpAddr, udp = $udpAddr, dataDir = conf.dataDir + notice "DHT bootstrap SPR", spr = dhtSprStr, file = dhtSprPath + notice "Mix node SPR", spr = mixNodeSprStr, file = mixNodeSprPath + + try: + while not shutdownRequested: + await sleepAsync(200.milliseconds) + finally: + notice "Stopping" + try: + await noCancel dht.closeWait() + except CatchableError as exc: + warn "discv5 close error", err = exc.msg + await switch.stop() + notice "Stopped" + +proc run(conf: Conf) {.async: (raises: [CatchableError]).} = + let + mixIdentityPath = conf.dataDir / "mix-identity" + libp2pKeyPath = conf.dataDir / "key" + mixIdentityExists = fileExists(mixIdentityPath) + libp2pKeyExists = fileExists(libp2pKeyPath) + + if not mixIdentityExists and not libp2pKeyExists: + if not conf.generate: + fail( + "No identity files in --data-dir " & conf.dataDir & + ". Either provide them or pass --generate to create fresh keys." + ) + generateKeys(conf.dataDir) + elif mixIdentityExists xor libp2pKeyExists: + fail( + "Partial identity in --data-dir " & conf.dataDir & + " (one of mix-identity / key is missing). Aborting." + ) + elif conf.generate: + warn "Ignoring --generate: identity files already exist in --data-dir", + dataDir = conf.dataDir + + let + (mixPub, mixPriv) = loadMixKeys(mixIdentityPath) + libp2pPriv = loadLibp2pKey(libp2pKeyPath) + + if libp2pPriv.scheme != Secp256k1: + raise newException( + ValueError, "Mix requires a Secp256k1 libp2p key; got " & $libp2pPriv.scheme + ) + + let libp2pPub = libp2pPriv.getPublicKey().valueOr: + raise newException(ValueError, "Failed to derive libp2p public key: " & $error) + + let peerId = PeerId.init(libp2pPub).valueOr: + raise newException(ValueError, "Failed to derive peerId: " & $error) + + let listenIp = + try: + parseIpAddress(conf.listenIp) + except ValueError as exc: + raise newException(ValueError, "Invalid --listen-ip: " & exc.msg) + + let tcpAddr = MultiAddress.init(fmt"/ip4/{$listenIp}/tcp/{conf.listenPort}").valueOr: + raise newException(ValueError, "Invalid libp2p multiaddr: " & $error) + + let nodeInfo = initMixNodeInfo( + peerId = peerId, + multiAddr = tcpAddr, + mixPubKey = mixPub, + mixPrivKey = mixPriv, + libp2pPubKey = libp2pPub.skkey, + libp2pPrivKey = libp2pPriv.skkey, + ) + + let switch = SwitchBuilder + .new() + .withPrivateKey(libp2pPriv) + .withAddresses(@[tcpAddr]) + .withRng(newRng()) + .withNoise() + .withYamux() + .withTcpTransport({ServerFlags.ReuseAddr, ServerFlags.TcpNoDelay}) + .build() + + let mixProto = MixProtocol.new(nodeInfo, switch) + + if conf.noDhtProxy: + await runRelayOnly(conf, switch, mixProto, peerId, tcpAddr) + else: + await runWithDhtProxy(conf, switch, mixProto, libp2pPriv, peerId, listenIp, tcpAddr) + +var logFileHandle: File + +proc setupLogging(conf: Conf) = + proc writeAndFlush(f: File, msg: LogOutputStr) = + try: + f.write(msg) + f.flushFile() + except IOError as err: + logLoggingFailure(cstring(msg), err) + + proc noOutput(logLevel: LogLevel, msg: LogOutputStr) = + discard + + proc stdoutWriter(logLevel: LogLevel, msg: LogOutputStr) = + writeAndFlush(stdout, msg) + + defaultChroniclesStream.outputs[1].writer = noOutput + + if conf.logFile.len == 0: + defaultChroniclesStream.outputs[0].writer = stdoutWriter + defaultChroniclesStream.outputs[2].writer = noOutput + return + + try: + logFileHandle = open(conf.logFile, fmWrite) + except IOError as exc: + fail "Failed to open --log-file " & conf.logFile & ": " & exc.msg + + proc fileWriter(logLevel: LogLevel, msg: LogOutputStr) = + writeAndFlush(logFileHandle, msg) + + defaultChroniclesStream.outputs[0].writer = noOutput + defaultChroniclesStream.outputs[2].writer = fileWriter + +proc main() = + let conf = parseConf() + + when defined(chronicles_runtime_filtering): + setLogLevel(parseEnum[LogLevel](conf.logLevel)) + + setupLogging(conf) + + try: + setControlCHook(controlCHandler) + except Exception as exc: + warn "Cannot set ctrl-c handler", msg = exc.msg + + when defined(posix): + discard posix.signal(SIGTERM, sigtermHandler) + + try: + waitFor run(conf) + except CatchableError as exc: + fatal "Mix relay + DHT proxy aborted", err = exc.msg + quit(1) + +when isMainModule: + main() diff --git a/vendor/nim-libp2p-mix b/vendor/nim-libp2p-mix new file mode 160000 index 00000000..fc220354 --- /dev/null +++ b/vendor/nim-libp2p-mix @@ -0,0 +1 @@ +Subproject commit fc22035416ac3df258e043ad8a53cf929f225e9d