diff --git a/.gitignore b/.gitignore index 1ca9571..40b878d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ -node_modules/ -npm-debug.log +node_modules/ \ No newline at end of file diff --git a/README.md b/README.md index 936d229..7214889 100644 --- a/README.md +++ b/README.md @@ -2,33 +2,50 @@ Port mapping via UPnP APIs +## Installation + +```bash +npm i git+https://github.com/kaden-sharpin/node-nat-upnp.git +``` + ## Usage ```javascript -var natUpnp = require('nat-upnp'); +// using ES modules +import { Client } from "nat-upnp"; +const client = new Client(); -var client = natUpnp.createClient(); +// using node require +const natUpnp = require("nat-upnp"); +const client = new natUpnp.Client(); -client.portMapping({ - public: 12345, - private: 54321, - ttl: 10 -}, function(err) { - // Will be called once finished -}); +client + .createMapping({ + public: 12345, + private: 54321, + ttl: 10, + }) + .then(() => { + // Will be called once finished + }) + .catch(() => { + // Will be called on error + }); -client.portUnmapping({ - public: 12345 -}); +async () => { + await client.removeMapping({ + public: 12345, + }); +}; -client.getMappings(function(err, results) { -}); +client.getMappings(); -client.getMappings({ local: true }, function(err, results) { +client.getMappings({ + local: true, + description: "both of these fields are optional", }); -client.externalIp(function(err, ip) { -}); +client.getPublicIp(); ``` ### License diff --git a/build/src/index.d.ts b/build/src/index.d.ts new file mode 100644 index 0000000..8cebe0a --- /dev/null +++ b/build/src/index.d.ts @@ -0,0 +1,27 @@ +import { Device as impDevice } from "./nat-upnp/device"; +import { Client as impClient } from "./nat-upnp/client"; +import { Ssdp as impSsdp } from "./nat-upnp/ssdp"; +declare namespace natupnp { + const Ssdp: typeof impSsdp; + const Device: typeof impDevice; + const Client: typeof impClient; +} +export { Device } from "./nat-upnp/device"; +export type { Service, RawService, RawDevice } from "./nat-upnp/device"; +export { Ssdp } from "./nat-upnp/ssdp"; +export type { SearchCallback, ISsdp, SsdpEmitter } from "./nat-upnp/ssdp"; +export { Client } from "./nat-upnp/client"; +export type { GetMappingOpts, Mapping, DeletePortMappingOpts, NewPortMappingOpts, StandardOpts, } from "./nat-upnp/client"; +export default natupnp; +/** + * Raw SSDP/UPNP repsonse + * Entire SSDP/UPNP schema is beyond the scope of these typings. + * Please look up the protol documentation if you wanna do + * lower level communication. + */ +export declare type RawResponse = Partial>; diff --git a/build/src/index.js b/build/src/index.js new file mode 100644 index 0000000..d69ef30 --- /dev/null +++ b/build/src/index.js @@ -0,0 +1,19 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Client = exports.Ssdp = exports.Device = void 0; +const device_1 = require("./nat-upnp/device"); +const client_1 = require("./nat-upnp/client"); +const ssdp_1 = require("./nat-upnp/ssdp"); +var natupnp; +(function (natupnp) { + natupnp.Ssdp = ssdp_1.Ssdp; + natupnp.Device = device_1.Device; + natupnp.Client = client_1.Client; +})(natupnp || (natupnp = {})); +var device_2 = require("./nat-upnp/device"); +Object.defineProperty(exports, "Device", { enumerable: true, get: function () { return device_2.Device; } }); +var ssdp_2 = require("./nat-upnp/ssdp"); +Object.defineProperty(exports, "Ssdp", { enumerable: true, get: function () { return ssdp_2.Ssdp; } }); +var client_2 = require("./nat-upnp/client"); +Object.defineProperty(exports, "Client", { enumerable: true, get: function () { return client_2.Client; } }); +exports.default = natupnp; diff --git a/build/src/nat-upnp/client.d.ts b/build/src/nat-upnp/client.d.ts new file mode 100644 index 0000000..ecd7619 --- /dev/null +++ b/build/src/nat-upnp/client.d.ts @@ -0,0 +1,93 @@ +import { RawResponse } from "../index"; +import Device from "./device"; +import Ssdp from "./ssdp"; +export declare class Client implements IClient { + readonly timeout: number; + readonly ssdp: Ssdp; + constructor(options?: { + timeout?: number; + }); + createMapping(options: NewPortMappingOpts): Promise; + removeMapping(options: DeletePortMappingOpts): Promise; + getMappings(options?: GetMappingOpts): Promise; + getPublicIp(): Promise; + getGateway(): Promise<{ + gateway: Device; + address: string; + }>; + close(): void; +} +export default Client; +export interface Mapping { + public: { + host: string; + port: number; + }; + private: { + host: string; + port: number; + }; + protocol: string; + enabled: boolean; + description: string; + ttl: number; + local: boolean; +} +/** + * Standard options that many options use. + */ +export interface StandardOpts { + public?: number | { + port?: number; + host?: string; + }; + private?: number | { + port?: number; + host?: string; + }; + protocol?: string; +} +export interface NewPortMappingOpts extends StandardOpts { + description?: string; + ttl?: number; +} +export declare type DeletePortMappingOpts = StandardOpts; +export interface GetMappingOpts { + local?: boolean; + description?: RegExp | string; +} +/** + * Main client interface. + */ +export interface IClient { + /** + * Create a new port mapping + * @param options Options for the new port mapping + */ + createMapping(options: NewPortMappingOpts): Promise; + /** + * Remove a port mapping + * @param options Specify which port mapping to remove + */ + removeMapping(options: DeletePortMappingOpts): Promise; + /** + * Get a list of existing mappings + * @param options Filter mappings based on these options + */ + getMappings(options?: GetMappingOpts): Promise; + /** + * Fetch the external/public IP from the gateway + */ + getPublicIp(): Promise; + /** + * Get the gateway device for communication + */ + getGateway(): Promise<{ + gateway: Device; + address: string; + }>; + /** + * Close the underlaying sockets and resources + */ + close(): void; +} diff --git a/build/src/nat-upnp/client.js b/build/src/nat-upnp/client.js new file mode 100644 index 0000000..5a2163d --- /dev/null +++ b/build/src/nat-upnp/client.js @@ -0,0 +1,170 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Client = void 0; +const device_1 = __importDefault(require("./device")); +const ssdp_1 = __importDefault(require("./ssdp")); +class Client { + constructor(options = {}) { + this.ssdp = new ssdp_1.default(); + this.timeout = options.timeout || 1800; + } + createMapping(options) { + return __awaiter(this, void 0, void 0, function* () { + return this.getGateway().then(({ gateway, address }) => { + var _a; + const ports = normalizeOptions(options); + return gateway.run("AddPortMapping", [ + ["NewRemoteHost", ports.remote.host + ""], + ["NewExternalPort", ports.remote.port + ""], + [ + "NewProtocol", + options.protocol ? options.protocol.toUpperCase() : "TCP", + ], + ["NewInternalPort", ports.internal.port + ""], + ["NewInternalClient", ports.internal.host || address], + ["NewEnabled", 1], + ["NewPortMappingDescription", options.description || "node:nat:upnp"], + ["NewLeaseDuration", (_a = options.ttl) !== null && _a !== void 0 ? _a : 60 * 30], + ]); + }); + }); + } + removeMapping(options) { + return __awaiter(this, void 0, void 0, function* () { + return this.getGateway().then(({ gateway }) => { + const ports = normalizeOptions(options); + return gateway.run("DeletePortMapping", [ + ["NewRemoteHost", ports.remote.host + ""], + ["NewExternalPort", ports.remote.port + ""], + [ + "NewProtocol", + options.protocol ? options.protocol.toUpperCase() : "TCP", + ], + ]); + }); + }); + } + getMappings(options = {}) { + return __awaiter(this, void 0, void 0, function* () { + const { gateway, address } = yield this.getGateway(); + let i = 0; + let end = false; + const results = []; + while (true) { + const data = (yield gateway + .run("GetGenericPortMappingEntry", [["NewPortMappingIndex", i++]]) + .catch((err) => { + if (i !== 1) { + end = true; + } + })); + if (end) + break; + const key = Object.keys(data || {}).find((k) => /^GetGenericPortMappingEntryResponse/.test(k)); + if (!key) { + throw new Error("Incorrect response"); + } + const res = data[key]; + const result = { + public: { + host: (typeof res.NewRemoteHost === "string" && res.NewRemoteHost) || "", + port: parseInt(res.NewExternalPort, 10), + }, + private: { + host: res.NewInternalClient, + port: parseInt(res.NewInternalPort, 10), + }, + protocol: res.NewProtocol.toLowerCase(), + enabled: res.NewEnabled === "1", + description: res.NewPortMappingDescription, + ttl: parseInt(res.NewLeaseDuration, 10), + // temporary, so typescript will compile + local: false, + }; + result.local = result.private.host === address; + if (options.local && !result.local) { + continue; + } + if (options.description) { + if (typeof result.description !== "string") + continue; + if (options.description instanceof RegExp) { + if (!options.description.test(result.description)) + continue; + } + else { + if (result.description.indexOf(options.description) === -1) + continue; + } + } + results.push(result); + } + return results; + }); + } + getPublicIp() { + return __awaiter(this, void 0, void 0, function* () { + return this.getGateway().then(({ gateway, address }) => __awaiter(this, void 0, void 0, function* () { + var _a; + const data = yield gateway.run("GetExternalIPAddress", []); + const key = Object.keys(data || {}).find((k) => /^GetExternalIPAddressResponse$/.test(k)); + if (!key) + throw new Error("Incorrect response"); + return ((_a = data[key]) === null || _a === void 0 ? void 0 : _a.NewExternalIPAddress) + ""; + })); + }); + } + getGateway() { + return __awaiter(this, void 0, void 0, function* () { + let timeouted = false; + const p = this.ssdp.search("urn:schemas-upnp-org:device:InternetGatewayDevice:1"); + return new Promise((s, r) => { + const timeout = setTimeout(() => { + timeouted = true; + p.emit("end"); + r(new Error("Connection timed out while searching for the gateway.")); + }, this.timeout); + p.on("device", (info, address) => { + if (timeouted) + return; + p.emit("end"); + clearTimeout(timeout); + // Create gateway + s({ gateway: new device_1.default(info.location), address }); + }); + }); + }); + } + close() { + this.ssdp.close(); + } +} +exports.Client = Client; +function normalizeOptions(options) { + function toObject(addr) { + if (typeof addr === "number") + return { port: addr }; + if (typeof addr === "string" && !isNaN(addr)) + return { port: Number(addr) }; + if (typeof addr === "object") + return addr; + return {}; + } + return { + remote: toObject(options.public), + internal: toObject(options.private), + }; +} +exports.default = Client; diff --git a/build/src/nat-upnp/device.d.ts b/build/src/nat-upnp/device.d.ts new file mode 100644 index 0000000..b8d2073 --- /dev/null +++ b/build/src/nat-upnp/device.d.ts @@ -0,0 +1,77 @@ +import { RawResponse } from "../index"; +export declare class Device implements IDevice { + readonly description: string; + readonly services: string[]; + constructor(url: string); + private getXML; + getService(types: string[]): Promise<{ + service: string; + SCPDURL: string; + controlURL: string; + }>; + run(action: string, args: (string | number)[][]): Promise; + parseDescription(info: { + device?: RawDevice; + }): { + services: RawService[]; + devices: RawDevice[]; + }; +} +export default Device; +export interface Service { + service: string; + SCPDURL: string; + controlURL: string; +} +export interface RawService { + serviceType: string; + serviceId: string; + controlURL?: string; + eventSubURL?: string; + SCPDURL?: string; +} +export interface RawDevice { + deviceType: string; + presentationURL: string; + friendlyName: string; + manufacturer: string; + manufacturerURL: string; + modelDescription: string; + modelName: string; + modelNumber: string; + modelURL: string; + serialNumber: string; + UDN: string; + UPC: string; + serviceList?: { + service: RawService | RawService[]; + }; + deviceList?: { + device: RawDevice | RawDevice[]; + }; +} +export interface IDevice { + /** + * Get the available services on the network device + * @param types List of service types to look for + */ + getService(types: string[]): Promise; + /** + * Parse out available services + * and devices from a root device + * @param info + * @returns the available devices and services in array form + */ + parseDescription(info: { + device?: RawDevice; + }): { + services: RawService[]; + devices: RawDevice[]; + }; + /** + * Perform a SSDP/UPNP request + * @param action the action to perform + * @param kvpairs arguments of said action + */ + run(action: string, kvpairs: (string | number)[][]): Promise; +} diff --git a/build/src/nat-upnp/device.js b/build/src/nat-upnp/device.js new file mode 100644 index 0000000..2b92c7c --- /dev/null +++ b/build/src/nat-upnp/device.js @@ -0,0 +1,117 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Device = void 0; +const axios_1 = __importDefault(require("axios")); +const url_1 = require("url"); +const fast_xml_parser_1 = require("fast-xml-parser"); +class Device { + constructor(url) { + this.description = url; + this.services = [ + "urn:schemas-upnp-org:service:WANIPConnection:1", + "urn:schemas-upnp-org:service:WANIPConnection:2", + "urn:schemas-upnp-org:service:WANPPPConnection:1", + ]; + } + getXML(url) { + return __awaiter(this, void 0, void 0, function* () { + return axios_1.default + .get(url) + .then(({ data }) => new fast_xml_parser_1.XMLParser().parse(data)) + .catch(() => new Error("Failed to lookup device description")); + }); + } + getService(types) { + return __awaiter(this, void 0, void 0, function* () { + return this.getXML(this.description).then(({ root: xml }) => { + const services = this.parseDescription(xml).services.filter(({ serviceType }) => types.includes(serviceType)); + if (services.length === 0 || + !services[0].controlURL || + !services[0].SCPDURL) { + throw new Error("Service not found"); + } + const baseUrl = new url_1.URL(xml.baseURL, this.description); + const prefix = (url) => new url_1.URL(url, baseUrl.toString()).toString(); + return { + service: services[0].serviceType, + SCPDURL: prefix(services[0].SCPDURL), + controlURL: prefix(services[0].controlURL), + }; + }); + }); + } + run(action, args) { + return __awaiter(this, void 0, void 0, function* () { + const info = yield this.getService(this.services); + const body = '' + + "' + + "" + + "" + + args.reduce((p, [a, b]) => p + `<${a !== null && a !== void 0 ? a : ""}>${b !== null && b !== void 0 ? b : ""}`, "") + + "" + + "" + + ""; + return axios_1.default + .post(info.controlURL, body, { + headers: { + "Content-Type": 'text/xml; charset="utf-8"', + "Content-Length": "" + Buffer.byteLength(body), + Connection: "close", + SOAPAction: JSON.stringify(info.service + "#" + action), + }, + }) + .then(({ data }) => new fast_xml_parser_1.XMLParser({ removeNSPrefix: true }).parse(data).Envelope.Body); + }); + } + parseDescription(info) { + const services = []; + const devices = []; + function traverseDevices(device) { + var _a, _b, _c, _d; + if (!device) + return; + const serviceList = (_b = (_a = device.serviceList) === null || _a === void 0 ? void 0 : _a.service) !== null && _b !== void 0 ? _b : []; + const deviceList = (_d = (_c = device.deviceList) === null || _c === void 0 ? void 0 : _c.device) !== null && _d !== void 0 ? _d : []; + devices.push(device); + if (Array.isArray(serviceList)) { + services.push(...serviceList); + } + else { + services.push(serviceList); + } + if (Array.isArray(deviceList)) { + deviceList.forEach(traverseDevices); + } + else { + traverseDevices(deviceList); + } + } + traverseDevices(info.device); + return { + services, + devices, + }; + } +} +exports.Device = Device; +exports.default = Device; diff --git a/build/src/nat-upnp/ssdp.d.ts b/build/src/nat-upnp/ssdp.d.ts new file mode 100644 index 0000000..2e90341 --- /dev/null +++ b/build/src/nat-upnp/ssdp.d.ts @@ -0,0 +1,49 @@ +/// +import EventEmitter from "events"; +export declare class Ssdp implements ISsdp { + private options?; + private sourcePort; + private bound; + private boundCount; + private closed; + private readonly queue; + private readonly multicast; + private readonly port; + private readonly sockets; + private readonly ssdpEmitter; + constructor(options?: { + sourcePort?: number | undefined; + } | undefined); + private createSocket; + private parseResponse; + search(device: string, emitter?: SsdpEmitter): SsdpEmitter; + close(): void; +} +export default Ssdp; +declare type SearchArgs = [Record, string]; +export declare type SearchCallback = (...args: SearchArgs) => void; +declare type SearchEvent = (ev: E, ...args: E extends "device" ? SearchArgs : []) => boolean; +declare type Events = "device" | "end"; +declare type Event = E extends "device" ? SearchCallback : () => void; +declare type EventListener = (ev: E, callback: Event) => T; +export interface SsdpEmitter extends EventEmitter { + removeListener: EventListener; + addListener: EventListener; + once: EventListener; + on: EventListener; + emit: SearchEvent; + _ended?: boolean; +} +export interface ISsdp { + /** + * Search for a SSDP compatible server on the network + * @param device Search Type (ST) header, specifying which device to search for + * @param emitter An existing EventEmitter to emit event on + * @returns The event emitter provided in Promise, or a newly instantiated one. + */ + search(device: string, emitter?: SsdpEmitter): SsdpEmitter; + /** + * Close all sockets + */ + close(): void; +} diff --git a/build/src/nat-upnp/ssdp.js b/build/src/nat-upnp/ssdp.js new file mode 100644 index 0000000..40bce5c --- /dev/null +++ b/build/src/nat-upnp/ssdp.js @@ -0,0 +1,127 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Ssdp = void 0; +const dgram_1 = __importDefault(require("dgram")); +const os_1 = __importDefault(require("os")); +const events_1 = __importDefault(require("events")); +class Ssdp { + constructor(options) { + var _a; + this.options = options; + this.sourcePort = ((_a = this.options) === null || _a === void 0 ? void 0 : _a.sourcePort) || 0; + this.bound = false; + this.boundCount = 0; + this.closed = false; + this.queue = []; + this.multicast = "239.255.255.250"; + this.port = 1900; + this.ssdpEmitter = new events_1.default(); + // Create sockets on all external interfaces + const interfaces = os_1.default.networkInterfaces(); + this.sockets = Object.keys(interfaces).reduce((arr, key) => { + var _a, _b; + return arr.concat((_b = (_a = interfaces[key]) === null || _a === void 0 ? void 0 : _a.filter((item) => !item.internal).map((item) => this.createSocket(item))) !== null && _b !== void 0 ? _b : []); + }, []); + } + createSocket(iface) { + const socket = dgram_1.default.createSocket(iface.family === "IPv4" ? "udp4" : "udp6"); + socket.on("message", (message) => { + // Ignore messages after closing sockets + if (this.closed) + return; + // Parse response + this.parseResponse(message.toString(), socket.address); + }); + // Bind in next tick (sockets should be me in this.sockets array) + process.nextTick(() => { + // Unqueue this._queue once all sockets are ready + const onready = () => { + if (this.boundCount < this.sockets.length) + return; + this.bound = true; + this.queue.forEach(([device, emitter]) => this.search(device, emitter)); + }; + socket.on("listening", () => { + this.boundCount += 1; + onready(); + }); + // On error - remove socket from list and execute items from queue + socket.once("error", () => { + socket.close(); + this.sockets.splice(this.sockets.indexOf(socket), 1); + onready(); + }); + socket.address = iface.address; + socket.bind(this.sourcePort, iface.address); + }); + return socket; + } + parseResponse(response, addr) { + // Ignore incorrect packets + if (!/^(HTTP|NOTIFY)/m.test(response)) + return; + const headers = parseMimeHeader(response); + // We are only interested in messages that can be matched against the original + // search target + if (!headers.st) + return; + this.ssdpEmitter.emit("device", headers, addr); + } + search(device, emitter) { + if (!emitter) { + emitter = new events_1.default(); + emitter._ended = false; + emitter.once("end", () => { + emitter._ended = true; + }); + } + if (!this.bound) { + this.queue.push([device, emitter]); + return emitter; + } + const query = Buffer.from("M-SEARCH * HTTP/1.1\r\n" + + "HOST: " + + this.multicast + + ":" + + this.port + + "\r\n" + + 'MAN: "ssdp:discover"\r\n' + + "MX: 1\r\n" + + "ST: " + + device + + "\r\n" + + "\r\n"); + // Send query on each socket + this.sockets.forEach((socket) => socket.send(query, 0, query.length, this.port, this.multicast)); + const ondevice = (headers, address) => { + if (!emitter || emitter._ended || headers.st !== device) + return; + emitter.emit("device", headers, address); + }; + this.ssdpEmitter.on("device", ondevice); + // Detach listener after receiving 'end' event + emitter.once("end", () => this.ssdpEmitter.removeListener("device", ondevice)); + return emitter; + } + close() { + this.sockets.forEach((socket) => socket.close()); + this.closed = true; + } +} +exports.Ssdp = Ssdp; +function parseMimeHeader(headerStr) { + const lines = headerStr.split(/\r\n/g); + // Parse headers from lines to hashmap + return lines.reduce((headers, line) => { + var _a; + const [_, key, value] = (_a = line.match(/^([^:]*)\s*:\s*(.*)$/)) !== null && _a !== void 0 ? _a : []; + if (key && value) { + headers[key.toLowerCase()] = value; + } + return headers; + }, {}); +} +exports.default = Ssdp; diff --git a/build/src/nat-upnp/utils.d.ts b/build/src/nat-upnp/utils.d.ts new file mode 100644 index 0000000..6518096 --- /dev/null +++ b/build/src/nat-upnp/utils.d.ts @@ -0,0 +1,7 @@ +export declare function getNamespace(data: { + "@"?: Record; +}, uri: string): string; +declare const _default: { + getNamespace: typeof getNamespace; +}; +export default _default; diff --git a/build/src/nat-upnp/utils.js b/build/src/nat-upnp/utils.js new file mode 100644 index 0000000..ced6d53 --- /dev/null +++ b/build/src/nat-upnp/utils.js @@ -0,0 +1,17 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getNamespace = void 0; +function getNamespace(data, uri) { + var _a; + const keys = Object.keys((_a = data["@"]) !== null && _a !== void 0 ? _a : {}); + for (let x = 0; x < keys.length; x++) { + const key = keys[x]; + if (!/^xmlns:/.test(key) || data["@"][key] !== uri) { + continue; + } + return key.replace(/^xmlns:/, "") + ":"; + } + return ""; +} +exports.getNamespace = getNamespace; +exports.default = { getNamespace }; diff --git a/build/test/api.test.d.ts b/build/test/api.test.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/build/test/api.test.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/build/test/api.test.js b/build/test/api.test.js new file mode 100644 index 0000000..75c97c2 --- /dev/null +++ b/build/test/api.test.js @@ -0,0 +1,60 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const net_1 = __importDefault(require("net")); +const index_test_1 = require("./index.test"); +const src_1 = require("../src"); +(0, index_test_1.setupTest)("NAT-UPNP/Client", (opts) => { + let client; + opts.runBefore(() => { + client = new src_1.Client(); + }); + opts.runAfter(() => { + client.close(); + }); + opts.run("Port mapping/unmapping", () => __awaiter(void 0, void 0, void 0, function* () { + // Random port between 2000 and 65536 to avoid blockages + const publicPort = ~~(Math.random() * 63536 + 2000); + yield client.createMapping({ + public: publicPort, + private: ~~(Math.random() * 65536), + ttl: 0, + }); + yield client.removeMapping({ public: publicPort }); + return true; + })); + opts.run("Find port after mapping", () => __awaiter(void 0, void 0, void 0, function* () { + // Random port between 2000 and 65536 to avoid blockages + const publicPort = ~~(Math.random() * 63536 + 2000); + yield client.createMapping({ + public: publicPort, + private: ~~(Math.random() * 65536), + description: "node:nat:upnp:search-test", + ttl: 20, + }); + const mappings = yield client.getMappings({ + local: true, + description: /search-test/, + }); + if (!mappings.some((mapping) => mapping.public.port === publicPort)) { + return false; + } + yield client.removeMapping({ public: { port: publicPort } }); + return true; + })); + opts.run("Get public ip address", () => __awaiter(void 0, void 0, void 0, function* () { + const ip = yield client.getPublicIp(); + return net_1.default.isIP(ip) !== 0; + })); +}); diff --git a/build/test/index.d.ts b/build/test/index.d.ts new file mode 100644 index 0000000..535dccd --- /dev/null +++ b/build/test/index.d.ts @@ -0,0 +1,14 @@ +import "./api.test"; +export declare function setupTest(testName: string, callback: (options: TestOptions) => void): void; +export declare class TestOptions { + testCount: number; + readonly tests: [string, () => Promise | boolean][]; + private isRunning; + private runBeforeCallback; + private runAfterCallback; + runBefore(callback: (() => void) | null): void; + runAfter(callback: (() => void) | null): void; + run(desc: string, callback: () => Promise | boolean): void; + startTests(): Promise; + get isTestRunning(): boolean; +} diff --git a/build/test/index.js b/build/test/index.js new file mode 100644 index 0000000..d7fa5f7 --- /dev/null +++ b/build/test/index.js @@ -0,0 +1,72 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.TestOptions = exports.setupTest = void 0; +require("./api.test"); +function setupTest(testName, callback) { + const testOptions = new TestOptions(); + callback(testOptions); + console.log(testName); + testOptions.startTests(); +} +exports.setupTest = setupTest; +class TestOptions { + constructor() { + this.testCount = 5; + this.tests = []; + this.isRunning = false; + this.runBeforeCallback = null; + this.runAfterCallback = null; + } + runBefore(callback) { + this.runBeforeCallback = callback; + } + runAfter(callback) { + this.runAfterCallback = callback; + } + run(desc, callback) { + this.tests.push([desc, callback]); + } + startTests() { + var _a, _b; + return __awaiter(this, void 0, void 0, function* () { + if (this.isRunning) + return; + this.isRunning = true; + const testCount = this.testCount; + const tests = [...this.tests]; + const runBefore = (_a = this.runBeforeCallback) !== null && _a !== void 0 ? _a : (() => null); + const runAfter = (_b = this.runAfterCallback) !== null && _b !== void 0 ? _b : (() => null); + for (let x = 0; x < tests.length; x++) { + const [testName, run] = tests[x]; + const results = []; + console.log(testName); + for (let y = 0; y < testCount; y++) { + runBefore(); + results.push(yield run()); + runAfter(); + } + if (!results.some((el) => !el)) { + // success + console.log("success"); + } + else { + // failed + console.log("failed"); + } + } + }); + } + get isTestRunning() { + return this.isRunning; + } +} +exports.TestOptions = TestOptions; diff --git a/build/test/index.test.d.ts b/build/test/index.test.d.ts new file mode 100644 index 0000000..47a3111 --- /dev/null +++ b/build/test/index.test.d.ts @@ -0,0 +1,15 @@ +export declare function setupTest(testName: string, callback: (options: TestOptions) => void): void; +export declare class TestOptions { + testCount: number; + readonly tests: [string, () => Promise][]; + private isRunning; + private runBeforeCallback; + private runAfterCallback; + runBefore(callback: (() => void) | null): void; + runAfter(callback: (() => void) | null): void; + run(desc: string, callback: () => Promise): void; + startTests(): Promise; + get isTestRunning(): boolean; +} +import "./api.test"; +import "./ssdp.test"; diff --git a/build/test/index.test.js b/build/test/index.test.js new file mode 100644 index 0000000..745c823 --- /dev/null +++ b/build/test/index.test.js @@ -0,0 +1,115 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.TestOptions = exports.setupTest = void 0; +const queue = []; +let running = false; +function header(s) { + console.log("\n==========", s, "=========="); +} +function footer(n) { + const arr = []; + arr.length = n; + console.log("\n===========" + arr.fill("=").join("") + "===========\n"); +} +function runNextInQueue(prev) { + var _a; + return __awaiter(this, void 0, void 0, function* () { + footer(prev.length); + const [name, opts] = (_a = queue.shift()) !== null && _a !== void 0 ? _a : []; + if (!name || !opts) + return; + header(name); + opts.startTests().then(() => runNextInQueue(name)); + }); +} +function setupTest(testName, callback) { + const testOptions = new TestOptions(); + callback(testOptions); + if (running) { + queue.push([testName, testOptions]); + return; + } + running = true; + header(testName); + testOptions.startTests().then(() => runNextInQueue(testName)); +} +exports.setupTest = setupTest; +class TestOptions { + constructor() { + this.testCount = 5; + this.tests = []; + this.isRunning = false; + this.runBeforeCallback = null; + this.runAfterCallback = null; + } + runBefore(callback) { + this.runBeforeCallback = callback; + } + runAfter(callback) { + this.runAfterCallback = callback; + } + run(desc, callback) { + this.tests.push([desc, callback]); + } + startTests() { + var _a, _b; + return __awaiter(this, void 0, void 0, function* () { + if (this.isRunning) + return; + this.isRunning = true; + const testCount = this.testCount; + const tests = [...this.tests]; + const runBefore = (_a = this.runBeforeCallback) !== null && _a !== void 0 ? _a : (() => null); + const runAfter = (_b = this.runAfterCallback) !== null && _b !== void 0 ? _b : (() => null); + for (let x = 0; x < tests.length; x++) { + const [testName, run] = tests[x]; + const results = []; + const errors = []; + console.log("\n" + testName); + for (let y = 0; y < testCount; y++) { + runBefore(); + results.push(yield run() + .then((s) => { + if (s) { + console.log("Test #" + y + ":", "\x1b[32msuccess\x1b[0m"); + } + else { + console.log("Test #" + y + ":", "\x1b[31mfailed\x1b[0m"); + } + return s; + }) + .catch((err) => { + console.log("Test #" + y + ":", "\x1b[31mfailed\x1b[0m"); + errors.push(err); + return false; + })); + runAfter(); + } + if (!results.some((el) => !el)) { + // success + console.log("Testcase: \x1b[32msuccess\x1b[0m"); + } + else { + // failed + errors.forEach((err) => console.error(err)); + console.log("Testcase: \x1b[31mfailed with", errors.length, "errors\x1b[0m"); + } + } + }); + } + get isTestRunning() { + return this.isRunning; + } +} +exports.TestOptions = TestOptions; +require("./api.test"); +require("./ssdp.test"); diff --git a/build/test/ssdp.test.d.ts b/build/test/ssdp.test.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/build/test/ssdp.test.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/build/test/ssdp.test.js b/build/test/ssdp.test.js new file mode 100644 index 0000000..2ea25f3 --- /dev/null +++ b/build/test/ssdp.test.js @@ -0,0 +1,31 @@ +"use strict"; +var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const src_1 = require("../src"); +const index_test_1 = require("./index.test"); +(0, index_test_1.setupTest)("NAT-UPNP/Ssdp", (opts) => { + let client; + opts.runBefore(() => { + client = new src_1.Ssdp(); + }); + opts.runAfter(() => { + client.close(); + }); + opts.run("Find router device", () => __awaiter(void 0, void 0, void 0, function* () { + const p = client.search("urn:schemas-upnp-org:device:InternetGatewayDevice:1"); + return new Promise((s) => { + p.on("device", (device) => { + p.emit("end"); + s(typeof device.location === "string"); + }); + }); + })); +}); diff --git a/lib/nat-upnp.js b/lib/nat-upnp.js deleted file mode 100644 index 16e29f2..0000000 --- a/lib/nat-upnp.js +++ /dev/null @@ -1,6 +0,0 @@ -var nat = exports; - -nat.utils = require('./nat-upnp/utils'); -nat.ssdp = require('./nat-upnp/ssdp'); -nat.device = require('./nat-upnp/device'); -nat.createClient = require('./nat-upnp/client').create; diff --git a/lib/nat-upnp/client.js b/lib/nat-upnp/client.js deleted file mode 100644 index fdaf634..0000000 --- a/lib/nat-upnp/client.js +++ /dev/null @@ -1,202 +0,0 @@ -var nat = require('../nat-upnp'); -var async = require('async'); - -var client = exports; - -function Client() { - this.ssdp = nat.ssdp.create(); - this.timeout = 1800; -} - -client.create = function create() { - return new Client(); -}; - -function normalizeOptions(options) { - function toObject(addr) { - if (typeof addr === 'number') return { port: addr }; - if (typeof addr === 'string' && !isNaN(addr)) return { port: Number(addr) }; - if (typeof addr === 'object') return addr; - - return {}; - } - - return { - remote: toObject(options.public), - internal: toObject(options.private) - }; -} - -Client.prototype.portMapping = function portMapping(options, callback) { - if (!callback) callback = function() {}; - - this.findGateway(function(err, gateway, address) { - if (err) return callback(err); - - var ports = normalizeOptions(options); - var ttl = 60 * 30; - if (typeof options.ttl === 'number') { ttl = options.ttl; } - if (typeof options.ttl === 'string' && !isNaN(options.ttl)) { ttl = Number(options.ttl); } - - gateway.run('AddPortMapping', [ - [ 'NewRemoteHost', ports.remote.host ], - [ 'NewExternalPort', ports.remote.port ], - [ 'NewProtocol', options.protocol ? - options.protocol.toUpperCase() : 'TCP' ], - [ 'NewInternalPort', ports.internal.port ], - [ 'NewInternalClient', ports.internal.host || address ], - [ 'NewEnabled', 1 ], - [ 'NewPortMappingDescription', options.description || 'node:nat:upnp' ], - [ 'NewLeaseDuration', ttl ] - ], callback); - }); -}; - -Client.prototype.portUnmapping = function portMapping(options, callback) { - if (!callback) callback = function() {}; - - this.findGateway(function(err, gateway/*, address*/) { - if (err) return callback(err); - - var ports = normalizeOptions(options); - - gateway.run('DeletePortMapping', [ - [ 'NewRemoteHost', ports.remote.host ], - [ 'NewExternalPort', ports.remote.port ], - [ 'NewProtocol', options.protocol ? - options.protocol.toUpperCase() : 'TCP' ] - ], callback); - }); -}; - -Client.prototype.getMappings = function getMappings(options, callback) { - if (typeof options === 'function') { - callback = options; - options = null; - } - - if (!options) options = {}; - - this.findGateway(function(err, gateway, address) { - if (err) return callback(err); - var i = 0; - var end = false; - var results = []; - - async.whilst(function() { - return !end; - }, function(callback) { - gateway.run('GetGenericPortMappingEntry', [ - [ 'NewPortMappingIndex', i++ ] - ], function(err, data) { - if (err) { - // If we got an error on index 0, ignore it in case this router starts indicies on 1 - if (i !== 1) { - end = true; - } - return callback(null); - } - - var key; - Object.keys(data).some(function(k) { - if (!/:GetGenericPortMappingEntryResponse/.test(k)) return false; - - key = k; - return true; - }); - data = data[key]; - - var result = { - public: { - host: typeof data.NewRemoteHost === 'string' && - data.NewRemoteHost || '', - port: parseInt(data.NewExternalPort, 10) - }, - private: { - host: data.NewInternalClient, - port: parseInt(data.NewInternalPort, 10) - }, - protocol: data.NewProtocol.toLowerCase(), - enabled: data.NewEnabled === '1', - description: data.NewPortMappingDescription, - ttl: parseInt(data.NewLeaseDuration, 10) - }; - result.local = result.private.host === address; - - results.push(result); - - callback(null); - }); - }, function(err) { - if (err) return callback(err); - - if (options.local) { - results = results.filter(function(item) { - return item.local; - }); - } - - if (options.description) { - results = results.filter(function(item) { - if (typeof item.description !== 'string') - return; - - if (options.description instanceof RegExp) { - return item.description.match(options.description) !== null; - } else { - return item.description.indexOf(options.description) !== -1; - } - }); - } - - callback(null, results); - }); - }); -}; - -Client.prototype.externalIp = function externalIp(callback) { - this.findGateway(function(err, gateway/*, address*/) { - if (err) return callback(err); - gateway.run('GetExternalIPAddress', [], function(err, data) { - if (err) return callback(err); - var key; - - Object.keys(data).some(function(k) { - if (!/:GetExternalIPAddressResponse$/.test(k)) return false; - - key = k; - return true; - }); - - if (!key) return callback(Error('Incorrect response')); - callback(null, data[key].NewExternalIPAddress); - }); - }); -}; - -Client.prototype.findGateway = function findGateway(callback) { - var timeout; - var timeouted = false; - var p = this.ssdp.search( - 'urn:schemas-upnp-org:device:InternetGatewayDevice:1' - ); - - timeout = setTimeout(function() { - timeouted = true; - p.emit('end'); - callback(new Error('timeout')); - }, this.timeout); - - p.on('device', function (info, address) { - if (timeouted) return; - p.emit('end'); - clearTimeout(timeout); - - // Create gateway - callback(null, nat.device.create(info.location), address); - }); -}; - -Client.prototype.close = function close() { - this.ssdp.close(); -}; diff --git a/lib/nat-upnp/device.js b/lib/nat-upnp/device.js deleted file mode 100644 index 8a1b020..0000000 --- a/lib/nat-upnp/device.js +++ /dev/null @@ -1,162 +0,0 @@ -var nat = require('../nat-upnp'), - request = require('request'), - url = require('url'), - xml2js = require('xml2js'), - Buffer = require('buffer').Buffer; - -var device = exports; - -function Device(url) { - this.description = url; - this.services = [ - 'urn:schemas-upnp-org:service:WANIPConnection:1', - 'urn:schemas-upnp-org:service:WANPPPConnection:1' - ]; -}; - -device.create = function create(url) { - return new Device(url); -}; - -Device.prototype._getXml = function _getXml(url, callback) { - var once = false; - function respond(err, body) { - if (once) return; - once = true; - - callback(err, body); - } - - request(url, function(err, res, body) { - if (err) return callback(err); - - if (res.statusCode !== 200) { - respond(Error('Failed to lookup device description')); - return; - } - - var parser = new xml2js.Parser(); - parser.parseString(body, function(err, body) { - if (err) return respond(err); - - respond(null, body); - }); - }); -}; - -Device.prototype.getService= function getService(types, callback) { - var self = this; - - this._getXml(this.description, function(err, info) { - if (err) return callback(err); - - var s = self.parseDescription(info).services.filter(function(service) { - return types.indexOf(service.serviceType) !== -1; - }); - - if (s.length === 0 || !s[0].controlURL || !s[0].SCPDURL) { - return callback(Error('Service not found')); - } - - var base = url.parse(info.baseURL || self.description); - function prefix(u) { - var uri = url.parse(u); - - uri.host = uri.host || base.host; - uri.protocol = uri.protocol || base.protocol; - - return url.format(uri); - } - - callback(null,{ - service: s[0].serviceType, - SCPDURL: prefix(s[0].SCPDURL), - controlURL: prefix(s[0].controlURL) - }); - }); -}; - -Device.prototype.parseDescription = function parseDescription(info) { - var services = [], - devices = []; - - function toArray(item) { - return Array.isArray(item) ? item : [ item ]; - }; - - function traverseServices(service) { - if (!service) return; - services.push(service); - } - - function traverseDevices(device) { - if (!device) return; - devices.push(device); - - if (device.deviceList && device.deviceList.device) { - toArray(device.deviceList.device).forEach(traverseDevices); - } - - if (device.serviceList && device.serviceList.service) { - toArray(device.serviceList.service).forEach(traverseServices); - } - } - - traverseDevices(info.device); - - return { - services: services, - devices: devices - }; -}; - -Device.prototype.run = function run(action, args, callback) { - var self = this; - - this.getService(this.services, function(err, info) { - if (err) return callback(err); - - var body = '' + - '' + - '' + - '' + - args.map(function(args) { - return '<' + args[0]+ '>' + - (args[1] === undefined ? '' : args[1]) + - ''; - }).join('') + - '' + - '' + - ''; - - request({ - method: 'POST', - url: info.controlURL, - headers: { - 'Content-Type': 'text/xml; charset="utf-8"', - 'Content-Length': Buffer.byteLength(body), - 'Connection': 'close', - 'SOAPAction': JSON.stringify(info.service + '#' + action) - }, - body: body - }, function(err, res, body) { - if (err) return callback(err); - - var parser = new xml2js.Parser(); - parser.parseString(body, function(err, body) { - if (res.statusCode !== 200) { - return callback(Error('Request failed: ' + res.statusCode)); - } - - var soapns = nat.utils.getNamespace( - body, - 'http://schemas.xmlsoap.org/soap/envelope/'); - - callback(null, body[soapns + 'Body']); - }); - }); - }); -}; diff --git a/lib/nat-upnp/ssdp.js b/lib/nat-upnp/ssdp.js deleted file mode 100644 index 8475d65..0000000 --- a/lib/nat-upnp/ssdp.js +++ /dev/null @@ -1,162 +0,0 @@ -var dgram = require('dgram'); -var util = require('util'); -var os = require('os'); -var EventEmitter = require('events').EventEmitter; -var ssdp = exports; - -function Ssdp(opts) { - EventEmitter.call(this); - - this._opts = opts || {}; - this._sourcePort = this._opts.sourcePort || 0; - this.multicast = '239.255.255.250'; - this.port = 1900; - this._bound = false; - this._boundCount = 0; - this._closed = false; - this._queue = []; - - // Create sockets on all external interfaces - this.createSockets(); -} -util.inherits(Ssdp, EventEmitter); - -ssdp.create = function create() { - return new Ssdp(); -}; - -Ssdp.parseMimeHeader = function (headerStr) { - var lines = headerStr.split(/\r\n/g); - - // Parse headers from lines to hashmap - return lines.reduce(function(headers, line) { - line.replace(/^([^:]*)\s*:\s*(.*)$/, function (a, key, value) { - headers[key.toLowerCase()] = value; - }); - return headers; - }, {}); -}; - -Ssdp.prototype.createSockets = function createSockets() { - var self = this; - var interfaces = os.networkInterfaces(); - - this.sockets = Object.keys(interfaces).reduce(function(a, key) { - return a.concat(interfaces[key].filter(function(item) { - return !item.internal; - }).map(function(item) { - return self.createSocket(item); - })); - }, []); -}; - -Ssdp.prototype.search = function search(device, promise) { - if (!promise) { - promise = new EventEmitter(); - promise._ended = false; - promise.once('end', function() { - promise._ended = true; - }); - } - - if (!this._bound) { - this._queue.push({ action: 'search', device: device, promise: promise }); - return promise; - } - - // If promise was ended before binding - do not send queries - if (promise._ended) return; - - var self = this; - var query = new Buffer('M-SEARCH * HTTP/1.1\r\n' + - 'HOST: ' + this.multicast + ':' + this.port + '\r\n' + - 'MAN: "ssdp:discover"\r\n' + - 'MX: 1\r\n' + - 'ST: ' + device + '\r\n' + - '\r\n'); - - // Send query on each socket - this.sockets.forEach(function(socket) { - socket.send(query, 0, query.length, this.port, this.multicast); - }, this); - - function ondevice(info, address) { - if (promise._ended) return; - if (info.st !== device) return; - - promise.emit('device', info, address); - } - this.on('_device', ondevice); - - // Detach listener after receiving 'end' event - promise.once('end', function() { - self.removeListener('_device', ondevice); - }); - - return promise; -}; - -Ssdp.prototype.createSocket = function createSocket(interface) { - var self = this; - var socket = dgram.createSocket(interface.family === 'IPv4' ? - 'udp4' : 'udp6'); - - socket.on('message', function (message, info) { - // Ignore messages after closing sockets - if (self._closed) return; - - // Parse response - self.parseResponse(message.toString(), socket.address, info); - }); - - // Bind in next tick (sockets should be me in this.sockets array) - process.nextTick(function() { - // Unqueue this._queue once all sockets are ready - function onready() { - if (self._boundCount < self.sockets.length) return; - - self._bound = true; - self._queue.forEach(function(item) { - return self[item.action](item.device, item.promise); - }); - } - - socket.on('listening', function() { - self._boundCount += 1; - onready(); - }); - - // On error - remove socket from list and execute items from queue - socket.once('error', function() { - self.sockets.splice(self.sockets.indexOf(socket), 1); - onready(); - }); - - socket.address = interface.address; - socket.bind(self._sourcePort, interface.address); - }); - - return socket; -}; - -// TODO create separate logic for parsing unsolicited upnp broadcasts, -// if and when that need arises -Ssdp.prototype.parseResponse = function parseResponse(response, addr, remote) { - // Ignore incorrect packets - if (!/^(HTTP|NOTIFY)/m.test(response)) return; - - var headers = Ssdp.parseMimeHeader(response); - - // We are only interested in messages that can be matched against the original - // search target - if (!headers.st) return; - - this.emit('_device', headers, addr); -}; - -Ssdp.prototype.close = function close() { - this.sockets.forEach(function(socket) { - socket.close(); - }); - this._closed = true; -}; diff --git a/lib/nat-upnp/utils.js b/lib/nat-upnp/utils.js deleted file mode 100644 index 161cd3e..0000000 --- a/lib/nat-upnp/utils.js +++ /dev/null @@ -1,19 +0,0 @@ -var utils = exports; - -utils.getNamespace = function getNamespace(data, uri) { - var ns; - - if (data['@']) { - Object.keys(data['@']).some(function(key) { - if (!/^xmlns:/.test(key)) return; - if (data['@'][key] !== uri) { - return; - } - - ns = key.replace(/^xmlns:/, ''); - return true; - }); - } - - return ns ? ns + ':' : ''; -}; diff --git a/package.json b/package.json index f4ee130..70503a7 100644 --- a/package.json +++ b/package.json @@ -1,24 +1,24 @@ { "name": "nat-upnp", - "version": "1.1.1", - "main": "lib/nat-upnp", - "author": "Fedor Indutny ", - "homepage": "https://github.com/indutny/node-nat-upnp", + "version": "2.0.0", + "main": "build/src/index", + "author": "Fedor Indutny , SimplyLinn , Kaden Sharpin ", + "homepage": "https://github.com/kaden-sharpin/node-nat-upnp", "repository": { "type": "git", - "url": "http://github.com/indutny/node-nat-upnp.git" + "url": "http://github.com/kaden-sharpin/node-nat-upnp.git" }, "devDependencies": { - "jscs": "^1.11.2", - "mocha": "^3.2.0" + "@types/node": "^17.0.6", + "typescript": "^4.5.4" }, "scripts": { - "test": "jscs lib/*.js test/*.js && mocha --timeout 60000 --reporter spec test/*-test.js" + "build": "tsc", + "watch": "tsc --watch", + "test": "node ./build/test/index.test.js" }, "dependencies": { - "xml2js": "~0.1.14", - "request": "^2.79.0", - "async": "^2.1.5", - "ip": "^1.1.4" + "axios": "^0.24.0", + "fast-xml-parser": "^4.0.0-beta.8" } } diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..c0d5fab --- /dev/null +++ b/src/index.ts @@ -0,0 +1,48 @@ +import { Device as impDevice } from "./nat-upnp/device"; +import { Client as impClient } from "./nat-upnp/client"; +import { Ssdp as impSsdp } from "./nat-upnp/ssdp"; + +namespace natupnp { + export const Ssdp = impSsdp; + export const Device = impDevice; + export const Client = impClient; +} + +export { Device } from "./nat-upnp/device"; +export type { Service, RawService, RawDevice } from "./nat-upnp/device"; + +export { Ssdp } from "./nat-upnp/ssdp"; +export type { SearchCallback, ISsdp, SsdpEmitter } from "./nat-upnp/ssdp"; + +export { Client } from "./nat-upnp/client"; +export type { + GetMappingOpts, + Mapping, + DeletePortMappingOpts, + NewPortMappingOpts, + StandardOpts, +} from "./nat-upnp/client"; + +export default natupnp; + +/* + * =================== + * ====== Types ====== + * =================== + */ + +/** + * Raw SSDP/UPNP repsonse + * Entire SSDP/UPNP schema is beyond the scope of these typings. + * Please look up the protol documentation if you wanna do + * lower level communication. + */ +export type RawResponse = Partial< + Record< + string, + { + "@": { "xmlns:u": string }; + [key: string]: unknown; + } + > +>; diff --git a/src/nat-upnp/client.ts b/src/nat-upnp/client.ts new file mode 100644 index 0000000..38e4ace --- /dev/null +++ b/src/nat-upnp/client.ts @@ -0,0 +1,252 @@ +import { RawResponse } from "../index"; +import Device from "./device"; +import Ssdp from "./ssdp"; + +export class Client implements IClient { + readonly timeout: number; + readonly ssdp = new Ssdp(); + + constructor(options: { timeout?: number } = {}) { + this.timeout = options.timeout || 1800; + } + + public async createMapping( + options: NewPortMappingOpts + ): Promise { + return this.getGateway().then(({ gateway, address }) => { + const ports = normalizeOptions(options); + + return gateway.run("AddPortMapping", [ + ["NewRemoteHost", ports.remote.host + ""], + ["NewExternalPort", ports.remote.port + ""], + [ + "NewProtocol", + options.protocol ? options.protocol.toUpperCase() : "TCP", + ], + ["NewInternalPort", ports.internal.port + ""], + ["NewInternalClient", ports.internal.host || address], + ["NewEnabled", 1], + ["NewPortMappingDescription", options.description || "node:nat:upnp"], + ["NewLeaseDuration", options.ttl ?? 60 * 30], + ]); + }); + } + + public async removeMapping( + options: DeletePortMappingOpts + ): Promise { + return this.getGateway().then(({ gateway }) => { + const ports = normalizeOptions(options); + + return gateway.run("DeletePortMapping", [ + ["NewRemoteHost", ports.remote.host + ""], + ["NewExternalPort", ports.remote.port + ""], + [ + "NewProtocol", + options.protocol ? options.protocol.toUpperCase() : "TCP", + ], + ]); + }); + } + + public async getMappings(options: GetMappingOpts = {}) { + const { gateway, address } = await this.getGateway(); + let i = 0; + let end = false; + const results = []; + + while (true) { + const data = (await gateway + .run("GetGenericPortMappingEntry", [["NewPortMappingIndex", i++]]) + .catch((err) => { + if (i !== 1) { + end = true; + } + }))!; + + if (end) break; + + const key = Object.keys(data || {}).find((k) => + /^GetGenericPortMappingEntryResponse/.test(k) + ); + + if (!key) { + throw new Error("Incorrect response"); + } + + const res: any = data[key]; + + const result: Mapping = { + public: { + host: + (typeof res.NewRemoteHost === "string" && res.NewRemoteHost) || "", + port: parseInt(res.NewExternalPort, 10), + }, + private: { + host: res.NewInternalClient, + port: parseInt(res.NewInternalPort, 10), + }, + protocol: res.NewProtocol.toLowerCase(), + enabled: res.NewEnabled === "1", + description: res.NewPortMappingDescription, + ttl: parseInt(res.NewLeaseDuration, 10), + // temporary, so typescript will compile + local: false, + }; + result.local = result.private.host === address; + + if (options.local && !result.local) { + continue; + } + + if (options.description) { + if (typeof result.description !== "string") continue; + + if (options.description instanceof RegExp) { + if (!options.description.test(result.description)) continue; + } else { + if (result.description.indexOf(options.description) === -1) continue; + } + } + + results.push(result); + } + + return results; + } + + public async getPublicIp(): Promise { + return this.getGateway().then(async ({ gateway, address }) => { + const data = await gateway.run("GetExternalIPAddress", []); + + const key = Object.keys(data || {}).find((k) => + /^GetExternalIPAddressResponse$/.test(k) + ); + + if (!key) throw new Error("Incorrect response"); + return data[key]?.NewExternalIPAddress + ""; + }); + } + + public async getGateway() { + let timeouted = false; + const p = this.ssdp.search( + "urn:schemas-upnp-org:device:InternetGatewayDevice:1" + ); + + return new Promise<{ gateway: Device; address: string }>((s, r) => { + const timeout = setTimeout(() => { + timeouted = true; + p.emit("end"); + r(new Error("Connection timed out while searching for the gateway.")); + }, this.timeout); + p.on("device", (info, address) => { + if (timeouted) return; + p.emit("end"); + clearTimeout(timeout); + + // Create gateway + s({ gateway: new Device(info.location), address }); + }); + }); + } + + public close() { + this.ssdp.close(); + } +} + +function normalizeOptions(options: StandardOpts) { + function toObject(addr: StandardOpts["public"]) { + if (typeof addr === "number") return { port: addr }; + if (typeof addr === "string" && !isNaN(addr)) return { port: Number(addr) }; + if (typeof addr === "object") return addr; + + return {}; + } + + return { + remote: toObject(options.public), + internal: toObject(options.private), + }; +} + +export default Client; + +/* + * =================== + * ====== Types ====== + * =================== + */ + +export interface Mapping { + public: { host: string; port: number }; + private: { host: string; port: number }; + protocol: string; + enabled: boolean; + description: string; + ttl: number; + local: boolean; +} + +/** + * Standard options that many options use. + */ +export interface StandardOpts { + public?: + | number + | { + port?: number; + host?: string; + }; + private?: + | number + | { + port?: number; + host?: string; + }; + protocol?: string; +} + +export interface NewPortMappingOpts extends StandardOpts { + description?: string; + ttl?: number; +} +export type DeletePortMappingOpts = StandardOpts; +export interface GetMappingOpts { + local?: boolean; + description?: RegExp | string; +} + +/** + * Main client interface. + */ +export interface IClient { + /** + * Create a new port mapping + * @param options Options for the new port mapping + */ + createMapping(options: NewPortMappingOpts): Promise; + /** + * Remove a port mapping + * @param options Specify which port mapping to remove + */ + removeMapping(options: DeletePortMappingOpts): Promise; + /** + * Get a list of existing mappings + * @param options Filter mappings based on these options + */ + getMappings(options?: GetMappingOpts): Promise; + /** + * Fetch the external/public IP from the gateway + */ + getPublicIp(): Promise; + /** + * Get the gateway device for communication + */ + getGateway(): Promise<{ gateway: Device; address: string }>; + /** + * Close the underlaying sockets and resources + */ + close(): void; +} diff --git a/src/nat-upnp/device.ts b/src/nat-upnp/device.ts new file mode 100644 index 0000000..f1a7eb3 --- /dev/null +++ b/src/nat-upnp/device.ts @@ -0,0 +1,183 @@ +import axios from "axios"; +import { URL } from "url"; +import { XMLParser } from "fast-xml-parser"; + +import { RawResponse } from "../index"; + +export class Device implements IDevice { + readonly description: string; + readonly services: string[]; + constructor(url: string) { + this.description = url; + this.services = [ + "urn:schemas-upnp-org:service:WANIPConnection:1", + "urn:schemas-upnp-org:service:WANIPConnection:2", + "urn:schemas-upnp-org:service:WANPPPConnection:1", + ]; + } + private async getXML(url: string) { + return axios + .get(url) + .then(({ data }) => new XMLParser().parse(data)) + .catch(() => new Error("Failed to lookup device description")); + } + public async getService(types: string[]) { + return this.getXML(this.description).then(({ root: xml }) => { + const services = this.parseDescription(xml).services.filter( + ({ serviceType }) => types.includes(serviceType) + ); + + if ( + services.length === 0 || + !services[0].controlURL || + !services[0].SCPDURL + ) { + throw new Error("Service not found"); + } + + const baseUrl = new URL(xml.baseURL, this.description); + const prefix = (url: string) => + new URL(url, baseUrl.toString()).toString(); + + return { + service: services[0].serviceType, + SCPDURL: prefix(services[0].SCPDURL), + controlURL: prefix(services[0].controlURL), + }; + }); + } + public async run( + action: string, + args: (string | number)[][] + ): Promise { + const info = await this.getService(this.services); + + const body = + '' + + "' + + "" + + "" + + args.reduce( + (p, [a, b]) => p + `<${a ?? ""}>${b ?? ""}`, + "" + ) + + "" + + "" + + ""; + + return axios + .post(info.controlURL, body, { + headers: { + "Content-Type": 'text/xml; charset="utf-8"', + "Content-Length": "" + Buffer.byteLength(body), + Connection: "close", + SOAPAction: JSON.stringify(info.service + "#" + action), + }, + }) + .then( + ({ data }) => + new XMLParser({ removeNSPrefix: true }).parse(data).Envelope.Body + ); + } + public parseDescription(info: { device?: RawDevice }) { + const services: RawService[] = []; + const devices: RawDevice[] = []; + + function traverseDevices(device?: RawDevice) { + if (!device) return; + const serviceList = device.serviceList?.service ?? []; + const deviceList = device.deviceList?.device ?? []; + devices.push(device); + + if (Array.isArray(serviceList)) { + services.push(...serviceList); + } else { + services.push(serviceList); + } + + if (Array.isArray(deviceList)) { + deviceList.forEach(traverseDevices); + } else { + traverseDevices(deviceList); + } + } + + traverseDevices(info.device); + + return { + services, + devices, + }; + } +} + +export default Device; + +/* + * =================== + * ====== Types ====== + * =================== + */ + +export interface Service { + service: string; + SCPDURL: string; + controlURL: string; +} + +export interface RawService { + serviceType: string; + serviceId: string; + controlURL?: string; + eventSubURL?: string; + SCPDURL?: string; +} + +export interface RawDevice { + deviceType: string; + presentationURL: string; + friendlyName: string; + manufacturer: string; + manufacturerURL: string; + modelDescription: string; + modelName: string; + modelNumber: string; + modelURL: string; + serialNumber: string; + UDN: string; + UPC: string; + serviceList?: { service: RawService | RawService[] }; + deviceList?: { device: RawDevice | RawDevice[] }; +} + +export interface IDevice { + /** + * Get the available services on the network device + * @param types List of service types to look for + */ + getService(types: string[]): Promise; + /** + * Parse out available services + * and devices from a root device + * @param info + * @returns the available devices and services in array form + */ + parseDescription(info: { device?: RawDevice }): { + services: RawService[]; + devices: RawDevice[]; + }; + /** + * Perform a SSDP/UPNP request + * @param action the action to perform + * @param kvpairs arguments of said action + */ + run(action: string, kvpairs: (string | number)[][]): Promise; +} diff --git a/src/nat-upnp/ssdp.ts b/src/nat-upnp/ssdp.ts new file mode 100644 index 0000000..97c6c23 --- /dev/null +++ b/src/nat-upnp/ssdp.ts @@ -0,0 +1,195 @@ +import dgram, { Socket } from "dgram"; +import os from "os"; +import EventEmitter from "events"; + +export class Ssdp implements ISsdp { + private sourcePort = this.options?.sourcePort || 0; + private bound = false; + private boundCount = 0; + private closed = false; + + private readonly queue: [string, SsdpEmitter][] = []; + private readonly multicast = "239.255.255.250"; + private readonly port = 1900; + private readonly sockets; + private readonly ssdpEmitter: SsdpEmitter = new EventEmitter(); + + constructor(private options?: { sourcePort?: number }) { + // Create sockets on all external interfaces + const interfaces = os.networkInterfaces(); + this.sockets = Object.keys(interfaces).reduce( + (arr, key) => + arr.concat( + interfaces[key] + ?.filter((item) => !item.internal) + .map((item) => this.createSocket(item)) ?? [] + ), + [] + ); + } + + private createSocket(iface: any) { + const socket = dgram.createSocket( + iface.family === "IPv4" ? "udp4" : "udp6" + ); + + socket.on("message", (message) => { + // Ignore messages after closing sockets + if (this.closed) return; + + // Parse response + this.parseResponse(message.toString(), socket.address as any as string); + }); + + // Bind in next tick (sockets should be me in this.sockets array) + process.nextTick(() => { + // Unqueue this._queue once all sockets are ready + const onready = () => { + if (this.boundCount < this.sockets.length) return; + + this.bound = true; + this.queue.forEach(([device, emitter]) => this.search(device, emitter)); + }; + + socket.on("listening", () => { + this.boundCount += 1; + onready(); + }); + + // On error - remove socket from list and execute items from queue + socket.once("error", () => { + socket.close(); + this.sockets.splice(this.sockets.indexOf(socket), 1); + onready(); + }); + + socket.address = iface.address; + socket.bind(this.sourcePort, iface.address); + }); + + return socket; + } + + private parseResponse(response: string, addr: string) { + // Ignore incorrect packets + if (!/^(HTTP|NOTIFY)/m.test(response)) return; + + const headers = parseMimeHeader(response); + + // We are only interested in messages that can be matched against the original + // search target + if (!headers.st) return; + + this.ssdpEmitter.emit("device", headers, addr); + } + + public search(device: string, emitter?: SsdpEmitter): SsdpEmitter { + if (!emitter) { + emitter = new EventEmitter(); + emitter._ended = false; + emitter.once("end", () => { + emitter!._ended = true; + }); + } + + if (!this.bound) { + this.queue.push([device, emitter]); + return emitter; + } + + const query = Buffer.from( + "M-SEARCH * HTTP/1.1\r\n" + + "HOST: " + + this.multicast + + ":" + + this.port + + "\r\n" + + 'MAN: "ssdp:discover"\r\n' + + "MX: 1\r\n" + + "ST: " + + device + + "\r\n" + + "\r\n" + ); + + // Send query on each socket + this.sockets.forEach((socket) => + socket.send(query, 0, query.length, this.port, this.multicast) + ); + + const ondevice: SearchCallback = (headers, address) => { + if (!emitter || emitter._ended || headers.st !== device) return; + + emitter.emit("device", headers, address); + }; + this.ssdpEmitter.on("device", ondevice); + + // Detach listener after receiving 'end' event + emitter.once("end", () => + this.ssdpEmitter.removeListener("device", ondevice) + ); + + return emitter; + } + + public close() { + this.sockets.forEach((socket) => socket.close()); + this.closed = true; + } +} + +function parseMimeHeader(headerStr: string) { + const lines = headerStr.split(/\r\n/g); + + // Parse headers from lines to hashmap + return lines.reduce>((headers, line) => { + const [_, key, value] = line.match(/^([^:]*)\s*:\s*(.*)$/) ?? []; + if (key && value) { + headers[key.toLowerCase()] = value; + } + return headers; + }, {}); +} + +export default Ssdp; + +/* + * =================== + * ====== Types ====== + * =================== + */ + +type SearchArgs = [Record, string]; +export type SearchCallback = (...args: SearchArgs) => void; +type SearchEvent = ( + ev: E, + ...args: E extends "device" ? SearchArgs : [] +) => boolean; +type Events = "device" | "end"; +type Event = E extends "device" ? SearchCallback : () => void; +type EventListener = (ev: E, callback: Event) => T; + +export interface SsdpEmitter extends EventEmitter { + removeListener: EventListener; + addListener: EventListener; + once: EventListener; + on: EventListener; + + emit: SearchEvent; + + _ended?: boolean; +} + +export interface ISsdp { + /** + * Search for a SSDP compatible server on the network + * @param device Search Type (ST) header, specifying which device to search for + * @param emitter An existing EventEmitter to emit event on + * @returns The event emitter provided in Promise, or a newly instantiated one. + */ + search(device: string, emitter?: SsdpEmitter): SsdpEmitter; + /** + * Close all sockets + */ + close(): void; +} diff --git a/test/api-test.js b/test/api-test.js deleted file mode 100644 index ddea05f..0000000 --- a/test/api-test.js +++ /dev/null @@ -1,65 +0,0 @@ -var assert = require('assert'); -var async = require('async'); -var net = require('net'); -var natUpnp = require('..'); - -describe('NAT-UPNP/Client', function() { - var c; - - beforeEach(function() { - c = natUpnp.createClient(); - }); - - afterEach(function() { - c.close(); - }); - - it('should add port mapping/unmapping', function(callback) { - var public = ~~(Math.random() * 65536); - c.portMapping({ - public: public, - private: ~~(Math.random() * 65536), - ttl: 0 - }, function(err) { - assert.equal(err, null); - - c.portUnmapping({ public: public }, function(err) { - assert.equal(err, null); - callback(); - }); - }); - }); - - it('should find port after mapping', function(callback) { - var public = ~~(Math.random() * 65536); - c.portMapping({ - public: public, - private: ~~(Math.random() * 65536), - description: 'node:nat:upnp:search-test', - ttl: 0 - }, function(err) { - assert.equal(err, null); - - c.getMappings({ local: true, description: /search-test/ }, - function(err, list) { - assert.equal(err, null); - assert(list.length > 0); - - async.forEach(list, function(item, callback) { - c.portUnmapping(item, function(err) { - assert.equal(err, null); - callback(); - }); - }, callback); - }); - }); - }); - - it('should get external ip address', function(callback) { - c.externalIp(function(err, ip) { - assert.equal(err, null); - assert(net.isIP(ip)); - callback(); - }); - }); -}); diff --git a/test/api.test.ts b/test/api.test.ts new file mode 100644 index 0000000..ccd60bb --- /dev/null +++ b/test/api.test.ts @@ -0,0 +1,55 @@ +import net from "net"; +import { setupTest } from "./index.test"; +import { Client } from "../src"; + +setupTest("NAT-UPNP/Client", (opts) => { + let client: Client; + + opts.runBefore(() => { + client = new Client(); + }); + + opts.runAfter(() => { + client.close(); + }); + + opts.run("Port mapping/unmapping", async () => { + // Random port between 2000 and 65536 to avoid blockages + const publicPort = ~~(Math.random() * 63536 + 2000); + await client.createMapping({ + public: publicPort, + private: ~~(Math.random() * 65536), + ttl: 0, + }); + await client.removeMapping({ public: publicPort }); + return true; + }); + + opts.run("Find port after mapping", async () => { + // Random port between 2000 and 65536 to avoid blockages + const publicPort = ~~(Math.random() * 63536 + 2000); + await client.createMapping({ + public: publicPort, + private: ~~(Math.random() * 65536), + description: "node:nat:upnp:search-test", + ttl: 20, + }); + + const mappings = await client.getMappings({ + local: true, + description: /search-test/, + }); + + if (!mappings.some((mapping) => mapping.public.port === publicPort)) { + return false; + } + + await client.removeMapping({ public: { port: publicPort } }); + return true; + }); + + opts.run("Get public ip address", async () => { + const ip = await client.getPublicIp(); + return net.isIP(ip) !== 0; + }); +}); diff --git a/test/index.test.ts b/test/index.test.ts new file mode 100644 index 0000000..5e14dc8 --- /dev/null +++ b/test/index.test.ts @@ -0,0 +1,114 @@ +const queue: [string, TestOptions][] = []; +let running = false; + +function header(s: string) { + console.log("\n==========", s, "=========="); +} + +function footer(n: number) { + const arr: string[] = []; + arr.length = n; + console.log("\n===========" + arr.fill("=").join("") + "===========\n"); +} + +async function runNextInQueue(prev: string) { + footer(prev.length); + + const [name, opts] = queue.shift() ?? []; + if (!name || !opts) return; + header(name); + opts.startTests().then(() => runNextInQueue(name)); +} + +export function setupTest( + testName: string, + callback: (options: TestOptions) => void +) { + const testOptions = new TestOptions(); + callback(testOptions); + if (running) { + queue.push([testName, testOptions]); + return; + } + running = true; + header(testName); + testOptions.startTests().then(() => runNextInQueue(testName)); +} + +export class TestOptions { + public testCount = 5; + + readonly tests: [string, () => Promise][] = []; + + private isRunning = false; + + private runBeforeCallback: (() => void) | null = null; + private runAfterCallback: (() => void) | null = null; + + public runBefore(callback: (() => void) | null): void { + this.runBeforeCallback = callback; + } + public runAfter(callback: (() => void) | null): void { + this.runAfterCallback = callback; + } + public run(desc: string, callback: () => Promise): void { + this.tests.push([desc, callback]); + } + + public async startTests() { + if (this.isRunning) return; + this.isRunning = true; + const testCount = this.testCount; + const tests = [...this.tests]; + const runBefore = this.runBeforeCallback ?? (() => null); + const runAfter = this.runAfterCallback ?? (() => null); + + for (let x = 0; x < tests.length; x++) { + const [testName, run] = tests[x]; + const results = []; + const errors: Error[] = []; + + console.log("\n" + testName); + + for (let y = 0; y < testCount; y++) { + runBefore(); + results.push( + await run() + .then((s) => { + if (s) { + console.log("Test #" + y + ":", "\x1b[32msuccess\x1b[0m"); + } else { + console.log("Test #" + y + ":", "\x1b[31mfailed\x1b[0m"); + } + return s; + }) + .catch((err) => { + console.log("Test #" + y + ":", "\x1b[31mfailed\x1b[0m"); + errors.push(err); + return false; + }) + ); + runAfter(); + } + if (!results.some((el) => !el)) { + // success + console.log("Testcase: \x1b[32msuccess\x1b[0m"); + } else { + // failed + errors.forEach((err) => console.error(err)); + console.log( + "Testcase: \x1b[31mfailed with", + errors.length, + "errors\x1b[0m" + ); + } + } + } + + public get isTestRunning(): boolean { + return this.isRunning; + } +} + +import "./api.test"; +import "./ssdp.test"; diff --git a/test/ssdp-test.js b/test/ssdp-test.js deleted file mode 100644 index dd8914c..0000000 --- a/test/ssdp-test.js +++ /dev/null @@ -1,23 +0,0 @@ -var assert = require('assert'); -var natUpnp = require('..'); - -describe('NAT-UPNP/Ssdp', function() { - var c; - beforeEach(function() { - c = natUpnp.ssdp.create(); - }); - - afterEach(function() { - c.close(); - }); - - it('should find router device', function(callback) { - var p = c.search('urn:schemas-upnp-org:device:InternetGatewayDevice:1'); - - p.on('device', function(device) { - assert(typeof device.location === 'string'); - p.emit('end'); - callback(); - }); - }); -}); diff --git a/test/ssdp.test.ts b/test/ssdp.test.ts new file mode 100644 index 0000000..b28c7db --- /dev/null +++ b/test/ssdp.test.ts @@ -0,0 +1,27 @@ +import { Ssdp } from "../src"; +import { setupTest } from "./index.test"; + +setupTest("NAT-UPNP/Ssdp", (opts) => { + let client: Ssdp; + + opts.runBefore(() => { + client = new Ssdp(); + }); + + opts.runAfter(() => { + client.close(); + }); + + opts.run("Find router device", async () => { + const p = client.search( + "urn:schemas-upnp-org:device:InternetGatewayDevice:1" + ); + + return new Promise((s) => { + p.on("device", (device) => { + p.emit("end"); + s(typeof device.location === "string"); + }); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..34a778d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es2016", + "module": "commonjs", + "rootDirs": ["./src", "./test"], + "allowJs": false, + "checkJs": false, + "declaration": true, + "outDir": "./build", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["./src", "./test"] +}