Skip to content

Commit 7626b22

Browse files
authored
fix: run UPnP nat on address change, update nat port mapper (#2797)
- Updates NAT port mapper to handle unavailable ports - Runs NAT port mapping on address change rather than once at startup - Re-maps addresses when the public IP address changes
1 parent 02f285f commit 7626b22

File tree

6 files changed

+433
-96
lines changed

6 files changed

+433
-96
lines changed

packages/upnp-nat/package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,15 @@
5050
"test:electron-main": "aegir test -t electron-main"
5151
},
5252
"dependencies": {
53-
"@achingbrain/nat-port-mapper": "^1.0.13",
53+
"@achingbrain/nat-port-mapper": "^2.0.1",
54+
"@chainsafe/is-ip": "^2.0.2",
5455
"@libp2p/interface": "^2.2.0",
5556
"@libp2p/interface-internal": "^2.0.10",
5657
"@libp2p/utils": "^6.1.3",
5758
"@multiformats/multiaddr": "^12.2.3",
58-
"wherearewe": "^2.0.1"
59+
"@multiformats/multiaddr-matcher": "^1.4.0",
60+
"p-defer": "^4.0.1",
61+
"race-signal": "^1.1.0"
5962
},
6063
"devDependencies": {
6164
"@libp2p/crypto": "^5.0.6",
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { NotStartedError, start, stop } from '@libp2p/interface'
2+
import { repeatingTask } from '@libp2p/utils/repeating-task'
3+
import { multiaddr } from '@multiformats/multiaddr'
4+
import pDefer from 'p-defer'
5+
import { raceSignal } from 'race-signal'
6+
import type { NatAPI } from '@achingbrain/nat-port-mapper'
7+
import type { AbortOptions, ComponentLogger, Logger, Startable } from '@libp2p/interface'
8+
import type { AddressManager } from '@libp2p/interface-internal'
9+
import type { RepeatingTask } from '@libp2p/utils/repeating-task'
10+
import type { DeferredPromise } from 'p-defer'
11+
12+
export interface ExternalAddressCheckerComponents {
13+
client: NatAPI
14+
addressManager: AddressManager
15+
logger: ComponentLogger
16+
}
17+
18+
export interface ExternalAddressCheckerInit {
19+
interval?: number
20+
timeout?: number
21+
autoConfirmAddress?: boolean
22+
}
23+
24+
export interface ExternalAddress {
25+
getPublicIp (options?: AbortOptions): Promise<string> | string
26+
}
27+
28+
/**
29+
* Monitors the external network address and notifies when/if it changes
30+
*/
31+
class ExternalAddressChecker implements ExternalAddress, Startable {
32+
private readonly log: Logger
33+
private readonly client: NatAPI
34+
private readonly addressManager: AddressManager
35+
private started: boolean
36+
private lastPublicIp?: string
37+
private readonly lastPublicIpPromise: DeferredPromise<string>
38+
private readonly check: RepeatingTask
39+
private readonly autoConfirmAddress: boolean
40+
41+
constructor (components: ExternalAddressCheckerComponents, init: ExternalAddressCheckerInit = {}) {
42+
this.log = components.logger.forComponent('libp2p:upnp-nat:external-address-check')
43+
this.client = components.client
44+
this.addressManager = components.addressManager
45+
this.autoConfirmAddress = init.autoConfirmAddress ?? false
46+
this.started = false
47+
48+
this.checkExternalAddress = this.checkExternalAddress.bind(this)
49+
50+
this.lastPublicIpPromise = pDefer()
51+
52+
this.check = repeatingTask(this.checkExternalAddress, init.interval ?? 30000, {
53+
timeout: init.timeout ?? 10000,
54+
runImmediately: true
55+
})
56+
}
57+
58+
async start (): Promise<void> {
59+
if (this.started) {
60+
return
61+
}
62+
63+
await start(this.check)
64+
65+
this.check.start()
66+
this.started = true
67+
}
68+
69+
async stop (): Promise<void> {
70+
await stop(this.check)
71+
72+
this.started = false
73+
}
74+
75+
/**
76+
* Return the last public IP address we found, or wait for it to be found
77+
*/
78+
async getPublicIp (options?: AbortOptions): Promise<string> {
79+
if (!this.started) {
80+
throw new NotStartedError('Not started yet')
81+
}
82+
83+
return this.lastPublicIp ?? raceSignal(this.lastPublicIpPromise.promise, options?.signal, {
84+
errorMessage: 'Requesting the public IP from the network gateway timed out - UPnP may not be enabled'
85+
})
86+
}
87+
88+
private async checkExternalAddress (options?: AbortOptions): Promise<void> {
89+
try {
90+
const externalAddress = await this.client.externalIp(options)
91+
92+
// check if our public address has changed
93+
if (this.lastPublicIp != null && externalAddress !== this.lastPublicIp) {
94+
this.log('external address changed from %s to %s', this.lastPublicIp, externalAddress)
95+
96+
for (const ma of this.addressManager.getAddresses()) {
97+
const addrString = ma.toString()
98+
99+
if (!addrString.includes(this.lastPublicIp)) {
100+
continue
101+
}
102+
103+
// create a new version of the multiaddr with the new public IP
104+
const newAddress = multiaddr(addrString.replace(this.lastPublicIp, externalAddress))
105+
106+
// remove the old address and add the new one
107+
this.addressManager.removeObservedAddr(ma)
108+
this.addressManager.confirmObservedAddr(newAddress)
109+
110+
if (this.autoConfirmAddress) {
111+
this.addressManager.confirmObservedAddr(newAddress)
112+
} else {
113+
this.addressManager.addObservedAddr(newAddress)
114+
}
115+
}
116+
}
117+
118+
this.lastPublicIp = externalAddress
119+
this.lastPublicIpPromise.resolve(externalAddress)
120+
} catch (err: any) {
121+
if (this.lastPublicIp != null) {
122+
// ignore the error if we've previously run successfully
123+
return
124+
}
125+
126+
this.lastPublicIpPromise.reject(err)
127+
}
128+
}
129+
}
130+
131+
export function dynamicExternalAddress (components: ExternalAddressCheckerComponents, init: ExternalAddressCheckerInit = {}): ExternalAddress {
132+
return new ExternalAddressChecker(components, init)
133+
}
134+
135+
export function staticExternalAddress (address: string): ExternalAddress {
136+
return {
137+
getPublicIp: () => address
138+
}
139+
}

packages/upnp-nat/src/errors.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,8 @@ export class DoubleNATError extends Error {
44
this.name = 'DoubleNATError'
55
}
66
}
7+
8+
export class InvalidIPAddressError extends Error {
9+
static name = 'InvalidIPAddressError'
10+
name = 'InvalidIPAddressError'
11+
}

