From b8dc03b4aa3df5de68ded2068dc3f7ae47604ed4 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Tue, 12 May 2026 10:05:16 +0400 Subject: [PATCH] Add nat simulation util --- storage/conf.nim | 8 ++ storage/utils/natsimulation.nim | 141 ++++++++++++++++++++++++++++ tests/storage/testnatsimulation.nim | 115 +++++++++++++++++++++++ 3 files changed, 264 insertions(+) create mode 100644 storage/utils/natsimulation.nim create mode 100644 tests/storage/testnatsimulation.nim diff --git a/storage/conf.nim b/storage/conf.nim index 71e2753b..f50bd29e 100644 --- a/storage/conf.nim +++ b/storage/conf.nim @@ -341,6 +341,14 @@ type name: "nat-max-relays" .}: int + natSimulation* {. + desc: + "Simulate NAT filtering behavior for testing: endpoint-independent, address-dependent, address-and-port-dependent", + defaultValue: string.none, + name: "nat-simulation", + hidden + .}: Option[string] + relay* {. desc: "Enable circuit relay server (hop) - use on publicly reachable nodes only", defaultValue: false, diff --git a/storage/utils/natsimulation.nim b/storage/utils/natsimulation.nim new file mode 100644 index 00000000..013e70ab --- /dev/null +++ b/storage/utils/natsimulation.nim @@ -0,0 +1,141 @@ +{.push raises: [].} + +import std/sequtils +import pkg/chronos +import pkg/results +import pkg/libp2p +import pkg/libp2p/transports/tcptransport +import pkg/libp2p/transports/transport +import pkg/libp2p/wire + +type FilteringBehavior* = enum + EndpointIndependent + AddressDependent + AddressAndPortDependent + +type NatRouter* = ref object + filtering*: FilteringBehavior + conntrack: seq[TransportAddress] + +type NatTransport* = ref object of Transport + tcp: TcpTransport + router: NatRouter + +proc fromString*(T: type FilteringBehavior, s: string): Result[FilteringBehavior, string] = + case s + of "endpoint-independent": ok(EndpointIndependent) + of "address-dependent": ok(AddressDependent) + of "address-and-port-dependent": ok(AddressAndPortDependent) + else: err("Unknown filtering behavior: " & s) + +proc new*(T: type NatRouter, filtering: FilteringBehavior): T = + T(filtering: filtering) + +proc setFiltering*(r: NatRouter, filtering: FilteringBehavior) = + r.filtering = filtering + r.conntrack = @[] + +proc allowInbound(r: NatRouter, remote: TransportAddress): bool = + case r.filtering + of EndpointIndependent: + true + of AddressDependent: + r.conntrack.anyIt( + try: + it.address == remote.address + except ValueError: + false + ) + of AddressAndPortDependent: + remote in r.conntrack + +proc new*( + T: type NatTransport, + router: NatRouter, + upgrade: Upgrade, + flags: set[ServerFlags] = {}, +): T = + let self = T(tcp: TcpTransport.new(flags, upgrade), upgrader: upgrade, router: router) + procCall Transport(self).initialize() + return self + +method start*( + self: NatTransport, addrs: seq[MultiAddress] +) {.async: (raises: [LPError, transport.TransportError, CancelledError]).} = + await self.tcp.start(addrs) + self.addrs = self.tcp.addrs + self.running = true + self.onRunning.fire() + +method stop*(self: NatTransport) {.async: (raises: []).} = + await self.tcp.stop() + self.running = false + self.onStop.fire() + +method dial*( + self: NatTransport, + hostname: string, + address: MultiAddress, + peerId: Opt[PeerId] = Opt.none(PeerId), +): Future[Connection] {.async: (raises: [transport.TransportError, CancelledError]).} = + ## establishes an outgoing TCP connection and records the remote address + ## so it can connect back to us later + let conn = await self.tcp.dial(hostname, address) + + if conn.observedAddr.isSome: + let transportAddr = initTAddress(conn.observedAddr.get) + if transportAddr.isOk: + self.router.conntrack.add(transportAddr.get) + + return conn + +proc dropAfterTimeout(conn: Connection) {.async: (raises: []).} = + # Hold the connection open long enough for the remote's dial to time out, + # then close it. This simulates a NAT that drops packets rather than RSTs + # them, which is what AutoNAT needs to detect NotReachable. + await noCancel sleepAsync(20.seconds) + await noCancel conn.close() + +method accept*( + self: NatTransport +): Future[Connection] {.async: (raises: [transport.TransportError, CancelledError]).} = + ## waits for an incoming TCP connection and applies the NAT filtering rules + while true: + let conn = await self.tcp.accept() + + if self.router.filtering == EndpointIndependent: + return conn + + if conn.observedAddr.isNone: + await conn.close() + continue + + let transportAddr = initTAddress(conn.observedAddr.get) + if transportAddr.isErr: + await conn.close() + continue + + if not self.router.allowInbound(transportAddr.get): + # Do not close immediately: let the remote's dial time out naturally, + # then clean up. Returning a fast RST would produce EDialRefused (Unknown) + # instead of EDialError (NotReachable) in AutoNAT. + asyncSpawn dropAfterTimeout(conn) + continue + + return conn + +method handles*( + self: NatTransport, address: MultiAddress +): bool {.gcsafe, raises: [].} = + ## returns true if this transport handles the given address (TCP only) + if procCall Transport(self).handles(address): + if address.protocols.isOk: + return TCP.match(address) + +proc withNatTransport*( + b: SwitchBuilder, router: NatRouter, flags: set[ServerFlags] = {} +): SwitchBuilder = + b.withTransport( + proc(config: TransportConfig): Transport = + NatTransport.new(router, config.upgr, flags) + ) diff --git a/tests/storage/testnatsimulation.nim b/tests/storage/testnatsimulation.nim new file mode 100644 index 00000000..1563446a --- /dev/null +++ b/tests/storage/testnatsimulation.nim @@ -0,0 +1,115 @@ +import pkg/chronos + +import ./helpers +import ../asynctest +import ../../storage/rng +import ../../storage/utils/natsimulation + +const flags = {ServerFlags.ReuseAddr} +const listenAddr = "/ip4/127.0.0.1/tcp/0" + +proc newSwitch(rng: Rng): Switch = + SwitchBuilder + .new() + .withRng(rng) + .withPrivateKey(PrivateKey.random(rng[]).get()) + .withAddresses(@[MultiAddress.init(listenAddr).get()]) + .withTcpTransport(flags) + .withNoise() + .withYamux() + .build() + +proc newNatSwitch(router: NatRouter, rng: Rng): Switch = + SwitchBuilder + .new() + .withRng(rng) + .withPrivateKey(PrivateKey.random(rng[]).get()) + .withAddresses(@[MultiAddress.init(listenAddr).get()]) + .withNatTransport(router, flags) + .withNoise() + .withYamux() + .build() + +asyncchecksuite "NatTransport - Endpoint-Independent Filtering": + var bootstrap, natNode: Switch + + setup: + let router = NatRouter.new(EndpointIndependent) + bootstrap = newSwitch(Rng.instance()) + natNode = newNatSwitch(router, Rng.instance()) + await bootstrap.start() + await natNode.start() + + teardown: + await bootstrap.stop() + await natNode.stop() + + test "bootstrap can connect to nat node without any prior outbound": + await bootstrap.connect(natNode.peerInfo.peerId, natNode.peerInfo.addrs) + check bootstrap.isConnected(natNode.peerInfo.peerId) + +asyncchecksuite "NatTransport - Address-Dependent Filtering": + var bootstrap, thirdNode, natNode: Switch + + setup: + let router = NatRouter.new(AddressDependent) + bootstrap = newSwitch(Rng.instance()) + thirdNode = newSwitch(Rng.instance()) + natNode = newNatSwitch(router, Rng.instance()) + await bootstrap.start() + await thirdNode.start() + await natNode.start() + + teardown: + await bootstrap.stop() + await thirdNode.stop() + await natNode.stop() + + test "bootstrap can connect to nat node with a pre-existing connection": + await natNode.connect(bootstrap.peerInfo.peerId, bootstrap.peerInfo.addrs) + check natNode.isConnected(bootstrap.peerInfo.peerId) + + await bootstrap.connect(natNode.peerInfo.peerId, natNode.peerInfo.addrs) + check bootstrap.isConnected(natNode.peerInfo.peerId) + + test "third node can connect to nat node after nat node connected to bootstrap": + await natNode.connect(bootstrap.peerInfo.peerId, bootstrap.peerInfo.addrs) + await thirdNode.connect(natNode.peerInfo.peerId, natNode.peerInfo.addrs) + check thirdNode.isConnected(natNode.peerInfo.peerId) + + test "bootstrap cannot connect to nat node without a pre-existing connection": + expect(LPError): + await bootstrap.connect(natNode.peerInfo.peerId, natNode.peerInfo.addrs) + +asyncchecksuite "NatTransport - Address-and-Port-Dependent Filtering": + var bootstrap, thirdNode, natNode: Switch + + setup: + let router = NatRouter.new(AddressAndPortDependent) + bootstrap = newSwitch(Rng.instance()) + thirdNode = newSwitch(Rng.instance()) + natNode = newNatSwitch(router, Rng.instance()) + await bootstrap.start() + await thirdNode.start() + await natNode.start() + + teardown: + await bootstrap.stop() + await thirdNode.stop() + await natNode.stop() + + test "bootstrap can connect to nat node with a pre-existing connection": + await natNode.connect(bootstrap.peerInfo.peerId, bootstrap.peerInfo.addrs) + check natNode.isConnected(bootstrap.peerInfo.peerId) + + await bootstrap.connect(natNode.peerInfo.peerId, natNode.peerInfo.addrs) + check bootstrap.isConnected(natNode.peerInfo.peerId) + + test "bootstrap cannot connect to nat node without a pre-existing connection": + expect(LPError): + await bootstrap.connect(natNode.peerInfo.peerId, natNode.peerInfo.addrs) + + test "third node cannot connect to nat node even after nat node connected to bootstrap": + await natNode.connect(bootstrap.peerInfo.peerId, bootstrap.peerInfo.addrs) + expect(LPError): + await thirdNode.connect(natNode.peerInfo.peerId, natNode.peerInfo.addrs)