From 26a372370e27807449e2838aed85c039619531bf Mon Sep 17 00:00:00 2001 From: achingbrain Date: Tue, 26 Nov 2024 11:58:54 +0000 Subject: [PATCH] deps: update nat-port-mapper to v3 Updates nat-port-mapper to try to map ports on all available gateways instead of just the first one that is discovered on the network. --- packages/upnp-nat/package.json | 2 +- .../upnp-nat/src/check-external-address.ts | 15 +- packages/upnp-nat/src/constants.ts | 3 + packages/upnp-nat/src/gateway-finder.ts | 70 +++++ packages/upnp-nat/src/index.ts | 48 +--- packages/upnp-nat/src/upnp-nat.ts | 266 ++++-------------- packages/upnp-nat/src/upnp-port-mapper.ts | 223 +++++++++++++++ packages/upnp-nat/test/index.spec.ts | 67 +++-- 8 files changed, 408 insertions(+), 286 deletions(-) create mode 100644 packages/upnp-nat/src/constants.ts create mode 100644 packages/upnp-nat/src/gateway-finder.ts create mode 100644 packages/upnp-nat/src/upnp-port-mapper.ts diff --git a/packages/upnp-nat/package.json b/packages/upnp-nat/package.json index e82af1e05d..4907437c88 100644 --- a/packages/upnp-nat/package.json +++ b/packages/upnp-nat/package.json @@ -50,7 +50,7 @@ "test:electron-main": "aegir test -t electron-main" }, "dependencies": { - "@achingbrain/nat-port-mapper": "^2.0.1", + "@achingbrain/nat-port-mapper": "^3.0.1", "@chainsafe/is-ip": "^2.0.2", "@libp2p/interface": "^2.2.1", "@libp2p/interface-internal": "^2.1.1", diff --git a/packages/upnp-nat/src/check-external-address.ts b/packages/upnp-nat/src/check-external-address.ts index f6a9fdfe2d..38be3aab2c 100644 --- a/packages/upnp-nat/src/check-external-address.ts +++ b/packages/upnp-nat/src/check-external-address.ts @@ -2,14 +2,14 @@ import { NotStartedError, start, stop } from '@libp2p/interface' import { repeatingTask } from '@libp2p/utils/repeating-task' import pDefer from 'p-defer' import { raceSignal } from 'race-signal' -import type { NatAPI } from '@achingbrain/nat-port-mapper' +import type { Gateway } from '@achingbrain/nat-port-mapper' import type { AbortOptions, ComponentLogger, Logger, Startable } from '@libp2p/interface' import type { AddressManager } from '@libp2p/interface-internal' import type { RepeatingTask } from '@libp2p/utils/repeating-task' import type { DeferredPromise } from 'p-defer' export interface ExternalAddressCheckerComponents { - client: NatAPI + gateway: Gateway addressManager: AddressManager logger: ComponentLogger } @@ -29,7 +29,7 @@ export interface ExternalAddress { */ class ExternalAddressChecker implements ExternalAddress, Startable { private readonly log: Logger - private readonly client: NatAPI + private readonly gateway: Gateway private readonly addressManager: AddressManager private started: boolean private lastPublicIp?: string @@ -39,7 +39,7 @@ class ExternalAddressChecker implements ExternalAddress, Startable { constructor (components: ExternalAddressCheckerComponents, init: ExternalAddressCheckerInit) { this.log = components.logger.forComponent('libp2p:upnp-nat:external-address-check') - this.client = components.client + this.gateway = components.gateway this.addressManager = components.addressManager this.onExternalAddressChange = init.onExternalAddressChange this.started = false @@ -60,14 +60,11 @@ class ExternalAddressChecker implements ExternalAddress, Startable { } await start(this.check) - - this.check.start() this.started = true } async stop (): Promise { await stop(this.check) - this.started = false } @@ -86,7 +83,7 @@ class ExternalAddressChecker implements ExternalAddress, Startable { private async checkExternalAddress (options?: AbortOptions): Promise { try { - const externalAddress = await this.client.externalIp(options) + const externalAddress = await this.gateway.externalIp(options) // check if our public address has changed if (this.lastPublicIp != null && externalAddress !== this.lastPublicIp) { @@ -99,6 +96,8 @@ class ExternalAddressChecker implements ExternalAddress, Startable { this.lastPublicIp = externalAddress this.lastPublicIpPromise.resolve(externalAddress) } catch (err: any) { + this.log.error('could not resolve external address - %e', err) + if (this.lastPublicIp != null) { // ignore the error if we've previously run successfully return diff --git a/packages/upnp-nat/src/constants.ts b/packages/upnp-nat/src/constants.ts new file mode 100644 index 0000000000..ff8852c748 --- /dev/null +++ b/packages/upnp-nat/src/constants.ts @@ -0,0 +1,3 @@ +export const DEFAULT_PORT_MAPPING_TTL = 720_000 +export const DEFAULT_GATEWAY_SEARCH_TIMEOUT = 60_000 +export const DEFAULT_GATEWAY_SEARCH_INTERVAL = 300_000 diff --git a/packages/upnp-nat/src/gateway-finder.ts b/packages/upnp-nat/src/gateway-finder.ts new file mode 100644 index 0000000000..3702b0fbbe --- /dev/null +++ b/packages/upnp-nat/src/gateway-finder.ts @@ -0,0 +1,70 @@ +import { TypedEventEmitter, start, stop } from '@libp2p/interface' +import { repeatingTask } from '@libp2p/utils/repeating-task' +import { DEFAULT_GATEWAY_SEARCH_INTERVAL, DEFAULT_GATEWAY_SEARCH_TIMEOUT } from './constants.js' +import type { Gateway, UPnPNAT } from '@achingbrain/nat-port-mapper' +import type { ComponentLogger, Logger } from '@libp2p/interface' +import type { RepeatingTask } from '@libp2p/utils/repeating-task' + +export interface GatewayFinderComponents { + logger: ComponentLogger +} + +export interface GatewayFinderInit { + portMappingClient: UPnPNAT +} + +export interface GatewayFinderEvents { + 'gateway': CustomEvent +} + +export class GatewayFinder extends TypedEventEmitter { + private readonly log: Logger + private readonly gateways: Gateway[] + private readonly findGateways: RepeatingTask + private readonly portMappingClient: UPnPNAT + private started: boolean + + constructor (components: GatewayFinderComponents, init: GatewayFinderInit) { + super() + + this.log = components.logger.forComponent('libp2p:upnp-nat') + this.portMappingClient = init.portMappingClient + this.started = false + this.gateways = [] + + // every five minutes, search for network gateways for one minute + this.findGateways = repeatingTask(async (options) => { + for await (const gateway of this.portMappingClient.findGateways(options)) { + if (this.gateways.some(g => g.id === gateway.id)) { + // already seen this gateway + continue + } + + this.gateways.push(gateway) + this.safeDispatchEvent('gateway', { + detail: gateway + }) + } + }, DEFAULT_GATEWAY_SEARCH_INTERVAL, { + runImmediately: true, + timeout: DEFAULT_GATEWAY_SEARCH_TIMEOUT + }) + } + + async start (): Promise { + if (this.started) { + return + } + + this.started = true + await start(this.findGateways) + } + + /** + * Stops the NAT manager + */ + async stop (): Promise { + await stop(this.findGateways) + this.started = false + } +} diff --git a/packages/upnp-nat/src/index.ts b/packages/upnp-nat/src/index.ts index 8f5152b396..6ee6efdaa8 100644 --- a/packages/upnp-nat/src/index.ts +++ b/packages/upnp-nat/src/index.ts @@ -35,11 +35,12 @@ * ``` */ -import { UPnPNAT as UPnPNATClass, type NatAPI, type MapPortOptions } from './upnp-nat.js' +import { UPnPNAT as UPnPNATClass } from './upnp-nat.js' +import type { UPnPNAT as UPnPNATClient, MapPortOptions } from '@achingbrain/nat-port-mapper' import type { ComponentLogger, Libp2pEvents, NodeInfo, PeerId, TypedEventTarget } from '@libp2p/interface' import type { AddressManager } from '@libp2p/interface-internal' -export type { NatAPI, MapPortOptions } +export type { UPnPNATClient, MapPortOptions } export interface PMPOptions { /** @@ -49,12 +50,6 @@ export interface PMPOptions { } export interface UPnPNATInit { - /** - * Pass a string to hard code the external address, otherwise it will be - * auto-detected - */ - externalAddress?: string - /** * Check if the external address has changed this often in ms. Ignored if an * external address is specified. @@ -71,46 +66,31 @@ export interface UPnPNATInit { */ externalAddressCheckTimeout?: number - /** - * Pass a value to use instead of auto-detection - */ - localAddress?: string - /** * A string value to use for the port mapping description on the gateway */ - description?: string + portMappingDescription?: string /** - * How long UPnP port mappings should last for in seconds (minimum 1200) - */ - ttl?: number - - /** - * Whether to automatically refresh UPnP port mappings when their TTL is reached - */ - keepAlive?: boolean - - /** - * Pass a value to use instead of auto-detection + * How long UPnP port mappings should last for in ms + * + * @default 720_000 */ - gateway?: string + portMappingTTL?: number /** - * Ports are mapped when the `self:peer:update` event fires, which happens - * when the node's addresses change. To avoid starting to map ports while - * multiple addresses are being added, the mapping function is debounced by - * this number of ms + * Whether to automatically refresh UPnP port mappings when their TTL is + * reached * - * @default 5000 + * @default true */ - delay?: number + portMappingAutoRefresh?: boolean /** * A preconfigured instance of a NatAPI client can be passed as an option, * otherwise one will be created */ - client?: NatAPI + portMappingClient?: UPnPNATClient } export interface UPnPNATComponents { @@ -122,7 +102,7 @@ export interface UPnPNATComponents { } export interface UPnPNAT { - client: NatAPI + portMappingClient: UPnPNATClient } export function uPnPNAT (init: UPnPNATInit = {}): (components: UPnPNATComponents) => UPnPNAT { diff --git a/packages/upnp-nat/src/upnp-nat.ts b/packages/upnp-nat/src/upnp-nat.ts index 60a2ba4b94..eb51f78b03 100644 --- a/packages/upnp-nat/src/upnp-nat.ts +++ b/packages/upnp-nat/src/upnp-nat.ts @@ -1,82 +1,53 @@ import { upnpNat } from '@achingbrain/nat-port-mapper' -import { isIPv4, isIPv6 } from '@chainsafe/is-ip' -import { InvalidParametersError, serviceCapabilities, setMaxListeners, start, stop } from '@libp2p/interface' +import { serviceCapabilities, setMaxListeners, start, stop } from '@libp2p/interface' import { debounce } from '@libp2p/utils/debounce' -import { isLoopback } from '@libp2p/utils/multiaddr/is-loopback' -import { isPrivate } from '@libp2p/utils/multiaddr/is-private' -import { isPrivateIp } from '@libp2p/utils/private-ip' -import { QUICV1, TCP, WebSockets, WebSocketsSecure, WebTransport } from '@multiformats/multiaddr-matcher' -import { dynamicExternalAddress, staticExternalAddress } from './check-external-address.js' -import { DoubleNATError, InvalidIPAddressError } from './errors.js' -import type { ExternalAddress } from './check-external-address.js' +import { DEFAULT_PORT_MAPPING_TTL } from './constants.js' +import { GatewayFinder } from './gateway-finder.js' +import { UPnPPortMapper } from './upnp-port-mapper.js' import type { UPnPNATComponents, UPnPNATInit, UPnPNAT as UPnPNATInterface } from './index.js' -import type { NatAPI, MapPortOptions } from '@achingbrain/nat-port-mapper' -import type { Libp2pEvents, Logger, Startable, TypedEventTarget } from '@libp2p/interface' -import type { AddressManager } from '@libp2p/interface-internal' +import type { Gateway, UPnPNAT as UPnPNATClient } from '@achingbrain/nat-port-mapper' +import type { Logger, Startable } from '@libp2p/interface' import type { DebouncedFunction } from '@libp2p/utils/debounce' -import type { Multiaddr } from '@multiformats/multiaddr' - -const DEFAULT_TTL = 7200 - -export type { NatAPI, MapPortOptions } - -interface PortMapping { - externalHost: string - externalPort: number -} export class UPnPNAT implements Startable, UPnPNATInterface { - public client: NatAPI - private readonly addressManager: AddressManager - private readonly events: TypedEventTarget - private readonly externalAddress: ExternalAddress - private readonly description: string - private readonly ttl: number - private readonly keepAlive: boolean - private readonly gateway?: string - private started: boolean private readonly log: Logger - private readonly mappedPorts: Map - private readonly onSelfPeerUpdate: DebouncedFunction + private readonly components: UPnPNATComponents + private readonly init: UPnPNATInit + private started: boolean + public portMappingClient: UPnPNATClient private shutdownController?: AbortController + private readonly mapIpAddressesDebounced: DebouncedFunction + private readonly gatewayFinder: GatewayFinder + private readonly portMappers: UPnPPortMapper[] constructor (components: UPnPNATComponents, init: UPnPNATInit) { this.log = components.logger.forComponent('libp2p:upnp-nat') - this.addressManager = components.addressManager - this.events = components.events + this.components = components + this.init = init this.started = false - this.description = init.description ?? `${components.nodeInfo.name}@${components.nodeInfo.version} ${components.peerId.toString()}` - this.ttl = init.ttl ?? DEFAULT_TTL - this.keepAlive = init.keepAlive ?? true - this.gateway = init.gateway - this.mappedPorts = new Map() - - if (this.ttl < DEFAULT_TTL) { - throw new InvalidParametersError(`NatManager ttl should be at least ${DEFAULT_TTL} seconds`) - } + this.portMappers = [] - this.client = init.client ?? upnpNat({ - description: this.description, - ttl: this.ttl, - keepAlive: this.keepAlive, - gateway: this.gateway + this.portMappingClient = init.portMappingClient ?? upnpNat({ + description: init.portMappingDescription ?? `${components.nodeInfo.name}@${components.nodeInfo.version} ${components.peerId.toString()}`, + ttl: init.portMappingTTL ?? DEFAULT_PORT_MAPPING_TTL, + autoRefresh: init.portMappingAutoRefresh ?? true }) - this.onSelfPeerUpdate = debounce(this._onSelfPeerUpdate.bind(this), init.delay ?? 5000) + // trigger update when our addresses change + this.mapIpAddressesDebounced = debounce(async () => { + try { + await this.mapIpAddresses() + } catch (err: any) { + this.log.error('error mapping IP addresses - %e', err) + } + }, 5_000) - if (typeof init.externalAddress === 'string') { - this.externalAddress = staticExternalAddress(init.externalAddress) - } else { - this.externalAddress = dynamicExternalAddress({ - client: this.client, - addressManager: this.addressManager, - logger: components.logger - }, { - interval: init.externalAddressCheckInterval, - timeout: init.externalAddressCheckTimeout, - onExternalAddressChange: this.remapPorts.bind(this) - }) - } + // trigger update when we discovery gateways on the network + this.gatewayFinder = new GatewayFinder(components, { + portMappingClient: this.portMappingClient + }) + + this.onGatewayDiscovered = this.onGatewayDiscovered.bind(this) } readonly [Symbol.toStringTag] = '@libp2p/upnp-nat' @@ -97,167 +68,44 @@ export class UPnPNAT implements Startable, UPnPNATInterface { this.started = true this.shutdownController = new AbortController() setMaxListeners(Infinity, this.shutdownController.signal) - this.events.addEventListener('self:peer:update', this.onSelfPeerUpdate) - await start(this.externalAddress, this.onSelfPeerUpdate) + this.components.events.addEventListener('self:peer:update', this.mapIpAddressesDebounced) + this.gatewayFinder.addEventListener('gateway', this.onGatewayDiscovered) + await start(this.mapIpAddressesDebounced, this.gatewayFinder, ...this.portMappers) } /** * Stops the NAT manager */ async stop (): Promise { - try { - await this.client?.close() - } catch (err: any) { - this.log.error(err) - } - this.shutdownController?.abort() - this.events.removeEventListener('self:peer:update', this.onSelfPeerUpdate) - await stop(this.externalAddress, this.onSelfPeerUpdate) + this.components.events.removeEventListener('self:peer:update', this.mapIpAddressesDebounced) + this.gatewayFinder.removeEventListener('gateway', this.onGatewayDiscovered) + await stop(this.mapIpAddressesDebounced, this.gatewayFinder, ...this.portMappers) this.started = false } - _onSelfPeerUpdate (): void { - this.mapIpAddresses() - .catch(err => { - this.log.error('error mapping IP addresses - %e', err) - }) - } - - private getUnmappedAddresses (multiaddrs: Multiaddr[], ipType: 4 | 6): Multiaddr[] { - const output: Multiaddr[] = [] - - for (const ma of multiaddrs) { - // ignore public addresses - if (!isPrivate(ma)) { - continue - } - - // ignore loopback - if (isLoopback(ma)) { - continue - } - - // only IP based addresses - if (!( - TCP.exactMatch(ma) || - WebSockets.exactMatch(ma) || - WebSocketsSecure.exactMatch(ma) || - QUICV1.exactMatch(ma) || - WebTransport.exactMatch(ma) - )) { - continue - } - - const { port, host, family, transport } = ma.toOptions() - - if (family !== ipType) { - continue - } - - if (this.mappedPorts.has(`${host}-${port}-${transport}`)) { - continue - } - - output.push(ma) - } - - return output - } - - async mapIpAddresses (): Promise { - const externalHost = await this.externalAddress.getPublicIp({ - signal: this.shutdownController?.signal + onGatewayDiscovered (event: CustomEvent): void { + const mapper = new UPnPPortMapper(this.components, { + ...this.init, + gateway: event.detail }) - let ipType: 4 | 6 = 4 - - if (isIPv4(externalHost)) { - ipType = 4 - } else if (isIPv6(externalHost)) { - ipType = 6 - } else { - throw new InvalidIPAddressError(`Public address ${externalHost} was not an IPv4 address`) - } - - // filter addresses to get private, non-relay, IP based addresses that we - // haven't mapped yet - const addresses = this.getUnmappedAddresses(this.addressManager.getAddresses(), ipType) - - if (addresses.length === 0) { - this.log('no private, non-relay, unmapped, IP based addresses found') - return - } - - this.log('%s public IP %s', this.externalAddress != null ? 'using configured' : 'discovered', externalHost) - - this.assertNotBehindDoubleNAT(externalHost) - - for (const addr of addresses) { - // try to open uPnP ports for each thin waist address - const { family, host, port, transport } = addr.toOptions() - - if (family === 6) { - // only support IPv4 addresses - continue - } - - if (this.mappedPorts.has(`${host}-${port}-${transport}`)) { - // already mapped this port - continue - } - - try { - this.log(`creating mapping of %s:%s key ${host}-${port}-${transport}`, host, port) - - const externalPort = await this.client.map(port, { - localAddress: host, - protocol: transport === 'tcp' ? 'TCP' : 'UDP' - }) - - this.mappedPorts.set(`${host}-${port}-${transport}`, { - externalHost, - externalPort - }) + this.portMappers.push(mapper) - this.log('created mapping of %s:%s to %s:%s', externalHost, externalPort, host, port) - - this.addressManager.addPublicAddressMapping(host, port, externalHost, externalPort, transport === 'tcp' ? 'tcp' : 'udp') - } catch (err) { - this.log.error('failed to create mapping of %s:%s - %e', host, port, err) - } - } - } - - /** - * Some ISPs have double-NATs, there's not much we can do with them - */ - private assertNotBehindDoubleNAT (publicIp: string): void { - const isPrivate = isPrivateIp(publicIp) - - if (isPrivate === true) { - throw new DoubleNATError(`${publicIp} is private - please init uPnPNAT with 'externalAddress' set to an externally routable IP or ensure you are not behind a double NAT`) - } - - if (isPrivate == null) { - throw new InvalidParametersError(`${publicIp} is not an IP address`) - } + start(mapper) + .then(() => { + this.mapIpAddressesDebounced() + }) + .catch(() => {}) } - /** - * Update the local address mappings when the gateway's external interface - * address changes - */ - private remapPorts (newExternalHost: string): void { - for (const [key, { externalHost, externalPort }] of this.mappedPorts.entries()) { - const [ - host, - port, - transport - ] = key.split('-') - - this.addressManager.removePublicAddressMapping(host, parseInt(port), externalHost, externalPort, transport === 'tcp' ? 'tcp' : 'udp') - this.addressManager.addPublicAddressMapping(host, parseInt(port), newExternalHost, externalPort, transport === 'tcp' ? 'tcp' : 'udp') + async mapIpAddresses (): Promise { + try { + await Promise.all( + this.portMappers.map(async mapper => mapper.mapIpAddresses()) + ) + } catch (err: any) { + this.log.error('error mapping IP addresses - %e', err) } } } diff --git a/packages/upnp-nat/src/upnp-port-mapper.ts b/packages/upnp-nat/src/upnp-port-mapper.ts new file mode 100644 index 0000000000..203f0b0156 --- /dev/null +++ b/packages/upnp-nat/src/upnp-port-mapper.ts @@ -0,0 +1,223 @@ +import { isIPv4, isIPv6 } from '@chainsafe/is-ip' +import { InvalidParametersError, start, stop } from '@libp2p/interface' +import { isLoopback } from '@libp2p/utils/multiaddr/is-loopback' +import { isPrivate } from '@libp2p/utils/multiaddr/is-private' +import { isPrivateIp } from '@libp2p/utils/private-ip' +import { QUICV1, TCP, WebSockets, WebSocketsSecure, WebTransport } from '@multiformats/multiaddr-matcher' +import { dynamicExternalAddress } from './check-external-address.js' +import { DoubleNATError, InvalidIPAddressError } from './errors.js' +import type { ExternalAddress } from './check-external-address.js' +import type { Gateway } from '@achingbrain/nat-port-mapper' +import type { ComponentLogger, Logger } from '@libp2p/interface' +import type { AddressManager } from '@libp2p/interface-internal' +import type { Multiaddr } from '@multiformats/multiaddr' + +export interface UPnPPortMapperInit { + gateway: Gateway + externalAddressCheckInterval?: number + externalAddressCheckTimeout?: number +} + +export interface UPnPPortMapperComponents { + logger: ComponentLogger + addressManager: AddressManager +} + +interface PortMapping { + externalHost: string + externalPort: number +} + +export class UPnPPortMapper { + private readonly gateway: Gateway + private readonly externalAddress: ExternalAddress + private readonly addressManager: AddressManager + private readonly log: Logger + private readonly mappedPorts: Map + private started: boolean + + constructor (components: UPnPPortMapperComponents, init: UPnPPortMapperInit) { + this.log = components.logger.forComponent(`libp2p:upnp-nat:gateway:${init.gateway.id}`) + this.addressManager = components.addressManager + this.gateway = init.gateway + this.externalAddress = dynamicExternalAddress({ + gateway: this.gateway, + addressManager: this.addressManager, + logger: components.logger + }, { + interval: init.externalAddressCheckInterval, + timeout: init.externalAddressCheckTimeout, + onExternalAddressChange: this.remapPorts.bind(this) + }) + this.gateway = init.gateway + this.mappedPorts = new Map() + this.started = false + } + + async start (): Promise { + if (this.started) { + return + } + + await start(this.externalAddress) + this.started = true + } + + async stop (): Promise { + try { + const shutdownTimeout = AbortSignal.timeout(1000) + + await this.gateway.stop({ + signal: shutdownTimeout + }) + } catch (err: any) { + this.log.error('error closing gateway - %e', err) + } + + await stop(this.externalAddress) + this.started = false + } + + /** + * Update the local address mappings when the gateway's external interface + * address changes + */ + private remapPorts (newExternalHost: string): void { + for (const [key, { externalHost, externalPort }] of this.mappedPorts.entries()) { + const [ + host, + port, + transport + ] = key.split('-') + + this.addressManager.removePublicAddressMapping(host, parseInt(port), externalHost, externalPort, transport === 'tcp' ? 'tcp' : 'udp') + this.addressManager.addPublicAddressMapping(host, parseInt(port), newExternalHost, externalPort, transport === 'tcp' ? 'tcp' : 'udp') + } + } + + /** + * Return any eligible multiaddrs that are not mapped on the detected gateway + */ + private getUnmappedAddresses (multiaddrs: Multiaddr[], ipType: 4 | 6): Multiaddr[] { + const output: Multiaddr[] = [] + + for (const ma of multiaddrs) { + // ignore public addresses + if (!isPrivate(ma)) { + continue + } + + // ignore loopback + if (isLoopback(ma)) { + continue + } + + // only IP based addresses + if (!( + TCP.exactMatch(ma) || + WebSockets.exactMatch(ma) || + WebSocketsSecure.exactMatch(ma) || + QUICV1.exactMatch(ma) || + WebTransport.exactMatch(ma) + )) { + continue + } + + const { port, host, family, transport } = ma.toOptions() + + if (family !== ipType) { + continue + } + + if (this.mappedPorts.has(`${host}-${port}-${transport}`)) { + continue + } + + output.push(ma) + } + + return output + } + + async mapIpAddresses (): Promise { + try { + const externalHost = await this.externalAddress.getPublicIp() + + let ipType: 4 | 6 = 4 + + if (isIPv4(externalHost)) { + ipType = 4 + } else if (isIPv6(externalHost)) { + ipType = 6 + } else { + throw new InvalidIPAddressError(`Public address ${externalHost} was not an IPv4 address`) + } + + // filter addresses to get private, non-relay, IP based addresses that we + // haven't mapped yet + const addresses = this.getUnmappedAddresses(this.addressManager.getAddresses(), ipType) + + if (addresses.length === 0) { + this.log('no private, non-relay, unmapped, IP based addresses found') + return + } + + this.log('%s public IP %s', this.externalAddress != null ? 'using configured' : 'discovered', externalHost) + + this.assertNotBehindDoubleNAT(externalHost) + + for (const addr of addresses) { + // try to open uPnP ports for each thin waist address + const { family, host, port, transport } = addr.toOptions() + + if (family === 6) { + // only support IPv4 addresses + continue + } + + if (this.mappedPorts.has(`${host}-${port}-${transport}`)) { + // already mapped this port + continue + } + + try { + const key = `${host}-${port}-${transport}` + this.log('creating mapping of key %s', key) + + const externalPort = await this.gateway.map(port, { + localAddress: host, + protocol: transport === 'tcp' ? 'tcp' : 'udp' + }) + + this.mappedPorts.set(key, { + externalHost, + externalPort + }) + + this.log('created mapping of %s:%s to %s:%s', externalHost, externalPort, host, port) + + this.addressManager.addPublicAddressMapping(host, port, externalHost, externalPort, transport === 'tcp' ? 'tcp' : 'udp') + } catch (err) { + this.log.error('failed to create mapping of %s:%s - %e', host, port, err) + } + } + } catch (err: any) { + this.log.error('error finding gateways - %e', err) + } + } + + /** + * Some ISPs have double-NATs, there's not much we can do with them + */ + private assertNotBehindDoubleNAT (publicIp: string): void { + const isPrivate = isPrivateIp(publicIp) + + if (isPrivate === true) { + throw new DoubleNATError(`${publicIp} is private - please init uPnPNAT with 'externalAddress' set to an externally routable IP or ensure you are not behind a double NAT`) + } + + if (isPrivate == null) { + throw new InvalidParametersError(`${publicIp} is not an IP address`) + } + } +} diff --git a/packages/upnp-nat/test/index.spec.ts b/packages/upnp-nat/test/index.spec.ts index 7e5a54a2f7..b36388002d 100644 --- a/packages/upnp-nat/test/index.spec.ts +++ b/packages/upnp-nat/test/index.spec.ts @@ -9,7 +9,7 @@ import { expect } from 'aegir/chai' import { type StubbedInstance, stubInterface } from 'sinon-ts' import { UPnPNAT } from '../src/upnp-nat.js' import type { UPnPNATInit } from '../src/index.js' -import type { NatAPI } from '@achingbrain/nat-port-mapper' +import type { Gateway, UPnPNAT as UPnPNATClient } from '@achingbrain/nat-port-mapper' import type { ComponentLogger, Libp2pEvents, NodeInfo, PeerId, TypedEventTarget } from '@libp2p/interface' import type { AddressManager } from '@libp2p/interface-internal' @@ -23,7 +23,8 @@ interface StubbedUPnPNATComponents { describe('UPnP NAT (TCP)', () => { const teardown: Array<() => Promise> = [] - let client: StubbedInstance + let client: StubbedInstance + let gateway: StubbedInstance async function createNatManager (natManagerOptions: UPnPNATInit = {}): Promise<{ natManager: any, components: StubbedUPnPNATComponents }> { const components: StubbedUPnPNATComponents = { @@ -34,11 +35,15 @@ describe('UPnP NAT (TCP)', () => { events: new TypedEventEmitter() } - client = stubInterface() + gateway = stubInterface() + client = stubInterface({ + findGateways: async function * (options) { + yield gateway + } + }) const natManager = new UPnPNAT(components, { - keepAlive: true, - client, + portMappingClient: client, ...natManagerOptions }) @@ -66,7 +71,7 @@ describe('UPnP NAT (TCP)', () => { components } = await createNatManager() - client.externalIp.resolves('82.3.1.5') + gateway.externalIp.resolves('82.3.1.5') components.addressManager.getAddresses.returns([ multiaddr('/ip4/127.0.0.1/tcp/4002'), @@ -76,10 +81,10 @@ describe('UPnP NAT (TCP)', () => { await start(natManager) await natManager.mapIpAddresses() - expect(client.map.called).to.be.true() - expect(client.map.getCall(0).args[0]).to.equal(4002) - expect(client.map.getCall(0).args[1]).to.include({ - protocol: 'TCP' + expect(gateway.map.called).to.be.true() + expect(gateway.map.getCall(0).args[0]).to.equal(4002) + expect(gateway.map.getCall(0).args[1]).to.include({ + protocol: 'tcp' }) expect(components.addressManager.addPublicAddressMapping.called).to.be.true() }) @@ -90,7 +95,7 @@ describe('UPnP NAT (TCP)', () => { components } = await createNatManager() - client.externalIp.resolves('82.3.1.5') + gateway.externalIp.resolves('82.3.1.5') components.addressManager.getAddresses.returns([ multiaddr('/ip4/127.0.0.1/tcp/4002'), @@ -100,10 +105,10 @@ describe('UPnP NAT (TCP)', () => { await start(natManager) await natManager.mapIpAddresses() - expect(client.map.called).to.be.true() - expect(client.map.getCall(0).args[0]).to.equal(4002) - expect(client.map.getCall(0).args[1]).to.include({ - protocol: 'TCP' + expect(gateway.map.called).to.be.true() + expect(gateway.map.getCall(0).args[0]).to.equal(4002) + expect(gateway.map.getCall(0).args[1]).to.include({ + protocol: 'tcp' }) expect(components.addressManager.addPublicAddressMapping.called).to.be.true() }) @@ -114,7 +119,7 @@ describe('UPnP NAT (TCP)', () => { components } = await createNatManager() - client.externalIp.resolves('192.168.1.1') + gateway.externalIp.resolves('192.168.1.1') components.addressManager.getAddresses.returns([ multiaddr('/ip4/127.0.0.1/tcp/4002'), @@ -122,10 +127,9 @@ describe('UPnP NAT (TCP)', () => { ]) await start(natManager) - await expect(natManager.mapIpAddresses()).to.eventually.be.rejected - .with.property('name', 'DoubleNATError') + await natManager.mapIpAddresses() - expect(client.map.called).to.be.false() + expect(gateway.map.called).to.be.false() expect(components.addressManager.addPublicAddressMapping.called).to.be.false() }) @@ -135,7 +139,7 @@ describe('UPnP NAT (TCP)', () => { components } = await createNatManager() - client.externalIp.resolves('82.3.1.5') + gateway.externalIp.resolves('82.3.1.5') components.addressManager.getAddresses.returns([ multiaddr('/ip6/fe80::9400:67ff:fe19:2a0f/tcp/0') @@ -144,7 +148,7 @@ describe('UPnP NAT (TCP)', () => { await start(natManager) await natManager.mapIpAddresses() - expect(client.map.called).to.be.false() + expect(gateway.map.called).to.be.false() expect(components.addressManager.addPublicAddressMapping.called).to.be.false() }) @@ -154,7 +158,7 @@ describe('UPnP NAT (TCP)', () => { components } = await createNatManager() - client.externalIp.resolves('82.3.1.5') + gateway.externalIp.resolves('82.3.1.5') components.addressManager.getAddresses.returns([ multiaddr('/ip6/::1/tcp/0') @@ -163,7 +167,7 @@ describe('UPnP NAT (TCP)', () => { await start(natManager) await natManager.mapIpAddresses() - expect(client.map.called).to.be.false() + expect(gateway.map.called).to.be.false() expect(components.addressManager.addPublicAddressMapping.called).to.be.false() }) @@ -173,7 +177,7 @@ describe('UPnP NAT (TCP)', () => { components } = await createNatManager() - client.externalIp.resolves('82.3.1.5') + gateway.externalIp.resolves('82.3.1.5') components.addressManager.getAddresses.returns([ multiaddr('/ip4/192.168.1.12/udp/4001') @@ -182,7 +186,7 @@ describe('UPnP NAT (TCP)', () => { await start(natManager) await natManager.mapIpAddresses() - expect(client.map.called).to.be.false() + expect(gateway.map.called).to.be.false() expect(components.addressManager.addPublicAddressMapping.called).to.be.false() }) @@ -192,7 +196,7 @@ describe('UPnP NAT (TCP)', () => { components } = await createNatManager() - client.externalIp.resolves('82.3.1.5') + gateway.externalIp.resolves('82.3.1.5') components.addressManager.getAddresses.returns([ multiaddr('/ip4/127.0.0.1/tcp/4001') @@ -201,7 +205,7 @@ describe('UPnP NAT (TCP)', () => { await start(natManager) await natManager.mapIpAddresses() - expect(client.map.called).to.be.false() + expect(gateway.map.called).to.be.false() expect(components.addressManager.addPublicAddressMapping.called).to.be.false() }) @@ -211,7 +215,7 @@ describe('UPnP NAT (TCP)', () => { components } = await createNatManager() - client.externalIp.resolves('82.3.1.5') + gateway.externalIp.resolves('82.3.1.5') components.addressManager.getAddresses.returns([ multiaddr('/ip4/127.0.0.1/tcp/4001/sctp/0') @@ -220,12 +224,7 @@ describe('UPnP NAT (TCP)', () => { await start(natManager) await natManager.mapIpAddresses() - expect(client.map.called).to.be.false() + expect(gateway.map.called).to.be.false() expect(components.addressManager.addPublicAddressMapping.called).to.be.false() }) - - it('should specify large enough TTL', async () => { - await expect(createNatManager({ ttl: 5, keepAlive: true })).to.eventually.be.rejected - .with.property('name', 'InvalidParametersError') - }) })