UNPKG

@achingbrain/nat-port-mapper

Version:
350 lines 12.1 kB
import { createSocket } from 'dgram'; import { EventEmitter } from 'events'; import { isIPv4 } from '@chainsafe/is-ip'; import { logger } from '@libp2p/logger'; import errCode from 'err-code'; import defer from 'p-defer'; import { raceSignal } from 'race-signal'; import { DEFAULT_PORT_MAPPING_TTL, DEFAULT_REFRESH_THRESHOLD, DEFAULT_REFRESH_TIMEOUT } from '../upnp/constants.js'; import { findLocalAddresses } from '../upnp/utils.js'; import { isPrivateIp } from '../utils.js'; const log = logger('nat-port-mapper:pmp'); // Ports defined by draft const CLIENT_PORT = 5350; const SERVER_PORT = 5351; // Opcodes const OP_EXTERNAL_IP = 0; const OP_MAP_UDP = 1; const OP_MAP_TCP = 2; const SERVER_DELTA = 128; // Result codes const RESULT_CODES = { 0: 'Success', 1: 'Unsupported Version', 2: 'Not Authorized/Refused (gateway may have NAT-PMP disabled)', 3: 'Network Failure (gateway may have not obtained a DHCP lease)', 4: 'Out of Resources (no ports left)', 5: 'Unsupported opcode' }; export class PMPGateway extends EventEmitter { id; socket; queue; connecting; listening; req; reqActive; host; port; family; options; refreshIntervals; constructor(gateway, options = {}) { super(); this.queue = []; this.connecting = false; this.listening = false; this.req = null; this.reqActive = false; this.host = gateway; this.port = SERVER_PORT; this.family = isIPv4(gateway) ? 'IPv4' : 'IPv6'; this.id = this.host; this.options = options; this.refreshIntervals = new Map(); // Create socket this.socket = createSocket({ type: 'udp4', reuseAddr: true }); this.socket.on('listening', () => { this.onListening(); }); this.socket.on('message', (msg, rinfo) => { this.onMessage(msg, rinfo); }); this.socket.on('close', () => { this.onClose(); }); this.socket.on('error', (err) => { this.onError(err); }); // Try to connect this.connect(); } connect() { log('Client#connect()'); if (this.connecting) { return; } this.connecting = true; this.socket.bind(CLIENT_PORT); } async *mapAll(localPort, options = {}) { let mapped = false; for (const host of findLocalAddresses(this.family)) { try { const mapping = await this.map(localPort, host, options); mapped = true; yield mapping; } catch (err) { log.error('error mapping %s:%d - %e', host, localPort, err); } } if (!mapped) { throw new Error(`All attempts to map port ${localPort} failed`); } } async map(localPort, localHost, opts) { const options = { publicPort: opts?.externalPort ?? localPort, publicHost: opts?.remoteHost ?? '', localAddress: localHost, protocol: opts?.protocol ?? 'tcp', description: opts?.description ?? this.options.description ?? '@achingbrain/nat-port-mapper', ttl: opts?.ttl ?? this.options.ttl ?? DEFAULT_PORT_MAPPING_TTL, autoRefresh: opts?.autoRefresh ?? this.options.autoRefresh ?? true, refreshTimeout: opts?.refreshTimeout ?? this.options.refreshTimeout ?? DEFAULT_REFRESH_TIMEOUT, refreshBeforeExpiry: opts?.refreshThreshold ?? this.options.refreshThreshold ?? DEFAULT_REFRESH_THRESHOLD }; log('Client#portMapping()'); let opcode; switch (options.protocol.toLowerCase()) { case 'tcp': opcode = OP_MAP_TCP; break; case 'udp': opcode = OP_MAP_UDP; break; default: throw new Error('"type" must be either "tcp" or "udp"'); } const deferred = defer(); this.request(opcode, deferred, localPort, options); const result = await raceSignal(deferred.promise, opts?.signal); if (options.autoRefresh) { const refresh = ((localPort, opts = {}) => { this.map(localPort, localHost, { ...opts, signal: AbortSignal.timeout(options.refreshTimeout) }) .catch(err => { log.error('could not refresh port mapping - %e', err); }); }).bind(this, localPort, { ...options, signal: undefined }); this.refreshIntervals.set(localPort, setTimeout(refresh, options.ttl - options.refreshBeforeExpiry)); } return { externalHost: isPrivateIp(localHost) === true ? await this.externalIp(opts) : localHost, externalPort: result.public, internalHost: localHost, internalPort: result.private, protocol: result.type }; } async unmap(localPort, opts) { log('Client#portUnmapping()'); await this.map(localPort, '', { ...opts, description: '', ttl: 0 }); } async externalIp(options) { log('Client#externalIp()'); const deferred = defer(); this.request(OP_EXTERNAL_IP, deferred); const result = await raceSignal(deferred.promise, options?.signal); return result.ip.join('.'); } async stop(options) { log('Client#close()'); this.queue = []; this.connecting = false; this.listening = false; this.req = null; this.reqActive = false; await Promise.all([...this.refreshIntervals.entries()].map(async ([port, timeout]) => { clearTimeout(timeout); await this.unmap(port, options); })); this.refreshIntervals.clear(); if (this.socket != null) { this.socket.close(); } } request(op, deferred, localPort, obj) { log('Client#request()', [op, obj]); let buf; let size; let pos = 0; let ttl; switch (op) { case OP_MAP_UDP: case OP_MAP_TCP: if (obj == null) { throw new Error('mapping a port requires an "options" object'); } ttl = Number(obj.ttl ?? this.options.ttl ?? 0); if (ttl !== (ttl | 0)) { // The RECOMMENDED Port Mapping Lifetime is 7200 seconds (two hours) ttl = 7200; } size = 12; buf = Buffer.alloc(size); buf.writeUInt8(0, pos); pos++; // Vers = 0 buf.writeUInt8(op, pos); pos++; // OP = x buf.writeUInt16BE(0, pos); pos += 2; // Reserved (MUST be zero) buf.writeUInt16BE(localPort, pos); pos += 2; // Internal Port buf.writeUInt16BE(obj.externalPort ?? localPort, pos); pos += 2; // Requested External Port buf.writeUInt32BE(ttl, pos); pos += 4; // Requested Port Mapping Lifetime in Seconds break; case OP_EXTERNAL_IP: size = 2; buf = Buffer.alloc(size); // Vers = 0 buf.writeUInt8(0, 0); pos++; // OP = x buf.writeUInt8(op, 1); pos++; break; default: throw new Error(`Invalid opcode: ${op}`); } // assert.equal(pos, size, 'buffer not fully written!') // Add it to queue this.queue.push({ op, buf, deferred }); // Try to send next message this._next(); } /** * Processes the next request if the socket is listening. */ _next() { log('Client#_next()'); const req = this.queue[0]; if (req == null) { log('_next: nothing to process'); return; } if (this.socket == null) { log('_next: client is closed'); return; } if (!this.listening) { log('_next: not "listening" yet, cannot send out request yet'); if (!this.connecting) { this.connect(); } return; } if (this.reqActive) { log('_next: already an active request so wait...'); return; } this.reqActive = true; this.req = req; const buf = req.buf; log('_next: sending request', buf, this.host); this.socket.send(buf, 0, buf.length, SERVER_PORT, this.host); } onListening() { log('Client#onListening()'); this.listening = true; this.connecting = false; // Try to send next message this._next(); } onMessage(msg, rinfo) { // Ignore message if we're not expecting it if (this.queue.length === 0) { return; } log('Client#onMessage()', [msg, rinfo]); const cb = (err, parsed) => { this.req = null; this.reqActive = false; if (err != null) { if (req.deferred != null) { req.deferred.reject(err); } else { this.emit('error', err); } } else if (req.deferred != null) { req.deferred.resolve(parsed); } // Try to send next message this._next(); }; const req = this.queue[0]; const parsed = { msg }; parsed.vers = msg.readUInt8(0); parsed.op = msg.readUInt8(1); if (parsed.op - SERVER_DELTA !== req.op) { log('WARN: ignoring unexpected message opcode', parsed.op); return; } // if we got here, then we're gonna invoke the request's callback, // so shift this request off of the queue. log('removing "req" off of the queue'); this.queue.shift(); if (parsed.vers !== 0) { cb(new Error(`"vers" must be 0. Got: ${parsed.vers}`)); return; } // Common fields parsed.resultCode = msg.readUInt16BE(2); parsed.resultMessage = RESULT_CODES[parsed.resultCode]; parsed.epoch = msg.readUInt32BE(4); // Error if (parsed.resultCode !== 0) { cb(errCode(new Error(parsed.resultMessage), parsed.resultCode)); return; } // Success switch (req.op) { case OP_MAP_UDP: case OP_MAP_TCP: parsed.private = parsed.internal = msg.readUInt16BE(8); parsed.public = parsed.external = msg.readUInt16BE(10); parsed.ttl = msg.readUInt32BE(12); parsed.type = (req.op === OP_MAP_UDP) ? 'UDP' : 'TCP'; break; case OP_EXTERNAL_IP: parsed.ip = []; parsed.ip.push(msg.readUInt8(8)); parsed.ip.push(msg.readUInt8(9)); parsed.ip.push(msg.readUInt8(10)); parsed.ip.push(msg.readUInt8(11)); break; default: { cb(new Error(`Unknown opcode: ${req.op}`)); return; } } cb(undefined, parsed); } onClose() { log('Client#onClose()'); this.listening = false; this.connecting = false; } onError(err) { log('Client#onError()', [err]); if (this.req?.cb != null) { this.req.cb(err); } else { this.emit('error', err); } if (this.socket != null) { this.socket.close(); // Force close - close() does not guarantee to trigger onClose() this.onClose(); } } } //# sourceMappingURL=gateway.js.map