From f18dfecea128a57a244d8fde6fa7368dd2172c8e Mon Sep 17 00:00:00 2001 From: Alex Potsides Date: Wed, 8 Nov 2023 10:10:43 +0000 Subject: [PATCH] refactor!: extract UPnP NAT into separate module (#2217) Splits out UPnP NAT service module. BREAKING CHANGE: imports from `libp2p/upnp-nat` should be updated to `@libp2p/upnp-nat` --- .release-please.json | 1 + doc/CONFIGURATION.md | 2 +- doc/migrations/v0.46-v1.0.0.md | 29 +++ packages/interface/src/errors.ts | 1 + packages/interface/src/index.ts | 15 ++ packages/libp2p/package.json | 5 - packages/libp2p/src/components.ts | 4 +- packages/libp2p/src/index.ts | 7 +- packages/libp2p/src/libp2p.ts | 5 + .../libp2p/test/upnp-nat/upnp-nat.node.ts | 238 ------------------ packages/libp2p/tsconfig.json | 2 +- packages/upnp-nat/LICENSE | 4 + packages/upnp-nat/LICENSE-APACHE | 5 + packages/upnp-nat/LICENSE-MIT | 19 ++ packages/upnp-nat/README.md | 33 +++ packages/upnp-nat/package.json | 62 +++++ packages/upnp-nat/src/index.ts | 95 +++++++ .../index.ts => upnp-nat/src/upnp-nat.ts} | 84 ++----- packages/upnp-nat/test/index.spec.ts | 198 +++++++++++++++ packages/upnp-nat/tsconfig.json | 15 ++ packages/upnp-nat/typedoc.json | 5 + 21 files changed, 513 insertions(+), 316 deletions(-) delete mode 100644 packages/libp2p/test/upnp-nat/upnp-nat.node.ts create mode 100644 packages/upnp-nat/LICENSE create mode 100644 packages/upnp-nat/LICENSE-APACHE create mode 100644 packages/upnp-nat/LICENSE-MIT create mode 100644 packages/upnp-nat/README.md create mode 100644 packages/upnp-nat/package.json create mode 100644 packages/upnp-nat/src/index.ts rename packages/{libp2p/src/upnp-nat/index.ts => upnp-nat/src/upnp-nat.ts} (62%) create mode 100644 packages/upnp-nat/test/index.spec.ts create mode 100644 packages/upnp-nat/tsconfig.json create mode 100644 packages/upnp-nat/typedoc.json diff --git a/.release-please.json b/.release-please.json index 624149e52c..cda47e94f2 100644 --- a/.release-please.json +++ b/.release-please.json @@ -29,6 +29,7 @@ "packages/transport-webrtc": {}, "packages/transport-websockets": {}, "packages/transport-webtransport": {}, + "packages/upnp-nat": {}, "packages/utils": {} } } diff --git a/doc/CONFIGURATION.md b/doc/CONFIGURATION.md index 6f82e087bd..934613242e 100644 --- a/doc/CONFIGURATION.md +++ b/doc/CONFIGURATION.md @@ -954,7 +954,7 @@ If your router supports this, libp2p can be configured to use it as follows: ```js import { createLibp2p } from 'libp2p' -import { uPnPNATService } from 'libp2p/upnp-nat' +import { uPnPNATService } from '@libp2p/upnp-nat' const node = await createLibp2p({ services: { diff --git a/doc/migrations/v0.46-v1.0.0.md b/doc/migrations/v0.46-v1.0.0.md index 11e26bbc6f..0d76ab4305 100644 --- a/doc/migrations/v0.46-v1.0.0.md +++ b/doc/migrations/v0.46-v1.0.0.md @@ -7,6 +7,7 @@ A migration guide for refactoring your application code from libp2p `v0.46` to ` - [AutoNAT](#autonat) - [KeyChain](#keychain) +- [UPnPNat](#upnpnat) - [Pnet](#pnet) - [Metrics](#metrics) @@ -69,6 +70,34 @@ const libp2p = await createLibp2p({ const keychain: Keychain = libp2p.services.keychain ``` +## UPnPNat + +The UPnPNat service module is now published in its own package. + +```ts +import { createLibp2p } from 'libp2p' +import { uPnPNATService } from 'libp2p/upnp-nat' + +const node = await createLibp2p({ + services: { + uPnPNAT: uPnPNATService() + } +}) +``` + +**After** + +```ts +import { createLibp2p } from 'libp2p' +import { uPnPNAT } from '@libp2p/upnp-nat' + +const node = await createLibp2p({ + services: { + uPnPNAT: uPnPNAT() + } +}) +``` + ## Pnet The pnet module is now published in its own package. diff --git a/packages/interface/src/errors.ts b/packages/interface/src/errors.ts index 4f5f8b4c3a..00ce50505a 100644 --- a/packages/interface/src/errors.ts +++ b/packages/interface/src/errors.ts @@ -69,3 +69,4 @@ export class InvalidCryptoTransmissionError extends Error { // Error codes export const ERR_TIMEOUT = 'ERR_TIMEOUT' +export const ERR_INVALID_PARAMETERS = 'ERR_INVALID_PARAMETERS' diff --git a/packages/interface/src/index.ts b/packages/interface/src/index.ts index 0151c03cee..2389ffc1e3 100644 --- a/packages/interface/src/index.ts +++ b/packages/interface/src/index.ts @@ -609,6 +609,21 @@ export interface Libp2p extends Startable, Ty services: T } +/** + * Metadata about the current node + */ +export interface NodeInfo { + /** + * The implementation name + */ + name: string + + /** + * The implementation version + */ + version: string +} + /** * An object that contains an AbortSignal as * the optional `signal` property. diff --git a/packages/libp2p/package.json b/packages/libp2p/package.json index 3dd13fd921..42e908017d 100644 --- a/packages/libp2p/package.json +++ b/packages/libp2p/package.json @@ -71,10 +71,6 @@ "./ping": { "types": "./dist/src/ping/index.d.ts", "import": "./dist/src/ping/index.js" - }, - "./upnp-nat": { - "types": "./dist/src/upnp-nat/index.d.ts", - "import": "./dist/src/upnp-nat/index.js" } }, "eslintConfig": { @@ -111,7 +107,6 @@ "test:interop": "aegir test -t node -f dist/test/interop.js" }, "dependencies": { - "@achingbrain/nat-port-mapper": "^1.0.9", "@libp2p/crypto": "^2.0.8", "@libp2p/interface": "^0.1.6", "@libp2p/interface-internal": "^0.1.9", diff --git a/packages/libp2p/src/components.ts b/packages/libp2p/src/components.ts index 6a254e12df..f03169820d 100644 --- a/packages/libp2p/src/components.ts +++ b/packages/libp2p/src/components.ts @@ -1,7 +1,7 @@ import { CodeError } from '@libp2p/interface/errors' import { isStartable, type Startable } from '@libp2p/interface/startable' import { defaultLogger } from '@libp2p/logger' -import type { Libp2pEvents, ComponentLogger } from '@libp2p/interface' +import type { Libp2pEvents, ComponentLogger, NodeInfo } from '@libp2p/interface' import type { ConnectionProtector } from '@libp2p/interface/connection' import type { ConnectionGater } from '@libp2p/interface/connection-gater' import type { ContentRouting } from '@libp2p/interface/content-routing' @@ -19,6 +19,7 @@ import type { Datastore } from 'interface-datastore' export interface Components extends Record, Startable { peerId: PeerId + nodeInfo: NodeInfo logger: ComponentLogger events: TypedEventTarget addressManager: AddressManager @@ -37,6 +38,7 @@ export interface Components extends Record, Startable { export interface ComponentsInit { peerId?: PeerId + nodeInfo?: NodeInfo logger?: ComponentLogger events?: TypedEventTarget addressManager?: AddressManager diff --git a/packages/libp2p/src/index.ts b/packages/libp2p/src/index.ts index bb8e06c5b9..2c5fd44d45 100644 --- a/packages/libp2p/src/index.ts +++ b/packages/libp2p/src/index.ts @@ -19,7 +19,7 @@ import type { AddressManagerInit } from './address-manager/index.js' import type { Components } from './components.js' import type { ConnectionManagerInit } from './connection-manager/index.js' import type { TransportManagerInit } from './transport-manager.js' -import type { Libp2p, ServiceMap, RecursivePartial, ComponentLogger } from '@libp2p/interface' +import type { Libp2p, ServiceMap, RecursivePartial, ComponentLogger, NodeInfo } from '@libp2p/interface' import type { ConnectionProtector } from '@libp2p/interface/connection' import type { ConnectionEncrypter } from '@libp2p/interface/connection-encrypter' import type { ConnectionGater } from '@libp2p/interface/connection-gater' @@ -46,6 +46,11 @@ export interface Libp2pInit */ peerId: PeerId + /** + * Metadata about the node - implementation name, version number, etc + */ + nodeInfo: NodeInfo + /** * Addresses for transport listening and to advertise to the network */ diff --git a/packages/libp2p/src/libp2p.ts b/packages/libp2p/src/libp2p.ts index 2c5729bbd7..1afab1f81a 100644 --- a/packages/libp2p/src/libp2p.ts +++ b/packages/libp2p/src/libp2p.ts @@ -24,6 +24,7 @@ import { DefaultPeerRouting } from './peer-routing.js' import { DefaultRegistrar } from './registrar.js' import { DefaultTransportManager } from './transport-manager.js' import { DefaultUpgrader } from './upgrader.js' +import * as pkg from './version.js' import type { Components } from './components.js' import type { Libp2p, Libp2pInit, Libp2pOptions } from './index.js' import type { Libp2pEvents, PendingDial, ServiceMap, AbortOptions, ComponentLogger, Logger } from '@libp2p/interface' @@ -75,6 +76,10 @@ export class Libp2pNode> extends this.services = {} const components = this.components = defaultComponents({ peerId: init.peerId, + nodeInfo: init.nodeInfo ?? { + name: pkg.name, + version: pkg.version + }, logger: this.logger, events, datastore: init.datastore ?? new MemoryDatastore(), diff --git a/packages/libp2p/test/upnp-nat/upnp-nat.node.ts b/packages/libp2p/test/upnp-nat/upnp-nat.node.ts deleted file mode 100644 index 8e39909147..0000000000 --- a/packages/libp2p/test/upnp-nat/upnp-nat.node.ts +++ /dev/null @@ -1,238 +0,0 @@ -/* eslint-env mocha */ - -import { TypedEventEmitter } from '@libp2p/interface/events' -import { start, stop } from '@libp2p/interface/startable' -import { FaultTolerance } from '@libp2p/interface/transport' -import { mockUpgrader } from '@libp2p/interface-compliance-tests/mocks' -import { createEd25519PeerId } from '@libp2p/peer-id-factory' -import { tcp } from '@libp2p/tcp' -import { multiaddr } from '@multiformats/multiaddr' -import { expect } from 'aegir/chai' -import delay from 'delay' -import { pEvent } from 'p-event' -import { type StubbedInstance, stubInterface } from 'sinon-ts' -import { DefaultAddressManager } from '../../src/address-manager/index.js' -import { defaultComponents, type Components } from '../../src/components.js' -import { codes } from '../../src/errors.js' -import { DefaultTransportManager } from '../../src/transport-manager.js' -import { uPnPNATService } from '../../src/upnp-nat/index.js' -import type { NatAPI } from '@achingbrain/nat-port-mapper' -import type { PeerUpdate } from '@libp2p/interface' -import type { PeerId } from '@libp2p/interface/peer-id' -import type { PeerData, PeerStore } from '@libp2p/interface/peer-store' - -const DEFAULT_ADDRESSES = [ - '/ip4/127.0.0.1/tcp/0', - '/ip4/0.0.0.0/tcp/0' -] - -describe('UPnP NAT (TCP)', () => { - const teardown: Array<() => Promise> = [] - let client: StubbedInstance - - async function createNatManager (addrs = DEFAULT_ADDRESSES, natManagerOptions = {}): Promise<{ natManager: any, components: Components }> { - const events = new TypedEventEmitter() - const components: any = defaultComponents({ - peerId: await createEd25519PeerId(), - upgrader: mockUpgrader({ events }), - events, - peerStore: stubInterface() - }) - - components.peerStore.patch.callsFake(async (peerId: PeerId, details: PeerData) => { - components.events.safeDispatchEvent('self:peer:update', { - peer: { - id: peerId, - ...details - } - }) - }) - - components.addressManager = new DefaultAddressManager(components, { listen: addrs }) - components.transportManager = new DefaultTransportManager(components, { - faultTolerance: FaultTolerance.NO_FATAL - }) - - const natManager: any = uPnPNATService({ - keepAlive: true, - ...natManagerOptions - })(components) - - client = stubInterface() - - natManager._getClient = () => { - return client - } - - components.transportManager.add(tcp()()) - - await start(components) - - teardown.push(async () => { - await stop(natManager) - await components.transportManager.removeAll() - await stop(components) - }) - - return { - natManager, - components - } - } - - afterEach(async () => Promise.all(teardown.map(async t => { await t() }))) - - it('should map TCP connections to external ports', async () => { - const { - natManager, - components - } = await createNatManager() - - client.externalIp.resolves('82.3.1.5') - - let observed = components.addressManager.getObservedAddrs().map(ma => ma.toString()) - expect(observed).to.be.empty() - - await start(natManager) - - await delay(100) - - observed = components.addressManager.getObservedAddrs().map(ma => ma.toString()) - expect(observed).to.not.be.empty() - - const internalPorts = components.transportManager.getAddrs() - .filter(ma => ma.isThinWaistAddress()) - .map(ma => ma.toOptions()) - .filter(({ host, transport }) => host !== '127.0.0.1' && transport === 'tcp') - .map(({ port }) => port) - - expect(client.map.called).to.be.true() - - internalPorts.forEach(port => { - expect(client.map.getCall(0).args[0]).to.include({ - localPort: port, - protocol: 'TCP' - }) - }) - - const externalAddress = '/ip4/82.3.1.5/tcp/4002' - const eventPromise = pEvent<'self:peer:update', CustomEvent>(components.events, 'self:peer:update') - - // simulate autonat having run - components.addressManager.confirmObservedAddr(multiaddr(externalAddress)) - - await eventPromise - }) - - it('should not map TCP connections when double-natted', async () => { - const { - natManager, - components - } = await createNatManager() - - client.externalIp.resolves('192.168.1.1') - - let observed = components.addressManager.getObservedAddrs().map(ma => ma.toString()) - expect(observed).to.be.empty() - - await expect(natManager._start()).to.eventually.be.rejectedWith(/double NAT/) - - observed = components.addressManager.getObservedAddrs().map(ma => ma.toString()) - expect(observed).to.be.empty() - - expect(client.map.called).to.be.false() - }) - - it('should not map non-ipv4 connections to external ports', async () => { - const { - natManager, - components - } = await createNatManager([ - '/ip6/::/tcp/0' - ]) - - let observed = components.addressManager.getObservedAddrs().map(ma => ma.toString()) - expect(observed).to.be.empty() - - await start(natManager) - - observed = components.addressManager.getObservedAddrs().map(ma => ma.toString()) - expect(observed).to.be.empty() - }) - - it('should not map non-ipv6 loopback connections to external ports', async () => { - const { - natManager, - components - } = await createNatManager([ - '/ip6/::1/tcp/0' - ]) - - let observed = components.addressManager.getObservedAddrs().map(ma => ma.toString()) - expect(observed).to.be.empty() - - await start(natManager) - - observed = components.addressManager.getObservedAddrs().map(ma => ma.toString()) - expect(observed).to.be.empty() - }) - - it('should not map non-TCP connections to external ports', async () => { - const { - natManager, - components - } = await createNatManager([ - '/ip4/0.0.0.0/utp' - ]) - - let observed = components.addressManager.getObservedAddrs().map(ma => ma.toString()) - expect(observed).to.be.empty() - - await start(natManager) - - observed = components.addressManager.getObservedAddrs().map(ma => ma.toString()) - expect(observed).to.be.empty() - }) - - it('should not map loopback connections to external ports', async () => { - const { - natManager, - components - } = await createNatManager([ - '/ip4/127.0.0.1/tcp/0' - ]) - - let observed = components.addressManager.getObservedAddrs().map(ma => ma.toString()) - expect(observed).to.be.empty() - - await start(natManager) - - observed = components.addressManager.getObservedAddrs().map(ma => ma.toString()) - expect(observed).to.be.empty() - }) - - it('should not map non-thin-waist connections to external ports', async () => { - const { - natManager, - components - } = await createNatManager([ - '/ip4/0.0.0.0/tcp/0/sctp/0' - ]) - - let observed = components.addressManager.getObservedAddrs().map(ma => ma.toString()) - expect(observed).to.be.empty() - - await start(natManager) - - observed = components.addressManager.getObservedAddrs().map(ma => ma.toString()) - expect(observed).to.be.empty() - }) - - it('should specify large enough TTL', async () => { - const peerId = await createEd25519PeerId() - - expect(() => { - uPnPNATService({ ttl: 5, keepAlive: true })(defaultComponents({ peerId })) - }).to.throw().with.property('code', codes.ERR_INVALID_PARAMETERS) - }) -}) diff --git a/packages/libp2p/tsconfig.json b/packages/libp2p/tsconfig.json index e52e22da16..85307fab63 100644 --- a/packages/libp2p/tsconfig.json +++ b/packages/libp2p/tsconfig.json @@ -6,7 +6,7 @@ "include": [ "src", "test" - ], +, "../upnp-nat/test/upnp-nat" ], "references": [ { "path": "../crypto" diff --git a/packages/upnp-nat/LICENSE b/packages/upnp-nat/LICENSE new file mode 100644 index 0000000000..20ce483c86 --- /dev/null +++ b/packages/upnp-nat/LICENSE @@ -0,0 +1,4 @@ +This project is dual licensed under MIT and Apache-2.0. + +MIT: https://www.opensource.org/licenses/mit +Apache-2.0: https://www.apache.org/licenses/license-2.0 diff --git a/packages/upnp-nat/LICENSE-APACHE b/packages/upnp-nat/LICENSE-APACHE new file mode 100644 index 0000000000..14478a3b60 --- /dev/null +++ b/packages/upnp-nat/LICENSE-APACHE @@ -0,0 +1,5 @@ +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. diff --git a/packages/upnp-nat/LICENSE-MIT b/packages/upnp-nat/LICENSE-MIT new file mode 100644 index 0000000000..72dc60d84b --- /dev/null +++ b/packages/upnp-nat/LICENSE-MIT @@ -0,0 +1,19 @@ +The MIT License (MIT) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/upnp-nat/README.md b/packages/upnp-nat/README.md new file mode 100644 index 0000000000..aafa9396d7 --- /dev/null +++ b/packages/upnp-nat/README.md @@ -0,0 +1,33 @@ +[![libp2p.io](https://img.shields.io/badge/project-libp2p-yellow.svg?style=flat-square)](http://libp2p.io/) +[![Discuss](https://img.shields.io/discourse/https/discuss.libp2p.io/posts.svg?style=flat-square)](https://discuss.libp2p.io) +[![codecov](https://img.shields.io/codecov/c/github/libp2p/js-libp2p.svg?style=flat-square)](https://codecov.io/gh/libp2p/js-libp2p) +[![CI](https://img.shields.io/github/actions/workflow/status/libp2p/js-libp2p/main.yml?branch=master\&style=flat-square)](https://github.com/libp2p/js-libp2p/actions/workflows/main.yml?query=branch%3Amaster) + +# Install + +```console +$ npm i @libp2p/peer-id +``` + +## Browser ` +``` + +# API Docs + +- + +# License + +Licensed under either of + +- Apache 2.0, ([LICENSE-APACHE](LICENSE-APACHE) / ) +- MIT ([LICENSE-MIT](LICENSE-MIT) / ) + +# Contribution + +Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. diff --git a/packages/upnp-nat/package.json b/packages/upnp-nat/package.json new file mode 100644 index 0000000000..8e47d07434 --- /dev/null +++ b/packages/upnp-nat/package.json @@ -0,0 +1,62 @@ +{ + "name": "@libp2p/upnp-nat", + "version": "0.0.0", + "description": "UPnP NAT hole punching", + "license": "Apache-2.0 OR MIT", + "homepage": "https://github.com/libp2p/js-libp2p/tree/master/packages/peer-id#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/libp2p/js-libp2p.git" + }, + "bugs": { + "url": "https://github.com/libp2p/js-libp2p/issues" + }, + "keywords": [ + "IPFS" + ], + "type": "module", + "types": "./dist/src/index.d.ts", + "files": [ + "src", + "dist", + "!dist/test", + "!**/*.tsbuildinfo" + ], + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "import": "./dist/src/index.js" + } + }, + "eslintConfig": { + "extends": "ipfs", + "parserOptions": { + "project": true, + "sourceType": "module" + } + }, + "scripts": { + "clean": "aegir clean", + "lint": "aegir lint", + "dep-check": "aegir dep-check", + "build": "aegir build --no-bundle", + "test": "aegir test -t node -t electron-main", + "test:node": "aegir test -t node --cov", + "test:electron-main": "aegir test -t electron-main" + }, + "dependencies": { + "@achingbrain/nat-port-mapper": "^1.0.12", + "@libp2p/interface": "^0.1.6", + "@libp2p/interface-internal": "^0.1.9", + "@libp2p/utils": "^4.0.7", + "@multiformats/multiaddr": "^12.1.10", + "private-ip": "^3.0.1", + "wherearewe": "^2.0.1" + }, + "devDependencies": { + "@libp2p/logger": "^3.1.0", + "@libp2p/peer-id-factory": "^3.0.8", + "aegir": "^41.0.2", + "sinon-ts": "^2.0.0" + } +} diff --git a/packages/upnp-nat/src/index.ts b/packages/upnp-nat/src/index.ts new file mode 100644 index 0000000000..14698c1ce9 --- /dev/null +++ b/packages/upnp-nat/src/index.ts @@ -0,0 +1,95 @@ +/** + * @packageDocumentation + * + * The service exported by this module attempts to configure NAT hole punching + * via UPnP. + * + * This will make your node publicly accessible from the internet. + * + * For this to work there are some prerequisites: + * + * 1. Your router must have UPnP support enabled + * 2. Your libp2p node must be listening on a non-loopback IPv4 address + * 3. You must not be [double-NATed](https://kb.netgear.com/30186/What-is-double-NAT-and-why-is-it-bad) by your ISP + * + * @example + * + * ```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() + * } + * }) + * ``` + */ + +import { UPnPNAT } from './upnp-nat.js' +import type { ComponentLogger, NodeInfo } from '@libp2p/interface' +import type { PeerId } from '@libp2p/interface/peer-id' +import type { AddressManager } from '@libp2p/interface-internal/address-manager' +import type { TransportManager } from '@libp2p/interface-internal/transport-manager' + +export interface PMPOptions { + /** + * Whether to enable PMP as well as UPnP + */ + enabled?: boolean +} + +export interface UPnPNATInit { + /** + * Pass a value to use instead of auto-detection + */ + externalAddress?: string + + /** + * 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 + + /** + * 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 + */ + gateway?: string +} + +export interface UPnPNATComponents { + peerId: PeerId + nodeInfo: NodeInfo + logger: ComponentLogger + transportManager: TransportManager + addressManager: AddressManager +} + +export function uPnPNAT (init: UPnPNATInit = {}): (components: UPnPNATComponents) => unknown { + return (components: UPnPNATComponents) => { + return new UPnPNAT(components, init) + } +} diff --git a/packages/libp2p/src/upnp-nat/index.ts b/packages/upnp-nat/src/upnp-nat.ts similarity index 62% rename from packages/libp2p/src/upnp-nat/index.ts rename to packages/upnp-nat/src/upnp-nat.ts index 8e422f6b1a..2c86591a2e 100644 --- a/packages/libp2p/src/upnp-nat/index.ts +++ b/packages/upnp-nat/src/upnp-nat.ts @@ -1,70 +1,20 @@ import { upnpNat, type NatAPI } from '@achingbrain/nat-port-mapper' -import { CodeError } from '@libp2p/interface/errors' -import { logger } from '@libp2p/logger' +import { CodeError, ERR_INVALID_PARAMETERS } from '@libp2p/interface/errors' import { isLoopback } from '@libp2p/utils/multiaddr/is-loopback' import { fromNodeAddress } from '@multiformats/multiaddr' import isPrivateIp from 'private-ip' import { isBrowser } from 'wherearewe' -import { codes } from '../errors.js' -import * as pkg from '../version.js' -import type { PeerId } from '@libp2p/interface/peer-id' +import type { UPnPNATComponents, UPnPNATInit } from './index.js' +import type { Logger } from '@libp2p/interface' import type { Startable } from '@libp2p/interface/startable' -import type { AddressManager } from '@libp2p/interface-internal/address-manager' -import type { TransportManager } from '@libp2p/interface-internal/transport-manager' -const log = logger('libp2p:upnp-nat') const DEFAULT_TTL = 7200 function highPort (min = 1024, max = 65535): number { return Math.floor(Math.random() * (max - min + 1) + min) } -export interface PMPOptions { - /** - * Whether to enable PMP as well as UPnP - */ - enabled?: boolean -} - -export interface UPnPNATInit { - /** - * Pass a value to use instead of auto-detection - */ - externalAddress?: string - - /** - * 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 - - /** - * 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 - */ - gateway?: string -} - -export interface UPnPNATComponents { - peerId: PeerId - transportManager: TransportManager - addressManager: AddressManager -} - -class UPnPNAT implements Startable { +export class UPnPNAT implements Startable { private readonly components: UPnPNATComponents private readonly externalAddress?: string private readonly localAddress?: string @@ -74,20 +24,22 @@ class UPnPNAT implements Startable { private readonly gateway?: string private started: boolean private client?: NatAPI + readonly #log: Logger constructor (components: UPnPNATComponents, init: UPnPNATInit) { this.components = components + this.#log = components.logger.forComponent('libp2p:upnp-nat') this.started = false this.externalAddress = init.externalAddress this.localAddress = init.localAddress - this.description = init.description ?? `${pkg.name}@${pkg.version} ${this.components.peerId.toString()}` + this.description = init.description ?? `${components.nodeInfo.name}@${components.nodeInfo.version} ${this.components.peerId.toString()}` this.ttl = init.ttl ?? DEFAULT_TTL this.keepAlive = init.keepAlive ?? true this.gateway = init.gateway if (this.ttl < DEFAULT_TTL) { - throw new CodeError(`NatManager ttl should be at least ${DEFAULT_TTL} seconds`, codes.ERR_INVALID_PARAMETERS) + throw new CodeError(`NatManager ttl should be at least ${DEFAULT_TTL} seconds`, ERR_INVALID_PARAMETERS) } } @@ -112,13 +64,13 @@ class UPnPNAT implements Startable { this.started = true // done async to not slow down startup - void this._start().catch((err) => { + void this.mapIpAddresses().catch((err) => { // hole punching errors are non-fatal - log.error(err) + this.#log.error(err) }) } - async _start (): Promise { + async mapIpAddresses (): Promise { const addrs = this.components.transportManager.getAddrs() for (const addr of addrs) { @@ -147,16 +99,16 @@ class UPnPNAT implements Startable { const isPrivate = isPrivateIp(publicIp) if (isPrivate === true) { - throw new Error(`${publicIp} is private - please set config.nat.externalIp to an externally routable IP or ensure you are not behind a double NAT`) + throw new CodeError(`${publicIp} is private - please set config.nat.externalIp to an externally routable IP or ensure you are not behind a double NAT`, 'ERR_DOUBLE_NAT') } if (isPrivate == null) { - throw new Error(`${publicIp} is not an IP address`) + throw new CodeError(`${publicIp} is not an IP address`, ERR_INVALID_PARAMETERS) } const publicPort = highPort() - log(`opening uPnP connection from ${publicIp}:${publicPort} to ${host}:${port}`) + this.#log(`opening uPnP connection from ${publicIp}:${publicPort} to ${host}:${port}`) await client.map({ publicPort, @@ -200,13 +152,7 @@ class UPnPNAT implements Startable { await this.client.close() this.client = undefined } catch (err: any) { - log.error(err) + this.#log.error(err) } } } - -export function uPnPNATService (init: UPnPNATInit = {}): (components: UPnPNATComponents) => UPnPNAT { - return (components: UPnPNATComponents) => { - return new UPnPNAT(components, init) - } -} diff --git a/packages/upnp-nat/test/index.spec.ts b/packages/upnp-nat/test/index.spec.ts new file mode 100644 index 0000000000..df5a86549c --- /dev/null +++ b/packages/upnp-nat/test/index.spec.ts @@ -0,0 +1,198 @@ +/* eslint-env mocha */ + +import { ERR_INVALID_PARAMETERS } from '@libp2p/interface/errors' +import { stop } from '@libp2p/interface/startable' +import { defaultLogger } from '@libp2p/logger' +import { createEd25519PeerId } from '@libp2p/peer-id-factory' +import { multiaddr } from '@multiformats/multiaddr' +import { expect } from 'aegir/chai' +import { type StubbedInstance, stubInterface } from 'sinon-ts' +import { UPnPNAT } from '../src/upnp-nat.js' +import type { NatAPI } from '@achingbrain/nat-port-mapper' +import type { ComponentLogger, NodeInfo } from '@libp2p/interface' +import type { PeerId } from '@libp2p/interface/peer-id' +import type { AddressManager } from '@libp2p/interface-internal/address-manager' +import type { TransportManager } from '@libp2p/interface-internal/transport-manager' + +interface StubbedUPnPNATComponents { + peerId: PeerId + nodeInfo: NodeInfo + logger: ComponentLogger + transportManager: StubbedInstance + addressManager: StubbedInstance +} + +describe('UPnP NAT (TCP)', () => { + const teardown: Array<() => Promise> = [] + let client: StubbedInstance + + async function createNatManager (natManagerOptions = {}): Promise<{ natManager: any, components: StubbedUPnPNATComponents }> { + const components: StubbedUPnPNATComponents = { + peerId: await createEd25519PeerId(), + nodeInfo: { name: 'test', version: 'test' }, + logger: defaultLogger(), + addressManager: stubInterface(), + transportManager: stubInterface() + } + + const natManager = new UPnPNAT(components, { + keepAlive: true, + ...natManagerOptions + }) + + client = stubInterface() + + natManager._getClient = () => { + return client + } + + teardown.push(async () => { + await stop(natManager) + }) + + return { + natManager, + components + } + } + + afterEach(async () => Promise.all(teardown.map(async t => { await t() }))) + + it('should map TCP connections to external ports', async () => { + const { + natManager, + components + } = await createNatManager() + + client.externalIp.resolves('82.3.1.5') + + components.transportManager.getAddrs.returns([ + multiaddr('/ip4/127.0.0.1/tcp/4002'), + multiaddr('/ip4/192.168.1.12/tcp/4002') + ]) + + await natManager.mapIpAddresses() + + expect(client.map.called).to.be.true() + expect(client.map.getCall(0).args[0]).to.include({ + localPort: 4002, + protocol: 'TCP' + }) + expect(components.addressManager.addObservedAddr.called).to.be.true() + }) + + it('should not map TCP connections when double-natted', async () => { + const { + natManager, + components + } = await createNatManager() + + client.externalIp.resolves('192.168.1.1') + + components.transportManager.getAddrs.returns([ + multiaddr('/ip4/127.0.0.1/tcp/4002'), + multiaddr('/ip4/192.168.1.12/tcp/4002') + ]) + + await expect(natManager.mapIpAddresses()).to.eventually.be.rejected + .with.property('code', 'ERR_DOUBLE_NAT') + + expect(client.map.called).to.be.false() + expect(components.addressManager.addObservedAddr.called).to.be.false() + }) + + it('should not map non-ipv4 connections to external ports', async () => { + const { + natManager, + components + } = await createNatManager() + + client.externalIp.resolves('82.3.1.5') + + components.transportManager.getAddrs.returns([ + multiaddr('/ip6/fe80::9400:67ff:fe19:2a0f/tcp/0') + ]) + + await natManager.mapIpAddresses() + + expect(client.map.called).to.be.false() + expect(components.addressManager.addObservedAddr.called).to.be.false() + }) + + it('should not map non-ipv6 loopback connections to external ports', async () => { + const { + natManager, + components + } = await createNatManager() + + client.externalIp.resolves('82.3.1.5') + + components.transportManager.getAddrs.returns([ + multiaddr('/ip6/::1/tcp/0') + ]) + + await natManager.mapIpAddresses() + + expect(client.map.called).to.be.false() + expect(components.addressManager.addObservedAddr.called).to.be.false() + }) + + it('should not map non-TCP connections to external ports', async () => { + const { + natManager, + components + } = await createNatManager() + + client.externalIp.resolves('82.3.1.5') + + components.transportManager.getAddrs.returns([ + multiaddr('/ip4/192.168.1.12/udp/4001') + ]) + + await natManager.mapIpAddresses() + + expect(client.map.called).to.be.false() + expect(components.addressManager.addObservedAddr.called).to.be.false() + }) + + it('should not map loopback connections to external ports', async () => { + const { + natManager, + components + } = await createNatManager() + + client.externalIp.resolves('82.3.1.5') + + components.transportManager.getAddrs.returns([ + multiaddr('/ip4/127.0.0.1/tcp/4001') + ]) + + await natManager.mapIpAddresses() + + expect(client.map.called).to.be.false() + expect(components.addressManager.addObservedAddr.called).to.be.false() + }) + + it('should not map non-thin-waist connections to external ports', async () => { + const { + natManager, + components + } = await createNatManager() + + client.externalIp.resolves('82.3.1.5') + + components.transportManager.getAddrs.returns([ + multiaddr('/ip4/127.0.0.1/tcp/4001/sctp/0') + ]) + + await natManager.mapIpAddresses() + + expect(client.map.called).to.be.false() + expect(components.addressManager.addObservedAddr.called).to.be.false() + }) + + it('should specify large enough TTL', async () => { + await expect(createNatManager({ ttl: 5, keepAlive: true })).to.eventually.be.rejected + .with.property('code', ERR_INVALID_PARAMETERS) + }) +}) diff --git a/packages/upnp-nat/tsconfig.json b/packages/upnp-nat/tsconfig.json new file mode 100644 index 0000000000..4c0bdf7723 --- /dev/null +++ b/packages/upnp-nat/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "aegir/src/config/tsconfig.aegir.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": [ + "src", + "test" + ], + "references": [ + { + "path": "../interface" + } + ] +} diff --git a/packages/upnp-nat/typedoc.json b/packages/upnp-nat/typedoc.json new file mode 100644 index 0000000000..f599dc728d --- /dev/null +++ b/packages/upnp-nat/typedoc.json @@ -0,0 +1,5 @@ +{ + "entryPoints": [ + "./src/index.ts" + ] +}