From 0ae8b19b15cee2a5417724d6fc4af9923e762d6f Mon Sep 17 00:00:00 2001 From: Fredrik Lindberg Date: Thu, 21 Sep 2023 08:28:43 +0200 Subject: [PATCH 1/8] refactor: convert clusterservice to typescript --- src/cluster/cluster-node.js | 2 +- src/cluster/{eventbus.js => eventbus.ts} | 24 +++- src/cluster/{index.js => index.ts} | 174 +++++++++++++++-------- 3 files changed, 135 insertions(+), 65 deletions(-) rename src/cluster/{eventbus.js => eventbus.ts} (74%) rename src/cluster/{index.js => index.ts} (61%) diff --git a/src/cluster/cluster-node.js b/src/cluster/cluster-node.js index 0b92576..2f45e9d 100644 --- a/src/cluster/cluster-node.js +++ b/src/cluster/cluster-node.js @@ -8,7 +8,7 @@ class Node { static address4 = Node._getIP(Node.interface, 'IPv4'); static address6 = Node._getIP(Node.interface, 'IPv6'); - static address = Node.address4 || Node.address6; + static address = Node.address4 || Node.address6 || '0.0.0.0'; static getIP() { return Node._getIP(Node.interface, 'IPv4') || Node._getIP(Node.interface, 'IPv6'); diff --git a/src/cluster/eventbus.js b/src/cluster/eventbus.ts similarity index 74% rename from src/cluster/eventbus.js rename to src/cluster/eventbus.ts index 24fb269..8b7972c 100644 --- a/src/cluster/eventbus.js +++ b/src/cluster/eventbus.ts @@ -2,7 +2,19 @@ import { EventEmitter } from 'events'; import { Logger } from '../logger.js'; import ClusterService from './index.js'; +type EmitMeta = { + node: { + id: string, + host: string, + ip: string, + }, + ts: number, +} + class EventBus extends EventEmitter { + private logger: any; + private clusterService: ClusterService; + constructor() { super(); this.logger = Logger("eventbus"); @@ -19,13 +31,13 @@ class EventBus extends EventEmitter { clusterService.attach(this); } - async destroy() { + public async destroy() { this.removeAllListeners(); this.clusterService.detach(this); return this.clusterService.destroy(); } - _emit(event, message, meta) { + public _emit(event: string, message: string, meta: EmitMeta) { super.emit(event, message, meta); this.logger.isTraceEnabled() && this.logger.trace({ @@ -36,14 +48,14 @@ class EventBus extends EventEmitter { }); } - async publish(event, message) { + async publish(event: string, message: object) { return this.clusterService.publish(event, message); } - async waitFor(channel, predicate, timeout = undefined) { + async waitFor(channel: string, predicate: (message: string, meta: EmitMeta) => boolean, timeout: number | undefined) { return new Promise((resolve, reject) => { - let timer; - const fun = (message, meta) => { + let timer: NodeJS.Timeout; + const fun = (message: string, meta: EmitMeta) => { if (!predicate(message, meta)) { return; } diff --git a/src/cluster/index.js b/src/cluster/index.ts similarity index 61% rename from src/cluster/index.js rename to src/cluster/index.ts index d916828..452d1a0 100644 --- a/src/cluster/index.js +++ b/src/cluster/index.ts @@ -5,9 +5,40 @@ import RedisEventBus from './redis-eventbus.js'; import Node from './cluster-node.js'; import { Logger } from '../logger.js'; import UdpEventBus from './udp-eventbus.js'; +import EventBus from './eventbus.js'; + +type ClusterNode = { + id: string, + host: string, + ip: string, +} + +type ClusterServiceNode = ClusterNode & { + stale: boolean, + seq: number, + seq_win: number, + staleTimer?: NodeJS.Timeout, + removalTimer?: NodeJS.Timeout, +} class ClusterService { - constructor(type, opts) { + private static instance: ClusterService | undefined; + private static ref: number; + + private logger: any; + private _key: string = ''; + private _nodes: { [key: string]: ClusterServiceNode } = {}; + private _listeners: Array = []; + private _seq: number = 0; + private _window_size!: number; + private _staleTimeout!: number; + private _removalTimeout!: number; + private _heartbeatInterval!: number; + private _bus: any; + private multiNode: boolean = false; + private _heartbeat: NodeJS.Timeout | undefined; + + constructor(type?: 'redis' | 'udp' | 'single-node' | 'mem', opts?: any) { if (ClusterService.instance instanceof ClusterService) { ClusterService.ref++; return ClusterService.instance; @@ -17,7 +48,7 @@ class ClusterService { ClusterService.ref = 1; this.logger = Logger("cluster-service"); - this._key = opts.key || ''; + this._key = opts?.key || ''; this._nodes = {}; this._nodes[Node.identifier] = { seq: 0, @@ -35,11 +66,11 @@ class ClusterService { this._heartbeatInterval = opts.heartbeatInterval || 9500; this._listeners = []; - const onMessage = (payload) => { + const onMessage = (payload: string) => { this._receive(payload) }; - const ready = async (err) => { + const ready = async (err: Error) => { if (err) { await this.destroy(); } @@ -82,7 +113,7 @@ class ClusterService { } } - async setReady(ready = true) { + public async setReady(ready: boolean = true): Promise { if (!ready) { clearInterval(this._heartbeat); return this.multiNode; @@ -95,7 +126,7 @@ class ClusterService { this._heartbeat = setInterval(heartbeat, this._heartbeatInterval); if (!this.multiNode) { - return; + return this.multiNode; } const rapidHeartbeat = setInterval(heartbeat, 2000); @@ -108,62 +139,69 @@ class ClusterService { setTimeout(resolve, waitTime); }); clearInterval(rapidHeartbeat); + return this.multiNode; } - attach(bus) { + public attach(bus: EventBus): void { this._listeners.push(bus); } - detach(bus) { + public detach(bus: EventBus): void { this._listeners = this._listeners.filter((x) => x != bus); } - _getLearntPeers() { + private _getLearntPeers(): Array { return Array.from(new Set(Object.keys(this._nodes) .filter((k) => !this._nodes[k].stale) .map((k) => this._nodes[k].ip))); } - _learnNode(node) { - if (node?.id == undefined || node?.id == Node.identifier) { + private _learnNode(node: ClusterNode): void { + if (node.id == undefined || node.id == Node.identifier) { return; } - clearTimeout(this._nodes[node.id]?._staleTimer); - clearTimeout(this._nodes[node.id]?._removalTimer); - - if (!this._nodes[node.id]) { + let cnode: ClusterServiceNode; + if (this._nodes[node.id] == undefined) { this.logger.info({ message: `Discovered peer node ${node.id}, host ${node.host} (${node.ip}), total peers ${Object.keys(this._nodes).length}`, total_nodes: Object.keys(this._nodes).length + 1, }); - } else if (this._nodes[node.id]?.stale == true) { + cnode = { + seq: 0, + seq_win: 0, + stale: false, + ...node, + } + } else { + cnode = this._nodes[node.id]; + } + + if (cnode.stale == true) { this.logger.debug({ message: `node ${node.id} no longer marked as stale`, node, }); } - - this._nodes[node.id] ??= { - seq: 0, - seq_win: 0, - }; - this._nodes[node.id].id = node.id; - this._nodes[node.id].host = node.host; - this._nodes[node.id].ip = node.ip; - this._nodes[node.id].stale = false; - - this._nodes[node.id]._staleTimer = setTimeout(() => { - this._staleNode(node); + cnode.id = node.id; + cnode.host = node.host; + cnode.ip = node.ip; + cnode.stale = false; + + clearTimeout(cnode.staleTimer); + cnode.staleTimer = setTimeout(() => { + this._staleNode(cnode); }, this._staleTimeout); - this._nodes[node.id]._removalTimer = setTimeout(() => { - this._forgetNode(node); + clearTimeout(cnode.removalTimer); + cnode.removalTimer = setTimeout(() => { + this._forgetNode(cnode); }, this._removalTimeout); + this._nodes[node.id] = cnode; } - _forgetNode(node) { + private _forgetNode(node: ClusterServiceNode): void { delete this._nodes[node.id]; this.logger.info({ message: `Node ${node.id} ${node.host} (${node.ip}) permanently removed from peer list`, @@ -171,7 +209,7 @@ class ClusterService { }); } - _staleNode(node) { + private _staleNode(node: ClusterServiceNode): void { if (!this._nodes[node?.id]) { return; } @@ -181,7 +219,7 @@ class ClusterService { }); } - getSelf() { + public getSelf(): ClusterNode { return { id: Node.identifier, host: Node.hostname, @@ -189,8 +227,8 @@ class ClusterService { }; } - getNode(id) { - const node = this._nodes[id]; + public getNode(id: string): ClusterNode | undefined { + const node: ClusterServiceNode = this._nodes[id]; if (node?.stale === false) { return { id: node.id, @@ -202,7 +240,7 @@ class ClusterService { } } - _receive(payload) { + private _receive(payload: string): boolean | Error { try { const msg = JSON.parse(payload); const {s, ...data} = msg; @@ -214,37 +252,58 @@ class ClusterService { } const {event, message, node, ts, seq} = data; - if (event == 'cluster:heartbeat' || this.getNode(node?.id) == undefined) { + if (event == undefined || + message == undefined || + node == undefined || + ts == undefined || + seq == undefined) { + throw new Error(`invalid message ${payload}`); + } + + const cnode: ClusterNode = { + id: node.id, + host: node.host, + ip: node.ip, + } + + if (event == 'cluster:heartbeat' || this.getNode(cnode.id) == undefined) { this._learnNode(node); } - if (this.getNode(node?.id) == undefined) { - throw new Error(`The node ${node?.id} is not in set of learnt nodes`); + if (this.getNode(cnode.id) == undefined) { + throw new Error(`The node ${cnode.id} is not in set of learnt nodes`); } - const low = this._nodes[node.id].seq - this._window_size; + const low = this._nodes[cnode.id].seq - this._window_size; if (low > seq || seq < 0) { - throw new Error(`unexpected sequence number ${seq}, window=${this._nodes[node.id].seq}`); + throw new Error(`unexpected sequence number ${seq}, window=${this._nodes[cnode.id].seq}`); } - if (seq > this._nodes[node.id].seq) { - const diff = seq - this._nodes[node.id].seq; + if (seq > this._nodes[cnode.id].seq) { + const diff = seq - this._nodes[cnode.id].seq; - this._nodes[node.id].seq = seq; + this._nodes[cnode.id].seq = seq; - this._nodes[node.id].seq_win <<= diff; - this._nodes[node.id].seq_win &= (1 << this._window_size) - 1; + this._nodes[cnode.id].seq_win <<= diff; + this._nodes[cnode.id].seq_win &= (1 << this._window_size) - 1; } - const rel_seq = this._nodes[node.id].seq - seq; - if (this._nodes[node.id].seq_win & (1 << rel_seq)) { - throw new Error(`message ${seq} already received, window=${this._nodes[node.id].seq}`); + const rel_seq = this._nodes[cnode.id].seq - seq; + if (this._nodes[cnode.id].seq_win & (1 << rel_seq)) { + throw new Error(`message ${seq} already received, window=${this._nodes[cnode.id].seq}`); } - this._nodes[node.id].seq_win |= (1 << rel_seq); - - this._listeners.forEach((l) => l._emit(event, message, {node, ts})); + this._nodes[cnode.id].seq_win |= (1 << rel_seq); + + this._listeners.forEach((l) => l._emit(event, message, { + node: { + id: cnode.id, + ip: cnode.ip, + host: cnode.host, + }, + ts + })); return true; - } catch (e) { + } catch (e: any) { this.logger.debug({ message: `receive failed invalid message: ${e.message}`, payload @@ -253,7 +312,7 @@ class ClusterService { } } - async publish(event, message = {}) { + public async publish(event: any, message: object = {}) { const payload = { event, message, @@ -277,12 +336,11 @@ class ClusterService { return this._bus.publish(JSON.stringify(msg)); } - async destroy() { + public async destroy() { if (--ClusterService.ref == 0) { await this._bus.destroy(); - this.destroyed = true; - delete this._bus; - delete ClusterService.instance; + this._bus = undefined; + ClusterService.instance = undefined; } } } From 2c501757ee2d44f2ce7dcb6b4b737a2c52e57308 Mon Sep 17 00:00:00 2001 From: Fredrik Lindberg Date: Thu, 21 Sep 2023 14:47:40 +0200 Subject: [PATCH 2/8] feat: add admin/cluster endpoint The endpoint shows the known cluster nodes. --- src/cluster/index.ts | 55 ++++++++++++++++++-------- src/controller/admin-api-controller.js | 29 ++++++++++++++ 2 files changed, 67 insertions(+), 17 deletions(-) diff --git a/src/cluster/index.ts b/src/cluster/index.ts index 452d1a0..af8dbae 100644 --- a/src/cluster/index.ts +++ b/src/cluster/index.ts @@ -11,10 +11,11 @@ type ClusterNode = { id: string, host: string, ip: string, + last_ts: number, + stale: boolean, } type ClusterServiceNode = ClusterNode & { - stale: boolean, seq: number, seq_win: number, staleTimer?: NodeJS.Timeout, @@ -57,6 +58,7 @@ class ClusterService { host: Node.hostname, ip: Node.address, stale: false, + last_ts: 0, }; this._seq = 0; this._window_size = 16; @@ -156,7 +158,7 @@ class ClusterService { .map((k) => this._nodes[k].ip))); } - private _learnNode(node: ClusterNode): void { + private _learnNode(node: ClusterNode): ClusterServiceNode | undefined { if (node.id == undefined || node.id == Node.identifier) { return; } @@ -170,7 +172,6 @@ class ClusterService { cnode = { seq: 0, seq_win: 0, - stale: false, ...node, } } else { @@ -199,6 +200,7 @@ class ClusterService { }, this._removalTimeout); this._nodes[node.id] = cnode; + return cnode; } private _forgetNode(node: ClusterServiceNode): void { @@ -224,6 +226,8 @@ class ClusterService { id: Node.identifier, host: Node.hostname, ip: Node.address, + last_ts: new Date().getTime(), + stale: false, }; } @@ -234,12 +238,26 @@ class ClusterService { id: node.id, host: node.host, ip: node.ip, + last_ts: node.last_ts, + stale: node.stale, }; } else { return undefined; } } + public getNodes(): Array { + return Object.keys(this._nodes).map((k) => { + return { + id: this._nodes[k].id, + host: this._nodes[k].host, + ip: this._nodes[k].ip, + last_ts: Node.identifier == this._nodes[k].id ? new Date().getTime() : this._nodes[k].last_ts, + stale: this._nodes[k].stale, + } + }) + } + private _receive(payload: string): boolean | Error { try { const msg = JSON.parse(payload); @@ -264,35 +282,38 @@ class ClusterService { id: node.id, host: node.host, ip: node.ip, + last_ts: ts, + stale: false, } - if (event == 'cluster:heartbeat' || this.getNode(cnode.id) == undefined) { - this._learnNode(node); + let csnode: ClusterServiceNode | undefined = this._nodes[cnode.id]; + if (event == 'cluster:heartbeat' || csnode == undefined) { + csnode = this._learnNode(node); } - if (this.getNode(cnode.id) == undefined) { + if (csnode == undefined) { throw new Error(`The node ${cnode.id} is not in set of learnt nodes`); } - const low = this._nodes[cnode.id].seq - this._window_size; + const low = csnode.seq - this._window_size; if (low > seq || seq < 0) { - throw new Error(`unexpected sequence number ${seq}, window=${this._nodes[cnode.id].seq}`); + throw new Error(`unexpected sequence number ${seq}, window=${csnode.seq}`); } - if (seq > this._nodes[cnode.id].seq) { - const diff = seq - this._nodes[cnode.id].seq; + if (seq > csnode.seq) { + const diff = seq - csnode.seq; - this._nodes[cnode.id].seq = seq; + csnode.seq = seq; - this._nodes[cnode.id].seq_win <<= diff; - this._nodes[cnode.id].seq_win &= (1 << this._window_size) - 1; + csnode.seq_win <<= diff; + csnode.seq_win &= (1 << this._window_size) - 1; } - const rel_seq = this._nodes[cnode.id].seq - seq; - if (this._nodes[cnode.id].seq_win & (1 << rel_seq)) { - throw new Error(`message ${seq} already received, window=${this._nodes[cnode.id].seq}`); + const rel_seq = csnode.seq - seq; + if (csnode.seq_win & (1 << rel_seq)) { + throw new Error(`message ${seq} already received, window=${csnode.seq}`); } - this._nodes[cnode.id].seq_win |= (1 << rel_seq); + csnode.seq_win |= (1 << rel_seq); this._listeners.forEach((l) => l._emit(event, message, { node: { diff --git a/src/controller/admin-api-controller.js b/src/controller/admin-api-controller.js index 6a9eef4..0c56275 100644 --- a/src/controller/admin-api-controller.js +++ b/src/controller/admin-api-controller.js @@ -9,6 +9,7 @@ import { ERROR_UNKNOWN_ERROR, } from '../utils/errors.js'; import KoaController from './koa-controller.js'; +import ClusterService from '../cluster/index.js'; class AdminApiController extends KoaController { _name = 'Admin API' @@ -34,6 +35,7 @@ class AdminApiController extends KoaController { this.accountService = new AccountService(); this._tunnelService = new TunnelService(); this._transportService = new TransportService(); + this._clusterService = new ClusterService(); if (this.apiKey != undefined) { logger.info("Admin API resource enabled with API key"); @@ -345,6 +347,32 @@ class AdminApiController extends KoaController { }] }); + router.route({ + method: 'get', + path: '/v1/admin/cluster', + validate: { + failure: 400, + continueOnError: true, + }, + handler: [handleAdminAuth, handleError, async (ctx, next) => { + const now = new Date().getTime(); + const nodes = this._clusterService.getNodes().map((node) => { + return { + node_id: node.id, + host: node.host, + ip: node.ip, + alive_at: node.last_ts, + alive_age: Math.max(0, now - node.last_ts), + is_stale: node.stale, + } + }); + + ctx.status = 200; + ctx.body = { + nodes + }; + }] + }); } async _destroy() { @@ -352,6 +380,7 @@ class AdminApiController extends KoaController { this.accountService.destroy(), this._tunnelService.destroy(), this._transportService.destroy(), + this._clusterService.destroy(), ]); } } From 2a22cbd5f6186266265c48858c8fff1e86b9da42 Mon Sep 17 00:00:00 2001 From: Fredrik Lindberg Date: Fri, 29 Sep 2023 22:33:27 +0200 Subject: [PATCH 3/8] refactor: Transport into an abstract class Create parent destroy() and make sure 'close' always is emitted, child classes implement their own _destroy() logic. --- src/transport/ssh/ssh-transport.js | 7 +------ src/transport/transport.js | 19 ----------------- src/transport/transport.ts | 33 ++++++++++++++++++++++++++++++ src/transport/ws/ws-transport.ts | 28 ++++++++++--------------- 4 files changed, 45 insertions(+), 42 deletions(-) delete mode 100644 src/transport/transport.js create mode 100644 src/transport/transport.ts diff --git a/src/transport/ssh/ssh-transport.js b/src/transport/ssh/ssh-transport.js index 5328991..e131be4 100644 --- a/src/transport/ssh/ssh-transport.js +++ b/src/transport/ssh/ssh-transport.js @@ -98,13 +98,8 @@ class SSHTransport extends Transport { return sock; } - async destroy() { - if (this.destroyed) { - return; - } + async _destroy() { this._client.end(); - this.destroyed = true; - this.emit('close'); return this._tunnelService.destroy(); } } diff --git a/src/transport/transport.js b/src/transport/transport.js deleted file mode 100644 index 618d1dc..0000000 --- a/src/transport/transport.js +++ /dev/null @@ -1,19 +0,0 @@ -import assert from 'assert/strict'; -import { EventEmitter } from 'events'; - -class Transport extends EventEmitter { - constructor(opts) { - super(); - this.max_connections = opts.max_connections || 1; - } - - createConnection(opts = {}, callback) { - assert.fail("createConnection not implemented"); - } - - destroy() { - assert.fail("destroy not implemented"); - } -} - -export default Transport \ No newline at end of file diff --git a/src/transport/transport.ts b/src/transport/transport.ts new file mode 100644 index 0000000..c98408c --- /dev/null +++ b/src/transport/transport.ts @@ -0,0 +1,33 @@ +import { EventEmitter } from 'events'; +import { Duplex } from 'stream'; + +export type TransportConnectionOptions = { + tunnelId?: string, + port?: number, +}; + +export interface TransportOptions { + max_connections?: number +} + +export default abstract class Transport extends EventEmitter { + public readonly max_connections: number; + public destroyed: boolean = false; + + constructor(opts: TransportOptions) { + super(); + this.max_connections = opts.max_connections || 1; + } + + public abstract createConnection(opts: TransportConnectionOptions, callback: (err: Error | undefined, sock: Duplex) => void): Duplex; + + protected abstract _destroy(): Promise; + + public async destroy(err?: Error): Promise { + this.destroyed = true; + this._destroy(); + this.emit('close', err); + this.removeAllListeners(); + } + +} \ No newline at end of file diff --git a/src/transport/ws/ws-transport.ts b/src/transport/ws/ws-transport.ts index af27fab..de29f85 100644 --- a/src/transport/ws/ws-transport.ts +++ b/src/transport/ws/ws-transport.ts @@ -1,17 +1,15 @@ import WebSocket from 'ws'; -import Transport from '../transport.js'; +import Transport, { TransportOptions } from '../transport.js'; import { WebSocketMultiplex } from '@exposr/ws-multiplex'; import { Duplex } from 'stream'; -export type WebSocketTransportOptions = { +export type WebSocketTransportOptions = TransportOptions & { tunnelId: string, - max_connections: number, socket: WebSocket, }; export default class WebSocketTransport extends Transport { private wsm: WebSocketMultiplex; - private destroyed: boolean = false; constructor(options: WebSocketTransportOptions) { super({ @@ -22,25 +20,21 @@ export default class WebSocketTransport extends Transport { reference: options.tunnelId }); - this.wsm.on('error', (err: Error) => { - this._destroy(err); + this.wsm.once('error', (err: Error) => { + this.destroy(err); }); - } - public createConnection(opts: object = {}, callback: (err: Error | undefined, sock: Duplex) => void): any { - return this.wsm.createConnection({}, callback); + this.wsm.once('close', () => { + this.destroy(); + }); } - public async destroy(): Promise { - return this._destroy(); + public createConnection(opts: any = {}, callback: (err: Error | undefined, sock: Duplex) => void): Duplex { + return this.wsm.createConnection({}, callback); } - private async _destroy(err?: Error): Promise { - if (this.destroyed) { - return; - } - this.destroyed = true; + protected async _destroy(): Promise { + this.wsm.removeAllListeners(); await this.wsm.destroy(); - this.emit('close', err); } } \ No newline at end of file From e1113e30043b07761cf6995ccf70f98e38e01252 Mon Sep 17 00:00:00 2001 From: Fredrik Lindberg Date: Fri, 29 Sep 2023 22:37:19 +0200 Subject: [PATCH 4/8] refactor: simplify storage serializer --- src/storage/{serializer.js => serializer.ts} | 37 ++++----- test/unit/storage/test_serialize.ts | 79 ++++++++++++++++++++ 2 files changed, 92 insertions(+), 24 deletions(-) rename src/storage/{serializer.js => serializer.ts} (52%) create mode 100644 test/unit/storage/test_serialize.ts diff --git a/src/storage/serializer.js b/src/storage/serializer.ts similarity index 52% rename from src/storage/serializer.js rename to src/storage/serializer.ts index f2b1b96..b4c622a 100644 --- a/src/storage/serializer.js +++ b/src/storage/serializer.ts @@ -1,27 +1,20 @@ -class Serializer { - - static serialize(object) { - const clazz = object.constructor.name; - object = { - __class__: clazz, - ...object - }; +export interface Serializable {} + +export default class Serializer { + + static serialize(object: Serializable): string { return JSON.stringify(object, (key, value) => { - if (key[0] == '_' && key != '__class__') { + if (key[0] == '_') { return undefined; } return value; }); } - static deserialize(json, clazz) { + static deserialize(json: string | object, type: { new(): Type ;} ): Type { const obj = typeof json == 'object' ? json : JSON.parse(json) || {}; - if (obj.__class__ != clazz.name) { - return undefined; - } - delete obj['__class__']; - const merge = (target, source) => { + const merge = (target: any, source: any): Type => { for (const key of Object.keys(target)) { if (target[key] instanceof Array && source[key] instanceof Array) { target[key] = source[key]; @@ -35,15 +28,11 @@ class Serializer { return target; } - const canonicalObj = Object.assign(new clazz(), { - ...merge(new clazz(), obj) - }); - // Run migration hooks - typeof canonicalObj._deserialization_hook === 'function' && - canonicalObj._deserialization_hook(); + const canonicalObj = Object.assign(new type(), { + ...merge(new type(), obj as Type) + }) as Type; + return canonicalObj; } -} - -export default Serializer; \ No newline at end of file +} \ No newline at end of file diff --git a/test/unit/storage/test_serialize.ts b/test/unit/storage/test_serialize.ts new file mode 100644 index 0000000..b423e69 --- /dev/null +++ b/test/unit/storage/test_serialize.ts @@ -0,0 +1,79 @@ +import assert from 'assert/strict'; +import Serializer, { Serializable } from '../../../src/storage/serializer.js' + +type Sub = { + astring?: string; + anarray?: Array; +} + +class Test implements Serializable { + public astring?: string = undefined; + public anumber?: number = undefined; + public obj?: Sub = { + astring: undefined, + anarray: [], + }; + public anarray?: Array = []; +} + +describe('Serializer', () => { + + it(`Can serialize/deserialize`, () => { + const test: Test = { + anumber: 10 + } + + let str = Serializer.serialize(test) + assert(str == '{"anumber":10}') + + let test2 = Serializer.deserialize(str, Test); + assert(test2.anumber == 10); + }); + + it(`Nested objects`, () => { + const test: Test = { + anumber: 10, + obj: { + astring: "foo" + } + } + + let str = Serializer.serialize(test) + assert(str == '{"anumber":10,"obj":{"astring":"foo"}}') + + let test2 = Serializer.deserialize(str, Test); + assert(test2.obj?.astring == 'foo'); + }); + + it(`Array`, () => { + const test: Test = { + anumber: 10, + anarray: [ + "bar" + ] + } + + let str = Serializer.serialize(test) + assert(str == '{"anumber":10,"anarray":["bar"]}'); + + let test2 = Serializer.deserialize(str, Test); + assert(test2?.anarray?.[0] == 'bar'); + }); + + it(`Nested array`, () => { + const test: Test = { + anumber: 10, + obj: { + anarray: [1,2] + } + } + + let str = Serializer.serialize(test) + assert(str == '{"anumber":10,"obj":{"anarray":[1,2]}}'); + + let test2 = Serializer.deserialize(str, Test); + assert(test2?.obj?.anarray?.[0] == 1); + assert(test2?.obj?.anarray?.[1] == 2); + }); + +}); \ No newline at end of file From 5b040edcc27e69fd98c931530d696ef51ef1c1aa Mon Sep 17 00:00:00 2001 From: Fredrik Lindberg Date: Fri, 29 Sep 2023 22:37:54 +0200 Subject: [PATCH 5/8] refactor: simplify inter-cluster transport Create a proper transport class for inter-cluster connection redirects. --- src/transport/cluster/cluster-transport.ts | 46 +++++++++++++++++++++ test/unit/cluster/test_cluster-transport.ts | 45 ++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 src/transport/cluster/cluster-transport.ts create mode 100644 test/unit/cluster/test_cluster-transport.ts diff --git a/src/transport/cluster/cluster-transport.ts b/src/transport/cluster/cluster-transport.ts new file mode 100644 index 0000000..e7622b1 --- /dev/null +++ b/src/transport/cluster/cluster-transport.ts @@ -0,0 +1,46 @@ +import { Duplex } from "stream"; +import { Socket, TcpSocketConnectOpts } from "net"; +import Transport, { TransportConnectionOptions, TransportOptions } from "../transport.js"; +import ClusterService from "../../cluster/index.js"; + +export interface ClusterTransportOptions extends TransportOptions { + nodeId: string, +} + +export default class ClusterTransport extends Transport { + private nodeId: string; + private clusterService: ClusterService; + constructor(opts: ClusterTransportOptions) { + super(opts); + this.nodeId = opts.nodeId; + this.clusterService = new ClusterService(); + } + + public createConnection(opts: TransportConnectionOptions, callback: (err: Error | undefined, sock: Duplex) => void): Duplex { + const clusterNode = this.clusterService.getNode(this.nodeId); + const sock = new Socket(); + if (!clusterNode) { + sock.destroy(new Error('node_does_not_exist')); + return sock; + } + + const socketOpts: TcpSocketConnectOpts = { + host: clusterNode.ip, + port: opts.port || 0, + }; + + const errorHandler = (err: Error) => { + callback(err, sock); + }; + sock.once('error', errorHandler); + sock.connect(socketOpts, () => { + sock.off('error', errorHandler); + callback(undefined, sock); + }); + return sock; + } + + protected async _destroy(): Promise { + await this.clusterService.destroy(); + } +} \ No newline at end of file diff --git a/test/unit/cluster/test_cluster-transport.ts b/test/unit/cluster/test_cluster-transport.ts new file mode 100644 index 0000000..213d9c9 --- /dev/null +++ b/test/unit/cluster/test_cluster-transport.ts @@ -0,0 +1,45 @@ +import net from 'net'; +import sinon from 'sinon'; +import ClusterTransport from '../../../src/transport/cluster/cluster-transport.js'; +import ClusterService, { ClusterNode } from '../../../src/cluster/index.js'; +import { Duplex } from 'stream'; +import Config from '../../../src/config.js'; + +describe('cluster transport', () => { + it('can be created and connected', async () => { + const config = new Config(); + const server = net.createServer(); + server.listen(10000, () => {}); + + const clusterService = new ClusterService("mem"); + + sinon.stub(ClusterService.prototype, "getNode").returns({ + id: "some-node-id", + host: "some-node-host", + ip: "127.0.0.1", + last_ts: new Date().getTime(), + stale: false, + }); + + const clusterTransport = new ClusterTransport({ + nodeId: 'some-node-id' + }); + + const sock: Duplex = await new Promise((resolve) => { + const sock = clusterTransport.createConnection({ + port: 10000, + }, () => { + resolve(sock); + }); + }); + + await clusterService.destroy(); + sock.destroy(); + await new Promise((resolve) => { + server.close(() => { + resolve(undefined); + }); + }); + config.destroy(); + }); +}); \ No newline at end of file From 2186acb91a621f09e57d272f53dea3b8dedd6cb8 Mon Sep 17 00:00:00 2001 From: Fredrik Lindberg Date: Fri, 29 Sep 2023 22:39:53 +0200 Subject: [PATCH 6/8] refactor: overhaul and simplify tunnelservice - Handle local and remote connections in the same way - The Tunnel object represents both config and state. Config is written to persistent storage and state is kept in-memory. - Fixes long-standing issues with tunnel disconnection --- package.json | 2 + src/cluster/eventbus.ts | 10 +- src/cluster/index.ts | 29 +- src/ingress/http-ingress.js | 19 +- src/transport/node-socket.js | 44 - src/transport/ssh/ssh-endpoint.js | 6 +- src/transport/ssh/ssh-transport.js | 28 +- src/transport/transport-service.js | 8 +- src/transport/ws/ws-endpoint.js | 2 +- src/tunnel/tunnel-config.ts | 84 ++ src/tunnel/tunnel-service.js | 703 -------------- src/tunnel/tunnel-service.ts | 716 +++++++++++++++ src/tunnel/tunnel-state.js | 13 - src/tunnel/tunnel.js | 66 -- src/tunnel/tunnel.ts | 46 + test/system/ingress/test_http_ingress.js | 10 +- test/unit/cluster/test_node-socket.js | 30 - ...t_ssh-endpoint.js => test_ssh-endpoint.ts} | 9 +- test/unit/test-utils.ts | 2 +- test/unit/tunnel/test_tunnel-service.js | 868 +++++++++++++----- yarn.lock | 17 + 21 files changed, 1581 insertions(+), 1131 deletions(-) delete mode 100644 src/transport/node-socket.js create mode 100644 src/tunnel/tunnel-config.ts delete mode 100644 src/tunnel/tunnel-service.js create mode 100644 src/tunnel/tunnel-service.ts delete mode 100644 src/tunnel/tunnel-state.js delete mode 100644 src/tunnel/tunnel.js create mode 100644 src/tunnel/tunnel.ts delete mode 100644 test/unit/cluster/test_node-socket.js rename test/unit/endpoint/{test_ssh-endpoint.js => test_ssh-endpoint.ts} (90%) diff --git a/package.json b/package.json index b97b4a3..f08125c 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,9 @@ "devDependencies": { "@rollup/plugin-commonjs": "^24.0.1", "@rollup/plugin-json": "^6.0.0", + "@types/mocha": "^10.0.1", "@types/node": "^20.5.0", + "@types/sinon": "^10.0.17", "@types/ws": "^8.5.5", "commit-and-tag-version": "^11.2.1", "mocha": "^10.2.0", diff --git a/src/cluster/eventbus.ts b/src/cluster/eventbus.ts index 8b7972c..a728bc6 100644 --- a/src/cluster/eventbus.ts +++ b/src/cluster/eventbus.ts @@ -2,7 +2,7 @@ import { EventEmitter } from 'events'; import { Logger } from '../logger.js'; import ClusterService from './index.js'; -type EmitMeta = { +export type EmitMeta = { node: { id: string, host: string, @@ -37,7 +37,7 @@ class EventBus extends EventEmitter { return this.clusterService.destroy(); } - public _emit(event: string, message: string, meta: EmitMeta) { + public _emit(event: string, message: any, meta: EmitMeta) { super.emit(event, message, meta); this.logger.isTraceEnabled() && this.logger.trace({ @@ -48,14 +48,14 @@ class EventBus extends EventEmitter { }); } - async publish(event: string, message: object) { + async publish(event: string, message: any) { return this.clusterService.publish(event, message); } - async waitFor(channel: string, predicate: (message: string, meta: EmitMeta) => boolean, timeout: number | undefined) { + async waitFor(channel: string, predicate: (message: any, meta: EmitMeta) => boolean, timeout: number | undefined) { return new Promise((resolve, reject) => { let timer: NodeJS.Timeout; - const fun = (message: string, meta: EmitMeta) => { + const fun = (message: any, meta: EmitMeta) => { if (!predicate(message, meta)) { return; } diff --git a/src/cluster/index.ts b/src/cluster/index.ts index af8dbae..425f216 100644 --- a/src/cluster/index.ts +++ b/src/cluster/index.ts @@ -1,13 +1,14 @@ -import assert from 'assert/strict'; -import crypto from 'crypto'; +import { strict as assert } from 'assert'; +import crypto from 'node:crypto'; import MemoryEventBus from './memory-eventbus.js'; import RedisEventBus from './redis-eventbus.js'; import Node from './cluster-node.js'; import { Logger } from '../logger.js'; import UdpEventBus from './udp-eventbus.js'; import EventBus from './eventbus.js'; +import EventEmitter from 'node:events'; -type ClusterNode = { +export type ClusterNode = { id: string, host: string, ip: string, @@ -22,7 +23,7 @@ type ClusterServiceNode = ClusterNode & { removalTimer?: NodeJS.Timeout, } -class ClusterService { +class ClusterService extends EventEmitter { private static instance: ClusterService | undefined; private static ref: number; @@ -40,6 +41,7 @@ class ClusterService { private _heartbeat: NodeJS.Timeout | undefined; constructor(type?: 'redis' | 'udp' | 'single-node' | 'mem', opts?: any) { + super(); if (ClusterService.instance instanceof ClusterService) { ClusterService.ref++; return ClusterService.instance; @@ -63,9 +65,9 @@ class ClusterService { this._seq = 0; this._window_size = 16; - this._staleTimeout = opts.staleTimeout || 30000; - this._removalTimeout = opts.removalTimeout || 60000; - this._heartbeatInterval = opts.heartbeatInterval || 9500; + this._staleTimeout = opts?.staleTimeout || 30000; + this._removalTimeout = opts?.removalTimeout || 60000; + this._heartbeatInterval = opts?.heartbeatInterval || 9500; this._listeners = []; const onMessage = (payload: string) => { @@ -159,8 +161,11 @@ class ClusterService { } private _learnNode(node: ClusterNode): ClusterServiceNode | undefined { - if (node.id == undefined || node.id == Node.identifier) { - return; + if (node?.id == undefined) { + return undefined; + } + if (node.id == Node.identifier) { + return this._nodes[node.id]; } let cnode: ClusterServiceNode; @@ -209,6 +214,7 @@ class ClusterService { message: `Node ${node.id} ${node.host} (${node.ip}) permanently removed from peer list`, total_nodes: Object.keys(this._nodes).length, }); + this.emit('removed', {nodeId: node.id}); } private _staleNode(node: ClusterServiceNode): void { @@ -219,6 +225,8 @@ class ClusterService { this.logger.debug({ message: `marking ${node.id} as stale` }); + + this.emit('stale', {nodeId: node.id}); } public getSelf(): ClusterNode { @@ -333,7 +341,7 @@ class ClusterService { } } - public async publish(event: any, message: object = {}) { + public async publish(event: any, message: any = {}) { const payload = { event, message, @@ -361,6 +369,7 @@ class ClusterService { if (--ClusterService.ref == 0) { await this._bus.destroy(); this._bus = undefined; + this.removeAllListeners(); ClusterService.instance = undefined; } } diff --git a/src/ingress/http-ingress.js b/src/ingress/http-ingress.js index a69a429..90c4e64 100644 --- a/src/ingress/http-ingress.js +++ b/src/ingress/http-ingress.js @@ -134,11 +134,14 @@ class HttpIngress { if (!tunnelId) { tunnelId = await this.altNameService.get('http', host); if (!tunnelId) { - return tunnelId; + return undefined; } } - - return this.tunnelService.lookup(tunnelId); + try { + return this.tunnelService.lookup(tunnelId); + } catch (e) { + return false; + } } _clientIp(req) { @@ -223,9 +226,9 @@ class HttpIngress { const host = headers['host']; let target; - if (tunnel.target.url) { + if (tunnel.config.target.url) { try { - target = new URL(tunnel.target.url); + target = new URL(tunnel.config.target.url); } catch {} } if (target === undefined || !target.protocol.startsWith('http')) { @@ -278,14 +281,14 @@ class HttpIngress { return true; } - if (!tunnel.state().connected) { + if (!tunnel.state.connected) { httpResponse(502, { error: ERROR_TUNNEL_NOT_CONNECTED, }); return true; } - if (!tunnel.ingress?.http?.enabled) { + if (!tunnel.config.ingress?.http?.enabled) { httpResponse(403, { error: ERROR_TUNNEL_HTTP_INGRESS_DISABLED, }); @@ -366,7 +369,7 @@ class HttpIngress { return true; } - if (!tunnel.state().connected) { + if (!tunnel.state.connected) { _canonicalHttpResponse(sock, req, { status: 502, statusLine: 'Bad Gateway', diff --git a/src/transport/node-socket.js b/src/transport/node-socket.js deleted file mode 100644 index a41472b..0000000 --- a/src/transport/node-socket.js +++ /dev/null @@ -1,44 +0,0 @@ -import { Socket } from 'net'; - -class NodeSocket extends Socket { - constructor(opts) { - super(); - const {tunnelId, node, port} = opts; - this._tunnelId = tunnelId; - this._node = node; - this._port = port; - this._canonicalConnect = this.connect; - this.connect = (_opt, callback) => { - this.connecting = true; - setImmediate(async () => { - this._doConnect(callback); - }) - }; - } - - async destroy() { - super.destroy(); - } - - static createConnection(opts, callback) { - const sock = new NodeSocket(opts); - sock.connect({}, callback); - return sock; - } - - toString() { - return `<${NodeSocket.name} tunnel=${this._tunnelId} target=${this._node.id}>`; - } - - async _doConnect(callback) { - this._canonicalConnect({ - host: this._node.ip, - port: this._port, - setDefaultEncoding: 'binary' - }, () => { - typeof callback ==='function' && callback(); - }); - } -} - -export default NodeSocket; \ No newline at end of file diff --git a/src/transport/ssh/ssh-endpoint.js b/src/transport/ssh/ssh-endpoint.js index bcaee42..1385f15 100644 --- a/src/transport/ssh/ssh-endpoint.js +++ b/src/transport/ssh/ssh-endpoint.js @@ -93,7 +93,7 @@ class SSHEndpoint { const host = this.opts.host ?? baseUrl.hostname; const port = this.opts.port; const username = tunnel.id; - const password = tunnel.transport.token; + const password = tunnel.config.transport.token; const fingerprint = this._fingerprint; let url; @@ -142,7 +142,7 @@ class SSHEndpoint { tunnel = authResult.tunnel; account = authResult.account; - if (tunnel.state().connected) { + if (tunnel.state.connected) { return reject(); } @@ -152,7 +152,7 @@ class SSHEndpoint { client.on('ready', async (ctx) => { const transport = new SSHTransport({ tunnelId: tunnel.id, - target: tunnel.target.url, + target: tunnel.config.target.url, max_connections: this.opts.max_connections, client, }); diff --git a/src/transport/ssh/ssh-transport.js b/src/transport/ssh/ssh-transport.js index e131be4..7ed5aaf 100644 --- a/src/transport/ssh/ssh-transport.js +++ b/src/transport/ssh/ssh-transport.js @@ -27,17 +27,22 @@ class SSHTransport extends Transport { accept(); }); session.on('shell', async (accept, reject) => { - const tunnel = await this._tunnelService.lookup(opts.tunnelId); - if (!(tunnel instanceof Tunnel) || tunnel.id != opts.tunnelId) { + let tunnel; + try { + tunnel = await this._tunnelService.lookup(opts.tunnelId); + if (tunnel.id != opts.tunnelId) { + return reject(); + } + } catch (e) { return reject(); } const stream = accept(); stream.write(`Target URL: ${this._target.href}\r\n`); - Object.keys(tunnel.ingress).forEach((ing) => { - if (!tunnel.ingress[ing].enabled) { + Object.keys(tunnel.config.ingress).forEach((ing) => { + if (!tunnel.config.ingress[ing].enabled) { return; } - tunnel.ingress[ing]?.urls?.forEach((url) => { + tunnel.config.ingress[ing]?.urls?.forEach((url) => { stream.write(`${ing.toUpperCase()} ingress: ${url}\r\n`); }); }); @@ -55,8 +60,13 @@ class SSHTransport extends Transport { return reject(); } - const tunnel = await this._tunnelService.lookup(opts.tunnelId); - if (!(tunnel instanceof Tunnel) || tunnel.id != opts.tunnelId) { + let tunnel; + try { + tunnel = await this._tunnelService.lookup(opts.tunnelId); + if (tunnel.id != opts.tunnelId) { + return reject(); + } + } catch (e) { return reject(); } @@ -64,8 +74,8 @@ class SSHTransport extends Transport { if (bindUrl && bindUrl.hostname != 'localhost') { if (bindUrl.href != this._target.href) { this._target = bindUrl; - this._tunnelService.update(tunnel.id, tunnel.account, (tunnel) => { - tunnel.target.url = bindUrl.href; + this._tunnelService.update(tunnel.id, tunnel.account, (tunnelConfig) => { + tunnelConfig.target.url = bindUrl.href; }); this.logger.info({ operation: 'update-target', diff --git a/src/transport/transport-service.js b/src/transport/transport-service.js index b2faa80..43f60f2 100644 --- a/src/transport/transport-service.js +++ b/src/transport/transport-service.js @@ -63,16 +63,16 @@ class TransportService { max_connections: this.max_connections }; - if (tunnel.transport?.ws?.enabled === true && this._transports.ws) { + if (tunnel.config.transport?.ws?.enabled === true && this._transports.ws) { transports.ws = { - ...tunnel.transport.ws, + ...tunnel.config.transport.ws, ...this._transports.ws.getEndpoint(tunnel, baseUrl), }; } - if (tunnel.transport?.ssh?.enabled === true && this._transports.ssh) { + if (tunnel.config.transport?.ssh?.enabled === true && this._transports.ssh) { transports.ssh = { - ...tunnel.transport.ssh, + ...tunnel.config.transport.ssh, ...this._transports.ssh.getEndpoint(tunnel, baseUrl), }; } diff --git a/src/transport/ws/ws-endpoint.js b/src/transport/ws/ws-endpoint.js index f7ad6e9..0053edd 100644 --- a/src/transport/ws/ws-endpoint.js +++ b/src/transport/ws/ws-endpoint.js @@ -48,7 +48,7 @@ class WebSocketEndpoint { const url = new URL(baseUrl); url.protocol = baseUrl.protocol == 'https:' ? 'wss' : 'ws'; url.pathname = `${WebSocketEndpoint.BASE_PATH}/${tunnel.id}/ws-endpoint`; - url.search = '?' + querystring.encode({t: tunnel.transport.token}); + url.search = '?' + querystring.encode({t: tunnel.config.transport.token}); return { url: url.href, }; diff --git a/src/tunnel/tunnel-config.ts b/src/tunnel/tunnel-config.ts new file mode 100644 index 0000000..dcce0cb --- /dev/null +++ b/src/tunnel/tunnel-config.ts @@ -0,0 +1,84 @@ +import { Serializable } from "../storage/serializer.js"; + +type TunnelTransportConfig = { + token?: string, + max_connections: number, + ws: TunnelTransportTypeConfig, + ssh: TunnelTransportTypeConfig, +} + +type TunnelTransportTypeConfig = { + enabled: boolean, +} + +export type TunnelIngressConfig = { + http: TunnelHttpIngressConfig, + sni: TunnelIngressTypeConfig, +} + +type TunnelIngressTypeConfig = { + enabled: boolean, + url: string | undefined, + urls: Array, +} + +type TunnelHttpIngressConfig = TunnelIngressTypeConfig & { + alt_names: Array, +} + +type TunnelTargetConfig = { + url: string | undefined +} + +export class TunnelConfig implements Serializable { + public id: string; + public account: string; + + public transport: TunnelTransportConfig = { + token: undefined, + max_connections: 1, + ws: { + enabled: false + }, + ssh: { + enabled: false + } + } + + public ingress: TunnelIngressConfig = { + http: { + enabled: false, + url: undefined, + urls: [], + alt_names: [], + }, + sni: { + enabled: false, + url: undefined, + urls: [], + } + } + + public target: TunnelTargetConfig = { + url: undefined + } + + public created_at?: string; + public updated_at?: string; + + constructor(tunnelId: string, account: string) { + this.id = tunnelId; + this.account = account; + } +} + +export function cloneTunnelConfig(tunnelConfig: TunnelConfig): TunnelConfig { + const stringify = (object: any) => JSON.stringify(object, (key, value) => { + if (key[0] == '_') { + return undefined; + } + return value; + }); + + return Object.assign(new TunnelConfig(tunnelConfig.id, tunnelConfig.account), JSON.parse(stringify(tunnelConfig))); +} \ No newline at end of file diff --git a/src/tunnel/tunnel-service.js b/src/tunnel/tunnel-service.js deleted file mode 100644 index fe9d783..0000000 --- a/src/tunnel/tunnel-service.js +++ /dev/null @@ -1,703 +0,0 @@ -import assert from 'assert/strict'; -import crypto from 'crypto'; -import AccountService from '../account/account-service.js'; -import Account from '../account/account.js'; -import EventBus from '../cluster/eventbus.js'; -import ClusterService from '../cluster/index.js'; -import Ingress from '../ingress/index.js'; -import { Logger } from '../logger.js'; -import Storage from '../storage/index.js'; -import NodeSocket from '../transport/node-socket.js'; -import { safeEqual } from "../utils/misc.js"; -import TunnelState from './tunnel-state.js'; -import Tunnel from './tunnel.js'; -import Node from '../cluster/cluster-node.js'; - -class TunnelService { - - static TUNNEL_ID_REGEX = /^(?:[a-z0-9][a-z0-9\-]{4,63}[a-z0-9]|[a-z0-9]{4,63})$/; - - constructor() { - if (TunnelService.instance instanceof TunnelService) { - TunnelService.ref++; - return TunnelService.instance; - } - TunnelService.ref = 1; - TunnelService.instance = this; - - this.tunnelAnnounceInterval = 5000; - this.tunnelAnnounceBatchSize = 50; - this.tunnelConnectionAliveThreshold = 15000; - this.tunnelDeadSweepInterval = 1000; - this.tunnelConnectionDeleteThreshold = 300 * 1000; - this.tunnelDeleteSweepInterval = 60 * 1000; - - this.logger = Logger("tunnel-service"); - this._accountService = new AccountService(); - this._db = new Storage("tunnel"); - this._eventBus = new EventBus(); - this._clusterService = new ClusterService(); - this._ingress = new Ingress(); - this._ready = true; - - this._connectedTunnels = {}; - - this._tunnels = { - state: { - tunnels: {} - }, - - learn: (tunnelId, connections, meta) => { - const state = this._tunnels.state; - - state.tunnels[tunnelId] ??= { - connections: {}, - lastCon: undefined, - connected: false, - markSweep: setInterval(() => { - this._tunnels._markDeadConnections(tunnelId) - }, this.tunnelDeadSweepInterval), - deleteSweep: setInterval(() => { - this._tunnels._deleteDeadConnections(tunnelId) - }, this.tunnelDeleteSweepInterval) - }; - - const tunnel = state.tunnels[tunnelId]; - const cids = Array.from(new Set([ - ...Object.keys(tunnel.connections).filter((cid) => tunnel.connections[cid].node == meta.node.id), - ...Object.keys(connections) - ])); - - cids.forEach((cid) => { - const con = connections[cid]; - if (con) { - tunnel.connections[con.id] = { - ...con, - node: meta.node.id, - alive_at: meta.ts, - alive: true, - local: this._connectedTunnels[tunnelId]?.connections?.[con.id] != undefined, - }; - } else { - tunnel.connections[cid].alive = false; - tunnel.connections[cid].dead_at = meta.ts; - } - }); - - this._tunnels._updateConnectionState(tunnelId, meta.ts); - }, - - _updateConnectionState: (tunnelId, ts) => { - const tunnel = this._tunnels.state.tunnels[tunnelId]; - const was_connected = tunnel.connected; - - tunnel.connected_at = Object.keys(tunnel.connections).map((cid) => tunnel.connections[cid].connected_at).sort()[0]; - tunnel.connected = Object.keys(tunnel.connections).filter((cid) => tunnel.connections[cid].alive == true).length > 0; - tunnel.alive_at = Object.keys(tunnel.connections).map((cid) => tunnel.connections[cid].alive_at).sort((a, b) => b - a)[0]; - - if (!tunnel.connected && was_connected) { - tunnel.disconnected_at = ts; - } else if (tunnel.connected) { - tunnel.disconnected_at = undefined; - } - }, - - _markDeadConnections: (tunnelId) => { - const tunnel = this._tunnels.state.tunnels[tunnelId]; - if (!tunnel) { - return; - } - - const current_ts = Date.now(); - Object.keys(tunnel.connections).forEach((cid) => { - const con = tunnel.connections[cid]; - if (con.alive && (con.alive_at + this.tunnelConnectionAliveThreshold) < current_ts) { - con.alive = false; - con.dead_at = current_ts; - } - }); - this._tunnels._updateConnectionState(tunnelId, current_ts); - }, - - _deleteDeadConnections: (tunnelId) => { - const tunnel = this._tunnels.state.tunnels[tunnelId]; - if (!tunnel) { - return; - } - - const current_ts = Date.now(); - const dead_thres = this.tunnelConnectionDeleteThreshold; - Object.keys(tunnel.connections).forEach((cid) => { - const con = tunnel.connections[cid]; - if (!con.alive && (current_ts > (con.dead_at + dead_thres))) { - delete tunnel.connections[cid]; - } - }); - - if (Object.keys(tunnel.connections) == 0) { - clearTimeout(tunnel.markSweep); - clearTimeout(tunnel.deleteSweep); - delete this._tunnels.state[tunnelId]; - } - }, - - get: (tunnelId) => { - const tunnel = this._tunnels.state.tunnels[tunnelId]; - return tunnel; - }, - - getState: (tunnelId) => { - const tunnel = this._tunnels.state.tunnels[tunnelId]; - const tunnelState = new TunnelState(); - if (!tunnel) { - return tunnelState; - } - - tunnelState.connected = tunnel.connected; - tunnelState.connected_at = tunnel.connected_at ? new Date(tunnel.connected_at).toISOString() : undefined, - tunnelState.disconnected_at = tunnel.disconnected_at ? new Date(tunnel.disconnected_at).toISOString() : undefined; - tunnelState.alive_at = tunnel.alive_at ? new Date(tunnel.alive_at).toISOString() : undefined; - tunnelState.connections = Object.keys(tunnel.connections) - .map(cid => tunnel.connections[cid]) - .filter(con => con.alive) - .map((con => { - return { - connection_id: con.id, - node_id: con.node, - peer: con.peer, - alive_at: con.alive_at ? new Date(con.alive_at).toISOString() : undefined, - connected_at: con.connected_at ? new Date(con.connected_at).toISOString() : undefined, - } - })); - - return tunnelState; - }, - - getNextConnection: (tunnelId) => { - const tunnel = this._tunnels.state.tunnels[tunnelId]; - if (!tunnel) { - return undefined; - } - - const localCons = Object.keys(this._connectedTunnels[tunnelId]?.connections || {}); - if (localCons.length > 0) { - const idx = (localCons.indexOf(tunnel.lastCon) + 1) % localCons.length; - const nextCon = localCons[idx]; - tunnel.lastCon = nextCon; - return { - cid: nextCon, - local: true, - } - } else { - const remoteNodes = Array.from(new Set(Object.keys(tunnel.connections).filter((cid) => { - const con = tunnel.connections[cid]; - return con.alive && !con.local; - }) - .map((cid) => { - const con = tunnel.connections[cid]; - return con.node; - }))); - - const idx = (remoteNodes.indexOf(tunnel.lastCon) + 1) % remoteNodes.length; - const nextNode = remoteNodes[idx]; - tunnel.lastCon = nextNode; - return { - node: nextNode, - local: false, - } - } - }, - }; - - this._eventBus.on('tunnel:announce', (state, meta) => { - const tunnelIds = Object.keys(state); - tunnelIds.forEach((tunnelId) => { - setImmediate(() => { - const tunnel = state[tunnelId]; - this._tunnels.learn(tunnelId, tunnel.connections, meta) - }); - }); - }); - - this._eventBus.on('tunnel:disconnect', (message, meta) => { - const tunnelId = message?.tunnel; - if (!tunnelId) { - return; - } - this._closeTunnelConnection(tunnelId, message.connection); - }); - - const announceTunnels = async () => { - await this._announceTunnels(); - this._announceTimer = setTimeout(announceTunnels, this.tunnelAnnounceInterval); - }; - this._announceTimer = setTimeout(announceTunnels, this.tunnelAnnounceInterval); - } - - async destroy() { - if (--TunnelService.ref == 0) { - this.destroyed = true; - clearTimeout(this._announceTimer); - await this.end(); - await Promise.allSettled([ - this._db.destroy(), - this._clusterService.destroy(), - this._eventBus.destroy(), - this._accountService.destroy(), - this._ingress.destroy(), - ]); - delete TunnelService.instance; - } - } - - async end() { - this._ready = false; - const tunnels = Object.keys(this._connectedTunnels).map(async (tunnelId) => { - const tunnel = await this.lookup(tunnelId); - return this._disconnect(tunnel); - }); - await Promise.allSettled(tunnels); - } - - _isPermitted(tunnel, accountId) { - if (!(tunnel instanceof Tunnel)) { - return false; - } - return tunnel.isOwner(accountId); - } - - async _get(tunnelId) { - assert(tunnelId != undefined); - - const tunnel = await this._db.read(tunnelId, Tunnel); - if (tunnel instanceof Array) { - Promise.allSettled(tunnel.map((t) => { - return new Promise(async (resolve) => { - t._state = this._tunnels.getState(t); - resolve(); - }); - })); - } else if (tunnel instanceof Tunnel) { - tunnel._state = this._tunnels.getState(tunnelId); - } else { - return false; - } - - return tunnel; - } - - async get(tunnelId, accountId) { - assert(tunnelId != undefined); - assert(accountId != undefined); - - if (this.destroyed) { - return false; - } - - const tunnel = await this._get(tunnelId); - if (!this._isPermitted(tunnel, accountId)) { - return false; - } - - this.logger.isDebugEnabled() && this.logger.debug({ - operation: 'get_tunnel', - tunnel: tunnel.id, - account: tunnel.account, - }); - return tunnel; - } - - async lookup(tunnelId) { - return this._get(tunnelId); - } - - async list(cursor, count = 10, verbose = false) { - const res = await this._db.list(cursor, count); - const data = verbose ? await this._get(res.data) : res.data.map((id) => { return {tunnel_id: id}; }); - return { - cursor: res.cursor, - tunnels: data, - } - } - - async create(tunnelId, accountId) { - assert(tunnelId != undefined); - assert(accountId != undefined); - - const tunnel = new Tunnel(tunnelId, accountId); - tunnel.created_at = new Date().toISOString(); - tunnel.updated_at = tunnel.created_at; - tunnel.transport.token = crypto.randomBytes(64).toString('base64url'); - const created = await this._db.create(tunnelId, tunnel); - if (!created) { - return false; - } - - await this._accountService.update(accountId, (account) => { - if (!account.tunnels.includes(tunnelId)) { - account.tunnels.push(tunnelId); - } - }); - - this.logger.isDebugEnabled() && this.logger.debug({ - operation: 'create_tunnel', - tunnel: tunnel.id, - account: tunnel.account, - }); - return created; - } - - async update(tunnelId, accountId, cb) { - assert(tunnelId != undefined); - assert(accountId != undefined); - return this._db.update(tunnelId, Tunnel, async (tunnel) => { - if (!this._isPermitted(tunnel, accountId)) { - return false; - } - - const orig = tunnel.clone(); - cb(tunnel); - - const updatedIngress = await this._ingress.updateIngress(tunnel, orig); - if (updatedIngress instanceof Error) { - const err = updatedIngress; - this.logger.isDebugEnabled() && - this.logger - .withContext('tunnel', tunnelId) - .debug({ - operation: 'update_tunnel', - msg: 'update ingress failed', - err: err.message, - }); - return err; - } - tunnel.ingress = updatedIngress; - tunnel.updated_at = new Date().toISOString(); - - return true; - }); - } - - async delete(tunnelId, accountId) { - assert(tunnelId != undefined); - assert(accountId != undefined); - - const tunnel = await this.get(tunnelId, accountId); - if (tunnel instanceof Tunnel == false) { - return false; - } - if (!await this.disconnect(tunnelId, accountId)) { - this.logger - .withContext('tunnel', tunnelId) - .error({ - operation: 'delete_tunnel', - msg: 'tunnel still connected' - }); - return false; - }; - - const updateAccount = this._accountService.update(accountId, (account) => { - const pos = account.tunnels.indexOf(tunnelId); - if (pos >= 0) { - account.tunnels.splice(pos, 1); - } - }); - - try { - await Promise.all([ - this._ingress.deleteIngress(tunnel), - this._db.delete(tunnelId), - updateAccount, - ]); - } catch (e) { - this.logger - .withContext('tunnel', tunnelId) - .error({ - operation: 'delete_tunnel', - message: `failed to delete tunnel: ${e.message}`, - stack: `${e.stack}`, - }); - return false; - } - - this.logger.isDebugEnabled() && this.logger.debug({ - operation: 'delete_tunnel', - tunnel: tunnelId, - account: accountId, - }); - return true; - } - - async connect(tunnelId, accountId, transport, opts) { - assert(tunnelId != undefined); - assert(accountId != undefined); - - if (!this._ready) { - return false; - } - - let tunnel = await this.get(tunnelId, accountId); - if (tunnel instanceof Tunnel == false) { - return false; - } - - if (tunnel.state().connections.length >= transport.max_connections) { - this.logger - .withContext('tunnel',tunnelId) - .info({ - message: `Refused transport connection, current connections ${tunnel.state().connections.length}, max connections ${transport.max_connections}`, - operation: 'connect_tunnel', - connections: tunnel.state().connections.length, - max_connections: transport.max_connections, - }); - return false; - } - - const connection = { - id: `${Node.identifier}:${crypto.randomUUID()}`, - transport, - state: { - peer: opts.peer, - connected_at: Date.now(), - } - }; - this._connectedTunnels[tunnelId] ??= { - connections: {} - }; - this._connectedTunnels[tunnelId].connections[connection.id] = connection; - - transport.once('close', async () => { - this._closeTunnelConnection(tunnelId, connection.id); - }); - - this._announceTunnel(tunnelId); - - await this.update(tunnelId, accountId, (tunnel) => { - tunnel.transport.token = crypto.randomBytes(64).toString('base64url'); - }); - - this.logger - .withContext("tunnel", tunnelId) - .info({ - operation: 'connect_tunnel', - peer: opts.peer, - msg: 'tunnel connected', - }); - return true; - } - - _announceTunnel(tunnelId) { - const tunnel = this._connectedTunnels[tunnelId]; - if (!tunnel) { - return false; - } - - const announce = {}; - announce[tunnelId] = { - connections: Object.keys(tunnel.connections).map((cid) => { - const c = tunnel.connections[cid]; - return { - id: c.id, - peer: c.state.peer, - connected_at: c.state.connected_at, - }; - }), - }; - return this._eventBus.publish("tunnel:announce", announce); - } - - _announceTunnels() { - const tunnelIds = Object.keys(this._connectedTunnels); - const batchsize = this.tunnelAnnounceBatchSize; - - return new Promise((resolve) => { - const processChunk = async () => { - const chunk = tunnelIds.splice(0, batchsize); - - const tunnels = chunk.map((tunnelId) => { - const tunnel = this._connectedTunnels[tunnelId]; - return { - tunnel: tunnelId, - connections: Object.keys(tunnel?.connections || {}).map((cid) => { - const c = tunnel.connections[cid]; - return { - id: cid, - peer: c?.state?.peer, - connected_at: c?.state?.connected_at, - } - }) - } - }).reduce((acc, cur) => { - acc[cur.tunnel] = { - connections: cur.connections - } - return acc; - }, {}); - - await this._eventBus.publish("tunnel:announce", tunnels); - if (tunnelIds.length > 0) { - setImmediate(processChunk); - } else { - resolve(); - } - }; - - if (tunnelIds.length > 0) { - setImmediate(processChunk); - } else { - resolve(); - } - }); - } - - async _closeTunnelConnection(tunnelId, cid) { - const tunnel = this._connectedTunnels[tunnelId]; - if (!tunnel) { - return; - } - - let cids = []; - if (cid) { - cids.push(cid); - } else { - cids = Object.keys(tunnel.connections); - } - - const cons = cids.map(cid => { - const con = tunnel.connections[cid]; - return new Promise(async (resolve, reject) => { - if (!con) { - return resolve(); - } - try { - await con.transport.destroy(); - } catch (e) { - this.logger - .withContext("tunnel", tunnelId) - .error({ - message: `failed to gracefully close connection ${cid} to peer ${con.peer}`, - }); - } - delete tunnel.connections[cid]; - resolve(); - }) - }) - - const res = await Promise.allSettled(cons); - await this._announceTunnel(tunnelId); - - if (Object.keys(tunnel.connections).length == 0) { - delete this._connectedTunnels[tunnelId]; - } - } - - async _disconnect(tunnel, connection) { - assert(tunnel instanceof Tunnel); - const tunnelId = tunnel.id; - - let state = this._tunnels.get(tunnelId) - if (!state?.connected) { - return true; - } - - const announces = Array.from(new Set(Object.keys(state.connections) - .map(cid => state.connections[cid].node))) - .map(node => { - return this._eventBus.waitFor('tunnel:announce', (announce, meta) => { - return announce.tunnel == tunnelId && node == meta.node.id - }, 500); - }); - - this._eventBus.publish('tunnel:disconnect', { - tunnel: tunnelId, - connection - }); - - await Promise.allSettled(announces); - state = this._tunnels.get(tunnelId) - return state?.connected == false; - } - - async disconnect(tunnelId, accountId) { - assert(tunnelId != undefined); - assert(accountId != undefined); - - const tunnel = await this.get(tunnelId, accountId); - if (!this._isPermitted(tunnel, accountId)) { - return undefined; - } - return this._disconnect(tunnel); - } - - async authorize(tunnelId, token) { - const result = { - authorized: false, - tunnel: undefined, - account: undefined, - }; - - try { - const tunnel = await this._get(tunnelId); - if (!(tunnel instanceof Tunnel)) { - return result; - } - const account = await this._accountService.get(tunnel.account); - if (!(account instanceof Account)) { - return result; - } - const correctToken = safeEqual(token, tunnel?.transport?.token) - - result.authorized = correctToken && !account.status.disabled; - if (result.authorized) { - result.tunnel = tunnel; - result.account = account; - result.disabled = account.status.disabled; - } - } catch (e) { - result.error = e; - } - - return result; - } - - isLocalConnected(tunnelId) { - return this._connectedTunnels[tunnelId] != undefined; - } - - createConnection(tunnelId, ctx, callback) { - let next = this._tunnels.getNextConnection(tunnelId); - if (!next) { - return false; - } - - if (next.cid) { - const connection = this._connectedTunnels[tunnelId].connections[next.cid]; - return connection.transport.createConnection(ctx.opts, callback); - } - - let prev; - do { - const node = this._clusterService.getNode(next.node); - if (node && !next.local) { - this.logger.withContext('tunnel', tunnelId).debug({ - operation: 'connection-redirect', - next: node.id, - ip: node.ip, - port: ctx.ingress.port, - }); - return NodeSocket.createConnection({ - tunnelId, - node, - port: ctx.ingress.port, - }, callback); - } - prev = next; - next = this._tunnels.getNextConnection(tunnelId); - } while (next != undefined && next.id != prev?.id); - - return false; - } - -} - -export default TunnelService; \ No newline at end of file diff --git a/src/tunnel/tunnel-service.ts b/src/tunnel/tunnel-service.ts new file mode 100644 index 0000000..2fc9ee2 --- /dev/null +++ b/src/tunnel/tunnel-service.ts @@ -0,0 +1,716 @@ +import crypto from 'node:crypto'; + +import AccountService from "../account/account-service.js"; +import EventBus, { EmitMeta } from "../cluster/eventbus.js"; +import ClusterService from "../cluster/index.js"; +import { Logger } from "../logger.js"; +import Storage from "../storage/index.js"; +import { TunnelConfig, TunnelIngressConfig, cloneTunnelConfig } from "./tunnel-config.js"; +import { Tunnel, TunnelConnection, TunnelConnectionId, TunnelState } from "./tunnel.js"; +import Account from '../account/account.js'; +import Ingress from '../ingress/index.js'; +import { safeEqual } from '../utils/misc.js'; +import { Duplex } from 'node:stream'; +import Transport from '../transport/transport.js'; +import Node from '../cluster/cluster-node.js'; +import ClusterTransport from '../transport/cluster/cluster-transport.js'; + +export type ConnectOptions = { + peer: string, +} + +export type CreateConnectionContext = { + ingress: { + port: number, + tls?: boolean, + } +}; + +type AuthorizeResult = { + authorized: boolean, + disabled: boolean, + tunnel?: Tunnel, + account?: Account, + error?: Error, +}; + +type TunnelListResult = { + cursor: string | null, + tunnels: Array, +}; + +interface TunnelConnectionAnnounce { + connection_id: string, + node: string, + peer: string, + connected: boolean, + connected_at?: number, + disconnected_at?: number, +}; + +interface TunnelAnnounce { + tunnel_id: string; + connections: Array; +} + +interface TunnelDisconnectRequest { + tunnel_id: string; +} + +export default class TunnelService { + + static TUNNEL_ID_REGEX = /^(?:[a-z0-9][a-z0-9\-]{4,63}[a-z0-9]|[a-z0-9]{4,63})$/; + + static instance?: TunnelService; + static ref: number; + + private tunnelAnnounceInterval: number = 5000; + private tunnelAnnounceBatchSize: number = 50; + private stateRefreshInterval: number = 10000; + + private tunnelConnectionAliveThreshold: number = 15000; + private tunnelConnectionRemoveThreshold: number = 60000; + private tunnelRemoveThreshold: number = 60000 * 5; + + private announceTimer: NodeJS.Timeout | undefined; + private stateRefreshTimer: NodeJS.Timeout | undefined; + + private ended: boolean = false; + private destroyed: boolean = false; + private logger: any; + private storage!: Storage; + private ingress!: Ingress; + private eventBus!: EventBus; + private clusterService!: ClusterService; + private accountService!: AccountService; + + private connectedTunnels!: { [ id: string ]: TunnelState }; + private lastConnection!: { [ id: string]: string }; + + constructor() { + if (TunnelService.instance instanceof TunnelService) { + TunnelService.ref++; + return TunnelService.instance; + } + TunnelService.ref = 1; + TunnelService.instance = this; + + this.logger = Logger("tunnel-service"); + this.storage = new Storage("tunnel"); + this.ingress = new Ingress(); + this.eventBus = new EventBus(); + this.clusterService = new ClusterService(); + this.accountService = new AccountService(); + + this.connectedTunnels = {} + this.lastConnection = {}; + + this.eventBus.on('tunnel:announce', (announcement: Array, meta: EmitMeta) => { + this.learnRemoteTunnels(announcement, meta); + }); + + this.eventBus.on('tunnel:disconnect', async (request: TunnelDisconnectRequest) => { + await this.disconnectLocalTunnel(request.tunnel_id); + }); + + const announceTunnels = async () => { + await this.announceLocalTunnels(); + this.announceTimer = setTimeout(announceTunnels, this.tunnelAnnounceInterval); + }; + this.announceTimer = setTimeout(announceTunnels, this.tunnelAnnounceInterval); + + const refreshState = () => { + this.refreshConnectionState(); + this.stateRefreshTimer = setTimeout(refreshState, this.stateRefreshInterval); + }; + this.stateRefreshTimer = setTimeout(refreshState, this.stateRefreshInterval); + } + + public async destroy(): Promise { + if (this.destroyed) { + return; + } + + if (--TunnelService.ref == 0) { + await this.end(); + + clearTimeout(this.announceTimer); + this.announceTimer = undefined; + + clearTimeout(this.stateRefreshTimer); + this.stateRefreshTimer = undefined; + + this.destroyed = true + await Promise.allSettled([ + this.storage.destroy(), + this.eventBus.destroy(), + this.clusterService.destroy(), + this.accountService.destroy(), + this.ingress.destroy(), + ]); + TunnelService.instance = undefined; + } + } + + public async end(): Promise { + const results = Object.keys(this.connectedTunnels).map((tunnelId: string) => this.disconnectLocalTunnel(tunnelId)) + await Promise.allSettled(results); + this.ended = true; + } + + private async announceLocalTunnels(): Promise; + private async announceLocalTunnels(tunnelIds: Array): Promise; + private async announceLocalTunnels(tunnelIds?: Array): Promise { + + if (tunnelIds == undefined) { + tunnelIds = Object.keys(this.connectedTunnels).filter((tunnelId: string) => { + const state = this.connectedTunnels[tunnelId]; + return state.connections.filter((con) => con.local).length > 0; + }); + } + + const _tunnelIds: Array = tunnelIds; + const batchsize = this.tunnelAnnounceBatchSize; + + return new Promise((resolve) => { + const processChunk = async () => { + const chunk = _tunnelIds.splice(0, batchsize); + + const tunnels: Array = chunk.map((tunnelId) => { + const state = this.connectedTunnels[tunnelId]; + + const localConnections: Array = state.connections + .filter((con) => con.local) + .map((con) => { + return { + connection_id: con.connection_id, + node: con.node, + peer: con.peer, + connected: con.connected, + connected_at: con.connected_at, + disconnected_at: con.disconnected_at, + } + }); + + return { + tunnel_id: tunnelId, + connections: localConnections, + } + }); + + await this.eventBus.publish("tunnel:announce", tunnels); + if (_tunnelIds.length > 0) { + setImmediate(processChunk); + } else { + resolve(); + } + }; + + if (_tunnelIds.length > 0) { + setImmediate(processChunk); + } else { + resolve(); + } + }); + } + + private learnRemoteTunnels(tunnels: Array, meta: EmitMeta): void { + const nodeId = meta.node.id; + if (nodeId == Node.identifier) { + return; + } + + for (const tunnel of tunnels) { + this.connectedTunnels[tunnel.tunnel_id] ??= { + connected: false, + alive_connections: 0, + connections: [], + } + + const state = this.connectedTunnels[tunnel.tunnel_id]; + state.connections = state.connections.filter((con) => con.node != nodeId); + + const connections: Array = tunnel.connections.map((con) => { + const transport = con.connected ? new ClusterTransport({nodeId}) : undefined; + return { + connection_id: con.connection_id, + node: con.node, + peer: con.peer, + connected: con.connected, + connected_at: con.connected_at, + disconnected_at: con.disconnected_at, + alive_at: Date.now(), + local: false, + transport, + } + }); + + this.connectedTunnels[tunnel.tunnel_id].connections = state.connections.concat(connections); + this.updateTunnelState(tunnel.tunnel_id); + } + } + + private refreshConnectionState(): void { + const cur = Date.now(); + + for (const tunnelId in this.connectedTunnels) { + const state = this.connectedTunnels[tunnelId]; + + // Mark remote connections that have an alive_at timestamp + // exceeding the connection alive threshold as disconnected. + state.connections = state.connections.map((con) => { + if (!con.local && con.connected && (cur - con.alive_at) > this.tunnelConnectionAliveThreshold) { + con.connected = false; + con.disconnected_at = cur; + } + return con; + }); + + // Keep connections that are connected or disconnected + // but within the connection removal threshold. + const connections = []; + for (const con of state.connections) { + if (con.connected) { + connections.push(con); + continue; + } + else if (con.disconnected_at && ((cur - con.disconnected_at) < this.tunnelConnectionRemoveThreshold)) { + connections.push(con); + continue; + } + con.transport?.destroy(); + con.transport = undefined; + } + + this.connectedTunnels[tunnelId].connections = connections; + this.updateTunnelState(tunnelId); + + if (!state.connected && + state.connections.length == 0 && + (!state.disconnected_at || (cur - state.disconnected_at) > this.tunnelRemoveThreshold)) { + delete this.connectedTunnels[tunnelId]; + delete this.lastConnection[tunnelId] + } + } + } + + private async disconnectLocalTunnel(tunnelId: string, connectionId?: string): Promise { + const state = this.connectedTunnels[tunnelId]; + if (!state) { + return; + } + + for (const con of state.connections.filter((tc) => connectionId == undefined || tc.connection_id == connectionId)) { + if (!con.local) { + continue; + } + await con.transport?.destroy(); + con.transport = undefined; + con.disconnected_at = Date.now(); + con.connected = false; + } + if (this.updateTunnelState(tunnelId)) { + this.announceLocalTunnels([tunnelId]); + } + } + + private async _get(tunnelId: string): Promise; + private async _get(tunnelIds: Array): Promise>; + private async _get(tunnelIds: any): Promise { + const tunnelConfig = await this.storage.read(tunnelIds, TunnelConfig); + if (tunnelConfig instanceof Array) { + return tunnelConfig.map((tc: TunnelConfig) => { + const state = this.connectedTunnels[tc.id]; + return new Tunnel(tc, state); + }); + + } else if (tunnelConfig instanceof TunnelConfig) { + const state = this.connectedTunnels[tunnelConfig.id]; + return new Tunnel(tunnelConfig, state); + } else { + if (tunnelIds instanceof Array) { + return []; + } else { + throw Error('no_such_tunnel'); + } + } + } + + private _isPermitted(tunnel: Tunnel, accountId: string): boolean { + return accountId != undefined && accountId === tunnel.config.account; + } + + public async create(tunnelId: string, accountId: string): Promise { + const tunnelConfig = new TunnelConfig(tunnelId, accountId); + tunnelConfig.created_at = new Date().toISOString(); + tunnelConfig.updated_at = tunnelConfig.created_at; + tunnelConfig.transport.token = crypto.randomBytes(64).toString('base64url'); + + const created: boolean = await this.storage.create(tunnelId, tunnelConfig); + if (!created) { + throw Error("could_not_create_tunnel"); + } + + await this.accountService.update(accountId, (account: Account) => { + if (!account.tunnels.includes(tunnelId)) { + account.tunnels.push(tunnelId); + } + }); + + this.logger + .withContext('tunnel', tunnelId) + .debug({ + message: `tunnel ${tunnelId} created`, + operation: 'create_tunnel', + }); + + return this._get(tunnelId); + } + + public async delete(tunnelId: string, accountId: string): Promise { + const tunnel = await this._get(tunnelId); + if (!this._isPermitted(tunnel, accountId)) { + throw Error("permission_denied") + } + + //TODO disable tunnel + try { + const disconnected = await this.disconnect(tunnelId, accountId); + if (!disconnected) { + this.logger + .withContext('tunnel', tunnelId) + .warn({ + message: `tunnel not disconnected, deleting anyway`, + operation: 'delete_tunnel', + }); + } + } catch (e: any) { + this.logger + .withContext('tunnel', tunnelId) + .error({ + message: `failed to disconnect tunnel: ${e.message}`, + operation: 'delete_tunnel', + stack: `${e.stack}`, + }); + } + + const updateAccount = this.accountService.update(accountId, (account: Account) => { + const pos = account.tunnels.indexOf(tunnelId); + if (pos >= 0) { + account.tunnels.splice(pos, 1); + } + }); + + try { + await Promise.all([ + this.ingress.deleteIngress(tunnel.config), + this.storage.delete(tunnelId), + updateAccount, + ]); + } catch (e: any) { + this.logger + .withContext('tunnel', tunnelId) + .error({ + message: `failed to delete tunnel: ${e.message}`, + operation: 'delete_tunnel', + stack: `${e.stack}`, + }); + return false; + } + this.logger + .withContext('tunnel', tunnelId) + .debug({ + message: `tunnel ${tunnelId} deleted`, + operation: 'delete_tunnel', + }); + return true; + } + + public async update(tunnelId: string, accountId: string, callback: (tunnelConfig: TunnelConfig) => void): Promise { + let tunnel = await this._get(tunnelId); + if (!this._isPermitted(tunnel, accountId)) { + throw new Error('permission_denied'); + } + + const updatedConfig = await this.storage.update(tunnelId, TunnelConfig, async (tunnelConfig: TunnelConfig) => { + + const origConfig = cloneTunnelConfig(tunnelConfig); + callback(tunnelConfig); + + const updatedIngress = await this.ingress.updateIngress(tunnelConfig, origConfig); + if (updatedIngress instanceof Error) { + const err = updatedIngress; + this.logger.isDebugEnabled() && + this.logger + .withContext('tunnel', tunnelId) + .debug({ + message: 'update ingress failed', + operation: 'update_tunnel', + err: err.message, + }); + throw err; + } + tunnelConfig.ingress = updatedIngress; + tunnelConfig.updated_at = new Date().toISOString(); + + return true; + }); + tunnel.config = updatedConfig; + return tunnel; + } + + public async list(cursor: string | undefined, count: number = 10, verbose: boolean = false): Promise { + const res = await this.storage.list(cursor, count); + + const data: Array = verbose ? await this._get(res.data) : res.data.map((id: string) => { + return new Tunnel(new TunnelConfig(id, undefined), undefined) + }); + return { + cursor: res.cursor, + tunnels: data, + } + } + + public async get(tunnelId: string, accountId: string): Promise { + const tunnel = await this._get(tunnelId); + if (!this._isPermitted(tunnel, accountId)) { + throw Error("permission_denied") + } + return tunnel; + } + + public async lookup(tunnelId: string): Promise { + return this._get(tunnelId); + } + + public async authorize(tunnelId: string, token: string): Promise { + const result: AuthorizeResult = { + authorized: false, + disabled: false, + tunnel: undefined, + account: undefined, + error: undefined, + }; + + try { + const tunnel = await this._get(tunnelId); + const account = await this.accountService.get(tunnel.config.account); + if (!(account instanceof Account)) { + return result; + } + const correctToken = safeEqual(token, tunnel.config.transport.token) + + result.authorized = correctToken && !account.status.disabled; + if (result.authorized) { + result.tunnel = tunnel; + result.account = account; + result.disabled = account.status.disabled; + } + } catch (e: any) { + result.error = e; + } + + return result; + } + + public async connect(tunnelId: string, accountId: string, transport: Transport, opts: ConnectOptions): Promise { + if (this.ended) { + return false; + } + + let tunnel = await this._get(tunnelId); + if (!this._isPermitted(tunnel, accountId)) { + return false; + } + + if (tunnel.state.alive_connections >= transport.max_connections) { + this.logger + .withContext('tunnel',tunnelId) + .info({ + message: `Refused transport connection, current connections ${tunnel.state.connections.length}, max connections ${transport.max_connections}`, + operation: 'connect_tunnel', + connections: tunnel.state.alive_connections, + max_connections: transport.max_connections, + }); + return false; + } + + const connection: TunnelConnection = { + connection_id: `${Node.identifier}:${crypto.randomUUID()}`, + transport, + node: Node.identifier, + peer: opts.peer, + local: true, + connected: true, + connected_at: Date.now(), + alive_at: Date.now(), + }; + + this.addTunnelConnection(tunnel, connection); + + transport.once('close', () => { + this.disconnectLocalTunnel(tunnelId, connection.connection_id); + }); + await this.announceLocalTunnels([tunnelId]); + + tunnel = await this.update(tunnelId, accountId, (tunnelConfig) => { + tunnelConfig.transport.token = crypto.randomBytes(64).toString('base64url'); + }); + + this.logger + .withContext('tunnel',tunnelId) + .debug({ + message: `Tunnel transport connected, peer ${opts.peer}`, + operation: 'connect_tunnel', + connections: tunnel.state.alive_connections, + max_connections: transport.max_connections, + }); + return true; + } + + public async disconnect(tunnelId: string, accountId: string): Promise { + let tunnel = await this._get(tunnelId); + if (!this._isPermitted(tunnel, accountId)) { + return false; + } + + const alive_connections = tunnel.state.connections + .filter((con) => con.connected); + + const announces = alive_connections.map((con) => { + return this.eventBus.waitFor('tunnel:announce', (announces: Array, meta: EmitMeta) => { + return announces.filter((announce) => announce.tunnel_id == tunnelId).length > 0 + }, 500); + }); + + const tunnelDisconnect: TunnelDisconnectRequest = { + tunnel_id: tunnelId + }; + this.eventBus.publish('tunnel:disconnect', tunnelDisconnect); + + const res = await Promise.allSettled(announces); + tunnel = await this._get(tunnelId); + + this.logger + .withContext('tunnel',tunnelId) + .debug({ + message: `Tunnel disconnection result, disconnected=${!tunnel.state.connected}`, + operation: 'disconnect_tunnel', + connections: tunnel.state.alive_connections, + }); + + return !tunnel.state.connected; + } + + private addTunnelConnection(tunnel: Tunnel, connection: TunnelConnection): void { + this.connectedTunnels[tunnel.id] ??= { + connected: false, + alive_connections: 0, + connections: [], + } + + const connections = this.connectedTunnels[tunnel.id].connections; + connections.push(connection); + this.connectedTunnels[tunnel.id].connections = connections; + this.updateTunnelState(tunnel); + } + + private updateTunnelState(tunnel: Tunnel): boolean; + private updateTunnelState(tunnelId: string): boolean; + private updateTunnelState(tunnel: any): boolean { + const tunnelId = tunnel instanceof Tunnel ? tunnel.id : tunnel; + let state = this.connectedTunnels[tunnelId]; + if (!state) { + return false; + } + + const alive_connections = state.connections.filter((tc) => tc.connected).length; + const connected = alive_connections > 0; + + if (connected && !state.connected) { + state.connected_at = state.connections + .filter((con) => con.connected && con.connected_at) + .sort((a, b) => a.connected_at - b.connected_at)[0]?.connected_at || Date.now(); + } else if (!connected && state.connected) { + state.disconnected_at = state.connections + .filter((con) => !con.connected && con.disconnected_at) + .sort((b, a) => a.disconnected_at - b.disconnected_at)[0]?.disconnected_at || Date.now(); + } + + const changed = connected != state.connected || + alive_connections != state.alive_connections; + + state.connected = connected; + state.alive_connections = alive_connections; + if (connected) { + state.alive_at = Date.now(); + } + + this.connectedTunnels[tunnelId] = state; + return changed; + } + + private getNextConnection(tunnelId: string): TunnelConnection | undefined { + const state = this.connectedTunnels[tunnelId]; + if (!state) { + return undefined; + } + + const lastConnectionId = this.lastConnection[tunnelId]; + + const localConnections = state.connections + .filter((con) => con.connected && con.local) + .sort((a, b) => a.connection_id.localeCompare(b.connection_id)); + if (localConnections.length > 0) { + let i = 0; + for (; i < localConnections.length; i++) { + if (localConnections[i].connection_id == lastConnectionId) { + i++; + break; + } + } + i = i % localConnections.length; + this.lastConnection[tunnelId] = localConnections[i].connection_id; + return localConnections[i]; + } + + const remoteConnections = state.connections + .filter((con) => con.connected && !con.local) + .sort((a, b) => a.connection_id.localeCompare(b.connection_id)); + if (remoteConnections.length > 0) { + let i = 0; + for (; i < remoteConnections.length; i++) { + if (remoteConnections[i].connection_id == lastConnectionId) { + i++; + break; + } + } + i = i % remoteConnections.length; + this.lastConnection[tunnelId] = remoteConnections[i].connection_id; + return remoteConnections[i]; + } + + return undefined; + } + + public createConnection(tunnelId: string, ctx: CreateConnectionContext, callback: (err: Error | undefined, sock: Duplex) => void): Duplex { + const connection = this.getNextConnection(tunnelId); + if (!connection?.transport) { + callback(new Error('no_transport'), undefined); + return undefined; + } + const sock = connection.transport.createConnection({ + tunnelId, + port: ctx.ingress.port, + }, callback); + return sock; + } + + public isLocalConnected(tunnelId: string): boolean { + const state = this.connectedTunnels[tunnelId]; + if (!state) { + return false; + } + return state.connections.filter((con) => con.connected && con.local).length > 0; + } +} diff --git a/src/tunnel/tunnel-state.js b/src/tunnel/tunnel-state.js deleted file mode 100644 index 9ba59e9..0000000 --- a/src/tunnel/tunnel-state.js +++ /dev/null @@ -1,13 +0,0 @@ -class TunnelState { - constructor() { - this.connected = false; - this.peer = undefined; - this.node = undefined; - this.connected_at = undefined; - this.disconnected_at = undefined; - this.alive_at = undefined; - this.connections = []; - } -} - -export default TunnelState; \ No newline at end of file diff --git a/src/tunnel/tunnel.js b/src/tunnel/tunnel.js deleted file mode 100644 index 3a38b44..0000000 --- a/src/tunnel/tunnel.js +++ /dev/null @@ -1,66 +0,0 @@ -import TunnelState from "./tunnel-state.js"; - -// ORM object representing a tunnel -class Tunnel { - constructor(tunnelId, account) { - this.id = tunnelId; - this.account = account; - this.transport = { - token: undefined, - max_connections: undefined, - ws: { - enabled: false, - }, - ssh: { - enabled: false, - }, - }; - this.ingress = { - http: { - enabled: false, - url: undefined, - urls: undefined, - alt_names: [], - }, - sni: { - enabled: false, - url: undefined, - urls: undefined, - }, - }; - this.target = { - url: undefined, - }; - this.created_at = undefined; - this.updated_at = undefined; - this._state = new TunnelState(); - } - - _deserialization_hook() { - if (this.endpoints != undefined) { - this.transport = this.endpoints; - delete this.endpoints; - } - } - - state() { - return this._state; - } - - isOwner(accountId) { - return accountId != undefined && accountId === this.account; - } - - clone() { - const stringify = (object) => JSON.stringify(object, (key, value) => { - if (key[0] == '_') { - return undefined; - } - return value; - }); - - return Object.assign(new Tunnel(), JSON.parse(stringify(this))); - } -} - -export default Tunnel; \ No newline at end of file diff --git a/src/tunnel/tunnel.ts b/src/tunnel/tunnel.ts new file mode 100644 index 0000000..4d54fed --- /dev/null +++ b/src/tunnel/tunnel.ts @@ -0,0 +1,46 @@ +import { TunnelConfig } from "./tunnel-config.js"; +import Transport from "../transport/transport.js"; + +export type TunnelConnectionNode = string; +export type TunnelConnectionId = string; + +export type TunnelConnection = { + connection_id: TunnelConnectionId, + node: TunnelConnectionNode, + peer: string, + transport?: Transport, + local: boolean, + connected: boolean, + connected_at?: number, + disconnected_at?: number, + alive_at: number, +} + +export type TunnelState = { + connected: boolean, + connected_at?: number, + disconnected_at?: number, + alive_at?: number, + alive_connections: number, + connections: Array, +} + +export class Tunnel { + public readonly id; + public readonly account; + public config: TunnelConfig; + public readonly state: TunnelState + + constructor(config: TunnelConfig, state?: TunnelState) { + this.id = config?.id; + this.account = config?.account; + this.config = config; + this.state = state || { + connected: false, + alive_connections: 0, + connections: [], + }; + } +} + +export default Tunnel; \ No newline at end of file diff --git a/test/system/ingress/test_http_ingress.js b/test/system/ingress/test_http_ingress.js index 9a1dec4..5981eb4 100644 --- a/test/system/ingress/test_http_ingress.js +++ b/test/system/ingress/test_http_ingress.js @@ -89,8 +89,8 @@ describe('http ingress', () => { do { await setTimeout(100); tun = await tunnelService._get(tunnel.id) - } while (tun.state().connected == false && i++ < 10); - assert(tun.state().connected == true, "tunnel not connected") + } while (tun.state.connected == false && i++ < 10); + assert(tun.state.connected == true, "tunnel not connected") client.on('connection', (sock) => { sock.on('data', async (chunk) => { @@ -159,8 +159,8 @@ describe('http ingress', () => { do { await setTimeout(100); tun = await tunnelService._get(tunnel.id) - } while (tun.state().connected == false && i++ < 10); - assert(tun.state().connected == true, "tunnel not connected") + } while (tun.state.connected == false && i++ < 10); + assert(tun.state.connected == true, "tunnel not connected") const req = http.request({ hostname: 'localhost', @@ -186,7 +186,7 @@ describe('http ingress', () => { req.end(); const wsRes = await new Promise(done); - assert(wsRes.equals(Buffer.from("ws echo connected")), `got ${wsRes}`); + assert(wsRes.equals(Buffer.from("ws echo connected")), `did not get ws echo, got ${wsRes}`); await sock1.destroy(); await sock2.destroy(); diff --git a/test/unit/cluster/test_node-socket.js b/test/unit/cluster/test_node-socket.js deleted file mode 100644 index 5a63a3b..0000000 --- a/test/unit/cluster/test_node-socket.js +++ /dev/null @@ -1,30 +0,0 @@ -import net from 'net'; -import NodeSocket from "../../../src/transport/node-socket.js"; - -describe('node socket', () => { - it('can be created and connected', async () => { - const server = net.createServer(); - server.listen(10000, () => {}); - - const sock = await new Promise((resolve) => { - const sock = NodeSocket.createConnection({ - tunnelId: "tunnel", - node: { - id: "node-id", - hostname: "node-host", - ip: "127.0.0.1" - }, - port: 10000, - }, () => { - resolve(sock); - }); - }); - - await sock.destroy(); - await new Promise((resolve) => { - server.close(() => { - resolve(); - }); - }); - }); -}); \ No newline at end of file diff --git a/test/unit/endpoint/test_ssh-endpoint.js b/test/unit/endpoint/test_ssh-endpoint.ts similarity index 90% rename from test/unit/endpoint/test_ssh-endpoint.js rename to test/unit/endpoint/test_ssh-endpoint.ts index af864f4..d731968 100644 --- a/test/unit/endpoint/test_ssh-endpoint.js +++ b/test/unit/endpoint/test_ssh-endpoint.ts @@ -1,9 +1,10 @@ import assert from 'assert/strict'; import Tunnel from '../../../src/tunnel/tunnel.js'; import SSHEndpoint from '../../../src/transport/ssh/ssh-endpoint.js'; -import { initClusterService, initStorageService } from '../test-utils.ts' +import { initClusterService, initStorageService } from '../test-utils.js' import Config from '../../../src/config.js'; import Ingress from '../../../src/ingress/index.js'; +import { TunnelConfig } from '../../../src/tunnel/tunnel-config.js'; describe('ssh endpoint', () => { @@ -48,9 +49,9 @@ describe('ssh endpoint', () => { } }); - const tunnel = new Tunnel(); - tunnel.id = 'test'; - tunnel.transport.token = 'token'; + const tc = new TunnelConfig("test", "test"); + tc.transport.token = 'token'; + const tunnel = new Tunnel(tc) const endpoint = new SSHEndpoint(args); const ep = endpoint.getEndpoint(tunnel, baseUrl); diff --git a/test/unit/test-utils.ts b/test/unit/test-utils.ts index 0d4854e..1672393 100644 --- a/test/unit/test-utils.ts +++ b/test/unit/test-utils.ts @@ -6,7 +6,7 @@ import ClusterService from "../../src/cluster/index.js"; import { StorageService } from "../../src/storage/index.js"; import { WebSocketMultiplex } from "@exposr/ws-multiplex"; -export const initStorageService = async () => { +export const initStorageService = async (): Promise => { return new Promise((resolve) => { const storage = new StorageService({ url: new URL('memory://'), diff --git a/test/unit/tunnel/test_tunnel-service.js b/test/unit/tunnel/test_tunnel-service.js index 0c33e17..89fde77 100644 --- a/test/unit/tunnel/test_tunnel-service.js +++ b/test/unit/tunnel/test_tunnel-service.js @@ -1,6 +1,7 @@ import assert from 'assert/strict'; import crypto from 'crypto'; import sinon from 'sinon'; +import net from 'net'; import TunnelService from '../../../src/tunnel/tunnel-service.js'; import Config from '../../../src/config.js'; import ClusterService from '../../../src/cluster/index.js'; @@ -10,6 +11,7 @@ import Tunnel from '../../../src/tunnel/tunnel.js'; import WebSocketTransport from '../../../src/transport/ws/ws-transport.ts'; import { initStorageService, wsSocketPair } from '../test-utils.ts'; import EventBus from '../../../src/cluster/eventbus.js'; +import { WebSocketMultiplex } from '@exposr/ws-multiplex'; describe('tunnel service', () => { let clock; @@ -47,6 +49,7 @@ describe('tunnel service', () => { await accountService.destroy(); await ingress.destroy(); clock.restore(); + sinon.restore(); }) describe(`distributed tunnel state`, async () => { @@ -57,171 +60,267 @@ describe('tunnel service', () => { const nodeId2 = crypto.randomBytes(20).toString('hex'); const connected_at = Date.now(); - tunnelService._tunnels.learn(tunnelId, [ - { id: "con-1", connected_at: connected_at - 1000 }, - { id: "con-2", connected_at: connected_at - 2000 } - ], { - node: { id: nodeId }, - ts: Date.now(), - }); - - const state = tunnelService._tunnels.state.tunnels[tunnelId]; - - assert(state.connected == true, "not in connected state"); - assert(state.connected_at = connected_at - 2000, "wrong connected_at timestamp"); - assert(state.connections["con-1"].alive == true, "con-1 not marked as alive"); - assert(state.connections["con-2"].alive == true, "con-2 not marked as alive"); - assert(Object.keys(state.connections).length == 2, "unexpected number of connections"); - - tunnelService._tunnels.learn(tunnelId, [ - { id: "con-3", connected_at: connected_at }, + await clock.tickAsync(1000); + + tunnelService["learnRemoteTunnels"]([ + { tunnel_id: tunnelId, + connections: [ + { + connection_id: "con-1", + peer: "127.0.0.2", + node: nodeId, + connected: true, + connected_at: connected_at - 1000, + }, + { + connection_id: "con-2", + peer: "127.0.0.2", + node: nodeId, + connected: true, + connected_at: connected_at - 2000, + } + ] + } ], { - node: { id: nodeId2 }, - ts: Date.now(), + node: { + id: nodeId, + ip: "127.0.0.1", + host: "host" + }, + ts: Date.now() }); + let state = tunnelService["connectedTunnels"][tunnelId]; assert(state.connected == true, "not in connected state"); - assert(state.connected_at = connected_at - 2000, "wrong connected_at timestamp"); - assert(state.connections["con-1"].alive == true, "con-1 not marked as alive"); - assert(state.connections["con-2"].alive == true, "con-2 not marked as alive"); - assert(state.connections["con-3"].alive == true, "con-3 not marked as alive"); - assert(Object.keys(state.connections).length == 3, "unexpected number of connections"); - - tunnelService._tunnels.learn(tunnelId, [ - { id: "con-2", connected_at: connected_at - 2000 } + assert(state.connected_at == connected_at - 2000, "wrong connected_at timestamp"); + assert(state.connections[0].connected == true, "con-1 not marked as connected"); + assert(state.connections[1].connected == true, "con-2 not marked as connected"); + assert(state.alive_connections == 2, "unexpected number of connections"); + + tunnelService["learnRemoteTunnels"]([ + { tunnel_id: tunnelId, + connections: [ + { + connection_id: "con-3", + peer: "127.0.0.2", + node: nodeId2, + connected: true, + connected_at: connected_at - 500, + } + ] + } ], { - node: { id: nodeId }, - ts: Date.now(), + node: { + id: nodeId2, + ip: "127.0.0.5", + host: "host" + }, + ts: Date.now() }); + state = tunnelService["connectedTunnels"][tunnelId]; assert(state.connected == true, "not in connected state"); - assert(state.connected_at = connected_at - 2000, "wrong connected_at timestamp"); - assert(state.connections["con-1"].alive == false, "con-1 not marked as dead"); - assert(state.connections["con-2"].alive == true, "con-2 not marked as alive"); - assert(state.connections["con-3"].alive == true, "con-3 not marked as alive"); + assert(state.connected_at == connected_at - 2000, "wrong connected_at timestamp"); + assert(state.connections[0].connected == true, "con-1 not marked as connected"); + assert(state.connections[1].connected == true, "con-2 not marked as connected"); + assert(state.connections[2].connected == true, "con-3 not marked as connected"); + assert(state.alive_connections == 3, "unexpected number of connections"); const disconnected_at = Date.now(); - tunnelService._tunnels.learn(tunnelId, [ ], { - node: { id: nodeId }, - ts: disconnected_at, + tunnelService["learnRemoteTunnels"]([ + { tunnel_id: tunnelId, + connections: [ + { + connection_id: "con-1", + peer: "127.0.0.2", + node: nodeId, + connected: false, + connected_at: connected_at - 1000, + disconnected_at: disconnected_at, + }, + { + connection_id: "con-2", + peer: "127.0.0.2", + node: nodeId, + connected: true, + connected_at: connected_at - 2000, + } + ] + } + ], { + node: { + id: nodeId, + ip: "127.0.0.1", + host: "host" + }, + ts: Date.now() }); - tunnelService._tunnels.learn(tunnelId, [ ], { - node: { id: nodeId2 }, - ts: disconnected_at, + state = tunnelService["connectedTunnels"][tunnelId]; + assert(state.connected == true, "not in connected state"); + assert(state.connected_at == connected_at - 2000, "wrong connected_at timestamp"); + assert(state.connections.find((tc) => tc.connection_id == 'con-1').connected == false, "con-1 not marked as connected"); + assert(state.connections.find((tc) => tc.connection_id == 'con-2').connected == true, "con-2 not marked as connected"); + assert(state.connections.find((tc) => tc.connection_id == 'con-3').connected == true, "con-3 not marked as connected"); + assert(state.alive_connections == 2, "unexpected number of connections"); + + tunnelService["learnRemoteTunnels"]([ + { tunnel_id: tunnelId, + connections: [ + { + connection_id: "con-1", + peer: "127.0.0.2", + node: nodeId, + connected: false, + connected_at: connected_at - 1000, + disconnected_at: disconnected_at, + }, + { + connection_id: "con-2", + peer: "127.0.0.2", + node: nodeId, + connected: false, + disconnected_at: disconnected_at + 1000, + connected_at: connected_at - 2000, + } + ] + } + ], { + node: { + id: nodeId, + ip: "127.0.0.1", + host: "host" + }, + ts: Date.now() }); - assert(state.connected == false, "not in disconnected state"); - assert(state.disconnected_at = disconnected_at, "wrong disconnected_at timestamp"); - assert(state.connections["con-1"].alive == false, "con-1 not marked as dead"); - assert(state.connections["con-2"].alive == false, "con-2 not marked as dead"); - assert(state.connections["con-3"].alive == false, "con-3 not marked as dead"); - - await tunnelService.destroy(); - }); - - it(`connections are marked as dead on timeout`, async () => { - const tunnelService = new TunnelService(); - const tunnelId = crypto.randomBytes(20).toString('hex'); - const nodeId = crypto.randomBytes(20).toString('hex'); - - const connected_at = Date.now(); - tunnelService._tunnels.learn(tunnelId, [ - { id: "con-1", connected_at: connected_at - 1000 }, + tunnelService["learnRemoteTunnels"]([ + { tunnel_id: tunnelId, + connections: [ + { + connection_id: "con-3", + peer: "127.0.0.2", + node: nodeId2, + connected: false, + connected_at: connected_at - 500, + disconnected_at: disconnected_at + 1500, + } + ] + } ], { - node: { id: nodeId }, - ts: Date.now(), + node: { + id: nodeId2, + ip: "127.0.0.5", + host: "host" + }, + ts: Date.now() }); - const state = tunnelService._tunnels.state.tunnels[tunnelId]; - assert(state.connected == true, "not in connected state"); - assert(state.connections["con-1"].alive == true, "con-1 not marked as alive"); - - await clock.tickAsync(tunnelService.tunnelConnectionAliveThreshold + tunnelService.tunnelDeadSweepInterval); - - assert(state.connected == false, "not in disconnected state"); - assert(state.connections["con-1"].alive == false, "con-1 not marked as dead"); + state = tunnelService["connectedTunnels"][tunnelId]; + assert(state.connected == false, "in connected state"); + assert(state.connected_at == connected_at - 2000, "wrong connected_at timestamp"); + assert(state.disconnected_at == disconnected_at + 1500, "wrong disconnected_at timestamp"); + assert(state.alive_connections == 0, "unexpected number of connections"); await tunnelService.destroy(); }); - it(`dead connections are removed on delete timeout`, async () => { + it(`remote connections are marked as disconnected on timeout`, async () => { const tunnelService = new TunnelService(); const tunnelId = crypto.randomBytes(20).toString('hex'); const nodeId = crypto.randomBytes(20).toString('hex'); const connected_at = Date.now(); - tunnelService._tunnels.learn(tunnelId, [ - { id: "con-1", connected_at: connected_at - 1000 }, + tunnelService["learnRemoteTunnels"]([ + { tunnel_id: tunnelId, + connections: [ + { + connection_id: "con-1", + peer: "127.0.0.2", + node: nodeId, + connected: true, + connected_at: connected_at - 500, + } + ] + } ], { - node: { id: nodeId }, - ts: Date.now(), + node: { + id: nodeId, + ip: "127.0.0.5", + host: "host" + }, + ts: Date.now() }); - const state = tunnelService._tunnels.state.tunnels[tunnelId]; + let state = tunnelService.connectedTunnels[tunnelId]; assert(state.connected == true, "not in connected state"); - assert(state.connections["con-1"].alive == true, "con-1 not marked as alive"); + assert(state.connections[0].connected == true, "con-1 not marked as connected"); - await clock.tickAsync(tunnelService.tunnelConnectionDeleteThreshold + tunnelService.tunnelDeleteSweepInterval); + await clock.tickAsync(tunnelService.stateRefreshInterval + tunnelService.tunnelConnectionAliveThreshold); + state = tunnelService.connectedTunnels[tunnelId]; - assert(state.connected == false, "not in disconnected state"); - assert(Object.keys(state.connections).length == 0, "connection not removed"); + assert(state.connected == false, "in connected state"); + assert(state.connections[0].connected == false, "con-1 marked as connected"); + assert(state.alive_connections == 0, "wrong expected number of connections"); await tunnelService.destroy(); }); - it(`local connections are marked as local`, async () => { + it(`disconnected connections are removed on removal timeout`, async () => { const tunnelService = new TunnelService(); const tunnelId = crypto.randomBytes(20).toString('hex'); const nodeId = crypto.randomBytes(20).toString('hex'); const connected_at = Date.now(); - tunnelService._connectedTunnels[tunnelId] = { - connections: { - "con-1": {} + tunnelService["learnRemoteTunnels"]([ + { tunnel_id: tunnelId, + connections: [ + { + connection_id: "con-1", + peer: "127.0.0.2", + node: nodeId, + connected: false, + connected_at: connected_at - 500, + disconnected_at: connected_at, + }, + { + connection_id: "con-2", + peer: "127.0.0.2", + node: nodeId, + connected: true, + connected_at: connected_at - 500, + } + ] } - }; - - tunnelService._tunnels.learn(tunnelId, [ - { id: "con-1", connected_at: connected_at - 1000 }, ], { - node: { id: nodeId }, - ts: Date.now(), + node: { + id: nodeId, + ip: "127.0.0.5", + host: "host" + }, + ts: Date.now() }); - const state = tunnelService._tunnels.state.tunnels[tunnelId]; + + let state = tunnelService.connectedTunnels[tunnelId]; assert(state.connected == true, "not in connected state"); - assert(state.connections["con-1"].alive == true, "con-1 not marked as alive"); - assert(state.connections["con-1"].local == true, "con-1 not marked as local"); + assert(state.connections[0].connected == false); + assert(state.connections[1].connected == true); + assert(state.connections.length == 2); - await tunnelService.destroy(); - }); + await clock.tickAsync(tunnelService.tunnelConnectionAliveThreshold + tunnelService.stateRefreshInterval); + state = tunnelService.connectedTunnels[tunnelId]; + assert(state.connected == false, "in connected state"); + assert(state.connections.length == 2); - it(`returns external state`, async () => { - const tunnelService = new TunnelService(); - const tunnelId = crypto.randomBytes(20).toString('hex'); - const nodeId = crypto.randomBytes(20).toString('hex'); + state.connections[0].connected = true; + state.connections[0].alive_at = Date.now() + tunnelService.tunnelConnectionRemoveThreshold; - const connected_at = Date.now() - 1000; - const alive_at = Date.now(); - tunnelService._tunnels.learn(tunnelId, [ - { id: "con-1", connected_at: connected_at, peer: "127.0.0.1" }, - { id: "con-2", connected_at: connected_at + 50, peer: "127.0.0.2" }, - ], { - node: { id: nodeId }, - ts: alive_at, - }); - const xstate = tunnelService._tunnels.getState(tunnelId) - - assert(xstate.connected == true, "not in connected state"); - assert(xstate.connected_at == new Date(connected_at).toISOString(), "wrong connected_at"); - assert(xstate.disconnected_at == undefined, "disconnected_at set when not expected"); - assert(xstate.alive_at == new Date(alive_at).toISOString(), "wrong alive_at"); - assert(xstate.connections.length == 2, "unexpected number of connections"); - assert(xstate.connections[0].peer == "127.0.0.1", "unexpected connection peer"); - assert(xstate.connections[0].connected_at == new Date(connected_at).toISOString(), "unexpected connection connected_at"); - assert(xstate.connections[1].peer == "127.0.0.2", "unexpected connection peer"); - assert(xstate.connections[1].connected_at == new Date(connected_at + 50).toISOString(), "unexpected connection connected_at"); + await clock.tickAsync(tunnelService.tunnelConnectionRemoveThreshold + tunnelService.stateRefreshInterval); + state = tunnelService.connectedTunnels[tunnelId]; + assert(state.connected == true, "not in connected state"); + assert(state.connections[0].connected == true); + assert(state.connections.length == 1); await tunnelService.destroy(); }); @@ -229,58 +328,77 @@ describe('tunnel service', () => { it(`getNextConnection prefers local connections`, async () => { const tunnelService = new TunnelService(); const tunnelId = crypto.randomBytes(20).toString('hex'); - const nodeId = crypto.randomBytes(20).toString('hex'); - const connected_at = Date.now(); - tunnelService._connectedTunnels[tunnelId] = { - connections: { - "local-con-1": {} - } + tunnelService.connectedTunnels[tunnelId] = { + connected: true, + connected_at: Date.now(), + connections: [ + { + connection_id: "con-1", + node: "node-1", + peer: "127.0.0.1", + local: false, + connected: true, + connected_at: Date.now(), + alive_at: Date.now(), + }, + { + connection_id: "con-2", + node: "node-2", + peer: "127.0.0.1", + local: true, + connected: true, + connected_at: Date.now(), + alive_at: Date.now(), + } + ] }; - tunnelService._tunnels.learn(tunnelId, [ - { id: "con-2", connected_at: connected_at }, - ], { - node: { id: nodeId }, - ts: Date.now(), - }); - - let nextCon = tunnelService._tunnels.getNextConnection(tunnelId); - assert(nextCon.cid == "local-con-1", `getNextConnection did not return local connection got ${nextCon}`); + let nextCon = tunnelService["getNextConnection"](tunnelId); + assert(nextCon.connection_id == "con-2", `getNextConnection did not return local connection got ${nextCon}`); - nextCon = tunnelService._tunnels.getNextConnection(tunnelId); - assert(nextCon.cid == "local-con-1", `getNextConnection did not return local connection got ${nextCon}`); + nextCon = tunnelService["getNextConnection"](tunnelId); + assert(nextCon.connection_id == "con-2", `getNextConnection did not return local connection got ${nextCon}`); await tunnelService.destroy(); }); it(`getNextConnection round-robins local connections`, async () => { const tunnelService = new TunnelService(); const tunnelId = crypto.randomBytes(20).toString('hex'); - const nodeId = crypto.randomBytes(20).toString('hex'); - const connected_at = Date.now(); - tunnelService._connectedTunnels[tunnelId] = { - connections: { - "local-con-1": {}, - "local-con-2": {}, - } + tunnelService.connectedTunnels[tunnelId] = { + connected: true, + connected_at: Date.now(), + connections: [ + { + connection_id: "con-1", + node: "node-1", + peer: "127.0.0.1", + local: true, + connected: true, + connected_at: Date.now(), + alive_at: Date.now(), + }, + { + connection_id: "con-2", + node: "node-2", + peer: "127.0.0.1", + local: true, + connected: true, + connected_at: Date.now(), + alive_at: Date.now(), + } + ] }; - tunnelService._tunnels.learn(tunnelId, [ - { id: "con-3", connected_at: connected_at }, - ], { - node: { id: nodeId }, - ts: Date.now(), - }); - - let nextCon = tunnelService._tunnels.getNextConnection(tunnelId); - assert(nextCon.cid == "local-con-1", `getNextConnection did not return local connection got ${nextCon}`); + let nextCon = tunnelService["getNextConnection"](tunnelId); + assert(nextCon.connection_id == "con-1", `getNextConnection did not return local connection got ${nextCon}`); - nextCon = tunnelService._tunnels.getNextConnection(tunnelId); - assert(nextCon.cid == "local-con-2", `getNextConnection did not return local connection got ${nextCon}`); + nextCon = tunnelService["getNextConnection"](tunnelId); + assert(nextCon.connection_id == "con-2", `getNextConnection did not return local connection got ${nextCon}`); - nextCon = tunnelService._tunnels.getNextConnection(tunnelId); - assert(nextCon.cid == "local-con-1", `getNextConnection did not return local connection got ${nextCon}`); + nextCon = tunnelService["getNextConnection"](tunnelId); + assert(nextCon.connection_id == "con-1", `getNextConnection did not return local connection got ${nextCon}`); await tunnelService.destroy(); }); @@ -288,22 +406,34 @@ describe('tunnel service', () => { it(`getNextConnection selects remote node if no local connections`, async () => { const tunnelService = new TunnelService(); const tunnelId = crypto.randomBytes(20).toString('hex'); - const nodeId = crypto.randomBytes(20).toString('hex'); - const connected_at = Date.now(); - tunnelService._connectedTunnels[tunnelId] = { - connections: {} + tunnelService.connectedTunnels[tunnelId] = { + connected: true, + connected_at: Date.now(), + connections: [ + { + connection_id: "con-1", + node: "node-1", + peer: "127.0.0.1", + local: false, + connected: true, + connected_at: Date.now(), + alive_at: Date.now(), + }, + { + connection_id: "con-2", + node: "node-2", + peer: "127.0.0.1", + local: false, + connected: true, + connected_at: Date.now(), + alive_at: Date.now(), + } + ] }; - tunnelService._tunnels.learn(tunnelId, [ - { id: "con-3", connected_at: connected_at }, - ], { - node: { id: nodeId }, - ts: Date.now(), - }); - - let nextCon = tunnelService._tunnels.getNextConnection(tunnelId); - assert(nextCon.node == nodeId, `getNextConnection did not return remote node got ${nextCon}`); + let nextCon = tunnelService["getNextConnection"](tunnelId); + assert(nextCon.connection_id == "con-1", `getNextConnection did not return connection got ${nextCon}`); await tunnelService.destroy(); }); @@ -311,35 +441,40 @@ describe('tunnel service', () => { it(`getNextConnection round-robins remote nodes`, async () => { const tunnelService = new TunnelService(); const tunnelId = crypto.randomBytes(20).toString('hex'); - const nodeId = crypto.randomBytes(20).toString('hex'); - const nodeId2 = crypto.randomBytes(20).toString('hex'); - const connected_at = Date.now(); - tunnelService._connectedTunnels[tunnelId] = { - connections: {} + tunnelService.connectedTunnels[tunnelId] = { + connected: true, + connected_at: Date.now(), + connections: [ + { + connection_id: "con-1", + node: "node-1", + peer: "127.0.0.1", + local: false, + connected: true, + connected_at: Date.now(), + alive_at: Date.now(), + }, + { + connection_id: "con-2", + node: "node-2", + peer: "127.0.0.1", + local: false, + connected: true, + connected_at: Date.now(), + alive_at: Date.now(), + } + ] }; - tunnelService._tunnels.learn(tunnelId, [ - { id: "con-1", connected_at: connected_at }, - { id: "con-2", connected_at: connected_at }, - ], { - node: { id: nodeId }, - ts: Date.now(), - }); + let nextCon = tunnelService["getNextConnection"](tunnelId); + assert(nextCon.connection_id == "con-1", `getNextConnection did not return connection got ${nextCon}`); - tunnelService._tunnels.learn(tunnelId, [ - { id: "con-3", connected_at: connected_at }, - ], { - node: { id: nodeId2 }, - ts: Date.now(), - }); - - - let nextCon = tunnelService._tunnels.getNextConnection(tunnelId); - assert(nextCon.node == nodeId, `getNextConnection did not return remote node got ${nextCon}`); + nextCon = tunnelService["getNextConnection"](tunnelId); + assert(nextCon.connection_id == "con-2", `getNextConnection did not return connection got ${nextCon}`); - nextCon = tunnelService._tunnels.getNextConnection(tunnelId); - assert(nextCon.node == nodeId2, `getNextConnection did not return remote node got ${nextCon}`); + nextCon = tunnelService["getNextConnection"](tunnelId); + assert(nextCon.connection_id == "con-1", `getNextConnection did not return connection got ${nextCon}`); await tunnelService.destroy(); }); @@ -348,7 +483,7 @@ describe('tunnel service', () => { const tunnelService = new TunnelService(); const tunnelId = crypto.randomBytes(20).toString('hex'); - const nextCon = tunnelService._tunnels.getNextConnection(tunnelId); + let nextCon = tunnelService["getNextConnection"](tunnelId); assert(nextCon == undefined, `getNextConnection dit not return undefined`); await tunnelService.destroy(); @@ -358,24 +493,28 @@ describe('tunnel service', () => { const tunnelService = new TunnelService(); const bus = new EventBus(); - const connected_at = Date.now(); for (let i = 0; i < tunnelService.tunnelAnnounceBatchSize * 1.5; i++) { const tunnelId = crypto.randomBytes(20).toString('hex'); - const connections = {}; const cid = `${tunnelId}-con-1`; - connections[cid] = { - id: cid, - state: { - peer: "127.0.0.1", - connected_at: connected_at, - } + + tunnelService.connectedTunnels[tunnelId] = { + connected: true, + connected_at: Date.now(), + connections: [ + { + connection_id: cid, + node: "node-1", + peer: "127.0.0.1", + local: true, + connected: true, + connected_at: Date.now(), + alive_at: Date.now(), + } + ] }; - tunnelService._connectedTunnels[tunnelId] = { - connections - } } - const expectedAnnouncements = Math.ceil(Object.keys(tunnelService._connectedTunnels).length / tunnelService.tunnelAnnounceBatchSize); + const expectedAnnouncements = Math.ceil(Object.keys(tunnelService.connectedTunnels).length / tunnelService.tunnelAnnounceBatchSize); let announcements = 0; bus.on('tunnel:announce', (msg) => { announcements++; @@ -384,12 +523,6 @@ describe('tunnel service', () => { await clock.tickAsync(tunnelService.tunnelAnnounceInterval + 1000); assert(announcements == expectedAnnouncements, `expected ${expectedAnnouncements} batch announcements, got ${announcements}`); - Object.keys(tunnelService._connectedTunnels).forEach((tunnelId) => { - const tunnel = tunnelService._tunnels.get(tunnelId); - assert(tunnel != undefined, `tunnel ${tunnelId} not learnt in the global state`); - assert(tunnel.connected == true, `tunnel ${tunnelId} not marked as connected in global state`); - }); - await bus.destroy(); await tunnelService.destroy(); }); @@ -406,10 +539,13 @@ describe('tunnel service', () => { assert(tunnel instanceof Tunnel, `tunnel not created, got ${tunnel}`); assert(tunnel?.id == tunnelId, `expected id ${tunnelId}, got ${tunnel?.id}`); + const tunnel2 = await tunnelService.get(tunnelId, account.id); + assert(tunnel2.id == tunnelId); + await tunnelService.destroy(); }); - it(`can create and delete tunnel`, async () => { + it(`can create, update and delete tunnel`, async () => { const tunnelService = new TunnelService(); let account = await accountService.create(); @@ -423,11 +559,21 @@ describe('tunnel service', () => { account = await accountService.get(account.id); assert(account.tunnels.indexOf(tunnelId) != -1, "account does not own created tunnel"); + await tunnelService.update(tunnelId, account.id, (tunnelConfig) => { + tunnelConfig.target.url = "http://example.com" + }); + + tunnel = await tunnelService.lookup(tunnelId); + assert(tunnel.config.target.url == 'http://example.com', 'tunnel config not updated'); + let res = await tunnelService.delete(tunnelId, account.id); assert(res == true, `tunnel not deleted, got ${res}`); - tunnel = await tunnelService.get(tunnelId, account.id); - assert(tunnel == false, `tunnel not deleted, got ${tunnel}`); + try { + tunnel = await tunnelService.get(tunnelId, account.id); + } catch (e) { + assert(e.message == 'no_such_tunnel', `tunnel not deleted, got ${e.message}`); + } account = await accountService.get(account.id); assert(account.tunnels.indexOf(tunnelId) == -1, "tunnel listed on account after deletion"); @@ -435,6 +581,40 @@ describe('tunnel service', () => { await tunnelService.destroy(); }); + it(`can list tunnels`, async () => { + const tunnelService = new TunnelService(); + let account = await accountService.create(); + + for (let i = 0; i < 100; i++) { + const tunnelId = crypto.randomBytes(20).toString('hex'); + await tunnelService.create(tunnelId, account.id); + } + + const expectedTunnels = 100; + + let cursor; + let tunnels = 0; + do { + const result = await tunnelService.list(cursor, 10, false); + tunnels += result.tunnels.length; + cursor = result.cursor; + } while (cursor != null); + + assert(tunnels == expectedTunnels, "wrong number of tunnels"); + + tunnels = 0; + do { + const result = await tunnelService.list(cursor, 10, true); + tunnels += result.tunnels.length; + cursor = result.cursor; + } while (cursor != null); + + assert(tunnels == expectedTunnels, "wrong number of tunnels"); + + + await tunnelService.destroy(); + }); + it(`can connect and disconnect tunnel`, async () => { const tunnelService = new TunnelService(); const bus = new EventBus(); @@ -442,7 +622,7 @@ describe('tunnel service', () => { const account = await accountService.create(); const tunnelId = crypto.randomBytes(20).toString('hex'); - const tunnel = await tunnelService.create(tunnelId, account.id); + let tunnel = await tunnelService.create(tunnelId, account.id); assert(tunnel instanceof Tunnel, `tunnel not created, got ${tunnel}`); assert(tunnel?.id == tunnelId, `expected id ${tunnelId}, got ${tunnel?.id}`); @@ -464,14 +644,126 @@ describe('tunnel service', () => { await msg; assert(msg.tunnel != tunnelId, "did not get tunnel announcement"); - let state = await tunnelService._tunnels.get(tunnelId); - assert(state.connected == true, "tunnel state is not connected"); + tunnel = await tunnelService.lookup(tunnelId); + assert(tunnel.state.connected == true, "tunnel state is not connected"); + + const localCon = tunnelService.isLocalConnected(tunnelId); + assert(localCon == true, "isLocalConnected returned false"); + + res = await tunnelService.disconnect(tunnelId, account.id); + assert(res == true, "failed to disconnect tunnel"); + + tunnel = await tunnelService.lookup(tunnelId); + assert(tunnel.state.connected == false, "tunnel state is connected"); + + assert(tunnel.state.connections[0].connected == false, "tunnel connection is disconnected"); + + await tunnelService.destroy(); + await bus.destroy(); + await transport.destroy(); + await sockPair.terminate(); + }); + + it(`can have multiple connections`, async () => { + const tunnelService = new TunnelService(); + const bus = new EventBus(); + + const account = await accountService.create(); + const tunnelId = crypto.randomBytes(20).toString('hex'); + + let tunnel = await tunnelService.create(tunnelId, account.id); + assert(tunnel instanceof Tunnel, `tunnel not created, got ${tunnel}`); + assert(tunnel?.id == tunnelId, `expected id ${tunnelId}, got ${tunnel?.id}`); + + const sockPair1 = await wsSocketPair.create(10000); + const transport1 = new WebSocketTransport({ + tunnelId: tunnelId, + socket: sockPair1.sock1, + max_connections: 2, + }) + + let res = await tunnelService.connect(tunnelId, account.id, transport1, {peer: "127.0.0.1"}); + assert(res == true, `connect did not return true, got ${res}`); + + const sockPair2 = await wsSocketPair.create(10001); + const transport2 = new WebSocketTransport({ + tunnelId: tunnelId, + socket: sockPair2.sock1, + max_connections: 2, + }) + + res = await tunnelService.connect(tunnelId, account.id, transport2, {peer: "127.0.0.1"}); + assert(res == true, `connect did not return true, got ${res}`); + + tunnel = await tunnelService.lookup(tunnelId); + assert(tunnel.state.connected == true, "tunnel state is not connected"); + assert(tunnel.state.alive_connections == 2); + assert(tunnel.state.connections.length == 2) + assert(tunnel.state.connections[0].connected == true); + assert(tunnel.state.connections[1].connected == true); + + let tunnelConnection1 = tunnelService["getNextConnection"](tunnelId); + let tunnelConnection2 = tunnelService["getNextConnection"](tunnelId); + assert(tunnelConnection1.connection_id != tunnelConnection2.connection_id, "getNextConnection repeated connection"); + + let tunnelConnection3 = tunnelService["getNextConnection"](tunnelId); + assert(tunnelConnection1.connection_id == tunnelConnection3.connection_id, "getNextConnection did not wrap around"); + let tunnelConnection4 = tunnelService["getNextConnection"](tunnelId); + assert(tunnelConnection2.connection_id == tunnelConnection4.connection_id, "getNextConnection did not wrap around"); res = await tunnelService.disconnect(tunnelId, account.id); assert(res == true, "failed to disconnect tunnel"); - state = await tunnelService._tunnels.get(tunnelId); - assert(state.connected == false, "tunnel state is connected"); + tunnel = await tunnelService.lookup(tunnelId); + assert(tunnel.state.connected == false, "tunnel state is connected"); + assert(tunnel.state.connections[0].connected == false, "tunnel connection is disconnected"); + assert(tunnel.state.connections[1].connected == false, "tunnel connection is disconnected"); + + await tunnelService.destroy(); + await bus.destroy(); + await transport1.destroy(); + await sockPair1.terminate(); + await transport2.destroy(); + await sockPair2.terminate(); + }); + + it(`is disconnected when transport is destroyed`, async () => { + const tunnelService = new TunnelService(); + const bus = new EventBus(); + + const account = await accountService.create(); + const tunnelId = crypto.randomBytes(20).toString('hex'); + + let tunnel = await tunnelService.create(tunnelId, account.id); + assert(tunnel instanceof Tunnel, `tunnel not created, got ${tunnel}`); + assert(tunnel?.id == tunnelId, `expected id ${tunnelId}, got ${tunnel?.id}`); + + const sockPair = await wsSocketPair.create(); + const transport = new WebSocketTransport({ + tunnelId: tunnelId, + socket: sockPair.sock1, + }) + + let res = await tunnelService.connect(tunnelId, account.id, transport, {peer: "127.0.0.1"}); + assert(res == true, `connect did not return true, got ${res}`); + + tunnel = await tunnelService.lookup(tunnelId); + assert(tunnel.state.connected == true, "tunnel state is not connected"); + + const msg = new Promise((resolve) => { + bus.once('tunnel:announce', (msg) => { + setImmediate(() => { resolve(msg) }); + }) + }); + + // Close remote socket to trigger a destroy of the transport + sockPair.sock2.close(); + await msg; + + tunnel = await tunnelService.lookup(tunnelId); + assert(tunnel.state.connected == false, "tunnel state is connected"); + assert(tunnel.state.connections[0].connected == false, "tunnel connection is disconnected"); + assert(tunnel.state.alive_connections == 0, "alive connections is not zero"); await tunnelService.destroy(); await bus.destroy(); @@ -485,7 +777,7 @@ describe('tunnel service', () => { const tunnelId = crypto.randomBytes(20).toString('hex'); const tunnel = await tunnelService.create(tunnelId, account.id); - const token = tunnel?.transport?.token; + const token = tunnel?.config.transport?.token; assert(token != undefined, "no connection token set"); let res = await tunnelService.authorize(tunnelId, token); @@ -505,7 +797,7 @@ describe('tunnel service', () => { const account = await accountService.create(); const tunnelId = crypto.randomBytes(20).toString('hex'); - const tunnel = await tunnelService.create(tunnelId, account.id); + let tunnel = await tunnelService.create(tunnelId, account.id); assert(tunnel instanceof Tunnel, `tunnel not created, got ${tunnel}`); assert(tunnel?.id == tunnelId, `expected id ${tunnelId}, got ${tunnel?.id}`); @@ -524,16 +816,16 @@ describe('tunnel service', () => { let res = await tunnelService.connect(tunnelId, account.id, transport, {peer: "127.0.0.1"}); assert(res == true, `connect did not return true, got ${res}`); - await msg; - assert(msg.tunnel != tunnelId, "did not get tunnel announcement"); + res = await msg; + assert(res[0]["tunnel_id"] == tunnelId, "did not get tunnel announcement"); - let state = await tunnelService._tunnels.get(tunnelId); - assert(state.connected == true, "tunnel state is not connected"); + tunnel = await tunnelService.lookup(tunnelId); + assert(tunnel.state.connected == true, "tunnel state is not connected"); await tunnelService.end(); - state = await tunnelService._tunnels.get(tunnelId); - assert(state.connected == false, "tunnel state is connected after end()"); + tunnel = await tunnelService.lookup(tunnelId); + assert(tunnel.state.connected == false, "tunnel state is connected after end()"); res = await tunnelService.connect(tunnelId, account.id, transport, {peer: "127.0.0.1"}); assert(res == false, `connect did not return false, got ${res}`); @@ -544,4 +836,130 @@ describe('tunnel service', () => { await sockPair.terminate(); }); + it(`remote connections are re-routed`, async () => { + const tunnelService = new TunnelService(); + const bus = new EventBus(); + + const account = await accountService.create(); + const tunnelId = crypto.randomBytes(20).toString('hex'); + + let tunnel = await tunnelService.create(tunnelId, account.id); + assert(tunnel instanceof Tunnel, `tunnel not created, got ${tunnel}`); + assert(tunnel?.id == tunnelId, `expected id ${tunnelId}, got ${tunnel?.id}`); + + sinon.stub(ClusterService.prototype, 'getNode').returns({ + id: "node-1", + host: "some-node-host", + ip: "127.0.0.1", + last_ts: new Date().getTime(), + stale: false, + }); + + tunnelService["learnRemoteTunnels"]([ + { tunnel_id: tunnelId, + connections: [ + { + connection_id: "con-1", + peer: "127.0.0.1", + node: "node-1", + connected: true, + connected_at: Date.now(), + } + ] + } + ], { + node: { + id: "node-1", + ip: "127.0.0.1", + host: "host" + }, + ts: Date.now() + }); + + tunnel = await tunnelService.lookup(tunnelId); + assert(tunnel.state.connected == true); + + const server = net.createServer(); + await new Promise((resolve) => { server.listen(30000, () => { resolve() }) }); + + const con = new Promise((resolve) => { + server.on('connection', () => { + resolve(); + }); + }); + + const sock = await new Promise((resolve, reject) => { + tunnelService.createConnection(tunnelId, { + ingress: { port: 30000 } + }, (err, sock) => { + err ? reject() : resolve(sock); + }) + }); + + await con; + sock.destroy(); + + await tunnelService.destroy(); + await bus.destroy(); + await new Promise((resolve) => { + server.close(() => { + resolve(undefined); + }); + }); + }); + + it(`local connections are connected`, async () => { + const tunnelService = new TunnelService(); + const bus = new EventBus(); + + const account = await accountService.create(); + const tunnelId = crypto.randomBytes(20).toString('hex'); + + let tunnel = await tunnelService.create(tunnelId, account.id); + assert(tunnel instanceof Tunnel, `tunnel not created, got ${tunnel}`); + assert(tunnel?.id == tunnelId, `expected id ${tunnelId}, got ${tunnel?.id}`); + + const sockPair = await wsSocketPair.create(); + const transport = new WebSocketTransport({ + tunnelId: tunnelId, + socket: sockPair.sock1, + }); + + let res = await tunnelService.connect(tunnelId, account.id, transport, {peer: "127.0.0.1"}); + assert(res == true, `connect did not return true, got ${res}`); + + tunnel = await tunnelService.lookup(tunnelId); + assert(tunnel.state.connected == true, "tunnel state is not connected"); + + const wsm = new WebSocketMultiplex(sockPair.sock2); + const con = new Promise((resolve) => { + wsm.on('connection', () => { + resolve(); + }); + }); + + const sock = await new Promise((resolve, reject) => { + tunnelService.createConnection(tunnelId, { + ingress: { port: 0 } + }, (err, sock) => { + err ? reject() : resolve(sock); + }) + }); + + await con; + + res = await tunnelService.disconnect(tunnelId, account.id); + assert(res == true, "failed to disconnect tunnel"); + + tunnel = await tunnelService.lookup(tunnelId); + assert(tunnel.state.connected == false, "tunnel state is connected"); + + assert(tunnel.state.connections[0].connected == false, "tunnel connection is disconnected"); + + await tunnelService.destroy(); + await bus.destroy(); + await wsm.destroy(); + await transport.destroy(); + await sockPair.terminate(); + }); }); \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index ec36049..85a68a9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -238,6 +238,11 @@ resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.2.tgz#ee771e2ba4b3dc5b372935d549fd9617bf345b8c" integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ== +"@types/mocha@^10.0.1": + version "10.0.1" + resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-10.0.1.tgz#2f4f65bb08bc368ac39c96da7b2f09140b26851b" + integrity sha512-/fvYntiO1GeICvqbQ3doGDIP97vWmvFt83GKguJ6prmQM2iXZfFcq6YE8KteFyRtX2/h5Hf91BYvPodJKFYv5Q== + "@types/node@*": version "20.4.9" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.4.9.tgz#c7164e0f8d3f12dfae336af0b1f7fdec8c6b204f" @@ -253,6 +258,18 @@ resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301" integrity sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw== +"@types/sinon@^10.0.17": + version "10.0.17" + resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-10.0.17.tgz#97592d7c73ded11907f9121a2837f5ab5f9ccaa0" + integrity sha512-+6ILpcixQ0Ma3dHMTLv4rSycbDXkDljgKL+E0nI2RUxxhYTFyPSjt6RVMxh7jUshvyVcBvicb0Ktj+lAJcjgeA== + dependencies: + "@types/sinonjs__fake-timers" "*" + +"@types/sinonjs__fake-timers@*": + version "8.1.3" + resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.3.tgz#575789c5cf6d410cb288b0b4affaf7e6da44ca51" + integrity sha512-4g+2YyWe0Ve+LBh+WUm1697PD0Kdi6coG1eU0YjQbwx61AZ8XbEpL1zIT6WjuUKrCMCROpEaYQPDjBnDouBVAQ== + "@types/ws@^8.5.5": version "8.5.5" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.5.tgz#af587964aa06682702ee6dcbc7be41a80e4b28eb" From 9a616ac65f704da44ec2ddd1a81d894a3640cf81 Mon Sep 17 00:00:00 2001 From: Fredrik Lindberg Date: Sat, 30 Sep 2023 14:27:31 +0200 Subject: [PATCH 7/8] refactor: update API controller to work with new tunnel service interface --- package.json | 1 + ...-controller.js => admin-api-controller.ts} | 159 +++++++++------ src/controller/admin-controller.js | 51 ----- src/controller/admin-controller.ts | 53 +++++ .../{api-controller.js => api-controller.ts} | 185 ++++++++++-------- .../{koa-controller.js => koa-controller.ts} | 55 ++++-- test/e2e/test_api.js | 1 + yarn.lock | 177 ++++++++++++++++- 8 files changed, 468 insertions(+), 214 deletions(-) rename src/controller/{admin-api-controller.js => admin-api-controller.ts} (70%) delete mode 100644 src/controller/admin-controller.js create mode 100644 src/controller/admin-controller.ts rename src/controller/{api-controller.js => api-controller.ts} (66%) rename src/controller/{koa-controller.js => koa-controller.ts} (54%) diff --git a/package.json b/package.json index f08125c..8428f17 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "devDependencies": { "@rollup/plugin-commonjs": "^24.0.1", "@rollup/plugin-json": "^6.0.0", + "@types/koa-joi-router": "^8.0.5", "@types/mocha": "^10.0.1", "@types/node": "^20.5.0", "@types/sinon": "^10.0.17", diff --git a/src/controller/admin-api-controller.js b/src/controller/admin-api-controller.ts similarity index 70% rename from src/controller/admin-api-controller.js rename to src/controller/admin-api-controller.ts index 0c56275..1b3bda9 100644 --- a/src/controller/admin-api-controller.js +++ b/src/controller/admin-api-controller.ts @@ -10,24 +10,30 @@ import { } from '../utils/errors.js'; import KoaController from './koa-controller.js'; import ClusterService from '../cluster/index.js'; +import Account from '../account/account.js'; +import Tunnel from '../tunnel/tunnel.js'; class AdminApiController extends KoaController { - _name = 'Admin API' + public readonly _name: string = 'Admin API' - constructor(opts) { - const logger = Logger("admin-api"); + private apiKey!: string; + private unauthAccess: boolean = false; + private accountService!: AccountService; + private _tunnelService!: TunnelService; + private _transportService!: TransportService; + private _clusterService!: ClusterService; + constructor(opts: any) { + const logger: any = Logger("admin-api"); + + super({...opts, logger: logger}); if (!opts.enable) { logger.info({ message: `HTTP Admin API disabled`, }); - typeof opts.callback === 'function' && process.nextTick(opts.callback); - return super(); + return; } - super({...opts, logger: logger}); - this.logger = logger; - this.apiKey = typeof opts.apiKey === 'string' && opts.apiKey?.length > 0 ? opts.apiKey : undefined; this.unauthAccess = this.apiKey === undefined && opts.unauthAccess === true; @@ -45,13 +51,11 @@ class AdminApiController extends KoaController { logger.warn("Admin API resource disabled - no API key given"); return; } - - this.setRoutes((router) => this._initializeRoutes(router)); } - _initializeRoutes(router) { + protected _initializeRoutes(router: Router.Router) { - const handleError = async (ctx, next) => { + const handleError: Router.FullHandler = async (ctx, next) => { if (!ctx.invalid) { return next(); } @@ -89,7 +93,7 @@ class AdminApiController extends KoaController { } }; - const handleAdminAuth = (ctx, next) => { + const handleAdminAuth: Router.FullHandler = (ctx, next) => { if (this.unauthAccess === true) { return next(); } else if (this.apiKey != undefined) { @@ -106,7 +110,7 @@ class AdminApiController extends KoaController { } }; - const accountProps = (account) => { + const accountProps = (account: Account) => { const {accountId, formatted} = account.getId(); return { account_id: accountId, @@ -118,29 +122,42 @@ class AdminApiController extends KoaController { } }; - const tunnelProps = (tunnel, baseUrl) => { + const tunnelProps = (tunnel: Tunnel, baseUrl: String) => { return { tunnel_id: tunnel.id, - accound_id: tunnel.account, + account_id: tunnel.account, + connection: { + connected: tunnel.state.connected, + connected_at: tunnel.state.connected_at ? new Date(tunnel.state.connected_at).toISOString() : undefined, + disconnected_at: tunnel.state.disconnected_at ? new Date(tunnel.state.disconnected_at).toISOString() : undefined, + alive_at: tunnel.state.alive_at ? new Date(tunnel.state.alive_at).toISOString() : undefined, + }, + connections: tunnel.state.connections.map((tc) => { + return { + connection_id: tc.connection_id, + node_id: tc.node, + peer: tc.peer, + connected: tc.connected, + connected_at: tc.connected_at ? new Date(tc.connected_at).toISOString() : undefined, + disconnected_at: tc.disconnected_at ? new Date(tc.disconnected_at).toISOString() : undefined, + alive_at: tc.alive_at ? new Date(tc.alive_at).toISOString() : undefined, + } + }), transport: { - ...tunnel.transport, + ...tunnel.config.transport, ...this._transportService.getTransports(tunnel, baseUrl), }, - ingress: tunnel.ingress, - target: tunnel.target, - connection: { - connected: tunnel.state().connected, - peer: tunnel.state().peer, - connected_at: tunnel.state().connected_at, - disconnected_at: tunnel.state().disconnected_at, - alive_at: tunnel.state().alive_at, - }, - connections: tunnel.state().connections, - created_at: tunnel.created_at, - updated_at: tunnel.updated_at, + ingress: tunnel.config.ingress, + target: tunnel.config.target, + created_at: tunnel.config.created_at, + updated_at: tunnel.config.updated_at, } }; + const getBaseUrl = (req: any) => { + return req._exposrBaseUrl; + } + router.route({ method: 'post', path: '/v1/admin/account', @@ -166,7 +183,7 @@ class AdminApiController extends KoaController { } }, handler: [handleAdminAuth, handleError, async (ctx, next) => { - const account = await this.accountService.get(ctx.params.account_id); + const account: Account = await this.accountService.get(ctx.params.account_id); if (!account) { ctx.status = 404; ctx.body = {}; @@ -190,12 +207,12 @@ class AdminApiController extends KoaController { } }, handler: [handleAdminAuth, handleError, async (ctx, next) => { - const res = await this.accountService.list(ctx.query.cursor, ctx.query.count, ctx.query.verbose); + const res = await this.accountService.list(ctx.query.cursor, ctx.query.count, ctx.query.verbose); ctx.status = 200; ctx.body = { cursor: res.cursor, - accounts: res.accounts.map((a) => { return accountProps(a); }), + accounts: res.accounts, }; }] }); @@ -256,14 +273,23 @@ class AdminApiController extends KoaController { } }, handler: [handleAdminAuth, handleError, async (ctx, next) => { - const tunnel = await this._tunnelService._get(ctx.params.tunnel_id); - if (!tunnel) { - ctx.body = { - error: ERROR_TUNNEL_NOT_FOUND, - }; - } else { + try { + const tunnel = await this._tunnelService.lookup(ctx.params.tunnel_id); ctx.status = 200; - ctx.body = tunnelProps(tunnel, ctx.req._exposrBaseUrl); + ctx.body = tunnelProps(tunnel, getBaseUrl(ctx.req)); + } catch (e: any) { + if (e.message == 'no_such_tunnel') { + ctx.status = 404; + ctx.body = { + error: ERROR_TUNNEL_NOT_FOUND, + }; + } else { + ctx.status = 500; + ctx.body = { + error: ERROR_TUNNEL_NOT_FOUND, + details: e.message, + }; + } } }] }); @@ -279,47 +305,48 @@ class AdminApiController extends KoaController { } }, handler: [handleAdminAuth, handleError, async (ctx, next) => { - const tunnel = await this._tunnelService._get(ctx.params.tunnel_id); - if (!tunnel) { - ctx.body = { - error: ERROR_TUNNEL_NOT_FOUND, - }; - } - const result = await this._tunnelService.delete(tunnel.id, tunnel.account); - if (result === false) { - ctx.status = 500; + try { + const tunnel = await this._tunnelService.lookup(ctx.params.tunnel_id); + const result = await this._tunnelService.delete(tunnel.id, tunnel.account); + if (result) { + ctx.status = 204; + } else { + ctx.status = 403; + } + } catch (e: any) { + ctx.status = 403; ctx.body = { error: ERROR_UNKNOWN_ERROR, - }; - } else { - ctx.status = 204; + details: e.message, + } } }] }); router.route({ method: 'post', - path: '/v1/admin/tunnel/:tunnel_id/disconnect/:connection_id?', + path: '/v1/admin/tunnel/:tunnel_id/disconnect', validate: { failure: 400, continueOnError: true, params: { tunnel_id: Router.Joi.string().regex(TunnelService.TUNNEL_ID_REGEX).required(), - connection_id: Router.Joi.string().optional() } }, handler: [handleAdminAuth, handleError, async (ctx, next) => { - const tunnel = await this._tunnelService._get(ctx.params.tunnel_id); - if (!tunnel) { - ctx.body = { - error: ERROR_TUNNEL_NOT_FOUND, - }; - } else { - const res = await this._tunnelService._disconnect(tunnel, ctx.params.connection_id); + try { + const tunnel = await this._tunnelService.lookup(ctx.params.tunnel_id); + const res = await this._tunnelService.disconnect(tunnel.id, tunnel.account); ctx.status = 200; ctx.body = { result: res } + + } catch (e:any) { + ctx.status = 403, + ctx.body = { + details: e.message + } } }] }); @@ -337,12 +364,14 @@ class AdminApiController extends KoaController { } }, handler: [handleAdminAuth, handleError, async (ctx, next) => { - const res = await this._tunnelService.list(ctx.query.cursor, ctx.query.count, ctx.query.verbose); + const res = await this._tunnelService.list(ctx.query.cursor, ctx.query.count, ctx.query.verbose); ctx.status = 200; ctx.body = { cursor: res.cursor, - tunnels: res.tunnels.map((t) => { return ctx.query.verbose ? tunnelProps(t, ctx.req._exposrBaseUrl) : t; }), + tunnels: res.tunnels.map((t) => { + return ctx.query.verbose ? tunnelProps(t, getBaseUrl(ctx.req)) : t.id; + }), }; }] }); @@ -361,7 +390,7 @@ class AdminApiController extends KoaController { node_id: node.id, host: node.host, ip: node.ip, - alive_at: node.last_ts, + alive_at: new Date(node.last_ts).toISOString(), alive_age: Math.max(0, now - node.last_ts), is_stale: node.stale, } @@ -375,8 +404,8 @@ class AdminApiController extends KoaController { }); } - async _destroy() { - return Promise.allSettled([ + protected async _destroy(): Promise { + Promise.allSettled([ this.accountService.destroy(), this._tunnelService.destroy(), this._transportService.destroy(), diff --git a/src/controller/admin-controller.js b/src/controller/admin-controller.js deleted file mode 100644 index 5d559c9..0000000 --- a/src/controller/admin-controller.js +++ /dev/null @@ -1,51 +0,0 @@ -import { Logger } from '../logger.js'; -import KoaController from "./koa-controller.js"; - -class AdminController extends KoaController { - _name = 'Admin' - - constructor(opts) { - const logger = Logger("admin"); - - if (!opts.enable) { - logger.info({ - message: `HTTP Admin disabled`, - }); - typeof opts.callback === 'function' && process.nextTick(opts.callback); - return super(); - } - - super({...opts, logger}); - - this.appReady = undefined; - - this.setRoutes((router) => { - router.route({ - method: 'get', - path: '/ping', - handler: async (ctx, next) => { - ctx.status = this.appReady != undefined ? 200 : 404; - }, - }); - - router.route({ - method: 'get', - path: '/health', - handler: async (ctx, next) => { - ctx.status = this.appReady ? 200 : 404; - }, - }); - }); - } - - setReady(ready) { - ready ??= true; - this.appReady = ready; - } - - async _destroy() { - this.appReady = undefined; - } -} - -export default AdminController; \ No newline at end of file diff --git a/src/controller/admin-controller.ts b/src/controller/admin-controller.ts new file mode 100644 index 0000000..ddec961 --- /dev/null +++ b/src/controller/admin-controller.ts @@ -0,0 +1,53 @@ +import { Router } from 'koa-joi-router'; +import { Logger } from '../logger.js'; +import KoaController from "./koa-controller.js"; + +class AdminController extends KoaController { + + public readonly _name: string = 'Admin' + + public appReady: boolean | undefined; + + constructor(opts: any) { + const logger: any = Logger("admin"); + + super({...opts, logger}); + if (!opts.enable) { + logger.info({ + message: `HTTP Admin disabled`, + }); + return; + } + + this.appReady = undefined; + } + + public setReady(ready: boolean) { + ready ??= true; + this.appReady = ready; + } + + protected _initializeRoutes(router: Router): void { + router.route({ + method: 'get', + path: '/ping', + handler: async (ctx, next) => { + ctx.status = this.appReady != undefined ? 200 : 404; + }, + }); + + router.route({ + method: 'get', + path: '/health', + handler: async (ctx, next) => { + ctx.status = this.appReady ? 200 : 404; + }, + }); + } + + protected async _destroy() { + this.appReady = undefined; + } +} + +export default AdminController; \ No newline at end of file diff --git a/src/controller/api-controller.js b/src/controller/api-controller.ts similarity index 66% rename from src/controller/api-controller.js rename to src/controller/api-controller.ts index 04f27a4..ac1861a 100644 --- a/src/controller/api-controller.js +++ b/src/controller/api-controller.ts @@ -1,4 +1,4 @@ -import Router from 'koa-joi-router'; +import Router from 'koa-joi-router' import AccountService from '../account/account-service.js'; import Account from '../account/account.js'; import { Logger } from '../logger.js'; @@ -9,13 +9,20 @@ import { ERROR_AUTH_NO_ACCESS_TOKEN, ERROR_AUTH_PERMISSION_DENIED, ERROR_BAD_INPUT, - ERROR_TUNNEL_NOT_FOUND + ERROR_TUNNEL_NOT_FOUND, + ERROR_UNKNOWN_ERROR, } from '../utils/errors.js'; import KoaController from './koa-controller.js'; class ApiController extends KoaController { - constructor(opts) { + private opts: any; + private logger: any; + private accountService: AccountService; + private tunnelService: TunnelService; + private transportService: TransportService; + + constructor(opts: any) { const logger = Logger("api"); super({ @@ -33,15 +40,13 @@ class ApiController extends KoaController { if (opts.allowRegistration) { this.logger.warn({message: "Public account registration is enabled"}); } - - this.setRoutes((router) => this._initializeRoutes(router)); } - _initializeRoutes(router) { + _initializeRoutes(router: Router.Router) { - const handleError = async (ctx, next) => { + const handleError: Router.FullHandler = async (ctx, next) => { if (!ctx.invalid) { - return next(ctx, next); + return next(); } if (ctx.invalid.type) { @@ -70,7 +75,7 @@ class ApiController extends KoaController { } }; - const handleAuth = async (ctx, next) => { + const handleAuth: Router.FullHandler = async (ctx, next) => { const token = ctx.request.header.authorization ? ctx.request.header.authorization.split(' ')[1] : undefined; const accountId = token ? Buffer.from(token, 'base64').toString('utf-8') : undefined; if (!token || !accountId) { @@ -93,38 +98,43 @@ class ApiController extends KoaController { return next(); }; - const tunnelInfo = (tunnel, baseUrl) => { + const tunnelInfo = (tunnel: Tunnel, baseUrl: string) => { const info = { id: tunnel.id, connection: { - connected: tunnel.state().connected, - connections: tunnel.state().connections.length || 0, - peer: tunnel.state().peer, - connected_at: tunnel.state().connected_at, - disconnected_at: tunnel.state().disconnected_at, - alive_at: tunnel.state().alive_at, + connected: tunnel.state.connected, + connections: tunnel.state.alive_connections, + connected_at: tunnel.state.connected_at, + disconnected_at: tunnel.state.disconnected_at, + alive_at: tunnel.state.alive_at, }, transport: { - max_connections: tunnel.transport.max_connections, - ...this.transportService.getTransports(tunnel, baseUrl) + ...this.transportService.getTransports(tunnel, baseUrl), + }, + ingress: { + http: {}, + sni: {}, }, - ingress: {}, target: { - url: tunnel.target.url, + url: tunnel.config.target.url, }, - created_at: tunnel.created_at, + created_at: tunnel.config.created_at, }; - Object.keys(tunnel.ingress).forEach((k) => { - const ingress = tunnel.ingress[k]; - if (ingress.enabled) { - info.ingress[k] = ingress; - } - }); + if (tunnel.config.ingress['http']?.enabled) { + info.ingress['http'] = tunnel.config.ingress['http']; + } + if (tunnel.config.ingress['sni']?.enabled) { + info.ingress['sni'] = tunnel.config.ingress['sni']; + } return info; }; + const getBaseUrl = (req: any) => { + return req._exposrBaseUrl; + }; + router.route({ method: ['put', 'patch'], path: '/v1/tunnel/:tunnel_id', @@ -163,12 +173,16 @@ class ApiController extends KoaController { const tunnelId = ctx.params.tunnel_id; const account = ctx._context.account; + let tunnel; if (ctx.request.method == 'PUT') { - let tunnel - tunnel = await this.tunnelService.create(tunnelId, account.id); - if (tunnel == false) { + try { + tunnel = await this.tunnelService.create(tunnelId, account.id); + } catch (e: any) {} + + try { tunnel = await this.tunnelService.get(tunnelId, account.id); - } + } catch (e:any) {} + if (!(tunnel instanceof Tunnel)) { ctx.status = 403; ctx.body = {error: ERROR_AUTH_PERMISSION_DENIED}; @@ -177,31 +191,40 @@ class ApiController extends KoaController { } const body = ctx.request.body; - const updatedTunnel = await this.tunnelService.update(tunnelId, account.id, (tunnel) => { - tunnel.ingress.http.enabled = - body?.ingress?.http?.enabled ?? tunnel.ingress.http.enabled; - tunnel.ingress.sni.enabled = - body?.ingress?.sni?.enabled ?? tunnel.ingress.sni.enabled; - tunnel.ingress.http.alt_names = - body?.ingress?.http?.alt_names === null ? undefined : - body?.ingress?.http?.alt_names ?? tunnel.ingress.http.alt_names; - tunnel.target.url = - body?.target?.url === null ? undefined : - body?.target?.url ?? tunnel.target.url; - tunnel.transport.ws.enabled = - body?.transport?.ws?.enabled ?? tunnel.transport.ws.enabled; - tunnel.transport.ssh.enabled = - body?.transport?.ssh?.enabled ?? tunnel.transport.ssh.enabled; - }); - if (updatedTunnel instanceof Tunnel) { - ctx.body = tunnelInfo(updatedTunnel, ctx.req._exposrBaseUrl); + + try { + const updatedTunnel = await this.tunnelService.update(tunnelId, account.id, (tunnel) => { + tunnel.ingress.http.enabled = + body?.ingress?.http?.enabled ?? tunnel.ingress.http.enabled; + tunnel.ingress.sni.enabled = + body?.ingress?.sni?.enabled ?? tunnel.ingress.sni.enabled; + tunnel.ingress.http.alt_names = + body?.ingress?.http?.alt_names === null ? undefined : + body?.ingress?.http?.alt_names ?? tunnel.ingress.http.alt_names; + tunnel.target.url = + body?.target?.url === null ? undefined : + body?.target?.url ?? tunnel.target.url; + tunnel.transport.ws.enabled = + body?.transport?.ws?.enabled ?? tunnel.transport.ws.enabled; + tunnel.transport.ssh.enabled = + body?.transport?.ssh?.enabled ?? tunnel.transport.ssh.enabled; + }); + ctx.body = tunnelInfo(updatedTunnel, getBaseUrl(ctx.req)); ctx.status = 200; - } else if (updatedTunnel instanceof Error) { - ctx.status = 400; - ctx.body = {error: updatedTunnel.code, details: updatedTunnel.details}; - } else { - ctx.status = 403; - ctx.body = {error: ERROR_AUTH_PERMISSION_DENIED}; + } catch (e: any) { + if (e.message == 'permission_denied') { + ctx.status = 403; + ctx.body = {error: ERROR_AUTH_PERMISSION_DENIED}; + } else { + this.logger.error({ + message: `Failed to update tunnel: ${e.message}`, + }); + this.logger.debug({ + stack: e.stack + }) + ctx.status = 500; + ctx.body = {error: ERROR_UNKNOWN_ERROR, detailed: e.message}; + } } }] }); @@ -219,14 +242,22 @@ class ApiController extends KoaController { handler: [handleError, handleAuth, async (ctx, next) => { const tunnelId = ctx.params.tunnel_id; const account = ctx._context.account; - const result = await this.tunnelService.delete(tunnelId, account.id); - if (result === false) { - ctx.status = 404; - ctx.body = { - error: ERROR_TUNNEL_NOT_FOUND, - }; - } else { - ctx.status = 204; + try { + const result = await this.tunnelService.delete(tunnelId, account.id); + if (result) { + ctx.status = 204; + } else { + ctx.status = 404; + ctx.body = { + error: ERROR_TUNNEL_NOT_FOUND, + }; + } + + } catch (e: any) { + ctx.status = 404; + ctx.body = { + error: ERROR_TUNNEL_NOT_FOUND, + }; } }] }); @@ -244,15 +275,15 @@ class ApiController extends KoaController { handler: [handleError, handleAuth, async (ctx, next) => { const tunnelId = ctx.params.tunnel_id; const account = ctx._context.account; - const tunnel = await this.tunnelService.get(tunnelId, account.id); - if (!tunnel) { + try { + const tunnel = await this.tunnelService.get(tunnelId, account.id); + ctx.status = 200; + ctx.body = tunnelInfo(tunnel, getBaseUrl(ctx.req)); + } catch (e: any) { ctx.status = 404; ctx.body = { error: ERROR_TUNNEL_NOT_FOUND, }; - } else { - ctx.status = 200; - ctx.body = tunnelInfo(tunnel, ctx.req._exposrBaseUrl); } }] }); @@ -270,22 +301,22 @@ class ApiController extends KoaController { handler: [handleError, handleAuth, async (ctx, next) => { const tunnelId = ctx.params.tunnel_id; const account = ctx._context.account; - const result = await this.tunnelService.disconnect(tunnelId, account.id); - if (result == undefined) { - ctx.status = 404; - ctx.body = { - error: ERROR_TUNNEL_NOT_FOUND, - }; - } else { + try { + const result = await this.tunnelService.disconnect(tunnelId, account.id); ctx.status = 200; ctx.body = { result }; + } catch (e: any) { + ctx.status = 404; + ctx.body = { + error: ERROR_TUNNEL_NOT_FOUND, + }; } }] }); - const accountProps = (account) => { + const accountProps = (account: Account) => { const {accountId, formatted} = account.getId(); return { account_id: accountId, @@ -345,7 +376,7 @@ class ApiController extends KoaController { } async _destroy() { - return Promise.allSettled([ + await Promise.allSettled([ this.accountService.destroy(), this.tunnelService.destroy(), this.transportService.destroy(), diff --git a/src/controller/koa-controller.js b/src/controller/koa-controller.ts similarity index 54% rename from src/controller/koa-controller.js rename to src/controller/koa-controller.ts index 9b1ec54..869684e 100644 --- a/src/controller/koa-controller.js +++ b/src/controller/koa-controller.ts @@ -1,25 +1,43 @@ import Koa from 'koa'; -import Router from 'koa-joi-router'; +import Router, { FullHandler } from 'koa-joi-router'; import Listener from '../listener/index.js'; +import HttpListener from '../listener/http-listener.js'; +import { IncomingMessage, ServerResponse } from 'http'; -class KoaController { +abstract class KoaController { - _name = 'controller' + public readonly _name: string = 'controller' + private _port!: number; + private httpListener!: HttpListener; + private _requestHandler: any; + private router!: Router.Router; + private app!: Koa; - constructor(opts) { + constructor(opts: any) { if (opts == undefined) { return; } const {port, callback, logger, host, prio} = opts; + + if (opts?.enable === false) { + typeof callback === 'function' && process.nextTick(() => callback()); + return; + } + this._port = port; - const httpListener = this.httpListener = Listener.acquire('http', port, { app: new Koa() }); - this._requestHandler = httpListener.use('request', { host, logger, prio, logBody: true }, async (ctx, next) => { - ctx.req._exposrBaseUrl = ctx.baseUrl; + const useCallback: FullHandler = async (ctx, next) => { + const setBaseUrl = (req: any, baseUrl: string) => { + req._exposrBaseUrl = baseUrl; + }; + setBaseUrl(ctx.req, ctx.baseUrl) if (!await this.appCallback(ctx.req, ctx.res)) { return next(); } - }); + } + + const httpListener = this.httpListener = Listener.acquire('http', port, { app: new Koa() }); + this._requestHandler = httpListener.use('request', { host, logger, prio, logBody: true }, useCallback); httpListener.setState({ app: new Koa(), @@ -28,15 +46,8 @@ class KoaController { this.app = httpListener.state.app; this.router = Router(); - this.setRoutes = (initFun) => { - initFun(this.router); - this.app.use(this.router.middleware()); - }; - - this.appCallback = async (req, res) => { - await (this.app.callback()(req, res)); - return true; - }; + this._initializeRoutes(this.router); + this.app.use(this.router.middleware()); this.httpListener.listen() .then(() => { @@ -51,14 +62,18 @@ class KoaController { }); typeof callback === 'function' && process.nextTick(() => callback(err)); }); - } - async _destroy() { + private async appCallback(req: IncomingMessage, res: ServerResponse): Promise { + await (this.app.callback()(req, res)); return true; } - async destroy() { + protected abstract _initializeRoutes(router: Router.Router): void; + + protected abstract _destroy(): Promise; + + public async destroy() { this.httpListener.removeHandler('request', this._requestHandler); return Promise.allSettled([ Listener.release('http', this._port), diff --git a/test/e2e/test_api.js b/test/e2e/test_api.js index e42bcac..8daab2e 100644 --- a/test/e2e/test_api.js +++ b/test/e2e/test_api.js @@ -13,6 +13,7 @@ describe('API test', () => { terminator = await exposr.default([ "node", "--admin-enable", + "--admin-api-enable", "--allow-registration", "--ingress", "http", "--ingress-http-url", "http://localhost:8080" diff --git a/yarn.lock b/yarn.lock index 85a68a9..e1d545f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -158,7 +158,7 @@ dependencies: "@hapi/hoek" "^9.0.0" -"@sideway/formula@^3.0.0": +"@sideway/formula@^3.0.0", "@sideway/formula@^3.0.1": version "3.0.1" resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.1.tgz#80fcbcbaf7ce031e0ef2dd29b1bfc7c3f583611f" integrity sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg== @@ -223,6 +223,58 @@ resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.4.tgz#0b92dcc0cc1c81f6f306a381f28e31b1a56536e9" integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA== +"@types/accepts@*": + version "1.3.5" + resolved "https://registry.yarnpkg.com/@types/accepts/-/accepts-1.3.5.tgz#c34bec115cfc746e04fe5a059df4ce7e7b391575" + integrity sha512-jOdnI/3qTpHABjM5cx1Hc0sKsPoYCp+DP/GJRGtDlPd7fiV9oXGGIcjW/ZOxLIvjGz8MA+uMZI9metHlgqbgwQ== + dependencies: + "@types/node" "*" + +"@types/body-parser@*": + version "1.19.3" + resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.3.tgz#fb558014374f7d9e56c8f34bab2042a3a07d25cd" + integrity sha512-oyl4jvAfTGX9Bt6Or4H9ni1Z447/tQuxnZsytsCaExKlmJiU8sFgnIBRzJUpKwB5eWn9HuBYlUlVA74q/yN0eQ== + dependencies: + "@types/connect" "*" + "@types/node" "*" + +"@types/busboy@*": + version "1.5.1" + resolved "https://registry.yarnpkg.com/@types/busboy/-/busboy-1.5.1.tgz#c8112818cfde780c2d4df384ba59744d2faae2c7" + integrity sha512-JAymE2skNionWnBUwby3MatzPUw4D/6/7FX1qxBXLzmRnFxmqU0luIof7om0I8R3B/rSr9FKUnFCqxZ/NeGbrw== + dependencies: + "@types/node" "*" + +"@types/co-body@*": + version "6.1.1" + resolved "https://registry.yarnpkg.com/@types/co-body/-/co-body-6.1.1.tgz#28d253c95cfbe30c8e8c5d69d4c0dbbcffc101c2" + integrity sha512-I9A1k7o4m8m6YPYJIGb1JyNTLqRWtSPg1JOZPWlE19w8Su2VRgRVp/SkKftQSwoxWHGUxGbON4jltONMumC8bQ== + dependencies: + "@types/node" "*" + "@types/qs" "*" + +"@types/connect@*": + version "3.4.36" + resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.36.tgz#e511558c15a39cb29bd5357eebb57bd1459cd1ab" + integrity sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w== + dependencies: + "@types/node" "*" + +"@types/content-disposition@*": + version "0.5.6" + resolved "https://registry.yarnpkg.com/@types/content-disposition/-/content-disposition-0.5.6.tgz#0f5fa03609f308a7a1a57e0b0afe4b95f1d19740" + integrity sha512-GmShTb4qA9+HMPPaV2+Up8tJafgi38geFi7vL4qAM7k8BwjoelgHZqEUKJZLvughUw22h6vD/wvwN4IUCaWpDA== + +"@types/cookies@*": + version "0.7.8" + resolved "https://registry.yarnpkg.com/@types/cookies/-/cookies-0.7.8.tgz#16fccd6d58513a9833c527701a90cc96d216bc18" + integrity sha512-y6KhF1GtsLERUpqOV+qZJrjUGzc0GE6UTa0b5Z/LZ7Nm2mKSdCXmS6Kdnl7fctPNnMSouHjxqEWI12/YqQfk5w== + dependencies: + "@types/connect" "*" + "@types/express" "*" + "@types/keygrip" "*" + "@types/node" "*" + "@types/estree@*": version "0.0.51" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.51.tgz#cfd70924a25a3fd32b218e5e420e6897e1ac4f40" @@ -233,6 +285,91 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.0.tgz#5fb2e536c1ae9bf35366eed879e827fa59ca41c2" integrity sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ== +"@types/express-serve-static-core@^4.17.33": + version "4.17.37" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.37.tgz#7e4b7b59da9142138a2aaa7621f5abedce8c7320" + integrity sha512-ZohaCYTgGFcOP7u6aJOhY9uIZQgZ2vxC2yWoArY+FeDXlqeH66ZVBjgvg+RLVAS/DWNq4Ap9ZXu1+SUQiiWYMg== + dependencies: + "@types/node" "*" + "@types/qs" "*" + "@types/range-parser" "*" + "@types/send" "*" + +"@types/express@*": + version "4.17.18" + resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.18.tgz#efabf5c4495c1880df1bdffee604b143b29c4a95" + integrity sha512-Sxv8BSLLgsBYmcnGdGjjEjqET2U+AKAdCRODmMiq02FgjwuV75Ut85DRpvFjyw/Mk0vgUOliGRU0UUmuuZHByQ== + dependencies: + "@types/body-parser" "*" + "@types/express-serve-static-core" "^4.17.33" + "@types/qs" "*" + "@types/serve-static" "*" + +"@types/http-assert@*": + version "1.5.3" + resolved "https://registry.yarnpkg.com/@types/http-assert/-/http-assert-1.5.3.tgz#ef8e3d1a8d46c387f04ab0f2e8ab8cb0c5078661" + integrity sha512-FyAOrDuQmBi8/or3ns4rwPno7/9tJTijVW6aQQjK02+kOQ8zmoNg2XJtAuQhvQcy1ASJq38wirX5//9J1EqoUA== + +"@types/http-errors@*": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-2.0.2.tgz#a86e00bbde8950364f8e7846687259ffcd96e8c2" + integrity sha512-lPG6KlZs88gef6aD85z3HNkztpj7w2R7HmR3gygjfXCQmsLloWNARFkMuzKiiY8FGdh1XDpgBdrSf4aKDiA7Kg== + +"@types/keygrip@*": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@types/keygrip/-/keygrip-1.0.3.tgz#2286b16ef71d8dea74dab00902ef419a54341bfe" + integrity sha512-tfzBBb7OV2PbUfKbG6zRE5UbmtdLVCKT/XT364Z9ny6pXNbd9GnIB6aFYpq2A5lZ6mq9bhXgK6h5MFGNwhMmuQ== + +"@types/koa-compose@*": + version "3.2.6" + resolved "https://registry.yarnpkg.com/@types/koa-compose/-/koa-compose-3.2.6.tgz#17a077786d0ac5eee04c37a7d6c207b3252f6de9" + integrity sha512-PHiciWxH3NRyAaxUdEDE1NIZNfvhgtPlsdkjRPazHC6weqt90Jr0uLhIQs+SDwC8HQ/jnA7UQP6xOqGFB7ugWw== + dependencies: + "@types/koa" "*" + +"@types/koa-joi-router@^8.0.5": + version "8.0.5" + resolved "https://registry.yarnpkg.com/@types/koa-joi-router/-/koa-joi-router-8.0.5.tgz#24c3cfff8de44164407dc193e5315577ed7cea4f" + integrity sha512-K15LCQn+TcW727qdgbP2TY9ovInbq5xcBXgW3djlTR8VAoGrOKLvFVjOnDvo78SJALB2N+pONRYuM+A4Lj48Cg== + dependencies: + "@types/busboy" "*" + "@types/co-body" "*" + "@types/koa" "*" + "@types/koa-router" "*" + "@types/node" "*" + joi "^17.3.0" + +"@types/koa-router@*": + version "7.4.5" + resolved "https://registry.yarnpkg.com/@types/koa-router/-/koa-router-7.4.5.tgz#33cd1c7e5ee75e52fd4786f49a8577b45cfdccde" + integrity sha512-9DpkJcOpeK2bXUfyLDKzbDnohUp1TcNBQ7XsuadjYcHMsCOILRbzQdA/g5qJA+0zHi0cet64bOycXz2Snb0Rpw== + dependencies: + "@types/koa" "*" + +"@types/koa@*": + version "2.13.9" + resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.13.9.tgz#8d989ac17d7f033475fbe34c4f906c9287c2041a" + integrity sha512-tPX3cN1dGrMn+sjCDEiQqXH2AqlPoPd594S/8zxwUm/ZbPsQXKqHPUypr2gjCPhHUc+nDJLduhh5lXI/1olnGQ== + dependencies: + "@types/accepts" "*" + "@types/content-disposition" "*" + "@types/cookies" "*" + "@types/http-assert" "*" + "@types/http-errors" "*" + "@types/keygrip" "*" + "@types/koa-compose" "*" + "@types/node" "*" + +"@types/mime@*": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.2.tgz#c1ae807f13d308ee7511a5b81c74f327028e66e8" + integrity sha512-Wj+fqpTLtTbG7c0tH47dkahefpLKEbB+xAZuLq7b4/IDHPl/n6VoXcyUQ2bypFlbSwvCr0y+bD4euTTqTJsPxQ== + +"@types/mime@^1": + version "1.3.3" + resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.3.tgz#bbe64987e0eb05de150c305005055c7ad784a9ce" + integrity sha512-Ys+/St+2VF4+xuY6+kDIXGxbNRO0mesVg0bbxEfB97Od1Vjpjx9KD1qxs64Gcb3CWPirk9Xe+PT4YiiHQ9T+eg== + "@types/minimist@^1.2.0": version "1.2.2" resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.2.tgz#ee771e2ba4b3dc5b372935d549fd9617bf345b8c" @@ -258,6 +395,33 @@ resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301" integrity sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw== +"@types/qs@*": + version "6.9.8" + resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.8.tgz#f2a7de3c107b89b441e071d5472e6b726b4adf45" + integrity sha512-u95svzDlTysU5xecFNTgfFG5RUWu1A9P0VzgpcIiGZA9iraHOdSzcxMxQ55DyeRaGCSxQi7LxXDI4rzq/MYfdg== + +"@types/range-parser@*": + version "1.2.5" + resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.5.tgz#38bd1733ae299620771bd414837ade2e57757498" + integrity sha512-xrO9OoVPqFuYyR/loIHjnbvvyRZREYKLjxV4+dY6v3FQR3stQ9ZxIGkaclF7YhI9hfjpuTbu14hZEy94qKLtOA== + +"@types/send@*": + version "0.17.2" + resolved "https://registry.yarnpkg.com/@types/send/-/send-0.17.2.tgz#af78a4495e3c2b79bfbdac3955fdd50e03cc98f2" + integrity sha512-aAG6yRf6r0wQ29bkS+x97BIs64ZLxeE/ARwyS6wrldMm3C1MdKwCcnnEwMC1slI8wuxJOpiUH9MioC0A0i+GJw== + dependencies: + "@types/mime" "^1" + "@types/node" "*" + +"@types/serve-static@*": + version "1.15.3" + resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.3.tgz#2cfacfd1fd4520bbc3e292cca432d5e8e2e3ee61" + integrity sha512-yVRvFsEMrv7s0lGhzrggJjNOSmZCdgCjw9xWrPr/kNNLp6FaDfMC1KaYl3TSJ0c58bECwNBMoQrZJ8hA8E1eFg== + dependencies: + "@types/http-errors" "*" + "@types/mime" "*" + "@types/node" "*" + "@types/sinon@^10.0.17": version "10.0.17" resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-10.0.17.tgz#97592d7c73ded11907f9121a2837f5ab5f9ccaa0" @@ -1565,6 +1729,17 @@ joi@^17.2.1: "@sideway/formula" "^3.0.0" "@sideway/pinpoint" "^2.0.0" +joi@^17.3.0: + version "17.10.2" + resolved "https://registry.yarnpkg.com/joi/-/joi-17.10.2.tgz#4ecc348aa89ede0b48335aad172e0f5591e55b29" + integrity sha512-hcVhjBxRNW/is3nNLdGLIjkgXetkeGc2wyhydhz8KumG23Aerk4HPjU5zaPAMRqXQFc0xNqXTC7+zQjxr0GlKA== + dependencies: + "@hapi/hoek" "^9.0.0" + "@hapi/topo" "^5.0.0" + "@sideway/address" "^4.1.3" + "@sideway/formula" "^3.0.1" + "@sideway/pinpoint" "^2.0.0" + js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" From d627befa0cc323091809626abcdc678b565f14eb Mon Sep 17 00:00:00 2001 From: Fredrik Lindberg Date: Sat, 30 Sep 2023 21:16:26 +0200 Subject: [PATCH 8/8] fix: save last_ts when learning a new cluster node --- src/cluster/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/cluster/index.ts b/src/cluster/index.ts index 425f216..6c71926 100644 --- a/src/cluster/index.ts +++ b/src/cluster/index.ts @@ -192,6 +192,7 @@ class ClusterService extends EventEmitter { cnode.id = node.id; cnode.host = node.host; cnode.ip = node.ip; + cnode.last_ts = node.last_ts; cnode.stale = false; clearTimeout(cnode.staleTimer); @@ -234,7 +235,7 @@ class ClusterService extends EventEmitter { id: Node.identifier, host: Node.hostname, ip: Node.address, - last_ts: new Date().getTime(), + last_ts: Date.now(), stale: false, }; } @@ -260,7 +261,7 @@ class ClusterService extends EventEmitter { id: this._nodes[k].id, host: this._nodes[k].host, ip: this._nodes[k].ip, - last_ts: Node.identifier == this._nodes[k].id ? new Date().getTime() : this._nodes[k].last_ts, + last_ts: Node.identifier == this._nodes[k].id ? Date.now() : this._nodes[k].last_ts, stale: this._nodes[k].stale, } })