Skip to content

Commit

Permalink
fix: support IPv6 with IPv4
Browse files Browse the repository at this point in the history
Upgrades to latest nat-port-mapper for better IPv6 support.
  • Loading branch information
achingbrain committed Nov 29, 2024
1 parent f2f9008 commit 7faf93b
Show file tree
Hide file tree
Showing 7 changed files with 101 additions and 61 deletions.
2 changes: 1 addition & 1 deletion packages/upnp-nat/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
"test:electron-main": "aegir test -t electron-main"
},
"dependencies": {
"@achingbrain/nat-port-mapper": "^3.0.2",
"@achingbrain/nat-port-mapper": "^4.0.0",
"@chainsafe/is-ip": "^2.0.2",
"@libp2p/interface": "^2.2.1",
"@libp2p/interface-internal": "^2.1.1",
Expand Down
1 change: 0 additions & 1 deletion packages/upnp-nat/src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
export const DEFAULT_PORT_MAPPING_TTL = 720_000
export const DEFAULT_GATEWAY_SEARCH_TIMEOUT = 60_000
export const DEFAULT_GATEWAY_SEARCH_INTERVAL = 300_000
9 changes: 7 additions & 2 deletions packages/upnp-nat/src/gateway-finder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,13 @@ export class GatewayFinder extends TypedEventEmitter<GatewayFinderEvents> {

// 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)) {
for await (const gateway of this.portMappingClient.findGateways({
...options,
searchInterval: 10000
})) {
if (this.gateways.some(g => {
return g.id === gateway.id && g.family === gateway.family

Check warning on line 42 in packages/upnp-nat/src/gateway-finder.ts

View check run for this annotation

Codecov / codecov/patch

packages/upnp-nat/src/gateway-finder.ts#L42

Added line #L42 was not covered by tests
})) {
// already seen this gateway
continue
}
Expand Down
7 changes: 7 additions & 0 deletions packages/upnp-nat/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ export interface UPnPNATInit {
*/
portMappingAutoRefresh?: boolean

/**
* How long before a port mapping expires to refresh it in ms
*
* @default 60_000
*/
portMappingRefreshThreshold?: number

/**
* A preconfigured instance of a NatAPI client can be passed as an option,
* otherwise one will be created
Expand Down
6 changes: 3 additions & 3 deletions packages/upnp-nat/src/upnp-nat.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { upnpNat } from '@achingbrain/nat-port-mapper'
import { serviceCapabilities, setMaxListeners, start, stop } from '@libp2p/interface'
import { debounce } from '@libp2p/utils/debounce'
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'
Expand Down Expand Up @@ -29,8 +28,9 @@ export class UPnPNAT implements Startable, UPnPNATInterface {

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
ttl: init.portMappingTTL,
autoRefresh: init.portMappingAutoRefresh,
refreshThreshold: init.portMappingRefreshThreshold

Check warning on line 33 in packages/upnp-nat/src/upnp-nat.ts

View check run for this annotation

Codecov / codecov/patch

packages/upnp-nat/src/upnp-nat.ts#L31-L33

Added lines #L31 - L33 were not covered by tests
})

// trigger update when our addresses change
Expand Down
83 changes: 40 additions & 43 deletions packages/upnp-nat/src/upnp-port-mapper.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { isIPv4, isIPv6 } from '@chainsafe/is-ip'
import { isIPv4 } from '@chainsafe/is-ip'
import { InvalidParametersError, start, stop } from '@libp2p/interface'
import { isLinkLocal } from '@libp2p/utils/multiaddr/is-link-local'
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 { DoubleNATError } 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'
Expand Down Expand Up @@ -98,12 +99,20 @@ export class UPnPPortMapper {
/**
* Return any eligible multiaddrs that are not mapped on the detected gateway
*/
private getUnmappedAddresses (multiaddrs: Multiaddr[], ipType: 4 | 6): Multiaddr[] {
private getUnmappedAddresses (multiaddrs: Multiaddr[], publicAddresses: string[]): Multiaddr[] {
const output: Multiaddr[] = []

for (const ma of multiaddrs) {
// ignore public addresses
if (!isPrivate(ma)) {
const stringTuples = ma.stringTuples()
const address = `${stringTuples[0][1]}`

// ignore public IPv4 addresses
if (isIPv4(address) && !isPrivate(ma)) {
continue
}

Check warning on line 112 in packages/upnp-nat/src/upnp-port-mapper.ts

View check run for this annotation

Codecov / codecov/patch

packages/upnp-nat/src/upnp-port-mapper.ts#L111-L112

Added lines #L111 - L112 were not covered by tests

// ignore any addresses that match the interface on the network gateway
if (publicAddresses.includes(address)) {
continue
}

Expand All @@ -112,6 +121,11 @@ export class UPnPPortMapper {
continue
}

// ignore link-local addresses
if (isLinkLocal(ma)) {
continue
}

// only IP based addresses
if (!(
TCP.exactMatch(ma) ||
Expand All @@ -123,13 +137,9 @@ export class UPnPPortMapper {
continue
}

const { port, host, family, transport } = ma.toOptions()
const { port, transport } = ma.toOptions()

if (family !== ipType) {
continue
}

if (this.mappedPorts.has(`${host}-${port}-${transport}`)) {
if (this.mappedPorts.has(`${port}-${transport}`)) {
continue
}

Expand All @@ -143,62 +153,49 @@ export class UPnPPortMapper {
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)
const addresses = this.getUnmappedAddresses(this.addressManager.getAddresses(), [externalHost])

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.log('discovered public IP %s', 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()
const { port, host, transport, family } = addr.toOptions()

if (family === 6) {
// only support IPv4 addresses
// don't try to open port on IPv6 host via IPv4 gateway
if (family === 4 && this.gateway.family !== 'IPv4') {
continue
}

if (this.mappedPorts.has(`${host}-${port}-${transport}`)) {
// already mapped this port
// don't try to open port on IPv4 host via IPv6 gateway
if (family === 6 && this.gateway.family !== 'IPv6') {
continue
}

try {
const key = `${host}-${port}-${transport}`
this.log('creating mapping of key %s', key)
const key = `${host}-${port}-${transport}`

const externalPort = await this.gateway.map(port, {
localAddress: host,
protocol: transport === 'tcp' ? 'tcp' : 'udp'
})
if (this.mappedPorts.has(key)) {
// already mapped this port
continue
}

Check warning on line 188 in packages/upnp-nat/src/upnp-port-mapper.ts

View check run for this annotation

Codecov / codecov/patch

packages/upnp-nat/src/upnp-port-mapper.ts#L186-L188

Added lines #L186 - L188 were not covered by tests

this.mappedPorts.set(key, {
externalHost,
externalPort
try {
const mapping = await this.gateway.map(port, host, {
protocol: transport === 'tcp' ? 'TCP' : 'UDP'
})

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')
this.mappedPorts.set(key, mapping)
this.addressManager.addPublicAddressMapping(mapping.internalHost, mapping.internalPort, mapping.externalHost, mapping.externalPort, transport === 'tcp' ? 'tcp' : 'udp')
this.log('created mapping of %s:%s to %s:%s for protocol %s', mapping.internalHost, mapping.internalPort, mapping.externalHost, mapping.externalPort, transport)
} catch (err) {
this.log.error('failed to create mapping of %s:%s - %e', host, port, err)
this.log.error('failed to create mapping for %s:%d for protocol - %e', host, port, transport, err)

Check warning on line 198 in packages/upnp-nat/src/upnp-port-mapper.ts

View check run for this annotation

Codecov / codecov/patch

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

Added line #L198 was not covered by tests
}
}
} catch (err: any) {
Expand Down
54 changes: 43 additions & 11 deletions packages/upnp-nat/test/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ describe('UPnP NAT (TCP)', () => {
events: new TypedEventEmitter()
}

gateway = stubInterface<Gateway>()
gateway = stubInterface<Gateway>({
family: 'IPv4'
})
client = stubInterface<UPnPNATClient>({
findGateways: async function * (options) {
yield gateway
Expand Down Expand Up @@ -71,20 +73,35 @@ describe('UPnP NAT (TCP)', () => {
components
} = await createNatManager()

gateway.externalIp.resolves('82.3.1.5')
const internalHost = '192.168.1.12'
const internalPort = 4002

const externalHost = '82.3.1.5'
const externalPort = 4003

gateway.externalIp.resolves(externalHost)

components.addressManager.getAddresses.returns([
multiaddr('/ip4/127.0.0.1/tcp/4002'),
multiaddr('/ip4/192.168.1.12/tcp/4002')
multiaddr(`/ip4/${internalHost}/tcp/${internalPort}`)
])

gateway.map.withArgs(internalPort, internalHost).resolves({
internalHost,
internalPort,
externalHost,
externalPort,
protocol: 'TCP'
})

await start(natManager)
await natManager.mapIpAddresses()

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(gateway.map.getCall(0).args[0]).to.equal(internalPort)
expect(gateway.map.getCall(0).args[1]).to.equal(internalHost)
expect(gateway.map.getCall(0).args[2]).to.include({
protocol: 'TCP'
})
expect(components.addressManager.addPublicAddressMapping.called).to.be.true()
})
Expand All @@ -95,20 +112,35 @@ describe('UPnP NAT (TCP)', () => {
components
} = await createNatManager()

gateway.externalIp.resolves('82.3.1.5')
const internalHost = '192.168.1.12'
const internalPort = 4002

const externalHost = '82.3.1.5'
const externalPort = 4003

gateway.externalIp.resolves(externalHost)

components.addressManager.getAddresses.returns([
multiaddr('/ip4/127.0.0.1/tcp/4002'),
multiaddr('/ip4/192.168.1.12/tcp/4002')
multiaddr(`/ip4/${internalHost}/tcp/${internalPort}`)
])

gateway.map.withArgs(internalPort, internalHost).resolves({
internalHost,
internalPort,
externalHost,
externalPort,
protocol: 'TCP'
})

await start(natManager)
await natManager.mapIpAddresses()

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(gateway.map.getCall(0).args[0]).to.equal(internalPort)
expect(gateway.map.getCall(0).args[1]).to.equal(internalHost)
expect(gateway.map.getCall(0).args[2]).to.include({
protocol: 'TCP'
})
expect(components.addressManager.addPublicAddressMapping.called).to.be.true()
})
Expand Down

0 comments on commit 7faf93b

Please sign in to comment.