Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow specifying UPnP gateways and external address #2937

Merged
merged 1 commit into from
Feb 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
feat: allow specifying gateways and ports
Some ISP-provided routers are underpowered and require frequent
reboots before they will respond to SSDP M-SEARCH messages.

To make working with them easier, allow manually specifying
gateways and external network addresses.
  • Loading branch information
achingbrain committed Feb 4, 2025
commit 5ebd75e11fab2a3ddba0cc682023c625b62d1f52
40 changes: 40 additions & 0 deletions packages/upnp-nat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,46 @@ const node = await createLibp2p({
})
```

## Example - Manually specifying gateways and external ports

Some ISP-provided routers are underpowered and may require rebooting before
they will respond to SSDP M-SEARCH messages.

You can manually specify your external address and/or gateways, though note
that those gateways will still need to have UPnP enabled in order for libp2p
to configure mapping of external ports (for IPv4) and/or opening pinholes in
the firewall (for IPv6).

```typescript
import { createLibp2p } from 'libp2p'
import { tcp } from '@libp2p/tcp'
import { uPnPNAT } from '@libp2p/upnp-nat'

const node = await createLibp2p({
addresses: {
listen: [
'/ip4/0.0.0.0/tcp/0'
]
},
transports: [
tcp()
],
services: {
upnpNAT: uPnPNAT({
// manually specify external address - this will normally be an IPv4
// address that the router is performing NAT with
externalAddress: '92.137.164.96',
gateways: [
// an IPv4 gateway
'http://192.168.1.1:8080/path/to/descriptor.xml',
// an IPv6 gateway
'http://[xx:xx:xx:xx]:8080/path/to/descriptor.xml'
]
})
}
})
```

# Install

```console
Expand Down
2 changes: 0 additions & 2 deletions packages/upnp-nat/src/check-external-address.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ export interface ExternalAddress {
class ExternalAddressChecker implements ExternalAddress, Startable {
private readonly log: Logger
private readonly gateway: Gateway
private readonly addressManager: AddressManager
private started: boolean
private lastPublicIp?: string
private lastPublicIpPromise?: DeferredPromise<string>
Expand All @@ -40,7 +39,6 @@ class ExternalAddressChecker implements ExternalAddress, Startable {
constructor (components: ExternalAddressCheckerComponents, init: ExternalAddressCheckerInit) {
this.log = components.logger.forComponent('libp2p:upnp-nat:external-address-check')
this.gateway = components.gateway
this.addressManager = components.addressManager
this.onExternalAddressChange = init.onExternalAddressChange
this.started = false

Expand Down
65 changes: 65 additions & 0 deletions packages/upnp-nat/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,46 @@
* }
* })
* ```
*
* @example Manually specifying gateways and external ports
*
* Some ISP-provided routers are underpowered and may require rebooting before
* they will respond to SSDP M-SEARCH messages.
*
* You can manually specify your external address and/or gateways, though note
* that those gateways will still need to have UPnP enabled in order for libp2p
* to configure mapping of external ports (for IPv4) and/or opening pinholes in
* the firewall (for IPv6).
*
* ```typescript
* import { createLibp2p } from 'libp2p'
* import { tcp } from '@libp2p/tcp'
* import { uPnPNAT } from '@libp2p/upnp-nat'
*
* const node = await createLibp2p({
* addresses: {
* listen: [
* '/ip4/0.0.0.0/tcp/0'
* ]
* },
* transports: [
* tcp()
* ],
* services: {
* upnpNAT: uPnPNAT({
* // manually specify external address - this will normally be an IPv4
* // address that the router is performing NAT with
* externalAddress: '92.137.164.96',
* gateways: [
* // an IPv4 gateway
* 'http://192.168.1.1:8080/path/to/descriptor.xml',
* // an IPv6 gateway
* 'http://[xx:xx:xx:xx]:8080/path/to/descriptor.xml'
* ]
* })
* }
* })
* ```
*/

import { UPnPNAT as UPnPNATClass } from './upnp-nat.js'
Expand All @@ -50,6 +90,14 @@ export interface PMPOptions {
}

export interface UPnPNATInit {
/**
* By default we query discovered/configured gateways for their external
* address. To specify it manually instead, pass a value here.
*
* Typically this would be an IPv4 address that the router performs NAT with.
*/
externalAddress?: string

/**
* Check if the external address has changed this often in ms. Ignored if an
* external address is specified.
Expand Down Expand Up @@ -110,6 +158,23 @@ export interface UPnPNATInit {
*/
autoConfirmAddress?: boolean

/**
* By default we search for local gateways using SSDP M-SEARCH messages. To
* manually specify a gateway instead, pass values here.
*
* A lot of ISP-provided gateway/routers are underpowered so may need
* rebooting before they will respond to M-SEARCH messages.
*
* Each value is an IPv4 or IPv6 URL of the UPnP device descriptor document,
* e.g. `http://192.168.1.1:8080/description.xml`. Please see the
* documentation of your gateway to discover the URL.
*
* Note that some gateways will randomise the port/path the descriptor
* document is served from and even change it over time so you may be forced
* to use an SSDP search instead.
*/
gateways?: string[]

/**
* How often to search for network gateways in ms.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { TypedEventEmitter, start, stop } from '@libp2p/interface'
import { repeatingTask } from '@libp2p/utils/repeating-task'
import { DEFAULT_GATEWAY_SEARCH_INTERVAL, DEFAULT_GATEWAY_SEARCH_MESSAGE_INTERVAL, DEFAULT_GATEWAY_SEARCH_TIMEOUT, DEFAULT_INITIAL_GATEWAY_SEARCH_INTERVAL, DEFAULT_INITIAL_GATEWAY_SEARCH_MESSAGE_INTERVAL, DEFAULT_INITIAL_GATEWAY_SEARCH_TIMEOUT } from './constants.js'
import type { GatewayFinder, GatewayFinderEvents } from './upnp-nat.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 {
export interface SearchGatewayFinderComponents {
logger: ComponentLogger
}

export interface GatewayFinderInit {
export interface SearchGatewayFinderInit {
portMappingClient: UPnPNAT
initialSearchInterval?: number
initialSearchTimeout?: number
Expand All @@ -19,18 +20,14 @@ export interface GatewayFinderInit {
searchMessageInterval?: number
}

export interface GatewayFinderEvents {
'gateway': CustomEvent<Gateway>
}

export class GatewayFinder extends TypedEventEmitter<GatewayFinderEvents> {
export class SearchGatewayFinder extends TypedEventEmitter<GatewayFinderEvents> implements GatewayFinder {
private readonly log: Logger
private readonly gateways: Gateway[]
private readonly findGateways: RepeatingTask
private readonly portMappingClient: UPnPNAT
private started: boolean

constructor (components: GatewayFinderComponents, init: GatewayFinderInit) {
constructor (components: SearchGatewayFinderComponents, init: SearchGatewayFinderInit) {
super()

this.log = components.logger.forComponent('libp2p:upnp-nat')
Expand Down Expand Up @@ -90,9 +87,6 @@ export class GatewayFinder extends TypedEventEmitter<GatewayFinderEvents> {
await start(this.findGateways)
}

/**
* Stops the NAT manager
*/
async stop (): Promise<void> {
await stop(this.findGateways)
this.started = false
Expand Down
60 changes: 60 additions & 0 deletions packages/upnp-nat/src/static-gateway-finder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { TypedEventEmitter } from '@libp2p/interface'
import type { GatewayFinder, GatewayFinderEvents } from './upnp-nat.js'
import type { Gateway, UPnPNAT } from '@achingbrain/nat-port-mapper'
import type { ComponentLogger, Logger } from '@libp2p/interface'

export interface StaticGatewayFinderComponents {
logger: ComponentLogger
}

export interface StaticGatewayFinderInit {
portMappingClient: UPnPNAT
gateways: string[]
}

export class StaticGatewayFinder extends TypedEventEmitter<GatewayFinderEvents> implements GatewayFinder {
private readonly log: Logger
private readonly gatewayUrls: URL[]
private readonly gateways: Gateway[]
private readonly portMappingClient: UPnPNAT
private started: boolean

constructor (components: StaticGatewayFinderComponents, init: StaticGatewayFinderInit) {
super()

this.log = components.logger.forComponent('libp2p:upnp-nat:static-gateway-finder')
this.portMappingClient = init.portMappingClient
this.started = false
this.gateways = []
this.gatewayUrls = init.gateways.map(url => new URL(url))
}

async start (): Promise<void> {
this.started = true
}

async afterStart (): Promise<void> {
for (const url of this.gatewayUrls) {
try {
this.log('fetching gateway descriptor from %s', url)
const gateway = await this.portMappingClient.getGateway(url)

if (!this.started) {
return
}

this.log('found static gateway at %s', url)
this.gateways.push(gateway)
this.safeDispatchEvent('gateway', {
detail: gateway
})
} catch (err) {
this.log.error('could not contact static gateway at %s - %e', url, err)
}
}
}

async stop (): Promise<void> {
this.started = false
}
}
40 changes: 28 additions & 12 deletions packages/upnp-nat/src/upnp-nat.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import { upnpNat } from '@achingbrain/nat-port-mapper'
import { serviceCapabilities, serviceDependencies, setMaxListeners, start, stop } from '@libp2p/interface'
import { debounce } from '@libp2p/utils/debounce'
import { GatewayFinder } from './gateway-finder.js'
import { SearchGatewayFinder } from './search-gateway-finder.js'
import { StaticGatewayFinder } from './static-gateway-finder.js'
import { UPnPPortMapper } from './upnp-port-mapper.js'
import type { UPnPNATComponents, UPnPNATInit, UPnPNAT as UPnPNATInterface } from './index.js'
import type { Gateway, UPnPNAT as UPnPNATClient } from '@achingbrain/nat-port-mapper'
import type { Logger, Startable } from '@libp2p/interface'
import type { Logger, Startable, TypedEventTarget } from '@libp2p/interface'
import type { DebouncedFunction } from '@libp2p/utils/debounce'

export interface GatewayFinderEvents {
'gateway': CustomEvent<Gateway>
}

export interface GatewayFinder extends TypedEventTarget<GatewayFinderEvents> {

}

export class UPnPNAT implements Startable, UPnPNATInterface {
private readonly log: Logger
private readonly components: UPnPNATComponents
Expand Down Expand Up @@ -44,16 +53,23 @@ export class UPnPNAT implements Startable, UPnPNATInterface {
}
}, 5_000)

// trigger update when we discovery gateways on the network
this.gatewayFinder = new GatewayFinder(components, {
portMappingClient: this.portMappingClient,
initialSearchInterval: init.initialGatewaySearchInterval,
initialSearchTimeout: init.initialGatewaySearchTimeout,
initialSearchMessageInterval: init.initialGatewaySearchMessageInterval,
searchInterval: init.gatewaySearchInterval,
searchTimeout: init.gatewaySearchTimeout,
searchMessageInterval: init.gatewaySearchMessageInterval
})
if (init.gateways != null) {
this.gatewayFinder = new StaticGatewayFinder(components, {
portMappingClient: this.portMappingClient,
gateways: init.gateways
})
} else {
// trigger update when we discovery gateways on the network
this.gatewayFinder = new SearchGatewayFinder(components, {
portMappingClient: this.portMappingClient,
initialSearchInterval: init.initialGatewaySearchInterval,
initialSearchTimeout: init.initialGatewaySearchTimeout,
initialSearchMessageInterval: init.initialGatewaySearchMessageInterval,
searchInterval: init.gatewaySearchInterval,
searchTimeout: init.gatewaySearchTimeout,
searchMessageInterval: init.gatewaySearchMessageInterval
})
}

this.onGatewayDiscovered = this.onGatewayDiscovered.bind(this)
}
Expand Down
26 changes: 16 additions & 10 deletions packages/upnp-nat/src/upnp-port-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { isPrivate } from '@libp2p/utils/multiaddr/is-private'
import { isPrivateIp } from '@libp2p/utils/private-ip'
import { multiaddr } from '@multiformats/multiaddr'
import { QUICV1, TCP, WebSockets, WebSocketsSecure, WebTransport } from '@multiformats/multiaddr-matcher'
import { dynamicExternalAddress } from './check-external-address.js'
import { dynamicExternalAddress, staticExternalAddress } from './check-external-address.js'
import { DoubleNATError } from './errors.js'
import type { ExternalAddress } from './check-external-address.js'
import type { Gateway } from '@achingbrain/nat-port-mapper'
Expand All @@ -18,6 +18,7 @@ const MAX_DATE = 8_640_000_000_000_000

export interface UPnPPortMapperInit {
gateway: Gateway
externalAddress?: string
externalAddressCheckInterval?: number
externalAddressCheckTimeout?: number
}
Expand Down Expand Up @@ -48,15 +49,20 @@ export class UPnPPortMapper {
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)
})

if (init.externalAddress != null) {
this.externalAddress = staticExternalAddress(init.externalAddress)
} else {
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
Expand Down
Loading