packages/upnp-nat/src/index.ts

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@
3636
*/
3737

3838
import { UPnPNAT as UPnPNATClass, type NatAPI, type MapPortOptions } from './upnp-nat.js'
39-
import type { ComponentLogger, NodeInfo, PeerId } from '@libp2p/interface'
40-
import type { AddressManager, TransportManager } from '@libp2p/interface-internal'
39+
import type { ComponentLogger, Libp2pEvents, NodeInfo, PeerId, TypedEventTarget } from '@libp2p/interface'
40+
import type { AddressManager } from '@libp2p/interface-internal'
4141

4242
export type { NatAPI, MapPortOptions }
4343

@@ -50,10 +50,27 @@ export interface PMPOptions {
5050

5151
export interface UPnPNATInit {
5252
/**
53-
* Pass a value to use instead of auto-detection
53+
* Pass a string to hard code the external address, otherwise it will be
54+
* auto-detected
5455
*/
5556
externalAddress?: string
5657

58+
/**
59+
* Check if the external address has changed this often in ms. Ignored if an
60+
* external address is specified.
61+
*
62+
* @default 30000
63+
*/
64+
externalAddressCheckInterval?: number
65+
66+
/**
67+
* Do not take longer than this to check if the external address has changed
68+
* in ms. Ignored if an external address is specified.
69+
*
70+
* @default 10000
71+
*/
72+
externalAddressCheckTimeout?: number
73+
5774
/**
5875
* Pass a value to use instead of auto-detection
5976
*/
@@ -78,14 +95,50 @@ export interface UPnPNATInit {
7895
* Pass a value to use instead of auto-detection
7996
*/
8097
gateway?: string
98+
99+
/**
100+
* How long in ms to wait before giving up trying to auto-detect a
101+
* `urn:schemas-upnp-org:device:InternetGatewayDevice:1` device on the local
102+
* network
103+
*
104+
* @default 10000
105+
*/
106+
gatewayDetectionTimeout?: number
107+
108+
/**
109+
* Ports are mapped when the `self:peer:update` event fires, which happens
110+
* when the node's addresses change. To avoid starting to map ports while
111+
* multiple addresses are being added, the mapping function is debounced by
112+
* this number of ms
113+
*
114+
* @default 5000
115+
*/
116+
delay?: number
117+
118+
/**
119+
* A preconfigured instance of a NatAPI client can be passed as an option,
120+
* otherwise one will be created
121+
*/
122+
client?: NatAPI
123+
124+
/**
125+
* Any mapped addresses are added to the observed address list. These
126+
* addresses require additional verification by the `@libp2p/autonat` protocol
127+
* or similar before they are trusted.
128+
*
129+
* To skip this verification and trust them immediately pass `true` here
130+
*
131+
* @default false
132+
*/
133+
autoConfirmAddress?: boolean
81134
}
82135

83136
export interface UPnPNATComponents {
84137
peerId: PeerId
85138
nodeInfo: NodeInfo
86139
logger: ComponentLogger
87-
transportManager: TransportManager
88140
addressManager: AddressManager
141+
events: TypedEventTarget<Libp2pEvents>
89142
}
90143

91144
export interface UPnPNAT {

0 commit comments

Comments
 (0)