From 8e152ca7df33f9a3df5cdf2317aa3431cf532cdf Mon Sep 17 00:00:00 2001 From: Stefan Werfling Date: Mon, 25 Sep 2023 22:08:03 +0200 Subject: [PATCH] #15 Refactoring of upnp, remove axios --- backend/package.json | 1 - backend/src/inc/Cache/UpnpNatCache.ts | 2 +- backend/src/inc/Net/Ssdp/Ssdp.ts | 265 ++++++++++ backend/src/inc/Net/Ssdp/SsdpEmitter.ts | 18 + backend/src/inc/Net/Ssdp/SsdpEvent.ts | 11 + backend/src/inc/Net/Ssdp/SsdpEventListener.ts | 3 + backend/src/inc/Net/Ssdp/SsdpOptions.ts | 6 + .../src/inc/Net/Ssdp/SsdpSearchCallback.ts | 9 + backend/src/inc/Net/Ssdp/SsdpSearchEvent.ts | 7 + backend/src/inc/Net/Upnp/RawResponse.ts | 15 - backend/src/inc/Net/Upnp/Ssdp.ts | 337 ------------- backend/src/inc/Net/Upnp/UpnpNatClient.ts | 463 ------------------ .../inc/Net/UpnpNat/Client/ClientOptions.ts | 18 + .../Net/UpnpNat/Device/DeviceDescription.ts | 10 + .../inc/Net/UpnpNat/Device/DeviceService.ts | 8 + .../src/inc/Net/UpnpNat/Device/RawDevice.ts | 21 + .../src/inc/Net/UpnpNat/Device/RawResponse.ts | 12 + .../src/inc/Net/UpnpNat/Device/RawService.ts | 10 + .../src/inc/Net/UpnpNat/Mapping/Mapping.ts | 19 + .../inc/Net/UpnpNat/Mapping/MappingOptions.ts | 7 + .../Net/UpnpNat/Mapping/NewPortMappingOpts.ts | 22 + .../Net/UpnpNat/Mapping/StandardOptAddress.ts | 7 + .../inc/Net/UpnpNat/Mapping/StandardOpts.ts | 10 + backend/src/inc/Net/UpnpNat/UpnpNatClient.ts | 325 ++++++++++++ .../Device.ts => UpnpNat/UpnpNatDevice.ts} | 227 +++------ backend/src/inc/Net/UpnpNat/UpnpNatGateway.ts | 9 + backend/src/inc/Service/UpnpNatService.ts | 3 +- 27 files changed, 873 insertions(+), 972 deletions(-) create mode 100644 backend/src/inc/Net/Ssdp/Ssdp.ts create mode 100644 backend/src/inc/Net/Ssdp/SsdpEmitter.ts create mode 100644 backend/src/inc/Net/Ssdp/SsdpEvent.ts create mode 100644 backend/src/inc/Net/Ssdp/SsdpEventListener.ts create mode 100644 backend/src/inc/Net/Ssdp/SsdpOptions.ts create mode 100644 backend/src/inc/Net/Ssdp/SsdpSearchCallback.ts create mode 100644 backend/src/inc/Net/Ssdp/SsdpSearchEvent.ts delete mode 100644 backend/src/inc/Net/Upnp/RawResponse.ts delete mode 100644 backend/src/inc/Net/Upnp/Ssdp.ts delete mode 100644 backend/src/inc/Net/Upnp/UpnpNatClient.ts create mode 100644 backend/src/inc/Net/UpnpNat/Client/ClientOptions.ts create mode 100644 backend/src/inc/Net/UpnpNat/Device/DeviceDescription.ts create mode 100644 backend/src/inc/Net/UpnpNat/Device/DeviceService.ts create mode 100644 backend/src/inc/Net/UpnpNat/Device/RawDevice.ts create mode 100644 backend/src/inc/Net/UpnpNat/Device/RawResponse.ts create mode 100644 backend/src/inc/Net/UpnpNat/Device/RawService.ts create mode 100644 backend/src/inc/Net/UpnpNat/Mapping/Mapping.ts create mode 100644 backend/src/inc/Net/UpnpNat/Mapping/MappingOptions.ts create mode 100644 backend/src/inc/Net/UpnpNat/Mapping/NewPortMappingOpts.ts create mode 100644 backend/src/inc/Net/UpnpNat/Mapping/StandardOptAddress.ts create mode 100644 backend/src/inc/Net/UpnpNat/Mapping/StandardOpts.ts create mode 100644 backend/src/inc/Net/UpnpNat/UpnpNatClient.ts rename backend/src/inc/Net/{Upnp/Device.ts => UpnpNat/UpnpNatDevice.ts} (51%) create mode 100644 backend/src/inc/Net/UpnpNat/UpnpNatGateway.ts diff --git a/backend/package.json b/backend/package.json index 4097201..ee4750d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -44,7 +44,6 @@ "@influxdata/influxdb-client": "^1.33.2", "adm-zip": "^0.5.10", "async-exit-hook": "^2.0.1", - "axios": "^1.3.4", "bcrypt": "^5.1.0", "cookie-parser": "^1.4.6", "delay": "^5.0.0", diff --git a/backend/src/inc/Cache/UpnpNatCache.ts b/backend/src/inc/Cache/UpnpNatCache.ts index 703a53e..3057e69 100644 --- a/backend/src/inc/Cache/UpnpNatCache.ts +++ b/backend/src/inc/Cache/UpnpNatCache.ts @@ -1,6 +1,6 @@ import {UpnpNatCacheMapping} from 'flyingfish_schemas'; import NodeCache from 'node-cache'; -import {Mapping} from '../Net/Upnp/UpnpNatClient.js'; +import {Mapping} from '../Net/UpnpNat/Mapping/Mapping.js'; /** * UpnpNatCache diff --git a/backend/src/inc/Net/Ssdp/Ssdp.ts b/backend/src/inc/Net/Ssdp/Ssdp.ts new file mode 100644 index 0000000..0ce3652 --- /dev/null +++ b/backend/src/inc/Net/Ssdp/Ssdp.ts @@ -0,0 +1,265 @@ +import dgram from 'dgram'; +import EventEmitter from 'events'; +import os from 'os'; +import {nextTick} from 'process'; +import {SsdpEmitter} from './SsdpEmitter.js'; +import {SsdpOptions} from './SsdpOptions.js'; +import {SsdpSearchCallback} from './SsdpSearchCallback.js'; + +/** + * Simple Service Discovery Protocol (SSDP). + */ +export class Ssdp { + + /** + * Source port, default 0. + * @member {number} + */ + protected _sourcePort: number = 0; + + /** + * Socket list for requests by any network interface. + * @member {dgram.Socket[]} + */ + protected _sockets: dgram.Socket[] = []; + + /** + * Closed request, all incoming are ignored. + * @member {number} + */ + protected _closed: boolean = false; + + /** + * + * @member {number} + */ + protected _boundCount: number = 0; + + /** + * + * @member {boolean} + */ + protected _bound: boolean = false; + + protected _queue: [string, SsdpEmitter][] = []; + + /** + * ssdpEmitter + * @private + */ + protected _ssdpEmitter: SsdpEmitter = new EventEmitter(); + + /** + * Multicast address. + * @member {string} + */ + protected _multicast: string = '239.255.255.250'; + + /** + * Direct address to device. + * @member {string} + */ + protected _directAddress: string = ''; + + /** + * Port for ssdp. + * @member {number} + */ + protected _port: number = 1900; + + /** + * Constructor for Ssdp object. + * @param {SsdpOptions} options - Options for Ssdp object. + */ + public constructor(options?: SsdpOptions) { + if (options) { + if (options.sourcePort) { + this._sourcePort = options.sourcePort; + } + } + + const interfaces = os.networkInterfaces(); + + Object.keys(interfaces).forEach((name: string): void => { + const tInterface = interfaces[name]; + + if (tInterface) { + for (const interfaceInfo of tInterface) { + // no use internal (loopback etc ...) + if (!interfaceInfo.internal) { + this._sockets.push(this._createSocket(interfaceInfo)); + } + } + } + }); + } + + /** + * Set a direct address to a device. + * @param {string} address + */ + public setDirectAddress(address: string): void { + this._directAddress = address; + } + + /** + * Get the address from a direct device. + * @returns {string} + */ + public getDirectAddress(): string { + return this._directAddress; + } + + /** + * Create a socket to interface information. + * @param {os.NetworkInterfaceInfo} ifaceInfo - Interface information. + * @returns {dgram.Socket} Returns a Socket from dgram. + */ + protected _createSocket(ifaceInfo: os.NetworkInterfaceInfo): dgram.Socket { + const socket = dgram.createSocket(ifaceInfo.family === 'IPv4' ? 'udp4' : 'udp6'); + + socket.on('message', (message: Buffer): void => { + if (this._closed) { + return; + } + + this._parseResponse(message.toString(), ifaceInfo.address); + }); + + nextTick((): void => { + const onReady = (): void => { + if (this._boundCount < this._sockets.length) { + return; + } + + this._bound = true; + this._queue.forEach(([device, emitter]) => this.search(device, emitter)); + }; + + socket.on('listening', (): void => { + this._boundCount += 1; + onReady(); + }); + + socket.once('error', (): void => { + socket.close(); + + this._sockets.splice( + this._sockets.indexOf(socket), + 1 + ); + + onReady(); + }); + + socket.bind(this._sourcePort, ifaceInfo.address); + }); + + return socket; + } + + /** + * Parse the response by socket. + * @param {string} response + * @param {string} addr + */ + protected _parseResponse(response: string, addr: string): void { + if (!(/^(HTTP|NOTIFY)/mu).test(response)) { + return; + } + + const headers = Ssdp.parseMimeHeader(response); + + if (!headers.st) { + return; + } + + this._ssdpEmitter.emit('device', headers, addr); + } + + /** + * Search a device, build request and send on socket. + * @param {string} device + * @param {SsdpEmitter} emitter + * @returns {SsdpEmitter} + */ + public search(device: string, emitter?: SsdpEmitter): SsdpEmitter { + let nEmitter: SsdpEmitter; + + if (emitter) { + nEmitter = emitter; + } else { + nEmitter = new EventEmitter(); + nEmitter._ended = false; + nEmitter.once('end', () => { + nEmitter!._ended = true; + }); + } + + if (!this._bound) { + this._queue.push([device, nEmitter]); + return nEmitter; + } + + 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' + ); + + let destination = this._multicast; + + if (this._directAddress !== '') { + destination = this._directAddress; + } + + this._sockets.forEach((socket: dgram.Socket): void => { + socket.send(query, 0, query.length, this._port, destination); + }); + + const ondevice: SsdpSearchCallback = (headers, address) => { + if (!nEmitter || nEmitter._ended || headers.st !== device) { + return; + } + + nEmitter.emit('device', headers, address); + }; + + this._ssdpEmitter.on('device', ondevice); + + nEmitter.once('end', () => this._ssdpEmitter.removeListener('device', ondevice)); + + return nEmitter; + } + + /** + * Close all sockets. + */ + public close(): void { + this._sockets.forEach((socket: dgram.Socket) => socket.close()); + this._closed = true; + } + + /** + * Prase mime header. + * @param {string} headerStr + * @returns {Record} + */ + public static parseMimeHeader(headerStr: string): Record { + const lines = headerStr.split(/\r\n/gu); + + return lines.reduce>((headers, line) => { + const [, key, value] = line.match(/^([^:]*)\s*:\s*(.*)$/u) ?? []; + + if (key && value) { + headers[key.toLowerCase()] = value; + } + + return headers; + }, {}); + } + +} \ No newline at end of file diff --git a/backend/src/inc/Net/Ssdp/SsdpEmitter.ts b/backend/src/inc/Net/Ssdp/SsdpEmitter.ts new file mode 100644 index 0000000..cb8cc51 --- /dev/null +++ b/backend/src/inc/Net/Ssdp/SsdpEmitter.ts @@ -0,0 +1,18 @@ +import EventEmitter from 'events'; +import {SsdpEventListener} from './SsdpEventListener.js'; +import {SsdpSearchEvent} from './SsdpSearchEvent.js'; + +/** + * Ssdp emitter object. + */ +export interface SsdpEmitter extends EventEmitter { + + removeListener: SsdpEventListener; + addListener: SsdpEventListener; + once: SsdpEventListener; + on: SsdpEventListener; + + emit: SsdpSearchEvent; + + _ended?: boolean; +} \ No newline at end of file diff --git a/backend/src/inc/Net/Ssdp/SsdpEvent.ts b/backend/src/inc/Net/Ssdp/SsdpEvent.ts new file mode 100644 index 0000000..c3a2689 --- /dev/null +++ b/backend/src/inc/Net/Ssdp/SsdpEvent.ts @@ -0,0 +1,11 @@ +import {SsdpSearchCallback} from './SsdpSearchCallback.js'; + +/** + * Ssdp events. + */ +export type SsdpEvents = 'device' | 'end'; + +/** + * SsdpEvent + */ +export type SsdpEvent = E extends 'device' ? SsdpSearchCallback : () => void; \ No newline at end of file diff --git a/backend/src/inc/Net/Ssdp/SsdpEventListener.ts b/backend/src/inc/Net/Ssdp/SsdpEventListener.ts new file mode 100644 index 0000000..9104513 --- /dev/null +++ b/backend/src/inc/Net/Ssdp/SsdpEventListener.ts @@ -0,0 +1,3 @@ +import {SsdpEvent, SsdpEvents} from './SsdpEvent.js'; + +export type SsdpEventListener = (ev: E, callback: SsdpEvent) => T; \ No newline at end of file diff --git a/backend/src/inc/Net/Ssdp/SsdpOptions.ts b/backend/src/inc/Net/Ssdp/SsdpOptions.ts new file mode 100644 index 0000000..0988f25 --- /dev/null +++ b/backend/src/inc/Net/Ssdp/SsdpOptions.ts @@ -0,0 +1,6 @@ +/** + * Ssdp Options. + */ +export type SsdpOptions = { + sourcePort?: number; +}; \ No newline at end of file diff --git a/backend/src/inc/Net/Ssdp/SsdpSearchCallback.ts b/backend/src/inc/Net/Ssdp/SsdpSearchCallback.ts new file mode 100644 index 0000000..bfef588 --- /dev/null +++ b/backend/src/inc/Net/Ssdp/SsdpSearchCallback.ts @@ -0,0 +1,9 @@ +/** + * SsdpSearchArgs + */ +export type SsdpSearchArgs = [Record, string]; + +/** + * SsdpSearchCallback + */ +export type SsdpSearchCallback = (...args: SsdpSearchArgs) => void; \ No newline at end of file diff --git a/backend/src/inc/Net/Ssdp/SsdpSearchEvent.ts b/backend/src/inc/Net/Ssdp/SsdpSearchEvent.ts new file mode 100644 index 0000000..83dcc8d --- /dev/null +++ b/backend/src/inc/Net/Ssdp/SsdpSearchEvent.ts @@ -0,0 +1,7 @@ +import {SsdpEvents} from './SsdpEvent.js'; +import {SsdpSearchArgs} from './SsdpSearchCallback.js'; + +/** + * Ssdp search event type. + */ +export type SsdpSearchEvent = (ev: E, ...args: E extends 'device' ? SsdpSearchArgs : []) => boolean; \ No newline at end of file diff --git a/backend/src/inc/Net/Upnp/RawResponse.ts b/backend/src/inc/Net/Upnp/RawResponse.ts deleted file mode 100644 index d77b3fe..0000000 --- a/backend/src/inc/Net/Upnp/RawResponse.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Raw SSDP/UPNP repsonse - * Entire SSDP/UPNP schema is beyond the scope of these typings. - * Please look up the protol documentation if you want to do - * lower level communication. - */ -export type RawResponse = Partial< - Record< - string, - { - '@': { 'xmlns:u': string; }; - [key: string]: unknown; - } - > - >; \ No newline at end of file diff --git a/backend/src/inc/Net/Upnp/Ssdp.ts b/backend/src/inc/Net/Upnp/Ssdp.ts deleted file mode 100644 index ec47b7a..0000000 --- a/backend/src/inc/Net/Upnp/Ssdp.ts +++ /dev/null @@ -1,337 +0,0 @@ -import dgram, {Socket} from 'dgram'; -import os from 'os'; -import EventEmitter from 'events'; - -/** - * SearchArgs - */ -type SearchArgs = [Record, string]; - -/** - * SearchCallback - */ -export type SearchCallback = (...args: SearchArgs) => void; - -/** - * Events - */ -type Events = 'device' | 'end'; - -/** - * SearchEvent - */ -type SearchEvent = ( - ev: E, - ...args: (([Record, string] & 'device') | [])[] -) => boolean; - -/** - * Event - */ -type Event = E extends 'device' ? SearchCallback : () => void; - -/** - * EventListener - */ -type EventListener = (ev: E, callback: Event) => T; - -/** - * SsdpEmitter - */ -export interface SsdpEmitter extends EventEmitter { - removeListener: EventListener; - addListener: EventListener; - once: EventListener; - on: EventListener; - - emit: SearchEvent; - - _ended?: boolean; -} - -/** - * parseMimeHeader - * @param headerStr - */ -const parseMimeHeader = (headerStr: string): any => { - // eslint-disable-next-line require-unicode-regexp - const lines = headerStr.split(/\r\n/g); - - // Parse headers from lines to hashmap - return lines.reduce>((headers, line) => { - // eslint-disable-next-line require-unicode-regexp - const [, key, value] = line.match(/^([^:]*)\s*:\s*(.*)$/) ?? []; - if (key && value) { - headers[key.toLowerCase()] = value; - } - return headers; - }, {}); -}; - -/** - * ISsdp - */ -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; -} - -/** - * Ssdp - */ -export class Ssdp implements ISsdp { - - /** - * source port - * @private - */ - private sourcePort = 0; - - /** - * bound - * @private - */ - private bound = false; - - /** - * bound count - * @private - */ - private boundCount = 0; - - /** - * closed - * @private - */ - private closed = false; - - /** - * queue - * @private - */ - private readonly queue: [string, SsdpEmitter][] = []; - - /** - * multicast - * @private - */ - private readonly multicast = '239.255.255.250'; - - /** - * direct address to gateway - * @private - */ - private _directAddress: string = ''; - - /** - * port - * @private - */ - private readonly port = 1900; - - /** - * sockets - * @private - */ - private readonly sockets; - - /** - * ssdpEmitter - * @private - */ - private readonly ssdpEmitter: SsdpEmitter = new EventEmitter(); - - /** - * constructor - * @param options - */ - public constructor(private options?: { sourcePort?: number; }) { - if (options?.sourcePort) { - this.sourcePort = options?.sourcePort; - } - // 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)) ?? [] - ), - [] - ); - } - - /** - * setDirectAddress - * @param address - */ - public setDirectAddress(address: string): void { - this._directAddress = address; - } - - /** - * getDirectAddress - */ - public getDirectAddress(): string { - return this._directAddress; - } - - /** - * _createSocket - * @param iface - * @private - */ - private _createSocket(iface: any): 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 = (): void => { - 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 - * @param response - * @param addr - * @private - */ - private _parseResponse(response: string, addr: string): void { - // Ignore incorrect packets - // eslint-disable-next-line require-unicode-regexp - 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; - } - - // @ts-ignore - this.ssdpEmitter.emit('device', headers, addr); - } - - /** - * search - * @param device - * @param emitter - */ - public search(device: string, emitter?: SsdpEmitter): SsdpEmitter { - if (!emitter) { - // eslint-disable-next-line no-param-reassign - 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' - ); - - let destination = this.multicast; - - if (this._directAddress !== '') { - destination = this._directAddress; - } - - // Send query on each socket - this.sockets.forEach((socket) => socket.send(query, 0, query.length, this.port, destination)); - - const ondevice: SearchCallback = (headers, address) => { - if (!emitter || emitter._ended || headers.st !== device) { - return; - } - - // @ts-ignore - 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 - */ - public close(): void { - this.sockets.forEach((socket) => socket.close()); - this.closed = true; - } - -} \ No newline at end of file diff --git a/backend/src/inc/Net/Upnp/UpnpNatClient.ts b/backend/src/inc/Net/Upnp/UpnpNatClient.ts deleted file mode 100644 index 6b5a7fb..0000000 --- a/backend/src/inc/Net/Upnp/UpnpNatClient.ts +++ /dev/null @@ -1,463 +0,0 @@ -import {Device} from './Device.js'; -import {RawResponse} from './RawResponse.js'; -import {Ssdp} from './Ssdp.js'; - -/** - * This Upnp Nat Client is a fork/copy of the project https://github.com/runonflux/nat-upnp - * unfortunately I could not make an object derivation because the important change point - * was declared private. I decided to accept everything and then import my changes. - * The current client can only specifically address a device from a subnet in an - * upper network and request a UPNP-Nat there in order to forward a port. It must be - * ensured that the IP information is correct, as only one IP from the respective network - * area can be requested. - * @author Stefan Werfling - */ - -/** - * Mapping - */ -export interface Mapping { - public: { - gateway: string; - 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 StandardOptAddress { - port?: number; - host?: string; -} - -/** - * StandardOpts - */ -export interface StandardOpts { - public?: number | StandardOptAddress | string; - private?: number | StandardOptAddress | string; - protocol?: string; -} - -/** - * NewPortMappingOpts - */ -export interface NewPortMappingOpts extends StandardOpts { - description?: string; - - /* - * this is the address/ip of privat port destination - * you can set, when the nat-upnp client not listen in the same network - * for exsample in a docker container with own network - */ - clientAddress?: string; - - // value MUST be between 1 second and 604800 seconds - ttl?: number; -} - -/** - * UpnpNatClientOptions - */ -export type UpnpNatClientOptions = { - timeout?: number; - - /* - * A multicast address does not work in a subnetwork, e.g. in the - * Docker container, because it remains in the network area. - * In order to be able to address a device in the higher network, - * it can address it directly with the address/IP. - */ - gatewayAddress?: string; -}; - -/** - * DeletePortMappingOpts - */ -export type DeletePortMappingOpts = StandardOpts; - -/** - * GetMappingOpts - */ -export interface GetMappingOpts { - - /** - * local - */ - local?: boolean; - - /** - * description - */ - 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; -} - -/** - * normalizeOptions - * @param options - */ -const normalizeOptions = (options: StandardOpts): any => { - const toObject = (addr: StandardOpts['public']): { port?: number; } => { - if (typeof addr === 'number') { - return { - port: addr - }; - } - - // TODO debug - if (typeof addr === 'string') { - const aPort = parseInt(addr, 10) || 0; - - if (aPort > 0) { - return { - port: aPort - }; - } - } - - if (typeof addr === 'object') { - return addr; - } - - return {}; - }; - - return { - remote: toObject(options.public), - internal: toObject(options.private) - }; -}; - -/** - * UpnpNatClient - */ -export class UpnpNatClient implements IClient { - - /** - * timeout - */ - public readonly timeout: number; - - /** - * ssdp - */ - public readonly ssdp = new Ssdp(); - - /** - * constructor - * @param options - */ - public constructor(options: UpnpNatClientOptions = {}) { - this.timeout = options.timeout || 1800; - - if (options.gatewayAddress) { - this.ssdp.setDirectAddress(options.gatewayAddress); - } - } - - /** - * createMapping - * @param options - */ - public async createMapping( - options: NewPortMappingOpts - ): Promise { - return this.getGateway().then(({ - gateway, - address - }) => { - const ports = normalizeOptions(options); - - if (typeof ports.remote.host === 'undefined') { - ports.remote.host = ''; - } - - let clientAddress = ports.internal.host || address; - - if (options.clientAddress !== '') { - clientAddress = options.clientAddress; - } - - return gateway.run('AddPortMapping', [ - [ - 'NewRemoteHost', - `${ports.remote.host}` - ], - [ - 'NewExternalPort', - `${ports.remote.port}` - ], - [ - 'NewProtocol', - options.protocol ? options.protocol.toUpperCase() : 'TCP' - ], - [ - 'NewInternalPort', - `${ports.internal.port}` - ], - [ - 'NewInternalClient', - clientAddress - ], - [ - '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); - - if (typeof ports.remote.host === 'undefined') { - ports.remote.host = ''; - } - - return gateway.run('DeletePortMapping', [ - [ - 'NewRemoteHost', - `${ports.remote.host}` - ], - [ - 'NewExternalPort', - `${ports.remote.port}` - ], - [ - 'NewProtocol', - options.protocol ? options.protocol.toUpperCase() : 'TCP' - ] - ]); - }); - } - - /** - * getMappings - * @param options - */ - public async getMappings(options: GetMappingOpts = {}): Promise { - const { - gateway, - address - } = await this.getGateway(); - let i = 0; - let end = false; - const results = []; - - const publicIp = await this.getPublicIp(); - - // eslint-disable-next-line no-constant-condition - while (true) { - // eslint-disable-next-line no-await-in-loop - const data = (await gateway - .run( - 'GetGenericPortMappingEntry', - [ - [ - 'NewPortMappingIndex', - i++ - ] - ] - ) - // eslint-disable-next-line no-loop-func - .catch(() => { - if (i !== 1) { - end = true; - } - }))!; - - if (end) { - break; - } - - // eslint-disable-next-line require-unicode-regexp - 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: { - gateway: this.ssdp.getDirectAddress(), - host: - (typeof res.NewRemoteHost === 'string' && res.NewRemoteHost) || publicIp, - 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 - */ - public async getPublicIp(): Promise { - return this.getGateway().then(async({gateway}) => { - const data = await gateway.run('GetExternalIPAddress', []); - - // eslint-disable-next-line require-unicode-regexp - const key = Object.keys(data || {}).find((k) => (/^GetExternalIPAddressResponse$/).test(k)); - - if (!key) { - throw new Error('Incorrect response'); - } - return `${data[key]?.NewExternalIPAddress}`; - }); - } - - /** - * getGateway - */ - public async getGateway(): Promise<{ gateway: Device; address: string; }> { - 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); - - const usnParts = info.usn.split('::'); - let uuid = ''; - - if (usnParts.length > 1) { - const uuidParts = usnParts[0].split(':'); - - if (uuidParts.length > 1) { - uuid = uuidParts[1]; - } - } - - // Create gateway - s({ - gateway: new Device(info.location, uuid), - address: address - }); - } - ); - }); - } - - /** - * close - */ - public close(): void { - this.ssdp.close(); - } - -} \ No newline at end of file diff --git a/backend/src/inc/Net/UpnpNat/Client/ClientOptions.ts b/backend/src/inc/Net/UpnpNat/Client/ClientOptions.ts new file mode 100644 index 0000000..cc89cbf --- /dev/null +++ b/backend/src/inc/Net/UpnpNat/Client/ClientOptions.ts @@ -0,0 +1,18 @@ +/** + * UpnpNat client options + */ +export type ClientOptions = { + + /** + * Timeout + */ + timeout?: number; + + /* + * A multicast address does not work in a subnetwork, e.g. in the + * Docker container, because it remains in the network area. + * In order to be able to address a device in the higher network, + * it can address it directly with the address/IP. + */ + gatewayAddress?: string; +}; \ No newline at end of file diff --git a/backend/src/inc/Net/UpnpNat/Device/DeviceDescription.ts b/backend/src/inc/Net/UpnpNat/Device/DeviceDescription.ts new file mode 100644 index 0000000..934e107 --- /dev/null +++ b/backend/src/inc/Net/UpnpNat/Device/DeviceDescription.ts @@ -0,0 +1,10 @@ +import {RawService} from './RawService.js'; +import {RawDevice} from './RawDevice.js'; + +/** + * Device description type. + */ +export type DeviceDescription = { + services: RawService[]; + devices: RawDevice[]; +}; \ No newline at end of file diff --git a/backend/src/inc/Net/UpnpNat/Device/DeviceService.ts b/backend/src/inc/Net/UpnpNat/Device/DeviceService.ts new file mode 100644 index 0000000..023fbf6 --- /dev/null +++ b/backend/src/inc/Net/UpnpNat/Device/DeviceService.ts @@ -0,0 +1,8 @@ +/** + * Device service type. + */ +export type DeviceService = { + service: string; + SCPDURL: string; + controlURL: string; +}; \ No newline at end of file diff --git a/backend/src/inc/Net/UpnpNat/Device/RawDevice.ts b/backend/src/inc/Net/UpnpNat/Device/RawDevice.ts new file mode 100644 index 0000000..c569873 --- /dev/null +++ b/backend/src/inc/Net/UpnpNat/Device/RawDevice.ts @@ -0,0 +1,21 @@ +import {RawService} from './RawService.js'; + +/** + * Raw device type. + */ +export type 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[]; }; +}; \ No newline at end of file diff --git a/backend/src/inc/Net/UpnpNat/Device/RawResponse.ts b/backend/src/inc/Net/UpnpNat/Device/RawResponse.ts new file mode 100644 index 0000000..222bd1f --- /dev/null +++ b/backend/src/inc/Net/UpnpNat/Device/RawResponse.ts @@ -0,0 +1,12 @@ +/** + * Raw device response + */ +export type RawResponse = Partial< + Record< + string, + { + '@': { 'xmlns:u': string; }; + [key: string]: unknown; + } + > +>; \ No newline at end of file diff --git a/backend/src/inc/Net/UpnpNat/Device/RawService.ts b/backend/src/inc/Net/UpnpNat/Device/RawService.ts new file mode 100644 index 0000000..b413e60 --- /dev/null +++ b/backend/src/inc/Net/UpnpNat/Device/RawService.ts @@ -0,0 +1,10 @@ +/** + * Raw service type. + */ +export type RawService = { + serviceType: string; + serviceId: string; + controlURL?: string; + eventSubURL?: string; + SCPDURL?: string; +}; \ No newline at end of file diff --git a/backend/src/inc/Net/UpnpNat/Mapping/Mapping.ts b/backend/src/inc/Net/UpnpNat/Mapping/Mapping.ts new file mode 100644 index 0000000..ff4e98c --- /dev/null +++ b/backend/src/inc/Net/UpnpNat/Mapping/Mapping.ts @@ -0,0 +1,19 @@ +/** + * UpnpNat Mapping type. + */ +export type Mapping = { + public: { + gateway: string; + host: string; + port: number; + }; + private: { + host: string; + port: number; + }; + protocol: string; + enabled: boolean; + description?: string; + ttl: number; + local: boolean; +}; \ No newline at end of file diff --git a/backend/src/inc/Net/UpnpNat/Mapping/MappingOptions.ts b/backend/src/inc/Net/UpnpNat/Mapping/MappingOptions.ts new file mode 100644 index 0000000..e73f5da --- /dev/null +++ b/backend/src/inc/Net/UpnpNat/Mapping/MappingOptions.ts @@ -0,0 +1,7 @@ +/** + * UpnpNat client mapping options + */ +export type MappingOptions = { + local?: boolean; + description?: RegExp | string; +}; \ No newline at end of file diff --git a/backend/src/inc/Net/UpnpNat/Mapping/NewPortMappingOpts.ts b/backend/src/inc/Net/UpnpNat/Mapping/NewPortMappingOpts.ts new file mode 100644 index 0000000..c7c11fd --- /dev/null +++ b/backend/src/inc/Net/UpnpNat/Mapping/NewPortMappingOpts.ts @@ -0,0 +1,22 @@ +import {StandardOpts} from './StandardOpts.js'; + +/** + * New port mapping options. + */ +export type NewPortMappingOpts = StandardOpts & { + + /** + * Description for new port. + */ + description?: string; + + /* + * this is the address/ip of privat port destination + * you can set, when the nat-upnp client not listens in the same network + * for exsample in a docker container with own network + */ + clientAddress?: string; + + // the value MUST be between 1 second and 604800 seconds + ttl?: number; +}; \ No newline at end of file diff --git a/backend/src/inc/Net/UpnpNat/Mapping/StandardOptAddress.ts b/backend/src/inc/Net/UpnpNat/Mapping/StandardOptAddress.ts new file mode 100644 index 0000000..cae6e65 --- /dev/null +++ b/backend/src/inc/Net/UpnpNat/Mapping/StandardOptAddress.ts @@ -0,0 +1,7 @@ +/** + * Standard option address. + */ +export type StandardOptAddress = { + port?: number; + host?: string; +}; \ No newline at end of file diff --git a/backend/src/inc/Net/UpnpNat/Mapping/StandardOpts.ts b/backend/src/inc/Net/UpnpNat/Mapping/StandardOpts.ts new file mode 100644 index 0000000..436fe81 --- /dev/null +++ b/backend/src/inc/Net/UpnpNat/Mapping/StandardOpts.ts @@ -0,0 +1,10 @@ +import {StandardOptAddress} from './StandardOptAddress.js'; + +/** + * Standard options. + */ +export type StandardOpts = { + public?: number | StandardOptAddress | string; + private?: number | StandardOptAddress | string; + protocol?: string; +}; \ No newline at end of file diff --git a/backend/src/inc/Net/UpnpNat/UpnpNatClient.ts b/backend/src/inc/Net/UpnpNat/UpnpNatClient.ts new file mode 100644 index 0000000..d05520b --- /dev/null +++ b/backend/src/inc/Net/UpnpNat/UpnpNatClient.ts @@ -0,0 +1,325 @@ +import {Ssdp} from '../Ssdp/Ssdp.js'; +import {ClientOptions} from './Client/ClientOptions.js'; +import {UpnpNatDevice} from './UpnpNatDevice.js'; +import {UpnpNatGateway} from './UpnpNatGateway.js'; +import {Mapping} from './Mapping/Mapping.js'; +import {MappingOptions} from './Mapping/MappingOptions.js'; +import {StandardOpts} from './Mapping/StandardOpts.js'; +import {NewPortMappingOpts} from './Mapping/NewPortMappingOpts.js'; +import {RawResponse} from './Device/RawResponse.js'; + +/** + * UpnpNat client instance. + */ +export class UpnpNatClient { + + /** + * client connection timeout + */ + public _timeout: number = 1800; + + /** + * Ssdp client. + * @member {Ssdp} + */ + protected _ssdp: Ssdp = new Ssdp(); + + /** + * Constructor from UpnpNat client. + * @param {ClientOptions} options + */ + public constructor(options: ClientOptions) { + if (options.timeout) { + this._timeout = options.timeout; + } + + if (options.gatewayAddress) { + this._ssdp.setDirectAddress(options.gatewayAddress); + } + } + + /** + * Return the public ip. + * @returns {string} + */ + public async getPublicIp(): Promise { + return this.getGateway().then(async({gateway}) => { + const data = await gateway.run('GetExternalIPAddress', []); + + const key = Object.keys(data || {}).find((k) => (/^GetExternalIPAddressResponse$/u).test(k)); + + if (!key) { + throw new Error('Incorrect response'); + } + + return `${data[key]?.NewExternalIPAddress}`; + }); + } + + /** + * Return the current Mapping on a device. + * @param {MappingOptions} options + * @returns {Mapping[]} + */ + public async getMappings(options: MappingOptions = {}): Promise { + const {gateway, address} = await this.getGateway(); + + const publicIp = await this.getPublicIp(); + + let i = 0; + let end = false; + const results: Mapping[] = []; + + let loop = true; + + while (loop) { + try { + // eslint-disable-next-line no-await-in-loop + const rawdata = await gateway.run( + 'GetGenericPortMappingEntry', + [['NewPortMappingIndex', i++]] + ); + + const data = (rawdata)!; + + const key = Object.keys(data || {}).find( + (k) => (/^GetGenericPortMappingEntryResponse/u).test(k) + ); + + if (!key) { + throw new Error('Incorrect response'); + } + + const res: any = data[key]; + + const result: Mapping = { + public: { + gateway: this._ssdp.getDirectAddress(), + host: + (typeof res.NewRemoteHost === 'string' && res.NewRemoteHost) || publicIp, + 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), + 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); + } catch (error) { + if (i !== 1) { + end = true; + } + } + + if (end) { + loop = false; + break; + } + } + + return results; + } + + /** + * Return a gateway. + * @returns {UpnpNatGateway} + */ + public async getGateway(): Promise { + 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); + + const usnParts = info.usn.split('::'); + let uuid = ''; + + if (usnParts.length > 1) { + const uuidParts = usnParts[0].split(':'); + + if (uuidParts.length > 1) { + uuid = uuidParts[1]; + } + } + + s({ + gateway: new UpnpNatDevice(info.location, uuid), + address: address + }); + } + ); + }); + } + + protected _normalizeOptions(options: StandardOpts): any { + const toObject = (addr: StandardOpts['public']): { port?: number; } => { + if (typeof addr === 'number') { + return { + port: addr + }; + } + + if (typeof addr === 'string') { + const aPort = parseInt(addr, 10) || 0; + + if (aPort > 0) { + return { + port: aPort + }; + } + } + + if (typeof addr === 'object') { + return addr; + } + + return {}; + }; + + return { + remote: toObject(options.public), + internal: toObject(options.private) + }; + } + + /** + * Create port mapping on a device. + * @param {NewPortMappingOpts} options + * @returns {RawResponse} + */ + public async createMapping(options: NewPortMappingOpts): Promise { + return this.getGateway().then(({gateway, address}) => { + const ports = this._normalizeOptions(options); + + if (typeof ports.remote.host === 'undefined') { + ports.remote.host = ''; + } + + let clientAddress = ports.internal.host || address; + + if (options.clientAddress !== '') { + clientAddress = options.clientAddress; + } + + return gateway.run('AddPortMapping', [ + [ + 'NewRemoteHost', + `${ports.remote.host}` + ], + [ + 'NewExternalPort', + `${ports.remote.port}` + ], + [ + 'NewProtocol', + options.protocol ? options.protocol.toUpperCase() : 'TCP' + ], + [ + 'NewInternalPort', + `${ports.internal.port}` + ], + [ + 'NewInternalClient', + clientAddress + ], + [ + 'NewEnabled', + 1 + ], + [ + 'NewPortMappingDescription', + options.description || 'node:nat:upnp' + ], + [ + 'NewLeaseDuration', + options.ttl ?? 60 * 30 + ] + ]); + }); + } + + /** + * Remove a mapping from a device. + * @param {StandardOpts} options + * @returns {RawResponse} + */ + public async removeMapping(options: StandardOpts): Promise { + return this.getGateway().then(({gateway}) => { + const ports = this._normalizeOptions(options); + + if (typeof ports.remote.host === 'undefined') { + ports.remote.host = ''; + } + + return gateway.run('DeletePortMapping', [ + [ + 'NewRemoteHost', + `${ports.remote.host}` + ], + [ + 'NewExternalPort', + `${ports.remote.port}` + ], + [ + 'NewProtocol', + options.protocol ? options.protocol.toUpperCase() : 'TCP' + ] + ]); + }); + } + + /** + * Close the ssdp. + */ + public close(): void { + this._ssdp.close(); + } + +} \ No newline at end of file diff --git a/backend/src/inc/Net/Upnp/Device.ts b/backend/src/inc/Net/UpnpNat/UpnpNatDevice.ts similarity index 51% rename from backend/src/inc/Net/Upnp/Device.ts rename to backend/src/inc/Net/UpnpNat/UpnpNatDevice.ts index 7b9f8ae..5093426 100644 --- a/backend/src/inc/Net/Upnp/Device.ts +++ b/backend/src/inc/Net/UpnpNat/UpnpNatDevice.ts @@ -1,107 +1,27 @@ -import axios from 'axios'; import {XMLParser} from 'fast-xml-parser'; +import got from 'got'; import {URL} from 'url'; -import {RawResponse} from './RawResponse.js'; +import {DeviceDescription} from './Device/DeviceDescription.js'; +import {DeviceService} from './Device/DeviceService.js'; +import {RawDevice} from './Device/RawDevice.js'; +import {RawService} from './Device/RawService.js'; +import {RawResponse} from './Device/RawResponse.js'; /** - * Service + * UpnpNat device object. */ -export interface Service { - service: string; - SCPDURL: string; - controlURL: string; -} +export class UpnpNatDevice { -/** - * RawService - */ -export interface RawService { - serviceType: string; - serviceId: string; - controlURL?: string; - eventSubURL?: string; - SCPDURL?: string; -} - -/** - * RawDevice - */ -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[]; }; -} - -/** - * IDevice - */ -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[]; - }; + protected _uuid: string; - /** - * Perform a SSDP/UPNP request - * @param action the action to perform - * @param kvpairs arguments of said action - */ - run(action: string, kvpairs: (string | number)[][]): Promise; -} - -/** - * Device - */ -export class Device implements IDevice { - - /** - * uuid - */ - private readonly _uuid: string; - - /** - * description - */ - private readonly description: string; + protected _description: string; - /** - * services - */ - private readonly services: string[]; + protected _services: string[]; - /** - * constructor - * @param url - * @param uuid - */ public constructor(url: string, uuid: string) { - this.description = url; + this._description = url; this._uuid = uuid; - this.services = [ + this._services = [ 'urn:schemas-upnp-org:service:WANIPConnection:1', 'urn:schemas-upnp-org:service:WANIPConnection:2', 'urn:schemas-upnp-org:service:WANPPPConnection:1' @@ -109,7 +29,8 @@ export class Device implements IDevice { } /** - * getUuid + * Return the device uuid. + * @returns {string} */ public getUuid(): string { return this._uuid; @@ -121,20 +42,59 @@ export class Device implements IDevice { * @private */ private async getXML(url: string): Promise { - return axios - .get(url) - .then(({data}) => new XMLParser().parse(data)) - .catch(() => new Error('Failed to lookup device description')); + return got.get(url).then( + (data) => new XMLParser().parse(data.body) + ).catch(() => new Error('Failed to lookup device description')); } /** - * getService - * @param types + * Prase description. + * @param info + * @returns {DeviceDescription} */ - public async getService(types: string[]): Promise<{service: string; SCPDURL: string; controlURL: string;}> { - return this.getXML(this.description).then(({root: xml}) => { + public parseDescription(info: { device?: RawDevice; }): DeviceDescription { + const services: RawService[] = []; + const devices: RawDevice[] = []; + + const traverseDevices = (device?: RawDevice): void => { + 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: services, + devices: devices + }; + } + + /** + * Return service. + * @param {string[]} types + * @returns {DeviceService} + */ + public async getService(types: string[]): Promise { + return this.getXML(this._description).then(({root: xml}) => { const services = this.parseDescription(xml).services.filter( - // @ts-ignore ({serviceType}) => types.includes(serviceType) ); @@ -146,7 +106,7 @@ export class Device implements IDevice { throw new Error('Service not found'); } - const baseUrl = new URL(xml.baseURL, this.description); + const baseUrl = new URL(xml.baseURL, this._description); const prefix = (url: string): string => new URL(url, baseUrl.toString()).toString(); return { @@ -158,15 +118,12 @@ export class Device implements IDevice { } /** - * run - * @param action - * @param args + * Run the call to a device. + * @param {string} action + * @param {(string|number)[][]} args */ - public async run( - action: string, - args: (string | number)[][] - ): Promise { - const info = await this.getService(this.services); + public async run(action: string, args: (string | number)[][]): Promise { + const info = await this.getService(this._services); const body = `${'' + @@ -189,55 +146,17 @@ export class Device implements IDevice { '' + ''; - return axios - .post(info.controlURL, body, { + return got.post(info.controlURL, { + body: 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 + }).then( + (data) => new XMLParser({removeNSPrefix: true}).parse(data.body).Envelope.Body ); } - /** - * parseDescription - * @param info - */ - public parseDescription(info: { device?: RawDevice; }): { services: any; devices: any; } { - const services: RawService[] = []; - const devices: RawDevice[] = []; - - const traverseDevices = (device?: RawDevice): void => { - 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: services, - devices: devices - }; - } - } \ No newline at end of file diff --git a/backend/src/inc/Net/UpnpNat/UpnpNatGateway.ts b/backend/src/inc/Net/UpnpNat/UpnpNatGateway.ts new file mode 100644 index 0000000..7e44ad3 --- /dev/null +++ b/backend/src/inc/Net/UpnpNat/UpnpNatGateway.ts @@ -0,0 +1,9 @@ +import {UpnpNatDevice} from './UpnpNatDevice.js'; + +/** + * UpnpNat Gateway object. + */ +export type UpnpNatGateway = { + gateway: UpnpNatDevice; + address: string; +}; \ No newline at end of file diff --git a/backend/src/inc/Service/UpnpNatService.ts b/backend/src/inc/Service/UpnpNatService.ts index ebb51e0..9d1b1ea 100644 --- a/backend/src/inc/Service/UpnpNatService.ts +++ b/backend/src/inc/Service/UpnpNatService.ts @@ -5,7 +5,8 @@ import Ping from 'ping'; import {UpnpNatCache} from '../Cache/UpnpNatCache.js'; import {NginxListen as NginxListenDB} from '../Db/MariaDb/Entity/NginxListen.js'; import {HimHIP} from '../HimHIP/HimHIP.js'; -import {NewPortMappingOpts, UpnpNatClient} from '../Net/Upnp/UpnpNatClient.js'; +import {UpnpNatClient} from '../Net/UpnpNat/UpnpNatClient.js'; +import {NewPortMappingOpts} from '../Net/UpnpNat/Mapping/NewPortMappingOpts.js'; /** * UpnpNatService