diff --git a/storage/nat.nim b/storage/nat.nim index 49977830..616c64e7 100644 --- a/storage/nat.nim +++ b/storage/nat.nim @@ -8,10 +8,8 @@ {.push raises: [].} -import - std/[options, os, times, atomics, exitprocs], - nat_traversal/[miniupnpc, natpmp], - results +import std/[options, net] +import results import pkg/chronos import pkg/chronicles @@ -23,304 +21,52 @@ import ./utils/natutils import ./utils/addrutils import ./discovery -const - UPNP_TIMEOUT = 200 # ms - PORT_MAPPING_INTERVAL = 20 * 60 # seconds - NATPMP_LIFETIME = 60 * 60 # in seconds, must be longer than PORT_MAPPING_INTERVAL - -type PortMappings* = object - internalTcpPort: Port - externalTcpPort: Port - internalUdpPort: Port - externalUdpPort: Port - description: string - -type PortMappingArgs = - tuple[strategy: NatStrategy, tcpPort, udpPort: Port, description: string] +logScope: + topics = "nat" type NatConfig* = object case hasExtIp*: bool of true: extIp*: IpAddress of false: nat*: NatStrategy -var - upnp {.threadvar.}: Miniupnp - npmp {.threadvar.}: NatPmp - strategy = NatStrategy.NatAuto - natClosed: Atomic[bool] - activeMappings: seq[PortMappings] - natThreads: seq[Thread[PortMappingArgs]] = @[] - -logScope: - topics = "nat" - type NatMapper* = ref object of RootObj - -method mapNatPorts*(m: NatMapper): Option[(Port, Port)] {.base, gcsafe, raises: [].} = - none((Port, Port)) - -type DefaultNatMapper* = ref object of NatMapper natConfig*: NatConfig tcpPort*: Port discoveryPort*: Port + hasUpnpMapping: bool -## Initialises the UPnP or NAT-PMP threadvar and sets the `strategy` threadvar. -## Must be called before redirectPorts() in each thread. -proc initNatDevice(natStrategy: NatStrategy, quiet = false): bool = - if natStrategy == NatStrategy.NatAuto or natStrategy == NatStrategy.NatUpnp: - if upnp == nil: - upnp = newMiniupnp() - - upnp.discoverDelay = UPNP_TIMEOUT - let dres = upnp.discover() - if dres.isErr: - debug "UPnP", msg = dres.error - else: - var - msg: cstring - canContinue = true - case upnp.selectIGD() - of IGDNotFound: - msg = "Internet Gateway Device not found. Giving up." - canContinue = false - of IGDFound: - msg = "Internet Gateway Device found." - of IGDNotConnected: - msg = "Internet Gateway Device found but it's not connected. Trying anyway." - of NotAnIGD: - msg = - "Some device found, but it's not recognised as an Internet Gateway Device. Trying anyway." - of IGDIpNotRoutable: - msg = - "Internet Gateway Device found and is connected, but with a reserved or non-routable IP. Trying anyway." - if not quiet: - debug "UPnP", msg - if canContinue: - strategy = NatStrategy.NatUpnp - return true - - if natStrategy == NatStrategy.NatAuto or natStrategy == NatStrategy.NatPmp: - if npmp == nil: - npmp = newNatPmp() - let nres = npmp.init() - if nres.isErr: - debug "NAT-PMP", msg = nres.error - else: - strategy = NatStrategy.NatPmp - return true - - return false - -proc doPortMapping( - strategy: NatStrategy, tcpPort, udpPort: Port, description: string -): Option[(Port, Port)] {.gcsafe.} = - var - extTcpPort: Port - extUdpPort: Port - - if strategy == NatStrategy.NatUpnp: - for t in [(tcpPort, UPNPProtocol.TCP), (udpPort, UPNPProtocol.UDP)]: - let - (port, protocol) = t - pmres = upnp.addPortMapping( - externalPort = $port, - protocol = protocol, - internalHost = upnp.lanAddr, - internalPort = $port, - desc = description, - leaseDuration = 0, - ) - if pmres.isErr: - error "UPnP port mapping", msg = pmres.error, port - return - else: - # let's check it - let cres = - upnp.getSpecificPortMapping(externalPort = $port, protocol = protocol) - if cres.isErr: - warn "UPnP port mapping check failed. Assuming the check itself is broken and the port mapping was done.", - msg = cres.error - - info "UPnP: added port mapping", - externalPort = port, internalPort = port, protocol = protocol - case protocol - of UPNPProtocol.TCP: - extTcpPort = port - of UPNPProtocol.UDP: - extUdpPort = port - elif strategy == NatStrategy.NatPmp: - for t in [(tcpPort, NatPmpProtocol.TCP), (udpPort, NatPmpProtocol.UDP)]: - let - (port, protocol) = t - pmres = npmp.addPortMapping( - eport = port.cushort, - iport = port.cushort, - protocol = protocol, - lifetime = NATPMP_LIFETIME, - ) - if pmres.isErr: - error "NAT-PMP port mapping", msg = pmres.error, port - return - else: - let extPort = Port(pmres.value) - info "NAT-PMP: added port mapping", - externalPort = extPort, internalPort = port, protocol = protocol - case protocol - of NatPmpProtocol.TCP: - extTcpPort = extPort - of NatPmpProtocol.UDP: - extUdpPort = extPort - return some((extTcpPort, extUdpPort)) - -proc repeatPortMapping(args: PortMappingArgs) {.thread, raises: [ValueError].} = - ignoreSignalsInThread() - let - (strategy, tcpPort, udpPort, description) = args - interval = initDuration(seconds = PORT_MAPPING_INTERVAL) - sleepDuration = 1_000 # in ms, also the maximum delay after pressing Ctrl-C - - var lastUpdate = now() - - # We can't use copies of Miniupnp and NatPmp objects in this thread, because they share - # C pointers with other instances that have already been garbage collected, so - # we use threadvars instead and initialise them again here. - if initNatDevice(strategy, quiet = true): - while natClosed.load() == false: - let currTime = now() - if currTime >= (lastUpdate + interval): - discard doPortMapping(strategy, tcpPort, udpPort, description) - lastUpdate = currTime - - sleep(sleepDuration) - -proc stopNatThreads() {.noconv.} = - # stop the thread - debug "Stopping NAT port mapping renewal threads" - try: - natClosed.store(true) - joinThreads(natThreads) - except Exception as exc: - warn "Failed to stop NAT port mapping renewal thread", exc = exc.msg - - # delete our port mappings - - # FIXME: if the initial port mapping failed because it already existed for the - # required external port, we should not delete it. It might have been set up - # by another program. - - # In Windows, a new thread is created for the signal handler, so we need to - # initialise our threadvars again. - - if initNatDevice(strategy, quiet = true): - if strategy == NatStrategy.NatUpnp: - for entry in activeMappings: - for t in [ - (entry.externalTcpPort, entry.internalTcpPort, UPNPProtocol.TCP), - (entry.externalUdpPort, entry.internalUdpPort, UPNPProtocol.UDP), - ]: - let - (eport, iport, protocol) = t - pmres = upnp.deletePortMapping(externalPort = $eport, protocol = protocol) - if pmres.isErr: - error "UPnP port mapping deletion", msg = pmres.error - else: - debug "UPnP: deleted port mapping", - externalPort = eport, internalPort = iport, protocol = protocol - elif strategy == NatStrategy.NatPmp: - for entry in activeMappings: - for t in [ - (entry.externalTcpPort, entry.internalTcpPort, NatPmpProtocol.TCP), - (entry.externalUdpPort, entry.internalUdpPort, NatPmpProtocol.UDP), - ]: - let - (eport, iport, protocol) = t - pmres = npmp.deletePortMapping( - eport = eport.cushort, iport = iport.cushort, protocol = protocol - ) - if pmres.isErr: - error "NAT-PMP port mapping deletion", msg = pmres.error - else: - debug "NAT-PMP: deleted port mapping", - externalPort = eport, internalPort = iport, protocol = protocol - -proc redirectPorts*( - strategy: NatStrategy, tcpPort, udpPort: Port, description: string -): Option[(Port, Port)] = - result = doPortMapping(strategy, tcpPort, udpPort, description) - if result.isSome: - let (externalTcpPort, externalUdpPort) = result.get() - # needed by NAT-PMP on port mapping deletion - # Port mapping works. Let's launch a thread that repeats it, in case the - # NAT-PMP lease expires or the router is rebooted and forgets all about - # these mappings. - activeMappings.add( - PortMappings( - internalTcpPort: tcpPort, - externalTcpPort: externalTcpPort, - internalUdpPort: udpPort, - externalUdpPort: externalUdpPort, - description: description, - ) - ) - try: - natThreads.add(Thread[PortMappingArgs]()) - natThreads[^1].createThread( - repeatPortMapping, (strategy, externalTcpPort, externalUdpPort, description) - ) - # atexit() in disguise - if natThreads.len == 1: - # we should register the thread termination function only once - addExitProc(stopNatThreads) - except Exception as exc: - warn "Failed to create NAT port mapping renewal thread", exc = exc.msg - -proc setupNat*( - natStrategy: NatStrategy, tcpPort, udpPort: Port, clientId: string -): Option[(Port, Port)] = - ## Setup NAT port mapping. - ## Returns the external (tcpPort, udpPort) if port mapping succeeded, none otherwise. - ## TODO: Allow for tcp or udp port mapping to be optional. - if not initNatDevice(natStrategy): - warn "UPnP/NAT-PMP not available" +method mapNatPorts*(m: NatMapper): Option[(Port, Port)] {.base, gcsafe, raises: [].} = + if m.natConfig.hasExtIp: return none((Port, Port)) - let extPorts = ( - {.gcsafe.}: - redirectPorts( - strategy, tcpPort = tcpPort, udpPort = udpPort, description = clientId - ) - ) - if extPorts.isNone: - warn "UPnP/NAT-PMP available but port forwarding failed" - extPorts + # Devices are recreated on each call: discover() costs ~200ms but only fires + # when AutoNAT reports NotReachable, which is exactly when we want a fresh scan. + let upnpRes = UpnpDevice.init() + if upnpRes.isOk: + let ports = upnpRes.value.mapPorts(m.tcpPort, m.discoveryPort) + if ports.isSome: + m.hasUpnpMapping = true + return ports -proc findReachableNodes*(bootstrapNodes: seq[SignedPeerRecord]): seq[SignedPeerRecord] = - ## Returns the list of nodes known to be directly reachable. - ## Currently returns bootstrap nodes. In the future, any network participant - ## confirmed reachable by AutoNAT could be included. - bootstrapNodes + let pmpRes = PmpDevice.init() + if pmpRes.isOk: + let ports = pmpRes.value.mapPorts(m.tcpPort, m.discoveryPort) + if ports.isSome: + return ports -proc nattedPorts*(natConfig: NatConfig, tcpPort, udpPort: Port): Option[(Port, Port)] = - if natConfig.hasExtIp: - return none((Port, Port)) - let clientId = "storage" - return setupNat(natConfig.nat, tcpPort, udpPort, clientId) + none((Port, Port)) -method mapNatPorts*(m: DefaultNatMapper): Option[(Port, Port)] {.gcsafe, raises: [].} = - nattedPorts(m.natConfig, m.tcpPort, m.discoveryPort) - -proc handleNatStatus*( +method handleNatStatus*( + m: NatMapper, networkReachability: NetworkReachability, dialBackAddr: Opt[MultiAddress], discoveryPort: Port, - mapper: NatMapper, discovery: Discovery, switch: Switch, autoRelayService: AutoRelayService, -) {.async: (raises: [CancelledError]).} = +) {.async: (raises: [CancelledError]), base, gcsafe.} = case networkReachability of Unknown: - # Nothing to do here, not enough confidence score result discard of Reachable: if dialBackAddr.isNone: @@ -337,9 +83,9 @@ proc handleNatStatus*( var hasPortMapping = false if dialBackAddr.isNone: - warn "Got empty dialback address in AutoNat when node is Reachable" + warn "Got empty dialback address in AutoNat when node is NotReachable" else: - let maybePorts = mapper.mapNatPorts() + let maybePorts = m.mapNatPorts() if maybePorts.isSome: let (tcpPort, udpPort) = maybePorts.get() @@ -352,9 +98,34 @@ proc handleNatStatus*( debug "AutoRelayService stop method returned false" discovery.updateRecords(@[announceAddress], udpPort) - hasPortMapping = true if not hasPortMapping and not autoRelayService.isRunning: if not await autoRelayService.setup(switch): debug "AutoRelayService setup method returned false" + +proc close*(m: NatMapper, device = UpnpDevice()) = + # UPnP mappings are permanent (leaseDuration=0) and must be deleted explicitly. + # NAT-PMP mappings expire automatically after NATPMP_LIFETIME seconds. + if not m.hasUpnpMapping: + return + + # deletePortMapping requires the IGD control URL set during init + let deviceRes = device.init() + if deviceRes.isErr: + warn "UPnP reinit failed during cleanup, port mappings may remain", + msg = deviceRes.error + return + + for (port, proto) in [ + (m.tcpPort, NatIpProtocol.Tcp), (m.discoveryPort, NatIpProtocol.Udp) + ]: + let res = deviceRes.value.deletePortMapping(port, proto) + if res.isErr: + error "UPnP port mapping deletion failed", port, proto, msg = res.error + +proc findReachableNodes*(bootstrapNodes: seq[SignedPeerRecord]): seq[SignedPeerRecord] = + ## Returns the list of nodes known to be directly reachable. + ## Currently returns bootstrap nodes. In the future, any network participant + ## confirmed reachable by AutoNAT could be included. + bootstrapNodes diff --git a/storage/rest/api.nim b/storage/rest/api.nim index 6c54cc8b..5047686d 100644 --- a/storage/rest/api.nim +++ b/storage/rest/api.nim @@ -561,7 +561,7 @@ proc initNodeApi(node: StorageNodeRef, conf: StorageConf, router: var RestRouter proc initDebugApi( node: StorageNodeRef, conf: StorageConf, - autonat: AutonatV2Service, + autonat: Option[AutonatV2Service], router: var RestRouter, ) = let allowedOrigin = router.allowedOrigin @@ -583,7 +583,13 @@ proc initDebugApi( "announceAddresses": node.discovery.announceAddrs, "table": table, "storage": {"version": $storageVersion, "revision": $storageRevision}, - "nat": {"reachability": $autonat.networkReachability}, + "nat": { + "reachability": + if autonat.isSome: + $autonat.get.networkReachability + else: + "unknown" + }, } # return pretty json for human readability @@ -644,7 +650,7 @@ proc initRestApi*( node: StorageNodeRef, conf: StorageConf, repoStore: RepoStore, - autonat: AutonatV2Service, + autonat: Option[AutonatV2Service], corsAllowedOrigin: ?string, ): RestRouter = var router = RestRouter.init(validate, corsAllowedOrigin) diff --git a/storage/storage.nim b/storage/storage.nim index 2b0150fa..2176210f 100644 --- a/storage/storage.nim +++ b/storage/storage.nim @@ -53,8 +53,9 @@ type repoStore: RepoStore maintenance: BlockMaintainer taskpool: Taskpool - autonatService*: AutonatV2Service + autonatService*: Option[AutonatV2Service] autoRelayService: AutoRelayService + natMapper: NatMapper isStarted: bool StoragePrivateKey* = libp2p.PrivateKey # alias @@ -123,6 +124,8 @@ proc stop*(s: StorageServer) {.async.} = notice "Stopping Storage node" + s.natMapper.close() + var futures = @[ s.storageNode.switch.stop(), s.storageNode.stop(), @@ -190,17 +193,23 @@ proc new*( let relayClient = relayClientModule.RelayClient.new(canHop = config.relay) let autonatClient = AutonatV2Client.new(random.Rng.instance()) - let autonatService = AutonatV2Service.new( - rng = random.Rng.instance(), - client = autonatClient, - config = AutonatV2ServiceConfig.new( - scheduleInterval = Opt.some(config.natScheduleInterval), - askNewConnectedPeers = true, - numPeersToAsk = config.natNumPeersToAsk, - maxQueueSize = config.natMaxQueueSize, - minConfidence = config.natMinConfidence, - ), - ) + let autonatService = + if config.nat.hasExtIp: + none(AutonatV2Service) + else: + some( + AutonatV2Service.new( + rng = random.Rng.instance(), + client = autonatClient, + config = AutonatV2ServiceConfig.new( + scheduleInterval = Opt.some(config.natScheduleInterval), + askNewConnectedPeers = true, + numPeersToAsk = config.natNumPeersToAsk, + maxQueueSize = config.natMaxQueueSize, + minConfidence = config.natMinConfidence, + ), + ) + ) let switch = SwitchBuilder .new() @@ -215,7 +224,12 @@ proc new*( .withTcpTransport({ServerFlags.ReuseAddr, ServerFlags.TcpNoDelay}) .withAutonatV2Server() .withCircuitRelay(relayClient) - .withServices(@[Service(autonatService)]) + .withServices( + if autonatService.isSome: + @[Service(autonatService.get)] + else: + @[] + ) .build() autonatClient.setup(switch) @@ -349,23 +363,24 @@ proc new*( switch.mount(network) switch.mount(manifestProto) - let natMapper = DefaultNatMapper( + let natMapper = NatMapper( natConfig: config.nat, tcpPort: config.listenPort, discoveryPort: config.discoveryPort, ) - autonatService.setStatusAndConfidenceHandler( - proc( - networkReachability: NetworkReachability, - confidence: Opt[float], - addrs: Opt[MultiAddress], - ) {.async: (raises: [CancelledError]).} = - debug "AutoNAT status", reachability = networkReachability, confidence - await handleNatStatus( - networkReachability, addrs, config.discoveryPort, natMapper, discovery, switch, - autoRelayService, - ) - ) + if autonatService.isSome: + autonatService.get.setStatusAndConfidenceHandler( + proc( + networkReachability: NetworkReachability, + confidence: Opt[float], + addrs: Opt[MultiAddress], + ) {.async: (raises: [CancelledError]).} = + debug "AutoNAT status", reachability = networkReachability, confidence + await natMapper.handleNatStatus( + networkReachability, addrs, config.discoveryPort, discovery, switch, + autoRelayService, + ) + ) StorageServer( config: config, @@ -377,4 +392,5 @@ proc new*( logFile: logFile, autonatService: autonatService, autoRelayService: autoRelayService, + natMapper: natMapper, ) diff --git a/storage/utils/natutils.nim b/storage/utils/natutils.nim index 9d05dd58..7364efe6 100644 --- a/storage/utils/natutils.nim +++ b/storage/utils/natutils.nim @@ -1,6 +1,207 @@ {.push raises: [].} +import std/[options, net] +import nat_traversal/[miniupnpc, natpmp] +import pkg/chronicles +import results + +export miniupnpc, natpmp, results, options, net + +logScope: + topics = "nat" + +const UPNP_TIMEOUT* = 200 # ms +const NATPMP_LIFETIME* = 60 * 60 # seconds + type NatStrategy* = enum NatAuto NatUpnp NatPmp + +type NatIpProtocol* = enum + Tcp + Udp + +# Generic Nat device can be UPnP or PmP +type NatDevice* = ref object of RootObj + +type UpnpDevice* = ref object of NatDevice + upnp: Miniupnp + +type PmpDevice* = ref object of NatDevice + npmp: NatPmp + +# appPortMapping is specific to the type of Nat device +method addPortMapping*( + d: NatDevice, port: Port, proto: NatIpProtocol +): Result[Port, string] {.base, gcsafe.} = + return err("not implemented") + +# Creates the mapping the the router and +# returns the opened ports. +method mapPorts*( + d: NatDevice, tcpPort, udpPort: Port +): Option[(Port, Port)] {.base, gcsafe.} = + var extTcpPort, extUdpPort: Port + + for t in [(tcpPort, NatIpProtocol.Tcp), (udpPort, NatIpProtocol.Udp)]: + let (port, proto) = t + let pmres = d.addPortMapping(port, proto) + + if pmres.isErr: + error "port mapping failed", msg = pmres.error + return none((Port, Port)) + + case proto + of Tcp: + extTcpPort = pmres.value + of Udp: + extUdpPort = pmres.value + + return some((extTcpPort, extUdpPort)) + +method getSpecificPortMapping*( + d: UpnpDevice, externalPort: string, protocol: UPNPProtocol +): Result[PortMappingRes, cstring] {.base, gcsafe.} = + if d.upnp == nil: + return err(cstring("upnp not initialized")) + + d.upnp.getSpecificPortMapping(externalPort = externalPort, protocol = protocol) + +method discover*(d: UpnpDevice): Result[int, cstring] {.base, gcsafe.} = + if d.upnp == nil: + return err(cstring("upnp not initialized")) + + return d.upnp.discover() + +method selectIGD*(d: UpnpDevice): SelectIGDResult {.base, gcsafe.} = + if d.upnp == nil: + return IGDNotFound + + return d.upnp.selectIGD() + +proc init*(T: type UpnpDevice): Result[UpnpDevice, string] {.gcsafe.} = + UpnpDevice().init() + +# Init UPnP device and create miniupnp instance. +# It call "discover" to retrieve the UPnP devices on the network, +# and then "selectIGD" to select a suitable device. +proc init*(d: UpnpDevice): Result[UpnpDevice, string] {.gcsafe.} = + if d.upnp == nil: + d.upnp = newMiniupnp() + + d.upnp.discoverDelay = UPNP_TIMEOUT + + let dres = d.discover() + if dres.isErr: + debug "UPnP", msg = dres.error + return err($dres.error) + + case d.selectIGD() + of IGDNotFound: + debug "UPnP", msg = "Internet Gateway Device not found. Giving up." + return err("IGD not found") + of IGDFound: + debug "UPnP", msg = "Internet Gateway Device found." + of IGDNotConnected: + debug "UPnP", + msg = "Internet Gateway Device found but it's not connected. Trying anyway." + of NotAnIGD: + debug "UPnP", + msg = + "Some device found, but it's not recognised as an Internet Gateway Device. Trying anyway." + of IGDIpNotRoutable: + debug "UPnP", + msg = + "Internet Gateway Device found and is connected, but with a reserved or non-routable IP. Trying anyway." + + return ok(d) + +# For UPnP, the external port is the same as the application port. +# This should work for most of the case. +# We could change this by using addAnyPortMapping for IGD2 compatible routers +# if needed. +method addPortMapping*( + d: UpnpDevice, port: Port, proto: NatIpProtocol +): Result[Port, string] {.gcsafe.} = + if d.upnp == nil: + return err("upnp not initialized") + + let protocol = if proto == NatIpProtocol.Tcp: UPNPProtocol.TCP else: UPNPProtocol.UDP + let pmres = d.upnp.addPortMapping( + externalPort = $port, + protocol = protocol, + internalHost = d.upnp.lanAddr, + internalPort = $port, + desc = "logos-storage", + leaseDuration = 0, + ) + if pmres.isErr: + return err($pmres.error) + + let cres = d.getSpecificPortMapping(externalPort = $port, protocol = protocol) + if cres.isErr: + # Eventually, the check could fail on some router even if the router is successful. + # So we log a warning but we still want to continue because it is not sure it is a failure. + warn "UPnP port mapping check failed. Assuming the check itself is broken and the port mapping was done.", + msg = cres.error + + info "UPnP: added port mapping", externalPort = port, internalPort = port + + return ok(port) + +method deletePortMapping*( + d: UpnpDevice, port: Port, proto: NatIpProtocol +): Result[void, string] {.base, gcsafe.} = + if d.upnp == nil: + return err("upnp not initialized") + + let protocol = if proto == NatIpProtocol.Tcp: UPNPProtocol.TCP else: UPNPProtocol.UDP + let res = d.upnp.deletePortMapping(externalPort = $port, protocol = protocol) + if res.isErr: + return err($res.error) + + debug "UPnP: deleted port mapping", port, proto + + ok() + +proc init*(T: type PmpDevice): Result[PmpDevice, string] {.gcsafe.} = + PmpDevice().init() + +# Create a NatPmP instance. +proc init*(d: PmpDevice): Result[PmpDevice, string] {.gcsafe.} = + if d.npmp == nil: + d.npmp = newNatPmp() + + let res = d.npmp.init() + if res.isErr: + debug "NAT-PMP", msg = res.error + return err($res.error) + + return ok(d) + +# Add a port mapping on NAT-PMP device. +# The application port might not be the external port. +# The latter is returned. +method addPortMapping*( + d: PmpDevice, port: Port, proto: NatIpProtocol +): Result[Port, string] {.gcsafe.} = + if d.npmp == nil: + return err("npmp not initialized") + + let protocol = + if proto == NatIpProtocol.Tcp: NatPmpProtocol.TCP else: NatPmpProtocol.UDP + let pmres = d.npmp.addPortMapping( + eport = port.cushort, + iport = port.cushort, + protocol = protocol, + lifetime = NATPMP_LIFETIME, + ) + if pmres.isErr: + return err(pmres.error) + + let extPort = Port(pmres.value) + + info "NAT-PMP: added port mapping", externalPort = extPort, internalPort = port + + return ok(extPort) diff --git a/tests/integration/multinodes.nim b/tests/integration/multinodes.nim index 94b3ede3..4d6d6f62 100644 --- a/tests/integration/multinodes.nim +++ b/tests/integration/multinodes.nim @@ -213,7 +213,9 @@ template multinodesuite*(suiteName: string, body: untyped) = trace "Setting up test", suite = suiteName, test = currentTestName, nodeConfigs if var clients =? nodeConfigs.clients: failAndTeardownOnError "failed to start client nodes": - clients = clients.withExtIp() + # Only the first node (bootstrap) gets a known extip. Other nodes use + # nat=auto so AutoNAT can run and determine their reachability. + clients = clients.withExtIp(0) for config in clients.configs: let node = await startClientNode(config) running.add RunningNode(role: Role.Client, node: node) diff --git a/tests/storage/testnat.nim b/tests/storage/testnat.nim index f74c876f..27ff6e8e 100644 --- a/tests/storage/testnat.nim +++ b/tests/storage/testnat.nim @@ -1,5 +1,6 @@ -import std/net +import std/[net, importutils] import pkg/chronos +import ../../storage/utils/natutils import pkg/libp2p/[multiaddress, multihash, multicodec] import pkg/libp2p/protocols/connectivity/autonatv2/service except setup import pkg/libp2p/protocols/connectivity/autonatv2/types @@ -16,12 +17,52 @@ import ../../storage/rng import ../../storage/utils import ../../storage/utils/addrutils +privateAccess(NatMapper) + +type MockUpnpDevice = ref object of UpnpDevice + deletedPorts: seq[(Port, NatIpProtocol)] + +method discover*(d: MockUpnpDevice): Result[int, cstring] {.gcsafe.} = + ok(1) + +method selectIGD*(d: MockUpnpDevice): SelectIGDResult {.gcsafe.} = + IGDFound + +method deletePortMapping*( + d: MockUpnpDevice, port: Port, proto: NatIpProtocol +): Result[void, string] {.gcsafe.} = + d.deletedPorts.add((port, proto)) + ok() + type MockNatMapper = ref object of NatMapper mappedPorts: Option[(Port, Port)] -method mapNatPorts*(m: MockNatMapper): Option[(Port, Port)] {.raises: [].} = +method mapNatPorts*(m: MockNatMapper): Option[(Port, Port)] {.gcsafe, raises: [].} = m.mappedPorts +suite "NatMapper.close": + test "does nothing when no upnp mapping": + let mapper = MockNatMapper( + natConfig: NatConfig(hasExtIp: false, nat: NatAuto), + tcpPort: Port(8080), + discoveryPort: Port(8090), + ) + let device = MockUpnpDevice() + mapper.close(device) + check device.deletedPorts.len == 0 + + test "deletes tcp and udp ports when upnp mapping exists": + let mapper = MockNatMapper( + natConfig: NatConfig(hasExtIp: false, nat: NatAuto), + tcpPort: Port(8080), + discoveryPort: Port(8090), + ) + mapper.hasUpnpMapping = true + let device = MockUpnpDevice() + mapper.close(device) + check device.deletedPorts == + @[(Port(8080), NatIpProtocol.Tcp), (Port(8090), NatIpProtocol.Udp)] + suite "remapAddr": test "replaces protocol tcp with udp": let ma = MultiAddress.init("/ip4/1.2.3.4/tcp/5000").expect("valid") @@ -38,11 +79,6 @@ suite "remapAddr": let remapped = ma.remapAddr(ip = some(parseIpAddress("8.8.8.8"))) check remapped == MultiAddress.init("/ip4/8.8.8.8/tcp/5000").expect("valid") -suite "nattedPorts": - test "returns none when extIp is configured (manual setup)": - let natConfig = NatConfig(hasExtIp: true, extIp: parseIpAddress("8.8.8.8")) - check nattedPorts(natConfig, Port(5000), Port(1234)).isNone - asyncchecksuite "handleNatStatus": var sw: Switch var key: PrivateKey @@ -68,8 +104,8 @@ asyncchecksuite "handleNatStatus": let dialBack = MultiAddress.init("/ip4/1.2.3.4/tcp/8080").expect("valid") let mapper = MockNatMapper(mappedPorts: some((Port(9000), Port(9001)))) - await handleNatStatus( - NotReachable, Opt.some(dialBack), discoveryPort, mapper, disc, sw, autoRelay + await mapper.handleNatStatus( + NotReachable, Opt.some(dialBack), discoveryPort, disc, sw, autoRelay ) check disc.announceAddrs == @@ -79,17 +115,28 @@ asyncchecksuite "handleNatStatus": test "handleNatStatus starts autoRelay when NotReachable and UPnP failed": let mapper = MockNatMapper(mappedPorts: none((Port, Port))) - await handleNatStatus( - NotReachable, Opt.none(MultiAddress), discoveryPort, mapper, disc, sw, autoRelay + await mapper.handleNatStatus( + NotReachable, Opt.none(MultiAddress), discoveryPort, disc, sw, autoRelay ) check autoRelay.isRunning + test "handleNatStatus starts autoRelay when NotReachable and mapping fails": + let dialBack = MultiAddress.init("/ip4/1.2.3.4/tcp/8080").expect("valid") + let mapper = MockNatMapper(mappedPorts: none((Port, Port))) + + await mapper.handleNatStatus( + NotReachable, Opt.some(dialBack), discoveryPort, disc, sw, autoRelay + ) + + check autoRelay.isRunning + check disc.announceAddrs == newSeq[MultiAddress]() + test "handleNatStatus does not announce address when Reachable and no dialBackAddr": let mapper = MockNatMapper(mappedPorts: none((Port, Port))) - await handleNatStatus( - Reachable, Opt.none(MultiAddress), discoveryPort, mapper, disc, sw, autoRelay + await mapper.handleNatStatus( + Reachable, Opt.none(MultiAddress), discoveryPort, disc, sw, autoRelay ) check disc.announceAddrs == newSeq[MultiAddress]() @@ -100,8 +147,8 @@ asyncchecksuite "handleNatStatus": let mapper = MockNatMapper(mappedPorts: none((Port, Port))) discard await autorelayservice.setup(autoRelay, sw) - await handleNatStatus( - Reachable, Opt.some(dialBack), discoveryPort, mapper, disc, sw, autoRelay + await mapper.handleNatStatus( + Reachable, Opt.some(dialBack), discoveryPort, disc, sw, autoRelay ) check not autoRelay.isRunning diff --git a/tests/storage/testnatutils.nim b/tests/storage/testnatutils.nim index 08a6621e..9eea951a 100644 --- a/tests/storage/testnatutils.nim +++ b/tests/storage/testnatutils.nim @@ -1 +1,93 @@ -discard +import std/[options, net] +import nat_traversal/[miniupnpc, natpmp] +import pkg/results +import ../asynctest +import ../../storage/utils/natutils + +type MockUpnpDev = ref object of UpnpDevice + discoverOk: bool + igdResult: SelectIGDResult + addPortMappingOk: bool + failOnProto: Option[NatIpProtocol] + +type MockPmpDev = ref object of PmpDevice + addPortMappingOk: bool + mappedPort: Port + +method discover*(d: MockUpnpDev): Result[int, cstring] {.gcsafe.} = + if d.discoverOk: + ok(1) + else: + err(cstring("discover failed")) + +method selectIGD*(d: MockUpnpDev): SelectIGDResult {.gcsafe.} = + d.igdResult + +method addPortMapping*( + d: MockUpnpDev, port: Port, proto: NatIpProtocol +): Result[Port, string] {.gcsafe.} = + if d.failOnProto == some(proto): + err("mapping failed") + elif d.addPortMappingOk: + ok(port) + else: + err("mapping failed") + +method getSpecificPortMapping*( + d: MockUpnpDev, externalPort: string, protocol: UPNPProtocol +): Result[PortMappingRes, cstring] {.gcsafe.} = + ok(PortMappingRes()) + +method addPortMapping*( + d: MockPmpDev, port: Port, proto: NatIpProtocol +): Result[Port, string] {.gcsafe.} = + if d.addPortMappingOk: + ok(d.mappedPort) + else: + err("mapping failed") + +suite "UpnpDevice.init": + test "returns err when discover fails": + check MockUpnpDev(discoverOk: false).init().isErr + + test "returns err when IGD not found": + check MockUpnpDev(discoverOk: true, igdResult: IGDNotFound).init().isErr + + test "returns ok when IGD found": + check MockUpnpDev(discoverOk: true, igdResult: IGDFound).init().isOk + + test "returns ok when IGD not connected": + check MockUpnpDev(discoverOk: true, igdResult: IGDNotConnected).init().isOk + + test "returns ok when not an IGD": + check MockUpnpDev(discoverOk: true, igdResult: NotAnIGD).init().isOk + + test "returns ok when IP not routable": + check MockUpnpDev(discoverOk: true, igdResult: IGDIpNotRoutable).init().isOk + +suite "UpnpDevice.mapPorts": + test "returns none when addPortMapping fails": + check MockUpnpDev(addPortMappingOk: false).mapPorts(Port(8080), Port(8090)).isNone + + test "returns mapped ports": + let res = MockUpnpDev(addPortMappingOk: true).mapPorts(Port(8080), Port(8090)) + check res.isSome + check res.get() == (Port(8080), Port(8090)) + + test "returns none when tcp mapping fails": + let d = MockUpnpDev(addPortMappingOk: true, failOnProto: some(NatIpProtocol.Tcp)) + check d.mapPorts(Port(8080), Port(8090)).isNone + + test "returns none when udp mapping fails": + let d = MockUpnpDev(addPortMappingOk: true, failOnProto: some(NatIpProtocol.Udp)) + check d.mapPorts(Port(8080), Port(8090)).isNone + +suite "PmpDevice.mapPorts": + test "returns none when mapping fails": + check MockPmpDev(addPortMappingOk: false).mapPorts(Port(8080), Port(8090)).isNone + + test "returns assigned external ports": + let d = MockPmpDev(addPortMappingOk: true, mappedPort: Port(9000)) + let res = d.mapPorts(Port(8080), Port(8090)) + check res.isSome + check res.get() == (Port(9000), Port(9000))