Skip to content

Commit 26313e6

Browse files
authored
feat: allow specifying UPnP gateways and external address (#2937)
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.
1 parent 66c3ec5 commit 26313e6

File tree

7 files changed

+214
-35
lines changed

7 files changed

+214
-35
lines changed

packages/upnp-nat/README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,46 @@ const node = await createLibp2p({
5757
})
5858
```
5959

60+
## Example - Manually specifying gateways and external ports
61+
62+
Some ISP-provided routers are underpowered and may require rebooting before
63+
they will respond to SSDP M-SEARCH messages.
64+
65+
You can manually specify your external address and/or gateways, though note
66+
that those gateways will still need to have UPnP enabled in order for libp2p
67+
to configure mapping of external ports (for IPv4) and/or opening pinholes in
68+
the firewall (for IPv6).
69+
70+
```typescript
71+
import { createLibp2p } from 'libp2p'
72+
import { tcp } from '@libp2p/tcp'
73+
import { uPnPNAT } from '@libp2p/upnp-nat'
74+
75+
const node = await createLibp2p({
76+
addresses: {
77+
listen: [
78+
'/ip4/0.0.0.0/tcp/0'
79+
]
80+
},
81+
transports: [
82+
tcp()
83+
],
84+
services: {
85+
upnpNAT: uPnPNAT({
86+
// manually specify external address - this will normally be an IPv4
87+
// address that the router is performing NAT with
88+
externalAddress: '92.137.164.96',
89+
gateways: [
90+
// an IPv4 gateway
91+
'http://192.168.1.1:8080/path/to/descriptor.xml',
92+
// an IPv6 gateway
93+
'http://[xx:xx:xx:xx]:8080/path/to/descriptor.xml'
94+
]
95+
})
96+
}
97+
})
98+
```
99+
60100
# Install
61101

62102
```console

packages/upnp-nat/src/check-external-address.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ export interface ExternalAddress {
3030
class ExternalAddressChecker implements ExternalAddress, Startable {
3131
private readonly log: Logger
3232
private readonly gateway: Gateway
33-
private readonly addressManager: AddressManager
3433
private started: boolean
3534
private lastPublicIp?: string
3635
private lastPublicIpPromise?: DeferredPromise<string>
@@ -40,7 +39,6 @@ class ExternalAddressChecker implements ExternalAddress, Startable {
4039
constructor (components: ExternalAddressCheckerComponents, init: ExternalAddressCheckerInit) {
4140
this.log = components.logger.forComponent('libp2p:upnp-nat:external-address-check')
4241
this.gateway = components.gateway
43-
this.addressManager = components.addressManager
4442
this.onExternalAddressChange = init.onExternalAddressChange
4543
this.started = false
4644

packages/upnp-nat/src/index.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,46 @@
3333
* }
3434
* })
3535
* ```
36+
*
37+
* @example Manually specifying gateways and external ports
38+
*
39+
* Some ISP-provided routers are underpowered and may require rebooting before
40+
* they will respond to SSDP M-SEARCH messages.
41+
*
42+
* You can manually specify your external address and/or gateways, though note
43+
* that those gateways will still need to have UPnP enabled in order for libp2p
44+
* to configure mapping of external ports (for IPv4) and/or opening pinholes in
45+
* the firewall (for IPv6).
46+
*
47+
* ```typescript
48+
* import { createLibp2p } from 'libp2p'
49+
* import { tcp } from '@libp2p/tcp'
50+
* import { uPnPNAT } from '@libp2p/upnp-nat'
51+
*
52+
* const node = await createLibp2p({
53+
* addresses: {
54+
* listen: [
55+
* '/ip4/0.0.0.0/tcp/0'
56+
* ]
57+
* },
58+
* transports: [
59+
* tcp()
60+
* ],
61+
* services: {
62+
* upnpNAT: uPnPNAT({
63+
* // manually specify external address - this will normally be an IPv4
64+
* // address that the router is performing NAT with
65+
* externalAddress: '92.137.164.96',
66+
* gateways: [
67+
* // an IPv4 gateway
68+
* 'http://192.168.1.1:8080/path/to/descriptor.xml',
69+
* // an IPv6 gateway
70+
* 'http://[xx:xx:xx:xx]:8080/path/to/descriptor.xml'
71+
* ]
72+
* })
73+
* }
74+
* })
75+
* ```
3676
*/
3777

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

5292
export interface UPnPNATInit {
93+
/**
94+
* By default we query discovered/configured gateways for their external
95+
* address. To specify it manually instead, pass a value here.
96+
*
97+
* Typically this would be an IPv4 address that the router performs NAT with.
98+
*/
99+
externalAddress?: string
100+
53101
/**
54102
* Check if the external address has changed this often in ms. Ignored if an
55103
* external address is specified.
@@ -110,6 +158,23 @@ export interface UPnPNATInit {
110158
*/
111159
autoConfirmAddress?: boolean
112160

161+
/**
162+
* By default we search for local gateways using SSDP M-SEARCH messages. To
163+
* manually specify a gateway instead, pass values here.
164+
*
165+
* A lot of ISP-provided gateway/routers are underpowered so may need
166+
* rebooting before they will respond to M-SEARCH messages.
167+
*
168+
* Each value is an IPv4 or IPv6 URL of the UPnP device descriptor document,
169+
* e.g. `http://192.168.1.1:8080/description.xml`. Please see the
170+
* documentation of your gateway to discover the URL.
171+
*
172+
* Note that some gateways will randomise the port/path the descriptor
173+
* document is served from and even change it over time so you may be forced
174+
* to use an SSDP search instead.
175+
*/
176+
gateways?: string[]
177+
113178
/**
114179
* How often to search for network gateways in ms.
115180
*

packages/upnp-nat/src/gateway-finder.ts renamed to packages/upnp-nat/src/search-gateway-finder.ts

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
import { TypedEventEmitter, start, stop } from '@libp2p/interface'
22
import { repeatingTask } from '@libp2p/utils/repeating-task'
33
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'
4+
import type { GatewayFinder, GatewayFinderEvents } from './upnp-nat.js'
45
import type { Gateway, UPnPNAT } from '@achingbrain/nat-port-mapper'
56
import type { ComponentLogger, Logger } from '@libp2p/interface'
67
import type { RepeatingTask } from '@libp2p/utils/repeating-task'
78

8-
export interface GatewayFinderComponents {
9+
export interface SearchGatewayFinderComponents {
910
logger: ComponentLogger
1011
}
1112

12-
export interface GatewayFinderInit {
13+
export interface SearchGatewayFinderInit {
1314
portMappingClient: UPnPNAT
1415
initialSearchInterval?: number
1516
initialSearchTimeout?: number
@@ -19,18 +20,14 @@ export interface GatewayFinderInit {
1920
searchMessageInterval?: number
2021
}
2122

22-
export interface GatewayFinderEvents {
23-
'gateway': CustomEvent<Gateway>
24-
}
25-
26-
export class GatewayFinder extends TypedEventEmitter<GatewayFinderEvents> {
23+
export class SearchGatewayFinder extends TypedEventEmitter<GatewayFinderEvents> implements GatewayFinder {
2724
private readonly log: Logger
2825
private readonly gateways: Gateway[]
2926
private readonly findGateways: RepeatingTask
3027
private readonly portMappingClient: UPnPNAT
3128
private started: boolean
3229

33-
constructor (components: GatewayFinderComponents, init: GatewayFinderInit) {
30+
constructor (components: SearchGatewayFinderComponents, init: SearchGatewayFinderInit) {
3431
super()
3532

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

93-
/**
94-
* Stops the NAT manager
95-
*/
9690
async stop (): Promise<void> {
9791
await stop(this.findGateways)
9892
this.started = false
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { TypedEventEmitter } from '@libp2p/interface'
2+
import type { GatewayFinder, GatewayFinderEvents } from './upnp-nat.js'
3+
import type { Gateway, UPnPNAT } from '@achingbrain/nat-port-mapper'
4+
import type { ComponentLogger, Logger } from '@libp2p/interface'
5+
6+
export interface StaticGatewayFinderComponents {
7+
logger: ComponentLogger
8+
}
9+
10+
export interface StaticGatewayFinderInit {
11+
portMappingClient: UPnPNAT
12+
gateways: string[]
13+
}
14+
15+
export class StaticGatewayFinder extends TypedEventEmitter<GatewayFinderEvents> implements GatewayFinder {
16+
private readonly log: Logger
17+
private readonly gatewayUrls: URL[]
18+
private readonly gateways: Gateway[]
19+
private readonly portMappingClient: UPnPNAT
20+
private started: boolean
21+
22+
constructor (components: StaticGatewayFinderComponents, init: StaticGatewayFinderInit) {
23+
super()
24+
25+
this.log = components.logger.forComponent('libp2p:upnp-nat:static-gateway-finder')
26+
this.portMappingClient = init.portMappingClient
27+
this.started = false
28+
this.gateways = []
29+
this.gatewayUrls = init.gateways.map(url => new URL(url))
30+
}
31+
32+
async start (): Promise<void> {
33+
this.started = true
34+
}
35+
36+
async afterStart (): Promise<void> {
37+
for (const url of this.gatewayUrls) {
38+
try {
39+
this.log('fetching gateway descriptor from %s', url)
40+
const gateway = await this.portMappingClient.getGateway(url)
41+
42+
if (!this.started) {
43+
return
44+
}
45+
46+
this.log('found static gateway at %s', url)
47+
this.gateways.push(gateway)
48+
this.safeDispatchEvent('gateway', {
49+
detail: gateway
50+
})
51+
} catch (err) {
52+
this.log.error('could not contact static gateway at %s - %e', url, err)
53+
}
54+
}
55+
}
56+
57+
async stop (): Promise<void> {
58+
this.started = false
59+
}
60+
}

packages/upnp-nat/src/upnp-nat.ts

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
11
import { upnpNat } from '@achingbrain/nat-port-mapper'
22
import { serviceCapabilities, serviceDependencies, setMaxListeners, start, stop } from '@libp2p/interface'
33
import { debounce } from '@libp2p/utils/debounce'
4-
import { GatewayFinder } from './gateway-finder.js'
4+
import { SearchGatewayFinder } from './search-gateway-finder.js'
5+
import { StaticGatewayFinder } from './static-gateway-finder.js'
56
import { UPnPPortMapper } from './upnp-port-mapper.js'
67
import type { UPnPNATComponents, UPnPNATInit, UPnPNAT as UPnPNATInterface } from './index.js'
78
import type { Gateway, UPnPNAT as UPnPNATClient } from '@achingbrain/nat-port-mapper'
8-
import type { Logger, Startable } from '@libp2p/interface'
9+
import type { Logger, Startable, TypedEventTarget } from '@libp2p/interface'
910
import type { DebouncedFunction } from '@libp2p/utils/debounce'
1011

12+
export interface GatewayFinderEvents {
13+
'gateway': CustomEvent<Gateway>
14+
}
15+
16+
export interface GatewayFinder extends TypedEventTarget<GatewayFinderEvents> {
17+
18+
}
19+
1120
export class UPnPNAT implements Startable, UPnPNATInterface {
1221
private readonly log: Logger
1322
private readonly components: UPnPNATComponents
@@ -44,16 +53,23 @@ export class UPnPNAT implements Startable, UPnPNATInterface {
4453
}
4554
}, 5_000)
4655

47-
// trigger update when we discovery gateways on the network
48-
this.gatewayFinder = new GatewayFinder(components, {
49-
portMappingClient: this.portMappingClient,
50-
initialSearchInterval: init.initialGatewaySearchInterval,
51-
initialSearchTimeout: init.initialGatewaySearchTimeout,
52-
initialSearchMessageInterval: init.initialGatewaySearchMessageInterval,
53-
searchInterval: init.gatewaySearchInterval,
54-
searchTimeout: init.gatewaySearchTimeout,
55-
searchMessageInterval: init.gatewaySearchMessageInterval
56-
})
56+
if (init.gateways != null) {
57+
this.gatewayFinder = new StaticGatewayFinder(components, {
58+
portMappingClient: this.portMappingClient,
59+
gateways: init.gateways
60+
})
61+
} else {
62+
// trigger update when we discovery gateways on the network
63+
this.gatewayFinder = new SearchGatewayFinder(components, {
64+
portMappingClient: this.portMappingClient,
65+
initialSearchInterval: init.initialGatewaySearchInterval,
66+
initialSearchTimeout: init.initialGatewaySearchTimeout,
67+
initialSearchMessageInterval: init.initialGatewaySearchMessageInterval,
68+
searchInterval: init.gatewaySearchInterval,
69+
searchTimeout: init.gatewaySearchTimeout,
70+
searchMessageInterval: init.gatewaySearchMessageInterval
71+
})
72+
}
5773

5874
this.onGatewayDiscovered = this.onGatewayDiscovered.bind(this)
5975
}

packages/upnp-nat/src/upnp-port-mapper.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { isPrivate } from '@libp2p/utils/multiaddr/is-private'
66
import { isPrivateIp } from '@libp2p/utils/private-ip'
77
import { multiaddr } from '@multiformats/multiaddr'
88
import { QUICV1, TCP, WebSockets, WebSocketsSecure, WebTransport } from '@multiformats/multiaddr-matcher'
9-
import { dynamicExternalAddress } from './check-external-address.js'
9+
import { dynamicExternalAddress, staticExternalAddress } from './check-external-address.js'
1010
import { DoubleNATError } from './errors.js'
1111
import type { ExternalAddress } from './check-external-address.js'
1212
import type { Gateway } from '@achingbrain/nat-port-mapper'
@@ -18,6 +18,7 @@ const MAX_DATE = 8_640_000_000_000_000
1818

1919
export interface UPnPPortMapperInit {
2020
gateway: Gateway
21+
externalAddress?: string
2122
externalAddressCheckInterval?: number
2223
externalAddressCheckTimeout?: number
2324
}
@@ -48,15 +49,20 @@ export class UPnPPortMapper {
4849
this.log = components.logger.forComponent(`libp2p:upnp-nat:gateway:${init.gateway.id}`)
4950
this.addressManager = components.addressManager
5051
this.gateway = init.gateway
51-
this.externalAddress = dynamicExternalAddress({
52-
gateway: this.gateway,
53-
addressManager: this.addressManager,
54-
logger: components.logger
55-
}, {
56-
interval: init.externalAddressCheckInterval,
57-
timeout: init.externalAddressCheckTimeout,
58-
onExternalAddressChange: this.remapPorts.bind(this)
59-
})
52+
53+
if (init.externalAddress != null) {
54+
this.externalAddress = staticExternalAddress(init.externalAddress)
55+
} else {
56+
this.externalAddress = dynamicExternalAddress({
57+
gateway: this.gateway,
58+
addressManager: this.addressManager,
59+
logger: components.logger
60+
}, {
61+
interval: init.externalAddressCheckInterval,
62+
timeout: init.externalAddressCheckTimeout,
63+
onExternalAddressChange: this.remapPorts.bind(this)
64+
})
65+
}
6066
this.gateway = init.gateway
6167
this.mappedPorts = new Map()
6268
this.started = false

0 commit comments

Comments
 (0)