From fdf5396e60a5716086961290871f91112e1d267a Mon Sep 17 00:00:00 2001 From: Arnaud Date: Mon, 15 Jun 2026 14:55:04 +0400 Subject: [PATCH] Add custom address mapper to remap the port mapping by UPnP / PCP --- storage/nat.nim | 28 ++++++++++++ storage/storage.nim | 2 + tests/storage/testnatdetection.nim | 71 ++++++++++++++++++++++++++++++ tests/storage/testnatreaction.nim | 34 ++++++++++++++ 4 files changed, 135 insertions(+) diff --git a/storage/nat.nim b/storage/nat.nim index d7c1be54..1c65dbc7 100644 --- a/storage/nat.nim +++ b/storage/nat.nim @@ -165,6 +165,34 @@ proc setupPeerInfoObserver*( switch.peerInfo.addObserver(observer) observer +proc setupMappedAddrMapper*(switch: Switch, natMapper: NatPortMapper) = + ## We define a custom mapper that adds the external port to peerInfo.addrs when + ## a port mapping is active, so AutoNAT tests that port. + ## PCP/NAT-PMP may grant an external port different from the listen port. + let mapper: AddressMapper = proc( + addrs: seq[MultiAddress] + ): Future[seq[MultiAddress]] {.gcsafe, async: (raises: [CancelledError]).} = + result = addrs + + if natMapper.activeTcpPort.isNone: + return result + + let mappedPort = natMapper.activeTcpPort.get + for listenAddr in switch.peerInfo.listenAddrs: + # Dialable IP (observed public, or the listen IP if already public) + # used with the mapped port. + let mappedAddr = switch.peerStore.guessDialableAddr(listenAddr).remapAddr( + port = some(mappedPort) + ) + if mappedAddr.isPublicMA(): + # Insert first so AutoNAT dials it before the listen-port candidate (the + # server tests only the first dialable address). + result.insert(mappedAddr, 0) + + return result.deduplicate() + + switch.peerInfo.addressMappers.add(mapper) + method handleNatStatus*( m: NatPortMapper, networkReachability: NetworkReachability, diff --git a/storage/storage.nim b/storage/storage.nim index aa0ddc84..8b623de5 100644 --- a/storage/storage.nim +++ b/storage/storage.nim @@ -490,6 +490,8 @@ proc new*( peerInfoObserver = some(setupPeerInfoObserver(switch, autonatService.get, discovery, natMapper.get)) + setupMappedAddrMapper(switch, natMapper.get) + autonatService.get.setStatusAndConfidenceHandler( proc( networkReachability: NetworkReachability, diff --git a/tests/storage/testnatdetection.nim b/tests/storage/testnatdetection.nim index e6c73d89..0673be44 100644 --- a/tests/storage/testnatdetection.nim +++ b/tests/storage/testnatdetection.nim @@ -30,6 +30,8 @@ const discoveryPort = Port(8090) # ms — AutoNAT probe + confidence + reaction detectTimeout = 20000 + mockMappedTcpPort = Port(40000) + mockMappedUdpPort = Port(40001) type MockNatPortMapper = ref object of NatPortMapper @@ -40,6 +42,19 @@ method mapNatPorts*( .} = none((Port, Port, MappingProtocol)) +# Simulates a successful PCP mapping +type MockMappingNatPortMapper = ref object of NatPortMapper + +method mapNatPorts*( + m: MockMappingNatPortMapper +): Future[Option[(Port, Port, MappingProtocol)]] {. + async: (raises: [CancelledError]), gcsafe +.} = + m.activeTcpPort = some(mockMappedTcpPort) + m.activeUdpPort = some(mockMappedUdpPort) + m.activeMappingProtocol = some(MappingProtocol.PCP) + some((mockMappedTcpPort, mockMappedUdpPort, MappingProtocol.PCP)) + # Captures the candidate addresses the service sends and answers Reachable, so # the service flips to reachable and runs its address mapper — without dialing. type MockAutonatV2Client = ref object of AutonatV2Client @@ -241,3 +256,59 @@ asyncchecksuite "NAT detection - dial request candidates": await autonat.stop(sw) await sw2.stop() + + test "after a port mapping, the mapped address is AutoNAT's first dial candidate": + let mapper = MockMappingNatPortMapper() + + setupMappedAddrMapper(sw, mapper) + + # Reach the observation quorum so guessDialableAddr trusts 8.8.8.8 + let observed = MultiAddress.init("/ip4/8.8.8.8/tcp/4001").expect("valid") + let quorum = 3 + for _ in 0 ..< quorum: + discard sw.peerStore.identify.observedAddrManager.addObservation(observed) + + # Setup AutoRelayService + let relay = AutoRelayService.new( + 1, relayClientModule.RelayClient.new(), nil, Rng.instance().libp2pRng + ) + autorelayservice.setup(relay, sw) + + # Define our handleNatStatus callback + let disc = Discovery.new( + PrivateKey.random(Rng.instance().libp2pRng).get(), announceAddrs = @[] + ) + let dialBack = MultiAddress.init("/ip4/8.8.8.8/tcp/8080").expect("valid") + await mapper.handleNatStatus( + NotReachable, Opt.some(dialBack), discoveryPort, disc, sw, relay + ) + + # Define our AutonatV2Service + let mockClient = MockAutonatV2Client() + let autonat = AutonatV2Service.new( + Rng.instance().libp2pRng, + mockClient, + AutonatV2ServiceConfig.new( + enableDialableCandidates = true, maxQueueSize = 1, minConfidence = 0.5 + ), + ) + service.setup(autonat, sw) + await autonat.start(sw) + + # Connect to a second switch to test NAT detection + let sw2 = newStandardSwitch() + await sw2.start() + await sw.connect(sw2.peerInfo.peerId, sw2.peerInfo.addrs) + + # The expected mapped address should be the guessDialableAddr (8.8.8.8) + # using the mapping mocked port (40000) because a mapping was created. + let mapped = + MultiAddress.init("/ip4/8.8.8.8/tcp/" & $mockMappedTcpPort).expect("valid") + check eventually(mapped in mockClient.reqAddrs) + # Ensute that it comes first (because AutonatV2 test only the first candidate) + check mockClient.reqAddrs[0] == mapped + + await autonat.stop(sw) + await sw2.stop() + if relay.isRunning: + await relay.stop(sw) diff --git a/tests/storage/testnatreaction.nim b/tests/storage/testnatreaction.nim index 6607c1a0..468a3267 100644 --- a/tests/storage/testnatreaction.nim +++ b/tests/storage/testnatreaction.nim @@ -5,6 +5,7 @@ import pkg/libp2p/protocols/connectivity/autonat/types import pkg/libp2p/protocols/connectivity/autonatv2/service except setup import pkg/libp2p/protocols/connectivity/relay/client as relayClientModule import pkg/libp2p/services/autorelayservice except setup +import pkg/libp2p/observedaddrmanager import pkg/results import ./helpers @@ -223,3 +224,36 @@ asyncchecksuite "NAT reaction - address announcing": await sw.peerInfo.update() check disc.announceAddrs == newSeq[MultiAddress]() + + test "mapped-addr mapper injects the mapped port as the first candidate": + const mockMappedTcpPort = 40000 + + setupMappedAddrMapper( + sw, NatPortMapper(activeTcpPort: some(Port(mockMappedTcpPort))) + ) + + # Reach the observation quorum so guessDialableAddr trusts 8.8.8.8 + let observed = MultiAddress.init("/ip4/8.8.8.8/tcp/4001").expect("valid") + let quorum = 3 + for _ in 0 ..< quorum: + discard sw.peerStore.identify.observedAddrManager.addObservation(observed) + + await sw.peerInfo.update() + + # Ensure that the address mapper injects the mapped port as the first candidate + # after peer info update + check sw.peerInfo.addrs[0] == + MultiAddress.init("/ip4/8.8.8.8/tcp/" & $mockMappedTcpPort).expect("valid") + + test "mapped-addr mapper is a no-op without an active mapping": + setupMappedAddrMapper(sw, NatPortMapper()) + + let observed = MultiAddress.init("/ip4/8.8.8.8/tcp/4001").expect("valid") + let quorum = 3 + for _ in 0 ..< quorum: + discard sw.peerStore.identify.observedAddrManager.addObservation(observed) + + await sw.peerInfo.update() + + # Ensure that nothing is injected because there is no active mapping + check sw.peerInfo.addrs == sw.peerInfo.listenAddrs