diff --git a/archivist/archivist.nim b/archivist/archivist.nim index a45ebeff..099c30bd 100644 --- a/archivist/archivist.nim +++ b/archivist/archivist.nim @@ -49,6 +49,7 @@ type archivistNode: ArchivistNodeRef repoStore: RepoStore maintenance: BlockMaintainer + natTraversal: NatTraversal taskpool: Taskpool NodePrivateKey* = libp2p.PrivateKey # alias @@ -96,12 +97,15 @@ proc start*(s: NodeServer) {.async.} = await s.archivistNode.switch.start() - let (announceAddrs, discoveryAddrs) = nattedAddress( - s.config.nat, s.archivistNode.switch.peerInfo.addrs, s.config.discoveryPort - ) + await s.natTraversal.start() - s.archivistNode.discovery.updateAnnounceRecord(announceAddrs) - s.archivistNode.discovery.updateDhtRecord(discoveryAddrs) + let announceAddresses = s.archivistNode.switch.peerInfo.addrs + let discoveryPort = s.config.discoveryPort + let discoveryAddress = MultiAddress.init(IPv4_any(), udpProtocol, discoveryPort) + await s.natTraversal.mapPorts(@[discoveryAddress]) do(mapped: seq[MultiAddress]): + s.archivistNode.discovery.updateDhtRecord(mapped) + await s.natTraversal.mapPorts(announceAddresses) do(mapped: seq[MultiAddress]): + s.archivistNode.discovery.updateAnnounceRecord(mapped) await s.connectMarketplace() await s.archivistNode.start() @@ -117,6 +121,7 @@ proc stop*(s: NodeServer) {.async.} = s.archivistNode.stop(), s.repoStore.stop(), s.maintenance.stop(), + s.natTraversal.stop(), ] ) @@ -219,6 +224,8 @@ proc new*( maintenance = BlockMaintainer.new(repoStore, interval = config.overlayMaintenanceInterval) + natTraversal = !NatTraversal.new(config.nat, config.natRenewal, tp) + peerStore = PeerCtxStore.new() pendingBlocks = PendingBlocksManager.new() advertiser = Advertiser.new(repoStore, discovery) @@ -262,5 +269,6 @@ proc new*( restServer: restServer, repoStore: repoStore, maintenance: maintenance, + natTraversal: natTraversal, taskpool: tp, ) diff --git a/archivist/conf.nim b/archivist/conf.nim index 1a38d021..0f7fdf9d 100644 --- a/archivist/conf.nim +++ b/archivist/conf.nim @@ -151,6 +151,13 @@ type name: "nat" .}: NatConfig + natRenewal* {. + desc: "Time interval for renewing port mappings (NAT-PMP, UPnP)", + defaultValue: 20.minutes, + defaultValueDesc: "20m", + name: "nat-renewal" + .}: Duration + discoveryPort* {. desc: "Discovery (UDP) port", defaultValue: 8090.Port, diff --git a/archivist/nat.nim b/archivist/nat.nim index eafa4927..f496c4ad 100644 --- a/archivist/nat.nim +++ b/archivist/nat.nim @@ -1,432 +1,16 @@ -# Copyright (c) 2025 Archivist Authors -# Copyright (c) 2019-2023 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/[options, os, times, net, atomics], - std/exitprocs, - nat_traversal/[miniupnpc, natpmp], - json_serialization/std/net - -import pkg/results -import pkg/chronos -import pkg/chronicles -import pkg/libp2p - -import ./utils -import ./utils/addrutils import ./nat/config -import ./nat/utils - -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[config: NatConfig, tcpPort, udpPort: Port, description: string] - -var - upnp {.threadvar.}: Miniupnp - npmp {.threadvar.}: NatPmp - natConfig = NatConfig.noNat - natClosed: Atomic[bool] - extIp: Option[IpAddress] - activeMappings: seq[PortMappings] - natThreads: seq[Thread[PortMappingArgs]] = @[] - -logScope: - topics = "nat" - -type PrefSrcStatus = enum - NoRoutingInfo - PrefSrcIsPublic - PrefSrcIsPrivate - BindAddressIsPublic - BindAddressIsPrivate - -## Also does threadvar initialisation. -## Must be called before redirectPorts() in each thread. -proc getExternalIP*(config: NatConfig, quiet = false): Option[IpAddress] = - var externalIP: IpAddress - - if config.strategy in [NatStrategy.Any, NatStrategy.Upnp]: - 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: - let ires = upnp.externalIPAddress() - if ires.isErr: - debug "UPnP", msg = ires.error - else: - # if we got this far, UPnP is working and we don't need to try NAT-PMP - try: - externalIP = parseIpAddress(ires.value) - natConfig = NatConfig.upnp() - return some(externalIP) - except ValueError as e: - error "parseIpAddress() exception", err = e.msg - return - - if config.strategy in [NatStrategy.Any, NatStrategy.Pmp]: - if npmp == nil: - npmp = newNatPmp() - var nres: Result[bool, cstring] - if gateway =? config.gateway: - nres = npmp.init(gateway) - else: - nres = npmp.init() - if nres.isErr: - debug "NAT-PMP", msg = nres.error - else: - let nires = npmp.externalIPAddress() - if nires.isErr: - debug "NAT-PMP", msg = nires.error - else: - try: - externalIP = parseIpAddress($(nires.value)) - natConfig = NatConfig.pmp(config.gateway) - return some(externalIP) - except ValueError as e: - error "parseIpAddress() exception", err = e.msg - return - -# This queries the routing table to get the "preferred source" attribute and -# checks if it's a public IP. If so, then it's our public IP. -# -# Further more, we check if the bind address (user provided, or a "0.0.0.0" -# default) is a public IP. That's a long shot, because code paths involving a -# user-provided bind address are not supposed to get here. -proc getRoutePrefSrc(bindIp: IpAddress): (Option[IpAddress], PrefSrcStatus) = - let bindAddress = initTAddress(bindIp, Port(0)) - - if bindAddress.isAnyLocal(): - let ip = getRouteIpv4() - if ip.isErr(): - # No route was found, log error and continue without IP. - error "No routable IP address found, check your network connection", - error = ip.error - return (none(IpAddress), NoRoutingInfo) - elif ip.get().isGlobalUnicast(): - return (some(ip.get()), PrefSrcIsPublic) - else: - return (none(IpAddress), PrefSrcIsPrivate) - elif bindAddress.isGlobalUnicast(): - return (some(bindIp), BindAddressIsPublic) - else: - return (none(IpAddress), BindAddressIsPrivate) - -# Try to detect a public IP assigned to this host, before trying NAT traversal. -proc getPublicRoutePrefSrcOrExternalIP*( - config: NatConfig, bindIp: IpAddress, quiet = true -): Option[IpAddress] = - let (prefSrcIp, prefSrcStatus) = getRoutePrefSrc(bindIp) - - case prefSrcStatus - of NoRoutingInfo, PrefSrcIsPublic, BindAddressIsPublic: - return prefSrcIp - of PrefSrcIsPrivate, BindAddressIsPrivate: - let extIp = getExternalIP(config, quiet) - if extIp.isSome: - return some(extIp.get) - -proc doPortMapping( - strategy: NatStrategy, tcpPort, udpPort: Port, description: string -): Option[(Port, Port)] {.gcsafe.} = - var - extTcpPort: Port - extUdpPort: Port - - if strategy == NatStrategy.Upnp: - 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.Pmp: - 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.} = - ignoreSignalsInThread() - let - (config, 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 with getExternalIP(), - # even though we don't need the external IP's value. - let ipres = getExternalIP(config, quiet = true) - if ipres.isSome: - while natClosed.load() == false: - let - # we're being silly here with this channel polling because we can't - # select on Nim channels like on Go ones - currTime = now() - if currTime >= (lastUpdate + interval): - discard doPortMapping(config.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. - - let ipres = getExternalIP(natConfig, quiet = true) - if ipres.isSome: - if natConfig.strategy == NatStrategy.Upnp: - 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 natConfig.strategy == NatStrategy.Pmp: - 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*( - config: NatConfig, tcpPort, udpPort: Port, description: string -): Option[(Port, Port)] = - result = doPortMapping(config.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, (config, 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*( - config: NatConfig, tcpPort, udpPort: Port, clientId: string -): tuple[ip: Option[IpAddress], tcpPort, udpPort: Option[Port]] = - ## Setup NAT port mapping and get external IP address. - ## If any of this fails, we don't return any IP address but do return the - ## original ports as best effort. - ## TODO: Allow for tcp or udp port mapping to be optional. - if extIp.isNone: - extIp = getExternalIP(config) - if extIp.isSome: - let ip = extIp.get - let extPorts = ( - {.gcsafe.}: - redirectPorts( - config, tcpPort = tcpPort, udpPort = udpPort, description = clientId - ) - ) - if extPorts.isSome: - let (extTcpPort, extUdpPort) = extPorts.get() - (ip: some(ip), tcpPort: some(extTcpPort), udpPort: some(extUdpPort)) - else: - warn "UPnP/NAT-PMP available but port forwarding failed" - (ip: none(IpAddress), tcpPort: some(tcpPort), udpPort: some(udpPort)) - else: - warn "UPnP/NAT-PMP not available" - (ip: none(IpAddress), tcpPort: some(tcpPort), udpPort: some(udpPort)) - -proc setupAddress*( - natConfig: NatConfig, bindIp: IpAddress, tcpPort, udpPort: Port, clientId: string -): tuple[ip: Option[IpAddress], tcpPort, udpPort: Option[Port]] {.gcsafe.} = - ## Set-up of the external address via any of the ways as configured in - ## `NatConfig`. In case all fails an error is logged and the bind ports are - ## selected also as external ports, as best effort and in hope that the - ## external IP can be figured out by other means at a later stage. - ## TODO: Allow for tcp or udp bind ports to be optional. - - case natConfig.strategy - of NatStrategy.ExternalIp: - # any required port redirection must be done by hand - return (natConfig.externalIp, some(tcpPort), some(udpPort)) - of NatStrategy.Any: - let (prefSrcIp, prefSrcStatus) = getRoutePrefSrc(bindIp) - - case prefSrcStatus - of NoRoutingInfo, PrefSrcIsPublic, BindAddressIsPublic: - return (prefSrcIp, some(tcpPort), some(udpPort)) - of PrefSrcIsPrivate, BindAddressIsPrivate: - return setupNat(natConfig, tcpPort, udpPort, clientId) - of NatStrategy.None: - let (prefSrcIp, prefSrcStatus) = getRoutePrefSrc(bindIp) - case prefSrcStatus - of NoRoutingInfo, PrefSrcIsPublic, BindAddressIsPublic: - return (prefSrcIp, some(tcpPort), some(udpPort)) - of PrefSrcIsPrivate: - error "No public IP address found. Should not use --nat:none option" - return (none(IpAddress), some(tcpPort), some(udpPort)) - of BindAddressIsPrivate: - error "Bind IP is not a public IP address. Should not use --nat:none option" - return (none(IpAddress), some(tcpPort), some(udpPort)) - of NatStrategy.Upnp, NatStrategy.Pmp: - return setupNat(natConfig, tcpPort, udpPort, clientId) +export config.NatConfig +export config.upnp +export config.pmp +export config.externalIp +export config.anyStrategy +export config.noNat -proc nattedAddress*( - natConfig: NatConfig, addresses: seq[MultiAddress], udpPort: Port -): tuple[libp2p, discovery: seq[MultiAddress]] = - ## Takes a NAT configuration, sequence of multiaddresses and UDP port and returns: - ## - Modified multiaddresses with NAT-mapped addresses for libp2p - ## - Discovery addresses with NAT-mapped UDP ports +import ./nat/traversal - var discoveryAddrs = newSeq[MultiAddress](0) - var newAddrs: seq[MultiAddress] - for address in addresses: - # Extract IP address and port from the multiaddress - let (ipPart, port) = getAddressAndPort(address) - if ipPart.isSome and port.isSome: - # Try to setup NAT mapping for the address - let (newIP, tcp, udp) = - setupAddress(natConfig, ipPart.get, port.get, udpPort, "archivist") - if newIP.isSome: - # NAT mapping successful - add discovery address with mapped UDP port - discoveryAddrs.add(getMultiAddrWithIPAndUDPPort(newIP.get, udp.get)) - # Remap original address with NAT IP and TCP port - newAddrs.add(address.remapAddr(ip = newIP, port = tcp)) - else: - # NAT mapping failed - use original address - warn "Failed to get external IP, using original address", address - discoveryAddrs.add(getMultiAddrWithIPAndUDPPort(ipPart.get, udpPort)) - newAddrs.add(address) - else: - # Invalid multiaddress format - return as is - newAddrs.add(address) - (newAddrs, discoveryAddrs) +export traversal.NatTraversal +export traversal.new +export traversal.start +export traversal.stop +export traversal.mapPorts diff --git a/archivist/nat/multiaddress.nim b/archivist/nat/multiaddress.nim new file mode 100644 index 00000000..15df5534 --- /dev/null +++ b/archivist/nat/multiaddress.nim @@ -0,0 +1,60 @@ +import std/net +import pkg/libp2p/multiaddress +import pkg/libp2p/multicodec +import pkg/questionable +import pkg/questionable/results +import pkg/stew/endians2 + +{.push raises: [].} + +proc ip4*(address: MultiAddress): ?IpAddress = + if ip4 =? address[multiCodec("ip4")]: + if bytes =? ip4.protoArgument and bytes.len == 4: + var bytes4: array[4, byte] + bytes4[0 ..^ 1] = bytes[0 ..^ 1] + return some IpAddress(family: IPv4, address_v4: bytes4) + none IpAddress + +proc ip6*(address: MultiAddress): ?IpAddress = + if ip6 =? address[multiCodec("ip6")]: + if bytes =? ip6.protoArgument and bytes.len == 16: + var bytes16: array[16, byte] + bytes16[0 ..^ 1] = bytes[0 ..^ 1] + return some IpAddress(family: IPv6, address_v6: bytes16) + none IpAddress + +proc ip*(address: MultiAddress): ?IpAddress = + if ip =? address.ip4: + some ip + elif ip =? address.ip6: + some ip + else: + none IpAddress + +proc tcp*(address: MultiAddress): ?Port = + if tcp =? address[multiCodec("tcp")]: + if bytes =? tcp.protoArgument: + return some Port(uint16.fromBytesBE(bytes)) + none Port + +proc udp*(address: MultiAddress): ?Port = + if udp =? address[multiCodec("udp")]: + if bytes =? udp.protoArgument: + return some Port(uint16.fromBytesBE(bytes)) + none Port + +proc port*(address: MultiAddress): ?Port = + if port =? address.tcp: + some port + elif port =? address.udp: + some port + else: + none Port + +proc protocol*(address: MultiAddress): ?IpTransportProtocol = + if present =? (multiCodec("tcp") in address) and present: + some IpTransportProtocol.tcpProtocol + elif present =? (multiCodec("udp") in address) and present: + some IpTransportProtocol.udpProtocol + else: + none IpTransportProtocol diff --git a/archivist/nat/pmp.nim b/archivist/nat/pmp.nim new file mode 100644 index 00000000..a18e376d --- /dev/null +++ b/archivist/nat/pmp.nim @@ -0,0 +1,39 @@ +import std/net +import pkg/nat_traversal/natpmp +import pkg/libp2p/multiaddress +import pkg/chronos +import pkg/questionable/results + +{.push raises: [].} + +proc requestExternalIp*(pmp: NatPmp): ?!IpAddress = + let reslt = pmp.externalIPAddress() + without value =? reslt: + return failure reslt.error + without ip =? parseIpAddress($value).catch, error: + return failure error.msg + success ip + +func toNatPmpProtocol(protocol: IpTransportProtocol): NatPmpProtocol = + case protocol + of IpTransportProtocol.tcpProtocol: NatPmpProtocol.TCP + of IpTransportProtocol.udpProtocol: NatPmpProtocol.UDP + +proc addPortMapping*( + pmp: NatPmp, + external: Port, + internal: Port, + protocol: IpTransportProtocol, + lifetime: Duration, +): ?!Port = + let reslt = pmp.addPortMapping( + eport = external.cushort, + iport = internal.cushort, + protocol = protocol.toNatPmpProtocol, + lifetime = lifetime.seconds.cushort, + ) + if reslt.isOk: + {.hint[ConvFromXtoItselfNotNeeded]: off.}: + success Port(reslt.value) + else: + Port.failure reslt.error diff --git a/archivist/nat/portmapping.nim b/archivist/nat/portmapping.nim new file mode 100644 index 00000000..2877e320 --- /dev/null +++ b/archivist/nat/portmapping.nim @@ -0,0 +1,131 @@ +import std/net +import std/tables +import pkg/chronos +import pkg/libp2p/multiaddress +import pkg/questionable +import pkg/questionable/results +import pkg/nat_traversal/miniupnpc +import pkg/nat_traversal/natpmp +import pkg/chronicles +import ./config +import ./multiaddress +import ./pmp +import ./upnp + +{.push raises: [].} + +logScope: + topics = "nat" + +type PortMapping* = object + config: NatConfig + upnp: Miniupnp + pmp: NatPmp + externalPorts: Table[MultiAddress, Port] + upnpDiscoverTimeout*: Duration = 200.milliseconds + portMappingLifetime*: Duration = 1.hours + portMappingDescription*: string = "archivist" + +proc initUpnp(mapping: var PortMapping) = + mapping.upnp = newMiniupnp() + +proc initPmp(mapping: var PortMapping): ?!void = + mapping.pmp = newNatPmp() + if gateway =? mapping.config.gateway: + if error =? mapping.pmp.init(gateway).errorOption: + return failure $error + else: + if error =? mapping.pmp.init().errorOption: + return failure $error + success() + +proc init*(_: type PortMapping, config: NatConfig): ?!PortMapping = + var mapping = PortMapping(config: config) + if config.strategy in [NatStrategy.Upnp, NatStrategy.Any]: + mapping.initUpnp() + if config.strategy in [NatStrategy.Pmp, NatStrategy.Any]: + ?mapping.initPmp() + success mapping + +proc mapExternalIp(mapping: PortMapping, address: MultiAddress): ?!MultiAddress = + without protocol =? address.protocol and port =? address.port: + return failure "Missing port in multiaddress" + success MultiAddress.init(!mapping.config.externalIp, protocol, port) + +proc mapRoutingTable(mapping: PortMapping, address: MultiAddress): ?!MultiAddress = + without ip =? address.ip and port =? address.port: + return failure "Missing IP address or port in multiaddress" + let bindAddress = initTAddress(ip, port) + if bindAddress.isGlobal and bindAddress.isUnicast: + return success address + let destination = initTAddress(static parseIpAddress("1.1.1.1"), Port(0)) + let route = getBestRoute(destination) + if route.source.isGlobal and route.source.isUnicast: + if source =? route.source.address.catch and protocol =? address.protocol: + return success MultiAddress.init(source, protocol, port) + failure "No routable IP address found, check your network connection" + +proc mapPmp(mapping: var PortMapping, address: MultiAddress): ?!MultiAddress = + without protocol =? address.protocol and internal =? address.port: + return failure "Missing port in multiaddress" + let pmp = mapping.pmp + without externalIp =? pmp.requestExternalIp(), error: + return failure "NAT-PMP request external address failed: " & error.msg + let lifetime = mapping.portMappingLifetime + let external = mapping.externalPorts .? [address] |? internal + without mapped =? pmp.addPortMapping(external, internal, protocol, lifetime), error: + return failure "NAT-PMP port mapping failed: " & error.msg + mapping.externalPorts[address] = mapped + success MultiAddress.init(externalIp, protocol, mapped) + +proc mapUpnp(mapping: var PortMapping, address: MultiAddress): ?!MultiAddress = + without protocol =? address.protocol and internal =? address.port: + return failure "Missing port in multiaddress" + let upnp = mapping.upnp + ?upnp.discoverGateways(mapping.upnpDiscoverTimeout) + ?upnp.selectGateway() + without externalIp =? upnp.requestExternalIp(), error: + return failure "UPnP request external address failed: " & error.msg + let lifetime = mapping.portMappingLifetime + let external = mapping.externalPorts .? [address] |? internal + let description = mapping.portMappingDescription + let attempt = upnp.addPortMapping(external, internal, protocol, lifetime, description) + without mapped =? attempt, error: + return failure "UPnP port mapping failed: " & error.msg + mapping.externalPorts[address] = mapped + success MultiAddress.init(externalIp, protocol, mapped) + +proc mapAny(mapping: var PortMapping, address: MultiAddress): ?!MultiAddress = + let routingResult = mapping.mapRoutingTable(address) + if mapped =? routingResult: + mapping.config = NatConfig.noNat + return success mapped + + let upnpResult = mapping.mapUpnp(address) + if mapped =? upnpResult: + mapping.config = NatConfig.upnp + return success mapped + + let pmpResult = mapping.mapPmp(address) + if mapped =? pmpResult: + mapping.config = NatConfig.pmp(mapping.config.gateway) + return success mapped + + debug "port mapping using routing table failed", error = routingResult.error.msg + debug "port mapping using upnp failed", error = upnpResult.error.msg + debug "port mapping using pmp failed", error = pmpResult.error.msg + + failure "UPnP/NAT-PMP failed" + +proc map*(mapping: var PortMapping, address: MultiAddress): ?!MultiAddress = + case mapping.config.strategy + of NatStrategy.Any: + mapping.mapAny(address) + of NatStrategy.Upnp: + mapping.mapUpnp(address) + of NatStrategy.Pmp: + mapping.mapPmp(address) + of NatStrategy.ExternalIp: + mapping.mapExternalIp(address) + of NatStrategy.None: + mapping.mapRoutingTable(address) diff --git a/archivist/nat/traversal.nim b/archivist/nat/traversal.nim new file mode 100644 index 00000000..ef873403 --- /dev/null +++ b/archivist/nat/traversal.nim @@ -0,0 +1,117 @@ +import std/tables +import pkg/questionable +import pkg/questionable/results +import pkg/chronos +import pkg/chronos/threadsync +import pkg/taskpools +import pkg/libp2p/multiaddress +import pkg/chronicles +import ../errors +import ./config +import ./portmapping + +logScope: + topics = "nat" + +type + NatTraversal* = ref object + portmapping: PortMapping + callbacks: Table[seq[MultiAddress], OnPortsMapped] + mappings: Table[seq[MultiAddress], seq[MultiAddress]] + taskpool: TaskPool + renewing: Future[void].Raising([]) + renewalInterval: Duration + + OnPortsMapped* = proc(addresses: seq[MultiAddress]) {.gcsafe, raises: [].} + +proc new*( + _: type NatTraversal, + config: NatConfig, + renewalInterval: Duration, + taskpool: TaskPool, +): ?!NatTraversal = + let portmapping = ?PortMapping.init(config) + success NatTraversal( + portmapping: portmapping, renewalInterval: renewalInterval, taskpool: taskpool + ) + +proc renew(traversal: NatTraversal) {.async: (raises: []).} + +proc start*(traversal: NatTraversal) {.async: (raises: [CancelledError]).} = + traversal.renewing = traversal.renew() + debug "started nat traversal" + +proc stop*(traversal: NatTraversal) {.async: (raises: []).} = + await traversal.renewing.cancelAndWait() + debug "stopped nat traversal" + +proc mapTask( + portmapping: ptr PortMapping, + address: MultiAddress, + signal: ThreadSignalPtr, + result: ptr [?!MultiAddress], +) = + trace "started port mapping background task", address + result[] = portmapping[].map(address) + trace "finished port mapping background task", address, result = result[] + discard signal.fireSync() + +proc map( + traversal: NatTraversal, address: MultiAddress +): Future[?!MultiAddress] {.async: (raises: [CancelledError]).} = + without signal =? ThreadSignalPtr.new(): + return failure "unable to create thread signal" + let portmapping = addr traversal.portmapping + var mapped: ?!MultiAddress + trace "spawning port mapping background task", address + traversal.taskpool.spawn mapTask(portmapping, address, signal, addr mapped) + if error =? catchAsync(await signal.wait()).errorOption: + discard signal.close() + return failure error + if error =? signal.close().errorOption: + return failure error + mapped + +proc map( + traversal: NatTraversal, addresses: seq[MultiAddress] +): Future[seq[MultiAddress]] {.async: (raises: [CancelledError]).} = + for address in addresses: + without mapped =? await traversal.map(address), error: + warn "port mapping failed", address, error = error.msg + continue + if mapped notin result: + result.add(mapped) + +proc map( + traversal: NatTraversal, addresses: seq[MultiAddress], callback: OnPortsMapped +) {.async: (raises: [CancelledError]).} = + debug "mapping ports", addresses + let mapped = await traversal.map(addresses) + debug "mapping ports done", addresses, mapped + if not (previous =? traversal.mappings .? [addresses]) or previous != mapped: + trace "port mappings changed, notifying callback" + traversal.mappings[addresses] = mapped + callback(mapped) + else: + trace "port mappings remain unchanged" + +proc mapPorts*( + traversal: NatTraversal, addresses: seq[MultiAddress], callback: OnPortsMapped +) {.async: (raises: [CancelledError]).} = + await traversal.map(addresses, callback) + traversal.callbacks[addresses] = callback + +proc renew(traversal: NatTraversal) {.async: (raises: []).} = + try: + while true: + let interval = traversal.renewalInterval + trace "waiting before renewing port mappings", interval + await sleepAsync(interval) + trace "renewing port mappings" + for (addresses, callback) in traversal.callbacks.pairs: + trace "renewing port mappings", addresses + await traversal.map(addresses, callback) + trace "finished renewing port mappings" + except CancelledError: + debug "port mapping renewal stopped" + discard diff --git a/archivist/nat/upnp.nim b/archivist/nat/upnp.nim new file mode 100644 index 00000000..9188b5d9 --- /dev/null +++ b/archivist/nat/upnp.nim @@ -0,0 +1,64 @@ +import std/net +import std/strutils +import pkg/nat_traversal/miniupnpc +import pkg/libp2p/multiaddress +import pkg/chronos +import pkg/questionable/results + +proc discoverGateways*(upnp: Miniupnp, timeout: Duration): ?!void = + upnp.discoverDelay = timeout.seconds.cint + if error =? upnp.discover().errorOption: + return failure $error + success() + +proc selectGateway*(upnp: Miniupnp): ?!void = + case upnp.selectIGD() + of IGDNotFound: + failure "Gateway device not found" + of IGDFound, IGDNotConnected, NotAnIGD, IGDIpNotRoutable: + success() + +proc requestExternalIp*(upnp: Miniupnp): ?!IpAddress = + let reslt = upnp.externalIPAddress() + without value =? reslt: + return failure $(reslt.error) + without ip =? parseIpAddress($value).catch, error: + return failure error.msg + success ip + +func toUpnpProtocol(protocol: IpTransportProtocol): UPNPProtocol = + case protocol + of IpTransportProtocol.tcpProtocol: UPNPProtocol.TCP + of IpTransportProtocol.udpProtocol: UPNPProtocol.UDP + +proc addPortMapping*( + upnp: Miniupnp, + external: Port, + internal: Port, + protocol: IpTransportProtocol, + duration: Duration, + description: string, +): ?!Port = + let attempt1 = upnp.addPortMapping( + externalPort = $external, + protocol = protocol.toUpnpProtocol, + internalHost = upnp.lanAddr, + internalPort = $internal, + desc = description, + leaseDuration = duration.seconds, + ) + if succeeded =? attempt1 and succeeded: + return success external + let attempt2 = upnp.addAnyPortMapping( + externalPort = $external, + protocol = protocol.toUpnpProtocol, + internalHost = upnp.lanAddr, + internalPort = $internal, + desc = description, + leaseDuration = duration.seconds, + ) + without externalPortString =? attempt2: + return failure $(attempt2.error) + without externalPort =? parseInt(externalPortString).catch: + return failure "received invalid external port: " & externalPortString + return success Port(externalPort) diff --git a/archivist/nat/utils.nim b/archivist/nat/utils.nim deleted file mode 100644 index 8036e8ae..00000000 --- a/archivist/nat/utils.nim +++ /dev/null @@ -1,60 +0,0 @@ -{.push raises: [].} - -import std/[tables, hashes], pkg/results, chronos, chronicles -import std/net -import pkg/libp2p - -type IpLimits* = object - limit*: uint - ips: Table[IpAddress, uint] - -func hash*(ip: IpAddress): Hash = - case ip.family - of IpAddressFamily.IPv6: - hash(ip.address_v6) - of IpAddressFamily.IPv4: - hash(ip.address_v4) - -func inc*(ipLimits: var IpLimits, ip: IpAddress): bool = - let val = ipLimits.ips.getOrDefault(ip, 0) - if val < ipLimits.limit: - ipLimits.ips[ip] = val + 1 - true - else: - false - -func dec*(ipLimits: var IpLimits, ip: IpAddress) = - let val = ipLimits.ips.getOrDefault(ip, 0) - if val == 1: - ipLimits.ips.del(ip) - elif val > 1: - ipLimits.ips[ip] = val - 1 - -func isGlobalUnicast*(address: TransportAddress): bool = - if address.isGlobal() and address.isUnicast(): true else: false - -func isGlobalUnicast*(address: IpAddress): bool = - let a = initTAddress(address, Port(0)) - a.isGlobalUnicast() - -proc getRouteIpv4*(): Result[IpAddress, cstring] = - # Avoiding Exception with initTAddress and can't make it work with static. - # Note: `publicAddress` is only used an "example" IP to find the best route, - # no data is send over the network to this IP! - let - publicAddress = TransportAddress( - family: AddressFamily.IPv4, address_v4: [1'u8, 1, 1, 1], port: Port(0) - ) - route = getBestRoute(publicAddress) - - if route.source.isUnspecified(): - err("No best ipv4 route found") - else: - let ip = - try: - route.source.address() - except ValueError as e: - # This should not occur really. - error "Address conversion error", exception = e.name, msg = e.msg - return err("Invalid IP address") - ok(ip) diff --git a/tests/archivist/helpers/nodeutils.nim b/tests/archivist/helpers/nodeutils.nim index 15fcbaf9..e2b5089b 100644 --- a/tests/archivist/helpers/nodeutils.nim +++ b/tests/archivist/helpers/nodeutils.nim @@ -57,7 +57,6 @@ type findFreePorts*: bool = false basePort*: int = 8080 createFullNode*: bool = false - enableBootstrap*: bool = false converter toTuple*( nc: NodesComponents @@ -119,7 +118,7 @@ proc generateNodes*( switch = newStandardSwitch( transportFlags = {ServerFlags.ReuseAddr}, - sendSignedPeerRecord = config.enableBootstrap, + sendSignedPeerRecord = true, addrs = if config.findFreePorts: listenAddr @@ -179,7 +178,7 @@ proc generateNodes*( let node = if config.createFullNode: - let fullNode = ArchivistNodeRef.new( + ArchivistNodeRef.new( switch = switch, networkStore = networkStore, repoStore = localStore, @@ -188,17 +187,6 @@ proc generateNodes*( discovery = blockDiscovery, taskpool = taskpool, ) - - if config.enableBootstrap: - waitFor switch.peerInfo.update() - let (announceAddrs, discoveryAddrs) = - nattedAddress(NatConfig.noNat, switch.peerInfo.addrs, bindPort.Port) - blockDiscovery.updateAnnounceRecord(announceAddrs) - blockDiscovery.updateDhtRecord(discoveryAddrs) - if blockDiscovery.dhtRecord.isSome: - bootstrapNodes.add !blockDiscovery.dhtRecord - - fullNode else: nil diff --git a/tests/archivist/node/testslotrepair.nim b/tests/archivist/node/testslotrepair.nim index 1a07b789..2d0e5cfd 100644 --- a/tests/archivist/node/testslotrepair.nim +++ b/tests/archivist/node/testslotrepair.nim @@ -38,12 +38,7 @@ proc flatten[T](s: seq[seq[T]]): seq[T] = suite "Test Node - Slot Repair": let numNodes = 12 - config = NodeConfig( - useRepoStore: true, - findFreePorts: true, - createFullNode: true, - enableBootstrap: true, - ) + config = NodeConfig(useRepoStore: true, findFreePorts: true, createFullNode: true) var manifest: Manifest builder: Poseidon2Builder diff --git a/tests/archivist/testnat.nim b/tests/archivist/testnat.nim index 787fd45c..6d4bd75f 100644 --- a/tests/archivist/testnat.nim +++ b/tests/archivist/testnat.nim @@ -1,46 +1,41 @@ import std/net -import pkg/unittest2 +import pkg/questionable/results +import pkg/taskpools import pkg/chronos -import pkg/libp2p/[multiaddress, multihash, multicodec] -import pkg/results - -import ../../archivist/nat -import ../../archivist/nat/config - -suite "NAT Address Tests": - test "nattedAddress with local addresses": - # Setup test data - let - udpPort = Port(1234) - natConfig = NatConfig.externalIp(parseIpAddress("8.8.8.8")) - - # Create test addresses - localAddr = MultiAddress.init("/ip4/127.0.0.1/tcp/5000").expect("valid multiaddr") - anyAddr = MultiAddress.init("/ip4/0.0.0.0/tcp/5000").expect("valid multiaddr") - publicAddr = - MultiAddress.init("/ip4/192.168.1.1/tcp/5000").expect("valid multiaddr") - - # Expected results - let - expectedDiscoveryAddrs = - @[ - MultiAddress.init("/ip4/8.8.8.8/udp/1234").expect("valid multiaddr"), - MultiAddress.init("/ip4/8.8.8.8/udp/1234").expect("valid multiaddr"), - MultiAddress.init("/ip4/8.8.8.8/udp/1234").expect("valid multiaddr"), - ] - expectedlibp2pAddrs = - @[ - MultiAddress.init("/ip4/8.8.8.8/tcp/5000").expect("valid multiaddr"), - MultiAddress.init("/ip4/8.8.8.8/tcp/5000").expect("valid multiaddr"), - MultiAddress.init("/ip4/8.8.8.8/tcp/5000").expect("valid multiaddr"), - ] - - #ipv6Addr = MultiAddress.init("/ip6/::1/tcp/5000").expect("valid multiaddr") - addrs = @[localAddr, anyAddr, publicAddr] - - # Test address remapping - let (libp2pAddrs, discoveryAddrs) = nattedAddress(natConfig, addrs, udpPort) - - # Verify results - check(discoveryAddrs == expectedDiscoveryAddrs) - check(libp2pAddrs == expectedlibp2pAddrs) +import pkg/libp2p/multiaddress +import pkg/archivist/nat +import ../asynctest + +suite "NAT Traversal": + let config = NatConfig.externalIp(parseIpAddress("8.8.8.8")) + + var nat: NatTraversal + + setup: + nat = !NatTraversal.new(config, 20.minutes, TaskPool.new(2)) + await nat.start() + + teardown: + await nat.stop() + + test "replaces internal IP address with external IP address": + let addresses = + @[ + MultiAddress.init("/ip4/127.0.0.1/tcp/5000").get(), + MultiAddress.init("/ip4/0.0.0.0/tcp/5000").get(), + MultiAddress.init("/ip4/192.168.1.1/tcp/5000").get(), + MultiAddress.init("/ip4/0.0.0.0/udp/1234").get(), + ] + + let expected = + @[ + MultiAddress.init("/ip4/8.8.8.8/tcp/5000").get(), + MultiAddress.init("/ip4/8.8.8.8/udp/1234").get(), + ] + + var mapped: seq[MultiAddress] + + await nat.mapPorts(addresses) do(result: seq[MultiAddress]): + mapped = result + + check mapped == expected diff --git a/tests/testbed/builders/node.nim b/tests/testbed/builders/node.nim index 198aef65..60cc80d6 100644 --- a/tests/testbed/builders/node.nim +++ b/tests/testbed/builders/node.nim @@ -22,6 +22,7 @@ type NodeBuilder = ref object apiBindAddress: ?IpAddress apiPort: ?Port discoveryPort: ?Port + nat: ?string bootstrapNodes: ?seq[string] logToFile: bool logTopics: seq[string] @@ -63,6 +64,10 @@ func discoveryPort*(builder: NodeBuilder, port: Port): NodeBuilder = builder.discoveryPort = some port builder +func nat*(builder: NodeBuilder, config: string): NodeBuilder = + builder.nat = some config + builder + func bootstrapNodes*(builder: NodeBuilder, sprs: seq[string]): NodeBuilder = builder.bootstrapNodes = some sprs builder @@ -193,6 +198,9 @@ proc discoveryPortResolved(builder: NodeBuilder): Future[Port] {.async.} = const address = parseIpAddress("0.0.0.0") builder.discoveryPort |? await findFreePort(address, Port(8090), Udp) +proc natResolved(builder: NodeBuilder): string = + builder.nat |? "extip:127.0.0.1" + proc bootstrapNodesResolved(builder: NodeBuilder): Future[seq[string]] {.async.} = if nodes =? builder.bootstrapNodes: return nodes @@ -242,7 +250,7 @@ func circomGraphResolved(builder: NodeBuilder): ?string = proc start*(builder: NodeBuilder): Future[Node] {.async.} = var arguments: seq[string] arguments.add("--disc-port=" & $(await builder.discoveryPortResolved)) - arguments.add("--nat=none") # don't need nat for integration tests + arguments.add("--nat=" & builder.natResolved) for bootstrapNode in await builder.bootstrapNodesResolved: arguments.add("--bootstrap-node=" & bootstrapNode) if builder.logTopics.len > 0: