@achingbrain/nat-port-mapper
Version:
Port mapping with UPnP and NAT-PMP
350 lines • 12.1 kB
JavaScript
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