diff --git a/src/controller/admin-api-controller.ts b/src/controller/admin-api-controller.ts index 8126ac2..9742193 100644 --- a/src/controller/admin-api-controller.ts +++ b/src/controller/admin-api-controller.ts @@ -53,7 +53,7 @@ class AdminApiController extends KoaController { } } - protected _initializeRoutes(router: Router.Router) { + protected _initializeRoutes(router: Router.Router): void { const handleError: Router.FullHandler = async (ctx, next) => { if (!ctx.invalid) { @@ -122,7 +122,7 @@ class AdminApiController extends KoaController { } }; - const tunnelProps = (tunnel: Tunnel, baseUrl: string) => { + const tunnelProps = (tunnel: Tunnel, baseUrl: URL | undefined) => { return { tunnel_id: tunnel.id, account_id: tunnel.account, @@ -154,10 +154,6 @@ class AdminApiController extends KoaController { } }; - const getBaseUrl = (req: any) => { - return req._exposrBaseUrl; - } - router.route({ method: 'post', path: '/v1/admin/account', @@ -276,7 +272,7 @@ class AdminApiController extends KoaController { try { const tunnel = await this._tunnelService.lookup(ctx.params.tunnel_id); ctx.status = 200; - ctx.body = tunnelProps(tunnel, getBaseUrl(ctx.req)); + ctx.body = tunnelProps(tunnel, this.getBaseUrl(ctx.req)); } catch (e: any) { if (e.message == 'no_such_tunnel') { ctx.status = 404; @@ -370,7 +366,7 @@ class AdminApiController extends KoaController { ctx.body = { cursor: res.cursor, tunnels: res.tunnels.map((t) => { - return ctx.query.verbose ? tunnelProps(t, getBaseUrl(ctx.req)) : t.id; + return ctx.query.verbose ? tunnelProps(t, this.getBaseUrl(ctx.req)) : t.id; }), }; }] diff --git a/src/controller/api-controller.ts b/src/controller/api-controller.ts index ac1861a..9513de8 100644 --- a/src/controller/api-controller.ts +++ b/src/controller/api-controller.ts @@ -42,7 +42,7 @@ class ApiController extends KoaController { } } - _initializeRoutes(router: Router.Router) { + protected _initializeRoutes(router: Router.Router): void { const handleError: Router.FullHandler = async (ctx, next) => { if (!ctx.invalid) { @@ -98,7 +98,7 @@ class ApiController extends KoaController { return next(); }; - const tunnelInfo = (tunnel: Tunnel, baseUrl: string) => { + const tunnelInfo = (tunnel: Tunnel, baseUrl: URL | undefined) => { const info = { id: tunnel.id, connection: { @@ -131,10 +131,6 @@ class ApiController extends KoaController { return info; }; - const getBaseUrl = (req: any) => { - return req._exposrBaseUrl; - }; - router.route({ method: ['put', 'patch'], path: '/v1/tunnel/:tunnel_id', @@ -209,7 +205,7 @@ class ApiController extends KoaController { tunnel.transport.ssh.enabled = body?.transport?.ssh?.enabled ?? tunnel.transport.ssh.enabled; }); - ctx.body = tunnelInfo(updatedTunnel, getBaseUrl(ctx.req)); + ctx.body = tunnelInfo(updatedTunnel, this.getBaseUrl(ctx.req)); ctx.status = 200; } catch (e: any) { if (e.message == 'permission_denied') { @@ -278,7 +274,7 @@ class ApiController extends KoaController { try { const tunnel = await this.tunnelService.get(tunnelId, account.id); ctx.status = 200; - ctx.body = tunnelInfo(tunnel, getBaseUrl(ctx.req)); + ctx.body = tunnelInfo(tunnel, this.getBaseUrl(ctx.req)); } catch (e: any) { ctx.status = 404; ctx.body = { diff --git a/src/controller/koa-controller.ts b/src/controller/koa-controller.ts index 869684e..824411b 100644 --- a/src/controller/koa-controller.ts +++ b/src/controller/koa-controller.ts @@ -1,7 +1,8 @@ +import { strict as assert } from 'assert'; import Koa from 'koa'; -import Router, { FullHandler } from 'koa-joi-router'; -import Listener from '../listener/index.js'; -import HttpListener from '../listener/http-listener.js'; +import Router from 'koa-joi-router'; +import Listener from '../listener/listener.js'; +import HttpListener, { HttpRequestCallback, HttpRequestType } from '../listener/http-listener.js'; import { IncomingMessage, ServerResponse } from 'http'; abstract class KoaController { @@ -9,14 +10,12 @@ abstract class KoaController { public readonly _name: string = 'controller' private _port!: number; private httpListener!: HttpListener; - private _requestHandler: any; + private _requestHandler!: HttpRequestCallback; private router!: Router.Router; private app!: Koa; constructor(opts: any) { - if (opts == undefined) { - return; - } + assert(opts != undefined); const {port, callback, logger, host, prio} = opts; if (opts?.enable === false) { @@ -26,8 +25,8 @@ abstract class KoaController { this._port = port; - const useCallback: FullHandler = async (ctx, next) => { - const setBaseUrl = (req: any, baseUrl: string) => { + const useCallback: HttpRequestCallback = this._requestHandler = async (ctx, next) => { + const setBaseUrl = (req: any, baseUrl: URL | undefined) => { req._exposrBaseUrl = baseUrl; }; setBaseUrl(ctx.req, ctx.baseUrl) @@ -36,15 +35,10 @@ abstract class KoaController { } } - 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(), - ...httpListener.state, - }); - this.app = httpListener.state.app; + const httpListener = this.httpListener = Listener.acquire(HttpListener, port); + httpListener.use(HttpRequestType.request, { host, logger, prio, logBody: true }, useCallback); + this.app = new Koa(); this.router = Router(); this._initializeRoutes(this.router); this.app.use(this.router.middleware()); @@ -73,13 +67,17 @@ abstract class KoaController { protected abstract _destroy(): Promise; - public async destroy() { - this.httpListener.removeHandler('request', this._requestHandler); - return Promise.allSettled([ - Listener.release('http', this._port), + public async destroy(): Promise { + this.httpListener?.removeHandler(HttpRequestType.request, this._requestHandler); + await Promise.allSettled([ + Listener.release(this._port), this._destroy(), ]); } + + protected getBaseUrl(req: IncomingMessage): URL | undefined { + return ((req as any)._exposrBaseUrl as (URL | undefined)); + } } export default KoaController; \ No newline at end of file diff --git a/src/index.js b/src/index.js index 2afa3ed..187623c 100644 --- a/src/index.js +++ b/src/index.js @@ -3,7 +3,7 @@ import AdminApiController from './controller/admin-api-controller.js'; import AdminController from './controller/admin-controller.js'; import ApiController from './controller/api-controller.js'; import ClusterService from './cluster/index.js'; -import Ingress from './ingress/index.js'; +import IngressManager from './ingress/ingress-manager.js'; import { Logger } from './logger.js'; import { StorageService } from './storage/index.js'; import TransportService from './transport/transport-service.js'; @@ -88,28 +88,19 @@ export default async (argv) => { }); // Setup tunnel data ingress (incoming tunnel data) - const ingressReady = new Promise((resolve, reject) => { - try { - const ingress = new Ingress({ - callback: (err) => { - err ? reject(err) : resolve(ingress); - }, - http: { - enabled: config.get('ingress').includes('http'), - port: config.get('ingress-http-port'), - subdomainUrl: config.get('ingress-http-url'), - httpAgentTTL: config.get('ingress-http-agent-idle-timeout'), - }, - sni: { - enabled: config.get('ingress').includes('sni'), - port: config.get('ingress-sni-port'), - host: config.get('ingress-sni-host'), - cert: config.get('ingress-sni-cert'), - key: config.get('ingress-sni-key'), - } - }); - } catch (e) { - reject(e); + const ingressReady = IngressManager.listen({ + http: { + enabled: config.get('ingress').includes('http'), + port: config.get('ingress-http-port'), + subdomainUrl: config.get('ingress-http-url'), + httpAgentTTL: config.get('ingress-http-agent-idle-timeout'), + }, + sni: { + enabled: config.get('ingress').includes('sni'), + port: config.get('ingress-sni-port'), + host: config.get('ingress-sni-host'), + cert: config.get('ingress-sni-cert'), + key: config.get('ingress-sni-key'), } }); @@ -236,7 +227,7 @@ export default async (argv) => { adminApiController.destroy(), adminController.destroy(), transport.destroy(), - ingress.destroy(), + IngressManager.close(), storageService.destroy(), clusterService.destroy(), config.destroy(), diff --git a/src/ingress/http-ingress.js b/src/ingress/http-ingress.ts similarity index 64% rename from src/ingress/http-ingress.js rename to src/ingress/http-ingress.ts index a69f85d..ac02be3 100644 --- a/src/ingress/http-ingress.js +++ b/src/ingress/http-ingress.ts @@ -1,13 +1,12 @@ -import assert from 'assert/strict'; -import http, { Agent } from 'http'; +import http, { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'http'; import net from 'net'; import NodeCache from 'node-cache'; import EventBus from '../cluster/eventbus.js'; -import Listener from '../listener/index.js'; +import Listener from '../listener/listener.js'; import IngressUtils from './utils.js'; import { Logger } from '../logger.js'; -import TunnelService from '../tunnel/tunnel-service.js'; -import AltNameService from './altname-service.js'; +import TunnelService, { CreateConnectionContext } from '../tunnel/tunnel-service.js'; +import AltNameService from '../tunnel/altname-service.js'; import Node from '../cluster/cluster-node.js'; import { ERROR_TUNNEL_NOT_FOUND, ERROR_TUNNEL_NOT_CONNECTED, @@ -28,10 +27,53 @@ import { HTTP_HEADER_X_FORWARDED_PROTO, HTTP_HEADER_FORWARDED } from '../utils/http-headers.js'; +import HttpListener, { HttpRequestCallback, HttpRequestType, HttpUpgradeCallback } from '../listener/http-listener.js'; +import Tunnel from '../tunnel/tunnel.js'; +import { Duplex } from 'stream'; +import IngressBase from './ingress-base.js'; -class HttpIngress { +type CreateConnectionCallback = (options: object, callback: (err: Error | undefined, sock: Duplex) => void) => Duplex; - constructor(opts) { +class IngressHttpAgent extends http.Agent { + private createConnectionCallback: CreateConnectionCallback; + public activeTunnelConnections: number = 0; + + constructor(opts: http.AgentOptions, createConnectionCallback: CreateConnectionCallback) { + super(opts); + this.createConnectionCallback = createConnectionCallback; + } + + public createConnection(options: object, callback: (err: Error | undefined, sock: Duplex) => void): Duplex { + return this.createConnectionCallback(options, callback); + } +} + +export type HttpIngressOptions = { + subdomainUrl: URL; + httpAgentTTL?: number; + port: number; +} + +type _HttpIngressOptions = HttpIngressOptions & { + callback: (error?: Error) => void; +} + +export default class HttpIngress implements IngressBase { + + private opts: any; + private logger: any; + private _agent_ttl: number; + private altNameService: any; + private tunnelService: TunnelService; + private httpListener: HttpListener; + private _requestHandler: HttpRequestCallback; + private _upgradeHandler: HttpUpgradeCallback; + private _agentCache: NodeCache; + private eventBus: EventBus; + + public destroyed: boolean; + + constructor(opts: _HttpIngressOptions) { this.opts = opts; this.logger = Logger("http-ingress"); @@ -43,19 +85,22 @@ class HttpIngress { this.destroyed = false; this.altNameService = new AltNameService(); - this.tunnelService = opts.tunnelService; - assert(this.tunnelService instanceof TunnelService); - this.httpListener = Listener.acquire('http', opts.port); - this._requestHandler = this.httpListener.use('request', { logger: this.logger, prio: 1 }, async (ctx, next) => { + this.tunnelService = new TunnelService(); + this.httpListener = Listener.acquire(HttpListener, opts.port); + + this._requestHandler = async (ctx, next) => { if (!await this.handleRequest(ctx.req, ctx.res, ctx.baseUrl)) { next(); } - }); - this._upgradeHandler = this.httpListener.use('upgrade', { logger: this.logger }, async (ctx, next) => { + }; + this.httpListener.use(HttpRequestType.request, { logger: this.logger, prio: 1 }, this._requestHandler); + + this._upgradeHandler = async (ctx, next) => { if (!await this.handleUpgradeRequest(ctx.req, ctx.sock, ctx.head, ctx.baseUrl)) { next(); } - }); + }; + this.httpListener.use(HttpRequestType.upgrade, { logger: this.logger }, this._upgradeHandler); this._agentCache = new NodeCache({ useClones: false, @@ -74,6 +119,7 @@ class HttpIngress { } this._agentCache.del(key); agent.destroy(); + agent.removeAllListeners(); this.logger.isDebugEnabled() && this.logger.withContext("tunnel", key).debug("http agent destroyed") }); @@ -87,7 +133,7 @@ class HttpIngress { .then(() => { this.logger.info({ message: `HTTP ingress listening on port ${opts.port} (agent idle timeout ${opts.httpAgentTTL})`, - url: this.getBaseUrl(), + url: opts.subdomainUrl, }); typeof opts.callback === 'function' && opts.callback(); }) @@ -99,7 +145,7 @@ class HttpIngress { }); } - getBaseUrl(tunnelId = undefined) { + public getBaseUrl(tunnelId: string): URL { const url = new URL(this.opts.subdomainUrl.href); if (tunnelId) { url.hostname = `${tunnelId}.${url.hostname}`; @@ -107,24 +153,7 @@ class HttpIngress { return url; } - getIngress(tunnel, altNames = []) { - const altUrls = altNames.map((an) => { - const url = this.getBaseUrl(); - url.hostname = an; - return url.href; - }); - - const url = this.getBaseUrl(tunnel.id).href; - return { - url, - urls: [ - url, - ...altUrls, - ] - }; - } - - async _getTunnel(req) { + private async _getTunnel(req: IncomingMessage): Promise { const host = (req.headers.host || '').toLowerCase().split(":")[0]; if (!host) { return undefined; @@ -138,60 +167,59 @@ class HttpIngress { } } try { - return this.tunnelService.lookup(tunnelId); + const tunnel = await this.tunnelService.lookup(tunnelId); + return tunnel; } catch (e) { return false; } } - _clientIp(req) { - let ip; + private _clientIp(req: IncomingMessage): string { + let ip: string = req.socket.remoteAddress || ''; if (req.headers[HTTP_HEADER_X_FORWARDED_FOR]) { - ip = req.headers[HTTP_HEADER_X_FORWARDED_FOR].split(/\s*,\s*/)[0]; + ip = (req.headers[HTTP_HEADER_X_FORWARDED_FOR] as string).split(/\s*,\s*/)[0]; } - return net.isIP(ip) ? ip : req.socket.remoteAddress; + return net.isIP(ip) ? ip : ''; } - _createAgent(tunnelId, req) { - const agent = new Agent({ - keepAlive: true, - timeout: this._agent_ttl * 1000, - }); + private _createAgent(tunnelId: string, req: IncomingMessage): IngressHttpAgent { const remoteAddr = this._clientIp(req); - agent.createConnection = (opts, callback) => { - const ctx = { + const createConnection = (opts: object, callback: (err: Error | undefined, sock: Duplex) => void) => { + const ctx: CreateConnectionContext = { remoteAddr, ingress: { - tls: false, port: this.httpListener.getPort(), }, - opts, }; return this.tunnelService.createConnection(tunnelId, ctx, callback); }; - agent.activeTunnelConnections = 0; + + const agent = new IngressHttpAgent({ + keepAlive: true, + timeout: this._agent_ttl * 1000, + }, createConnection); this.logger.isDebugEnabled() && this.logger.withContext("tunnel", tunnelId).debug("http agent created") return agent; } - _getAgent(tunnelId, req) { - let agent; + private _getAgent(tunnelId: string, req: IncomingMessage): IngressHttpAgent { + let agent: IngressHttpAgent | undefined; try { - agent = this._agentCache.get(tunnelId); + agent = this._agentCache.get(tunnelId); } catch (e) {} if (agent === undefined) { agent = this._createAgent(tunnelId, req); - this._agentCache.set(tunnelId, agent, this._agent_ttl); + this._agentCache.set(tunnelId, agent, this._agent_ttl); } else { this._agentCache.ttl(tunnelId, this._agent_ttl); } return agent; } - _requestHeaders(req, tunnel, baseUrl) { + private _requestHeaders(req: IncomingMessage, tunnel: Tunnel, baseUrl: URL | undefined, isUpgrade: boolean): IncomingHttpHeaders { const headers = { ... req.headers }; const clientIp = this._clientIp(req); @@ -199,7 +227,7 @@ class HttpIngress { headers[HTTP_HEADER_X_REAL_IP] = headers[HTTP_HEADER_X_FORWARDED_FOR]; if (headers[HTTP_HEADER_EXPOSR_VIA]) { - headers[HTTP_HEADER_EXPOSR_VIA] = `${Node.identifier},${headers[HTTP_HEADER_EXPOSR_VIA] }`; + headers[HTTP_HEADER_EXPOSR_VIA] = `${Node.identifier},${headers[HTTP_HEADER_EXPOSR_VIA]}`; } else { headers[HTTP_HEADER_EXPOSR_VIA] = Node.identifier; } @@ -207,16 +235,18 @@ class HttpIngress { if (this.tunnelService.isLocalConnected(tunnel.id)) { // Delete connection header if tunnel is // locally connected and it's not an upgrade request - if (!req.upgrade) { + if (!isUpgrade) { delete headers[HTTP_HEADER_CONNECTION]; } - headers[HTTP_HEADER_X_FORWARDED_HOST] = baseUrl.host; - if (baseUrl.port) { - headers[HTTP_HEADER_X_FORWARDED_PORT] = baseUrl.port; + headers[HTTP_HEADER_X_FORWARDED_HOST] = baseUrl?.host; + if (baseUrl?.port) { + headers[HTTP_HEADER_X_FORWARDED_PORT] = baseUrl?.port; + } + headers[HTTP_HEADER_X_FORWARDED_PROTO] = baseUrl?.protocol.slice(0, -1); + if (baseUrl) { + headers[HTTP_HEADER_FORWARDED] = `by=_exposr;for=${clientIp};host=${baseUrl.host};proto=${baseUrl.protocol.slice(0, -1)}`; } - headers[HTTP_HEADER_X_FORWARDED_PROTO] = baseUrl.protocol.slice(0, -1); - headers[HTTP_HEADER_FORWARDED] = `by=_exposr;for=${clientIp};host=${baseUrl.host};proto=${baseUrl.protocol.slice(0, -1)}`; this._rewriteHeaders(headers, tunnel); } @@ -224,16 +254,20 @@ class HttpIngress { return headers; } - _rewriteHeaders(headers, tunnel) { + private _rewriteHeaders(headers: IncomingHttpHeaders, tunnel: Tunnel): void { const host = headers['host']; - let target; - if (tunnel.config.target.url) { - try { - target = new URL(tunnel.config.target.url); - } catch {} + if (!tunnel.config.target.url) { + return; } - if (target === undefined || !target.protocol.startsWith('http')) { + + let target: URL; + try { + target = new URL(tunnel.config.target.url); + } catch { + return; + } + if (!target.protocol.startsWith('http')) { return; } @@ -243,9 +277,9 @@ class HttpIngress { if (value == undefined) { return; } - if (value.startsWith('http')) { + if ((value as string).startsWith('http')) { try { - const url = new URL(value); + const url = new URL(value as string); if (url.host == host) { url.protocol = target.protocol; url.host = target.host; @@ -260,14 +294,14 @@ class HttpIngress { }); } - _loopDetected(req) { - const via = (req.headers[HTTP_HEADER_EXPOSR_VIA] || '').split(','); + private _loopDetected(req: IncomingMessage): boolean { + const via = ((req.headers[HTTP_HEADER_EXPOSR_VIA] as string) || '').split(','); return via.map((v) => v.trim()).includes(Node.identifier); } - async handleRequest(req, res, baseUrl) { + private async handleRequest(req: IncomingMessage, res: ServerResponse, baseUrl: URL | undefined) { - const httpResponse = (status, body) => { + const httpResponse = (status: number, body: object) => { res.setHeader('Content-Type', 'application/json'); res.statusCode = status; res.end(JSON.stringify(body)); @@ -284,7 +318,7 @@ class HttpIngress { } if (!tunnel.state.connected) { - httpResponse(502, { + httpResponse(503, { error: ERROR_TUNNEL_NOT_CONNECTED, }); return true; @@ -304,25 +338,25 @@ class HttpIngress { return true; } - const opt = { + const opt: http.RequestOptions = { path: req.url, method: req.method, - keepAlive: true, }; const agent = opt.agent = this._getAgent(tunnel.id, req); - opt.headers = this._requestHeaders(req, tunnel, baseUrl); - - this.logger.trace({ - operation: 'tunnel-request', - path: opt.path, - method: opt.method, - headers: opt.headers, - }); + opt.headers = this._requestHeaders(req, tunnel, baseUrl, false); + + this.logger.isTraceEnabled() && + this.logger.trace({ + operation: 'tunnel-request', + path: opt.path, + method: opt.method, + headers: opt.headers, + }); const clientReq = http.request(opt, (clientRes) => { agent.activeTunnelConnections++; - res.writeHead(clientRes.statusCode, clientRes.headers); + res.writeHead(clientRes.statusCode || 500, clientRes.headers); clientRes.pipe(res); }); @@ -330,16 +364,16 @@ class HttpIngress { agent.activeTunnelConnections = Math.max(agent.activeTunnelConnections - 1, 0); }); - clientReq.on('error', (err) => { + clientReq.on('error', (err: any) => { let msg; if (err.code === 'EMFILE') { res.statusCode = 429; msg = ERROR_TUNNEL_TRANSPORT_REQUEST_LIMIT; } else if (err.code == 'ECONNRESET') { - res.statusCode = 503; + res.statusCode = 502; msg = ERROR_TUNNEL_TARGET_CON_REFUSED; } else { - res.statusCode = 503; + res.statusCode = 502; msg = ERROR_TUNNEL_TARGET_CON_FAILED; } res.end(JSON.stringify({error: msg})); @@ -349,8 +383,8 @@ class HttpIngress { return true; } - async handleUpgradeRequest(req, sock, head, baseUrl) { - const _canonicalHttpResponse = (sock, request, response) => { + private async handleUpgradeRequest(req: IncomingMessage, sock: Duplex, head: Buffer, baseUrl: URL | undefined) { + const _canonicalHttpResponse = (sock: Duplex, request: IncomingMessage, response: any) => { sock.write(`HTTP/${request.httpVersion} ${response.status} ${response.statusLine}\r\n`); sock.write('\r\n'); response.body && sock.write(response.body); @@ -389,10 +423,9 @@ class HttpIngress { return true; } - const ctx = { + const ctx: CreateConnectionContext = { remoteAddr: this._clientIp(req), ingress: { - tls: false, port: this.httpListener.getPort(), } }; @@ -403,17 +436,18 @@ class HttpIngress { let statusCode; let statusLine; let msg; - if (err.code === 'EMFILE') { + + if ((err as any).code === 'EMFILE') { statusCode = 429; statusLine = 'Too Many Requests'; msg = ERROR_TUNNEL_TRANSPORT_REQUEST_LIMIT; - } else if (err.code == 'ECONNRESET') { - statusCode = 503; - statusLine = 'Service Unavailable'; + } else if ((err as any).code == 'ECONNRESET') { + statusCode = 502; + statusLine = 'Bad Gateway'; msg = ERROR_TUNNEL_TARGET_CON_REFUSED; } else { - statusCode = 503; - statusLine = 'Service Unavailable'; + statusCode = 502; + statusLine = 'Bad Gateway'; msg = ERROR_TUNNEL_TARGET_CON_FAILED; } _canonicalHttpResponse(sock, req, { @@ -431,9 +465,9 @@ class HttpIngress { return true; } - const headers = this._requestHeaders(req, tunnel, baseUrl); + const headers = this._requestHeaders(req, tunnel, baseUrl, true); - const close = (err) => { + const close = () => { target.off('error', close); target.off('close', close); sock.off('error', close); @@ -462,20 +496,19 @@ class HttpIngress { return true; } - async destroy() { + async destroy(): Promise { if (this.destroyed) { return; } this.destroyed = true; - this.httpListener.removeHandler('request', this._requestHandler); - this.httpListener.removeHandler('upgrade', this._upgradeHandler); - return Promise.allSettled([ + this.httpListener.removeHandler(HttpRequestType.request, this._requestHandler); + this.httpListener.removeHandler(HttpRequestType.upgrade, this._upgradeHandler); + await Promise.allSettled([ + this.tunnelService.destroy(), this.altNameService.destroy(), this.eventBus.destroy(), - Listener.release('http', this.opts.port), + Listener.release(this.opts.port), ]); } -} - -export default HttpIngress; \ No newline at end of file +} \ No newline at end of file diff --git a/src/ingress/index.js b/src/ingress/index.js deleted file mode 100644 index 9f62f47..0000000 --- a/src/ingress/index.js +++ /dev/null @@ -1,134 +0,0 @@ -import assert from 'assert/strict'; -import AltNameService from './altname-service.js'; -import { ERROR_TUNNEL_INGRESS_BAD_ALT_NAMES } from '../utils/errors.js'; -import { difference, symDifference } from '../utils/misc.js'; -import HttpIngress from './http-ingress.js'; -import SNIIngress from './sni-ingress.js'; -import TunnelService from '../tunnel/tunnel-service.js'; - -class Ingress { - constructor(opts) { - if (Ingress.instance instanceof Ingress) { - Ingress.ref++; - return Ingress.instance; - } - // There is a circular dependency between Ingress and TunnelService, we set the initial - // reference to 0, as it will be increased to 1 when creating the TunnelService instance. - Ingress.ref = 0; - Ingress.instance = this; - - assert(opts != undefined); - this.opts = opts; - this._tunnelService = new TunnelService(); - this.ingress = {}; - - const p = []; - - if (opts.http?.enabled == true) { - p.push(new Promise((resolve, reject) => { - this.ingress.http = new HttpIngress({ - tunnelService: this._tunnelService, - ...opts.http, - callback: (e) => { - e ? reject(e) : resolve() - }, - }); - })); - } - - if (opts.sni?.enabled == true) { - p.push(new Promise((resolve, reject) => { - this.ingress.sni = new SNIIngress({ - tunnelService: this._tunnelService, - ...opts.sni, - callback: (e) => { - e ? reject(e) : resolve() - }, - }); - })); - } - - this.altNameService = new AltNameService(); - - Promise.all(p).then(() => { - typeof opts.callback === 'function' && opts.callback(); - }).catch(e => { - typeof opts.callback === 'function' && opts.callback(e); - }); - } - - async destroy() { - if (--Ingress.ref == 0) { - this.destroyed = true; - const promises = Object.keys(this.ingress) - .map(k => this.ingress[k].destroy()) - .concat([this.altNameService.destroy()]); - const res = await Promise.allSettled(promises); - await this._tunnelService.destroy(); - delete Ingress.instance; - } - } - - async updateIngress(tunnel, prevTunnel) { - const error = (code, values) => { - const err = new Error(code); - err.code = code; - err.details = values; - return err; - }; - - const update = async (ing) => { - const obj = { - ...tunnel.ingress[ing], - }; - - const prevAltNames = prevTunnel.ingress[ing]?.alt_names || []; - const baseUrl = this.ingress[ing].getBaseUrl(tunnel.id); - const altNames = obj?.alt_names || []; - if (symDifference(altNames, prevAltNames).length != 0) { - const resolvedAltNames = await AltNameService.resolve(baseUrl.hostname, altNames); - const diff = symDifference(resolvedAltNames, altNames); - if (diff.length > 0) { - return error(ERROR_TUNNEL_INGRESS_BAD_ALT_NAMES, diff); - } - - obj.alt_names = await this.altNameService.update( - ing, - tunnel.id, - difference(resolvedAltNames, prevAltNames), - difference(prevAltNames, resolvedAltNames) - ); - } - - return { - ...obj, - ...this.ingress[ing].getIngress(tunnel, obj.alt_names), - } - }; - - const ingress = {}; - for (const ing of Object.keys(this.ingress)) { - const res = await update(ing); - if (res instanceof Error) { - return res; - } - ingress[ing] = res; - } - - return ingress; - } - - async deleteIngress(tunnel) { - for (const ing of ['http', 'sni']) { - await this.altNameService.update( - ing, - tunnel.id, - [], - tunnel.ingress[ing].alt_names, - ); - tunnel.ingress[ing].alt_names = []; - } - } -} - -export default Ingress; \ No newline at end of file diff --git a/src/ingress/ingress-base.ts b/src/ingress/ingress-base.ts new file mode 100644 index 0000000..f37558b --- /dev/null +++ b/src/ingress/ingress-base.ts @@ -0,0 +1,6 @@ +export default abstract class IngressBase { + + public abstract getBaseUrl(tunnelId: string): URL; + + public abstract destroy(): Promise; +} \ No newline at end of file diff --git a/src/ingress/ingress-manager.ts b/src/ingress/ingress-manager.ts new file mode 100644 index 0000000..dbfc0c1 --- /dev/null +++ b/src/ingress/ingress-manager.ts @@ -0,0 +1,101 @@ +import HttpIngress, { HttpIngressOptions } from './http-ingress.js'; +import SNIIngress, { SniIngressOptions } from './sni-ingress.js'; +import IngressBase from './ingress-base.js'; + +export type IngressOptions = { + http?: { + enabled: boolean, + } & HttpIngressOptions, + sni?: { + enabled: boolean, + } & SniIngressOptions, +} + +export enum IngressType { + INGRESS_HTTP = 'http', + INGRESS_SNI = 'sni', +} + +class IngressManager { + public static listening: boolean = false; + + private static ingress: { + [ key in IngressType ]: { + enabled: boolean, + instance?: IngressBase, + } + } + + public static async listen(opts: IngressOptions): Promise { + if (this.listening) { + return true; + } + + this.ingress = { + http: { + enabled: opts.http?.enabled || false, + }, + sni: { + enabled: opts.sni?.enabled || false, + }, + }; + + const p = []; + + if (this.ingress.http.enabled == true) { + p.push(new Promise((resolve, reject) => { + this.ingress.http.instance = new HttpIngress({ + ...opts.http, + callback: (e?: Error) => { + e ? reject(e) : resolve(undefined) + }, + }); + })); + } + + if (this.ingress.sni.enabled == true) { + p.push(new Promise((resolve, reject) => { + this.ingress.sni.instance = new SNIIngress({ + ...opts.sni, + callback: (e?: Error) => { + e ? reject(e) : resolve(undefined) + }, + }); + })); + } + + const res = await Promise.all(p).then(() => { + return true; + }).catch(async (e) => { + await this.close(); + throw e; + }); + return res; + } + + public static async close(): Promise { + await Promise.allSettled([ + this.ingress.http.instance?.destroy(), + this.ingress.sni.instance?.destroy(), + ]); + this.ingress = { + http: { + enabled: false, + }, + sni: { + enabled: false, + }, + }; + this.listening = false; + } + + public static getIngress(ingressType: IngressType): IngressBase { + return this.ingress[ingressType].instance; + } + + public static ingressEnabled(ingressType: IngressType): boolean { + return this.ingress[ingressType].enabled; + } +} + +export default IngressManager; \ No newline at end of file diff --git a/src/ingress/ingress-service.ts b/src/ingress/ingress-service.ts new file mode 100644 index 0000000..7d41f0b --- /dev/null +++ b/src/ingress/ingress-service.ts @@ -0,0 +1,37 @@ +import IngressManager, { IngressType } from "./ingress-manager.js"; + +export default class IngressService { + + static instance: IngressService | undefined; + static ref: number; + + private destroyed: boolean = false; + + constructor() { + if (IngressService.instance instanceof IngressService) { + IngressService.ref++; + return IngressService.instance; + } + } + + public async destroy(): Promise { + if (this.destroyed) { + return; + } + if (--IngressService.ref == 0) { + this.destroyed = true; + IngressService.instance = undefined; + } + } + + public enabled(ingressType: IngressType): boolean { + return IngressManager.ingressEnabled(ingressType) + } + + public getIngressURL(ingressType: IngressType, tunnelId: string): URL { + if (!this.enabled(ingressType)) { + throw new Error('ingress_administratively_disabled'); + } + return IngressManager.getIngress(ingressType).getBaseUrl(tunnelId); + } +} \ No newline at end of file diff --git a/src/ingress/sni-ingress.js b/src/ingress/sni-ingress.js deleted file mode 100644 index dab57f0..0000000 --- a/src/ingress/sni-ingress.js +++ /dev/null @@ -1,333 +0,0 @@ -import assert from 'assert/strict'; -import crypto, { X509Certificate } from 'crypto'; -import fs from 'fs'; -import tls from 'tls'; -import { Logger } from '../logger.js'; -import TunnelService from '../tunnel/tunnel-service.js'; -import IngressUtils from './utils.js'; - -class SNIIngress { - constructor(opts) { - this.opts = opts; - this.logger = Logger("sni-ingress"); - - if (!opts.cert) { - throw new Error("No certificate provided for SNI ingress"); - } - - if (!opts.key) { - throw new Error("No key provided for SNI ingress"); - } - - this.tunnelService = opts.tunnelService; - assert(this.tunnelService instanceof TunnelService); - - this.port = this.opts.port || 4430; - - if (this.opts.host) { - try { - let host = this.opts.host; - if (!host.includes("://")) { - host = `tcps://${host}`; - } - this.host = new URL(host); - if (!this.host.port) { - this.host.port = this.port; - } - } catch {} - } - - if (!this._loadCert()) { - throw new Error("Failed to load certificate"); - } - - const certUpdated = (cur, prev) => { - if (cur.mtime != prev.mtime) { - this._loadCert(); - } - }; - - fs.watchFile(opts.cert, certUpdated); - fs.watchFile(opts.key, certUpdated); - - const server = this.server = tls.createServer({ - SNICallback: (servername, cb) => { - cb(null, this.ctx); - }, - }); - - this._clients = new Set(); - server.on('secureConnection', (socket) => { - this._handleConnection(socket); - this._clients.add(socket); - socket.once('close', () => { - this._clients.delete(socket); - }); - }); - - const conError = (err) => { - typeof opts.callback === 'function' && opts.callback(err); - this.logger.error({ - message: `Failed to start SNI ingress: ${err.message}`, - }); - }; - server.once('error', conError); - - server.listen(this.port, () => { - this.logger.info({ - message: "SNI ingress initialized", - port: this.port, - host: this.host, - }); - server.removeListener('error', conError); - typeof opts.callback === 'function' && opts.callback(); - }); - } - - async destroy() { - return new Promise((resolve) => { - this.server.once('close', async () => { - resolve(); - }); - this.server.close(); - this._clients.forEach((sock) => sock.destroy()); - }); - } - - getBaseUrl(tunnelId = undefined) { - const url = new URL(this.sniUrl); - if (tunnelId) { - url.hostname = `${tunnelId}.${url.hostname}`; - } - return url; - } - - getIngress(tunnel) { - const url = this.getBaseUrl(tunnel.id).href; - return { - url, - urls: [ - url, - ], - }; - } - - static _getWildcardSubjects(x509cert) { - const subject = x509cert.subject.split('CN=')[1]; - const san = x509cert.subjectAltName - ?.split(',') - .map(s => { - return s.split('DNS:')[1]?.trim(); - }) - .filter(n => n?.length > 1); - - const names = []; - subject && names.push(subject); - san && names.push(...san); - - return names.filter(name => name?.startsWith('*.')); - } - - _loadCert() { - - const logerr = (msg) => { - this.logger.warn({ - operation: 'sni-load-cert', - msg, - }); - }; - - const tryable = (fn, err) => { - try { - const res = fn(); - return res; - } catch (e) { - err && err(e); - return undefined; - } - }; - - const cert = tryable( - () => { return fs.readFileSync(this.opts.cert) }, - (e) => { logerr(e.message) } - ); - const key = tryable( - () => { return fs.readFileSync(this.opts.key) }, - (e) => { logerr(e.message) } - ); - if (!cert || !key) { - return false; - } - - const x509cert = tryable( - () => { return new X509Certificate(cert); }, - (e) => { logerr(`Could not parse certificate: ${e.message}`) } - ); - const keyObj = tryable( - () => { return crypto.createPrivateKey(key); }, - (e) => { logerr(`Could not parse private key: ${e.message}`) } - ); - - if (!x509cert.checkPrivateKey(keyObj)) { - this.logger.warn({ - operation: 'sni-load-cert', - msg: 'private key does not match certificate', - }); - return false; - } - - const wildSubs = SNIIngress._getWildcardSubjects(x509cert); - if (wildSubs.length == 0) { - this.logger.warn({ - operation: 'sni-load-cert', - msg: 'certificate has no wildcard subjects', - }); - return false; - } - - let sniUrl; - for (const sub of wildSubs) { - const port = this.host?.port || this.port; - const host = sub.split('*.')[1]; - if (this.host != undefined && this.host.hostname != host) { - continue; - } - try { - sniUrl = new URL(`tcps://${host}:${port}`); - break; - } catch (e) {} - } - - if (!sniUrl) { - this.logger.warn({ - operation: 'sni-load-cert', - msg: 'failed to parse any of the certificate subjects as FQDN', - }); - return false; - } - - this.sniUrl = sniUrl; - - if (wildSubs.length > 1) { - this.logger.info({ - operation: 'sni-load-cert', - msg: `certificate has multiple wildcard subjects, using ${this.sniUrl.hostname} as primary ingress`, - }); - } - - this.cert = cert; - this.key = key; - this.x509cert = x509cert; - this.ctx = tls.createSecureContext({ - key: this.key, - cert: this.cert, - }); - - this.logger.info({ - operation: 'sni-load-cert', - msg: 'certificate loaded', - 'ingress-domain': this.sniUrl.hostname, - subjects: wildSubs.join(', ') - }); - return true; - } - - async _handleConnection(socket) { - const peer = { - addr: socket.remoteAddress, - port: socket.remotePort, - }; - - const close = () => { - socket.end(); - socket.destroy(); - }; - - const servername = socket.servername; - if (servername == undefined) { - return close(); - } - - const tunnelId = IngressUtils.getTunnelId(servername); - if (tunnelId == undefined) { - return close(); - } - - const tunnel = await this.tunnelService.lookup(tunnelId); - if (tunnel == undefined) { - return close(); - } - - if (!tunnel.ingress?.sni?.enabled) { - this.logger.withContext('tunnel', tunnelId).trace({ - msg: 'SNI ingress disabled for tunnel' - }); - return close(); - } - - this.logger.withContext('tunnel', tunnelId).info({ - operation: 'sni-connect', - servername, - peer, - target: { - ...tunnel.target - }, - }); - - const startTime = process.hrtime.bigint(); - socket.on('close', () => { - const elapsedMs = Math.round(Number((process.hrtime.bigint() - BigInt(startTime))) / 1e6); - this.logger.withContext('tunnel', tunnelId).info({ - operation: 'sni-disconnect', - servername, - peer, - target: { - ...tunnel.target - }, - duration: elapsedMs, - bytes: { - read: socket.bytesRead, - written: socket.bytesWritten, - }, - }); - }) - - const ctx = { - remoteAddr: socket.remoteAddress, - ingress: { - tls: true, - port: this.port, - }, - }; - const target = this.tunnelService.createConnection(tunnelId, ctx); - const logError = (err) => { - this.logger.info({ - operation: 'sni-error', - peer, - err, - }); - }; - - target.on('close', () => { - socket.end(); - socket.destroy(); - }); - - target.on('error', (err) => { - logError(err); - socket.end(); - socket.destroy(); - }); - - socket.on('error', (err) => { - logError(err); - target.end(); - target.destroy(); - }); - - target.pipe(socket); - socket.pipe(target); - } -} - -export default SNIIngress; \ No newline at end of file diff --git a/src/ingress/sni-ingress.ts b/src/ingress/sni-ingress.ts new file mode 100644 index 0000000..cb6e12d --- /dev/null +++ b/src/ingress/sni-ingress.ts @@ -0,0 +1,354 @@ +import crypto, { KeyObject, X509Certificate } from 'crypto'; +import fs from 'fs'; +import tls from 'tls'; +import { Logger } from '../logger.js'; +import TunnelService, { CreateConnectionContext } from '../tunnel/tunnel-service.js'; +import IngressUtils from './utils.js'; +import IngressBase from './ingress-base.js'; +import Tunnel from '../tunnel/tunnel.js'; + +export type SniIngressOptions = { + host?: string | undefined, + port: number, + cert: string, + key: string, +} + +type _SniIngressOptions = SniIngressOptions & { + callback: (error?: Error) => void; +} + +export default class SNIIngress implements IngressBase { + private opts: _SniIngressOptions; + private logger: any; + private tunnelService: TunnelService; + private server: tls.Server; + private _clients: Set; + private ctx!: tls.SecureContext; + + private port: number; + private host!: URL; + private sniUrl!: URL; + + private cert!: Buffer; + private rawKey!: Buffer; + private key!: KeyObject; + private x509cert: any; + + constructor(opts: _SniIngressOptions) { + this.opts = opts; + this.logger = Logger("sni-ingress"); + + if (!opts.cert) { + throw new Error("No certificate provided for SNI ingress"); + } + + if (!opts.key) { + throw new Error("No key provided for SNI ingress"); + } + + this.tunnelService = new TunnelService(); + + this.port = this.opts.port || 4430; + + if (this.opts.host) { + try { + let host = this.opts.host; + if (!host.includes("://")) { + host = `tcps://${host}`; + } + this.host = new URL(host); + if (!this.host.port) { + this.host.port = `${this.port}`; + } + } catch {} + } + + if (!this._loadCert()) { + throw new Error("Failed to load certificate"); + } + + const certUpdated = (cur: fs.Stats, prev: fs.Stats) => { + if (cur.mtime != prev.mtime) { + this._loadCert(); + } + }; + + fs.watchFile(opts.cert, certUpdated); + fs.watchFile(opts.key, certUpdated); + + const server = this.server = tls.createServer({ + SNICallback: (servername: string, cb: (err: Error | null, ctx: tls.SecureContext | undefined) => void) => { + this._sniCallback(servername, cb); + }, + }); + + this._clients = new Set(); + server.on('secureConnection', async (socket) => { + const res = await this._handleConnection(socket); + if (!res) { + return; + } + + this._clients.add(socket); + socket.once('close', () => { + this._clients.delete(socket); + }); + }); + + const conError = (err: Error) => { + typeof opts.callback === 'function' && opts.callback(err); + this.logger.error({ + message: `Failed to start SNI ingress: ${err.message}`, + }); + }; + server.once('error', conError); + + server.listen(this.port, () => { + this.logger.info({ + message: `SNI ingress listening on port ${this.port}`, + host: this.host, + }); + server.removeListener('error', conError); + typeof opts.callback === 'function' && opts.callback(); + }); + } + + async destroy(): Promise { + await new Promise((resolve) => { + this.server.once('close', async () => { + resolve(undefined); + }); + this.server.close(); + this._clients.forEach((sock) => sock.destroy()); + }); + await this.tunnelService.destroy(); + } + + public getBaseUrl(tunnelId: string): URL { + const url = new URL(this.sniUrl); + if (tunnelId) { + url.hostname = `${tunnelId}.${url.hostname}`; + } + return url; + } + + private static _getWildcardSubjects(x509cert: crypto.X509Certificate) { + const subject = x509cert.subject.split('CN=')[1]; + const san = x509cert.subjectAltName + ?.split(',') + .map(s => { + return s.split('DNS:')[1]?.trim(); + }) + .filter(n => n?.length > 1); + + const names = []; + subject && names.push(subject); + san && names.push(...san); + + return names.filter(name => name?.startsWith('*.')); + } + + private _loadCert(): boolean { + + const log_cert_load_error = (message: string): void => { + this.logger.warn({ + operation: 'sni-load-cert', + message, + }); + }; + + let x509cert: crypto.X509Certificate; + let cert: Buffer; + try { + cert = fs.readFileSync(this.opts.cert); + x509cert = new X509Certificate(cert); + } catch (e: any) { + log_cert_load_error(`Could not parse certificate: ${e.message}`); + return false; + } + + let key: Buffer; + try { + key = fs.readFileSync(this.opts.key); + this.key = crypto.createPrivateKey(key); + if (!x509cert.checkPrivateKey(this.key)) { + throw new Error(`private key does not match certificate'`) + } + } catch (e: any) { + log_cert_load_error(`Could not parse private key: ${e.message}`) + return false; + } + + const wildSubs = SNIIngress._getWildcardSubjects(x509cert); + if (wildSubs.length == 0) { + log_cert_load_error(`certificate has no wildcard subjects'`) + return false; + } + + let sniUrl; + for (const sub of wildSubs) { + const port = this.host?.port || this.port; + const host = sub.split('*.')[1]; + if (this.host != undefined && this.host.hostname != host) { + continue; + } + try { + sniUrl = new URL(`tcps://${host}:${port}`); + break; + } catch (e) {} + } + + if (!sniUrl) { + log_cert_load_error('failed to parse any of the certificate subjects as FQDN'); + return false; + } + + this.sniUrl = sniUrl; + + if (wildSubs.length > 1) { + this.logger.info({ + operation: 'sni-load-cert', + message: `certificate has multiple wildcard subjects, using ${this.sniUrl.hostname} as primary ingress`, + }); + } + + this.cert = cert; + this.rawKey = key; + this.x509cert = x509cert; + this.ctx = tls.createSecureContext({ + key: this.rawKey, + cert: this.cert, + }); + + this.logger.info({ + operation: 'sni-load-cert', + message: 'certificate loaded', + 'ingress-domain': this.sniUrl.hostname, + subjects: wildSubs.join(', ') + }); + return true; + } + + private async getTunnel(servername: string): Promise { + const tunnelId: string | undefined = IngressUtils.getTunnelId(servername); + + if (tunnelId == undefined) { + throw new Error('failed_to_parse_servername'); + } + + const tunnel = await this.tunnelService.lookup(tunnelId); + if (!tunnel.config.ingress?.sni?.enabled) { + throw new Error('ingress_disabled'); + } + return tunnel; + } + + private async _sniCallback(servername: string, cb: (err: Error | null, ctx: tls.SecureContext | undefined) => void): Promise { + try { + const tunnel = await this.getTunnel(servername); + cb(null, this.ctx); + } catch (err: any) { + this.logger.debug({ + message: `Failed to determine tunnel for ${servername}: ${err.message}` + }); + cb(err, undefined); + } + } + + private async _handleConnection(socket: tls.TLSSocket): Promise { + const peer = { + addr: socket.remoteAddress, + port: socket.remotePort, + }; + + const servername = (socket).servername; + let tunnel: Tunnel; + try { + tunnel = await this.getTunnel(servername); + } catch (e: any) { + socket.end(); + socket.destroy(); + return false; + } + + this.logger.withContext('tunnel', tunnel.id).info({ + operation: 'sni-connect', + servername, + peer, + target: { + ...tunnel.config.target + }, + }); + + const startTime = process.hrtime.bigint(); + socket.once('close', () => { + const elapsedMs = Math.round(Number((process.hrtime.bigint() - BigInt(startTime))) / 1e6); + this.logger.withContext('tunnel', tunnel.id).info({ + operation: 'sni-disconnect', + servername, + peer, + target: { + ...tunnel.config.target + }, + duration: elapsedMs, + bytes: { + read: socket.bytesRead, + written: socket.bytesWritten, + }, + }); + }) + + const ctx: CreateConnectionContext = { + remoteAddr: socket.remoteAddress || '', + ingress: { + tls: { + enabled: true, + servername, + cert: this.cert, + }, + port: this.port, + }, + }; + + const targetSock = this.tunnelService.createConnection(tunnel.id, ctx, (err, sock) => { + if (err) { + logError(err); + return; + } + sock.pipe(socket); + socket.pipe(sock); + }); + + const logError = (err: Error) => { + this.logger.info({ + operation: 'sni-error', + peer, + err, + }); + }; + + const error = (err: Error) => { + logError(err); + close(); + }; + + const close = () => { + targetSock.unpipe(socket); + socket.unpipe(targetSock); + socket.off('close', close); + targetSock.off('close', close); + socket.off('error', close); + targetSock.off('error', close); + socket.destroy(); + targetSock.destroy(); + }; + + targetSock.on('close', close); + socket.on('close', close); + targetSock.on('error', error); + socket.on('error', error); + + return true; + } +} \ No newline at end of file diff --git a/src/ingress/utils.js b/src/ingress/utils.ts similarity index 87% rename from src/ingress/utils.js rename to src/ingress/utils.ts index d26b677..711cee5 100644 --- a/src/ingress/utils.js +++ b/src/ingress/utils.ts @@ -1,5 +1,5 @@ class IngressUtils { - static getTunnelId(hostname, wildcardHost) { + static getTunnelId(hostname: string | undefined, wildcardHost?: string): string | undefined { if (hostname === undefined) { return undefined; } diff --git a/src/listener/http-listener.js b/src/listener/http-listener.js deleted file mode 100644 index 025ef7d..0000000 --- a/src/listener/http-listener.js +++ /dev/null @@ -1,201 +0,0 @@ -import http from 'http'; -import ListenerInterface from './listener-interface.js'; -import { Logger } from '../logger.js'; -import HttpCaptor from '../utils/http-captor.js'; -import { - HTTP_HEADER_FORWARDED, - HTTP_HEADER_HOST, - HTTP_HEADER_X_FORWARDED_PORT, - HTTP_HEADER_X_FORWARDED_PROTO, - HTTP_HEADER_X_SCHEME -} from '../utils/http-headers.js'; - -class HttpListener extends ListenerInterface { - constructor(opts) { - super(); - this.logger = Logger("http-listener"); - this.opts = opts; - this.callbacks = { - 'request': [], - 'upgrade': [] - }; - this.state = opts.state || {}; - - const parseForwarded = (forwarded) => { - return Object.fromEntries(forwarded - .split(';') - .map(x => x.trim()) - .filter(x => x.length > 0) - .map(x => x.split('=') - .map(y => y.trim()) - ) - ) - }; - - const getBaseUrl = (req) => { - const headers = req.headers || {}; - - const forwarded = parseForwarded(headers[HTTP_HEADER_FORWARDED] || ''); - const proto = forwarded?.proto - || headers[HTTP_HEADER_X_FORWARDED_PROTO] - || headers[HTTP_HEADER_X_SCHEME] - || req.protocol || 'http'; - const host = (forwarded?.host || headers[HTTP_HEADER_HOST])?.split(':')[0]; - const port = forwarded?.host?.split(':')[1] - || headers[HTTP_HEADER_X_FORWARDED_PORT] - || headers[HTTP_HEADER_HOST]?.split(':')[1]; - - try { - return new URL(`${proto}://${host.toLowerCase()}${port ? `:${port}` : ''}`); - } catch (e) { - this.logger.isTraceEnabled() && this.logger.trace({e}); - return undefined; - } - }; - - const handleRequest = async (event, ctx) => { - const captor = new HttpCaptor({ - request: ctx.req, - response: ctx.res, - opts: { - limit: 4*1024, - } - }); - - let next = true; - let customLogger; - const capture = captor.capture(); - - ctx.baseUrl = getBaseUrl(ctx.req); - if (ctx.baseUrl !== undefined) { - for (const obj of this.callbacks[event]) { - if (obj.opts.host && obj.opts?.host?.toLowerCase() !== ctx.baseUrl.host) { - next = true; - continue; - } - captor.captureRequestBody = obj.opts?.logBody || false; - captor.captureResponseBody = obj.opts?.logBody || false; - try { - next = false; - await obj.callback(ctx, () => { next = true }); - if (!next) { - customLogger = obj.opts?.logger; - break; - } - } catch (e) { - this.logger.error(e.message); - this.logger.debug(e.stack); - ctx.res.statusCode = 500; - ctx.res.end(); - } - } - } else { - ctx.res.statusCode = 400; - ctx.res.end(); - } - - customLogger ??= this.logger; - setImmediate(() => { - capture.then((res) => { - if (customLogger === false) { - return; - } - const logEntry = { - operation: 'http-request', - request: res.request, - response: res.response, - client: { - ip: res.client.ip, - remote: res.client.remoteAddr, - }, - duration: res.duration, - }; - customLogger.info(logEntry); - }); - }); - return !next; - } - - const server = this.server = http.createServer(); - this._clients = new Set(); - server.on('connection', (sock) => { - this._clients.add(sock); - - sock.once('close', () => { - this._clients.delete(sock); - }); - }); - - server.on('request', async (req, res) => { - if (!await handleRequest('request', {req, res})) { - res.statusCode = 404; - res.end(); - } - }); - - server.on('upgrade', async (req, sock, head) => { - if (!await handleRequest('upgrade', {req, sock, head})) { - sock.write(`HTTP/${req.httpVersion} 404 Not found\r\n`); - sock.end(); - sock.destroy(); - } - }); - } - - setState(state) { - this.state = state; - } - - getPort() { - return this.opts.port; - } - - use(event, opts, callback) { - if (typeof opts === 'function') { - callback = opts; - opts = {}; - } - - if (this.callbacks[event] === undefined) { - throw new Error("Unknown event " + event); - } - - opts.prio ??= 2**32; - - const pos = this.callbacks[event].reduce((pos, x) => x.opts.prio <= opts.prio ? pos + 1 : pos, 0); - this.callbacks[event].splice(pos, 0, {callback, opts}) - return callback; - } - - removeHandler(event, callback) { - this.callbacks[event] = this.callbacks[event].filter(obj => obj.callback != callback); - } - - async _listen() { - const listenError = (err) => { - this.logger.error(`Failed to start http listener: ${err.message}`); - }; - this.server.once('error', listenError); - return new Promise((resolve, reject) => { - this.server.listen({port: this.opts.port}, (err) => { - if (err) { - return reject(err); - } - this.server.removeListener('error', listenError); - resolve(); - }); - }); - } - - async _destroy() { - return new Promise((resolve) => { - this.server.once('close', () => { - resolve(); - }); - this.server.close(); - this._clients.forEach((sock) => sock.destroy()); - }); - } -} - -export default HttpListener; \ No newline at end of file diff --git a/src/listener/http-listener.ts b/src/listener/http-listener.ts new file mode 100644 index 0000000..cf0fd6d --- /dev/null +++ b/src/listener/http-listener.ts @@ -0,0 +1,255 @@ +import http from 'http'; +import { Logger } from '../logger.js'; +import HttpCaptor from '../utils/http-captor.js'; +import { + HTTP_HEADER_FORWARDED, + HTTP_HEADER_HOST, + HTTP_HEADER_X_FORWARDED_PORT, + HTTP_HEADER_X_FORWARDED_PROTO, + HTTP_HEADER_X_SCHEME +} from '../utils/http-headers.js'; +import { Duplex } from 'stream'; +import { ListenerBase } from './listener.js'; + +interface HttpListenerArguments { + port: number, +} + +interface HttpUseOptions { + logger?: any, + prio?: number, + logBody?: boolean, + host?: string, +} + +export type HttpRequestCallback = (ctx: HttpRequestContext, next: () => void) => Promise; +export type HttpUpgradeCallback = (ctx: HttpUpgradeContext, next: () => void) => Promise; +type HttpCallback = (ctx: HttpCallbackContext, next: () => void) => Promise; + +interface HttpCallbackOptions { + logger?: any, + prio: number, + host?: string, + logBody?: boolean, +} + +interface _HttpCallback { + callback: HttpCallback, + opts: HttpCallbackOptions, +} + +export enum HttpRequestType { + request = "request", + upgrade = "upgrade" +} + +interface HttpCallbackContext { + req: http.IncomingMessage, + baseUrl?: URL, +} + +interface HttpRequestContext extends HttpCallbackContext { + res: http.ServerResponse, +} + +interface HttpUpgradeContext extends HttpCallbackContext { + sock: Duplex, + head: Buffer, +} + +export default class HttpListener extends ListenerBase { + private logger: any; + private server: http.Server; + private callbacks: { [ key in HttpRequestType ]: Array<_HttpCallback> }; + + constructor(port: number) { + super(port); + this.logger = Logger("http-listener"); + this.callbacks = { + 'request': [], + 'upgrade': [] + }; + + const server = this.server = http.createServer(); + + server.on('request', async (req, res) => { + const [success, statusCode] = await this.handleRequest(HttpRequestType.request, {req, res}); + if (!success) { + res.statusCode = statusCode || 500; + res.end(); + } + }); + + server.on('upgrade', async (req, sock, head) => { + let [success, statusCode] = await this.handleRequest(HttpRequestType.upgrade, {req, sock, head}); + if (!success) { + statusCode ??= 500; + sock.write(`HTTP/${req.httpVersion} ${statusCode} ${http.STATUS_CODES[statusCode]}\r\n`); + sock.end(`\r\n`); + sock.destroy(); + } + }); + } + + protected async _destroy(): Promise { + return this.close(); + } + + protected async _close(): Promise { + return new Promise((resolve) => { + this.server.once('close', () => { + this.removeHandler(HttpRequestType.request); + this.removeHandler(HttpRequestType.upgrade); + this.server.removeAllListeners(); + resolve(); + }); + this.server.close(); + this.server.closeAllConnections(); + }); + } + + private static parseForwarded(forwarded: string): any { + return Object.fromEntries(forwarded + .split(';') + .map(x => x.trim()) + .filter(x => x.length > 0) + .map(x => x.split('=') + .map(y => y.trim()) + ) + ) + } + + private getBaseUrl(req: http.IncomingMessage): URL | undefined { + const headers = req.headers || {}; + + const forwarded = HttpListener.parseForwarded(headers[HTTP_HEADER_FORWARDED] || ''); + const proto = forwarded?.proto + || headers[HTTP_HEADER_X_FORWARDED_PROTO] + || headers[HTTP_HEADER_X_SCHEME] + || 'http'; + const host = (forwarded?.host || headers[HTTP_HEADER_HOST])?.split(':')[0]; + const port = forwarded?.host?.split(':')[1] + || headers[HTTP_HEADER_X_FORWARDED_PORT] + || headers[HTTP_HEADER_HOST]?.split(':')[1]; + + try { + return new URL(`${proto}://${host.toLowerCase()}${port ? `:${port}` : ''}`); + } catch (e) { + this.logger.isTraceEnabled() && this.logger.trace({e}); + return undefined; + } + }; + + private async handleRequest(event: HttpRequestType.upgrade, ctx: HttpUpgradeContext): Promise<[boolean, number | undefined]>; + private async handleRequest(event: HttpRequestType.request, ctx: HttpRequestContext): Promise<[boolean, number | undefined]>; + private async handleRequest(event: HttpRequestType, ctx: HttpCallbackContext): Promise<[boolean, number | undefined]> { + + const captor = new HttpCaptor({ + request: ctx.req, + response: (ctx as HttpRequestContext).res, + opts: { + limit: 4*1024, + } + }); + + let statusCode: number | undefined = undefined; + let next = true; + let customLogger: any; + const capture = captor.capture(); + + ctx.baseUrl = this.getBaseUrl(ctx.req); + if (ctx.baseUrl !== undefined) { + for (const obj of this.callbacks[event]) { + if (obj.opts.host && obj.opts?.host?.toLowerCase() !== ctx.baseUrl.host) { + next = true; + continue; + } + captor.captureRequestBody = obj.opts?.logBody || false; + captor.captureResponseBody = obj.opts?.logBody || false; + try { + next = false; + await obj.callback(ctx, () => { next = true }); + if (!next) { + customLogger = obj.opts?.logger; + break; + } + } catch (e: any) { + this.logger.error(e.message); + this.logger.debug(e.stack); + statusCode = 500; + } + } + } else { + statusCode = 400; + } + + customLogger ??= this.logger; + setImmediate(() => { + capture.then((res) => { + if (customLogger === false) { + return; + } + const logEntry = { + operation: 'http-request', + request: res.request, + response: res.response, + client: { + ip: res.client.ip, + remote: res.client.remoteAddr, + }, + duration: res.duration, + }; + customLogger.info(logEntry); + }); + }); + return [!next, statusCode]; + } + + public use(event: HttpRequestType.request, callback: HttpRequestCallback): this; + public use(event: HttpRequestType.request, opts: HttpUseOptions, callback: HttpRequestCallback): this; + public use(event: HttpRequestType.upgrade, callback: HttpUpgradeCallback): this; + public use(event: HttpRequestType.upgrade, opts: HttpUseOptions, callback: HttpUpgradeCallback): this; + public use(event: HttpRequestType, opts: any, callback?: any): this { + if (typeof opts === 'function') { + callback = opts; + opts = {}; + } + + if (this.callbacks[event] === undefined) { + throw new Error("Unknown event " + event); + } + + opts.prio ??= 2**32; + + const pos = this.callbacks[event].reduce((pos, x) => x.opts.prio <= opts.prio ? pos + 1 : pos, 0); + this.callbacks[event].splice(pos, 0, {callback, opts: { + logger: opts.logger, + prio: opts.prio, + logBody: opts.logBody, + host: opts.host, + }}); + return this; + } + + public removeHandler(event: HttpRequestType.request, callback?: HttpRequestCallback): void; + public removeHandler(event: HttpRequestType.upgrade, callback?: HttpUpgradeCallback): void; + public removeHandler(event: HttpRequestType, callback?: any): void { + this.callbacks[event] = this.callbacks[event].filter(obj => callback != undefined && obj.callback != callback); + } + + protected async _listen(): Promise { + return new Promise((resolve, reject) => { + const listenError = (err: Error) => { + this.logger.error(`Failed to start http listener: ${err.message}`); + reject(); + }; + this.server.once('error', listenError); + + this.server.listen({port: this.port}, () => { + this.server.off('error', listenError); + resolve(); + }); + }); + } + +} \ No newline at end of file diff --git a/src/listener/index.js b/src/listener/index.js deleted file mode 100644 index 98c5b07..0000000 --- a/src/listener/index.js +++ /dev/null @@ -1,42 +0,0 @@ -import assert from 'assert/strict'; -import HttpListener from './http-listener.js'; - -class Listener { - - static { - this._listeners = {} - } - - static _getNewListener(method, port, state) { - switch (method) { - case 'http': - return new HttpListener({port, state}); - default: - assert.fail(`unknown listener method ${method}`); - } - } - - static acquire(listener, port, state = {}) { - const k = `${listener}-${port}`; - if (!this._listeners[k]) { - this._listeners[k] = this._getNewListener(listener, port, state); - } else { - this._listeners[k].acquire(); - } - return this._listeners[k]; - } - - static async release(listener, port) { - const k = `${listener}-${port}`; - if (!this._listeners[k]) { - return; - } - const released = await this._listeners[k].destroy(); - if (released) { - delete this._listeners[k]; - } - } - -} - -export default Listener; \ No newline at end of file diff --git a/src/listener/listener-interface.js b/src/listener/listener-interface.js deleted file mode 100644 index 3fb6e98..0000000 --- a/src/listener/listener-interface.js +++ /dev/null @@ -1,65 +0,0 @@ -import assert from 'assert/strict'; - -class ListenerInterface { - constructor() { - this._ref = 1; - } - - acquire() { - this._ref++; - } - - async _listen() { - assert.fail("_listen not implemented"); - } - - async _destroy() { - assert.fail("_destroy not implemented"); - } - - async listen() { - if (this._listening) { - return new Promise((resolve) => { resolve() }); - } - - if (this._pending) { - return new Promise((resolve, reject) => { - const pending = (_err) => { - _err ? reject(_err) : resolve(); - }; - this._pending.push(pending); - }) - } - - return new Promise(async (resolve, reject) => { - this._listening = false; - this._pending = []; - - let err = undefined; - try { - await this._listen(); - this._listening = true; - } catch (e) { - err = e; - } - - this._pending.push((_err) => { - _err ? reject(_err) : resolve(); - }); - - this._pending.map((fn) => fn(err)); - delete this._pending; - }) - } - - async destroy() { - if (--this._ref == 0) { - this._destroyed = true; - await this._destroy(); - return true; - } - return false; - } -} - -export default ListenerInterface; \ No newline at end of file diff --git a/src/listener/listener.ts b/src/listener/listener.ts new file mode 100644 index 0000000..ac346f8 --- /dev/null +++ b/src/listener/listener.ts @@ -0,0 +1,122 @@ + +type ListenerPending = (err?: Error) => void; + +export default class Listener { + private static instances: Map> = new Map(); + + public static acquire(type: { new(port:number): T}, port: number): T { + if (this.instances.has(port)) { + const instance = this.instances.get(port) as T; + instance.acquire(); + return instance; + } else { + const instance = new type(port); + this.instances.set(port, instance as ListenerBase); + return instance; + } + } + + public static async release(port: number): Promise { + const instance = this.instances.get(port) as T; + if (!instance) { + return; + } + const release = await instance["destroy"](); + if (release) { + this.instances.delete(port); + } + } +} + +export abstract class ListenerBase { + private _ref: number; + private _listen_ref: number; + private _listening: boolean; + private _pending: Array | undefined; + private _destroyed: boolean; + public readonly port: number; + + constructor(port: number) { + this.port = port; + this._ref = 1; + this._listen_ref = 0; + this._listening = false; + this._pending = undefined; + this._destroyed = false; + } + + public getPort(): number { + return this.port; + } + + public acquire(): void { + this._ref++; + } + + protected abstract _listen(): Promise; + + protected abstract _destroy(): Promise; + + protected abstract _close(): Promise; + + public async listen(): Promise { + this._listen_ref++; + if (this._listening) { + return; + } + + if (this._pending != undefined) { + return new Promise((resolve, reject) => { + const pending = (_err?: Error) => { + _err ? reject(_err) : resolve(); + }; + this._pending!.push(pending); + }) + } + + return new Promise(async (resolve, reject) => { + this._listening = false; + this._pending = []; + + let err: Error | undefined = undefined; + try { + await this._listen(); + this._listening = true; + } catch (e: any) { + err = e; + } + + this._pending.push((_err) => { + _err ? reject(_err) : resolve(); + }); + + this._pending.map((fn) => fn(err)); + this._pending = undefined; + }); + } + + public async close(): Promise { + if (!this._listening) { + return; + } + if (--this._listen_ref == 0) { + await this._close(); + this._listening = false; + } + } + + protected async destroy(): Promise { + if (this._destroyed) { + return false; + } + if (--this._ref == 0) { + this._destroyed = true; + await this._close(); + this._listen_ref = 0; + this._listening = false; + await this._destroy(); + return true; + } + return false; + } +} \ No newline at end of file diff --git a/src/storage/index.js b/src/storage/index.js index da350b3..d5da2e2 100644 --- a/src/storage/index.js +++ b/src/storage/index.js @@ -119,9 +119,19 @@ class Storage { } const [obj, done] = result; - const res = await cb(obj); + let error; + let res = false; + try { + res = await cb(obj); + } catch (e) { + error = e; + } + if (res !== true) { done(); + if (error) { + throw error; + } return false; } const serialized = Serializer.serialize(obj); diff --git a/src/transport/cluster/cluster-transport.ts b/src/transport/cluster/cluster-transport.ts index e7622b1..ae9c67c 100644 --- a/src/transport/cluster/cluster-transport.ts +++ b/src/transport/cluster/cluster-transport.ts @@ -1,5 +1,6 @@ import { Duplex } from "stream"; -import { Socket, TcpSocketConnectOpts } from "net"; +import tls from "tls"; +import net from "net"; import Transport, { TransportConnectionOptions, TransportOptions } from "../transport.js"; import ClusterService from "../../cluster/index.js"; @@ -17,26 +18,47 @@ export default class ClusterTransport extends Transport { } 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) { + const sock = new net.Socket(); sock.destroy(new Error('node_does_not_exist')); return sock; } - const socketOpts: TcpSocketConnectOpts = { - host: clusterNode.ip, - port: opts.port || 0, - }; + let sock: tls.TLSSocket | net.Socket; const errorHandler = (err: Error) => { callback(err, sock); }; - sock.once('error', errorHandler); - sock.connect(socketOpts, () => { - sock.off('error', errorHandler); - callback(undefined, sock); - }); + + if (opts.tls?.enabled == true) { + const tlsConnectOpts: tls.ConnectionOptions = { + servername: opts.tls.servername, + host: clusterNode.ip, + port: opts.port || 0, + ca: [ + opts.tls?.cert?.toString(), + ...tls.rootCertificates, + ], + }; + sock = tls.connect(tlsConnectOpts, () => { + sock.off('error', errorHandler); + callback(undefined, sock); + }); + sock.once('error', errorHandler); + } else { + const socketConnectOpts: net.TcpSocketConnectOpts = { + host: clusterNode.ip, + port: opts.port || 0, + }; + sock = net.connect(socketConnectOpts, () => { + sock.off('error', errorHandler); + callback(undefined, sock); + }); + sock.once('error', errorHandler); + } + return sock; } diff --git a/src/transport/transport-service.ts b/src/transport/transport-service.ts index 58556b6..b83a52c 100644 --- a/src/transport/transport-service.ts +++ b/src/transport/transport-service.ts @@ -92,9 +92,9 @@ class TransportService { } public getTransports(tunnel: Tunnel, baseUrl: string): TunnelTransports; - public getTransports(tunnel: Tunnel, baseUrl: URL): TunnelTransports; + public getTransports(tunnel: Tunnel, baseUrl: URL | undefined): TunnelTransports; public getTransports(tunnel: Tunnel, baseUrl: any): TunnelTransports { - let _baseUrl: URL; + let _baseUrl: URL | undefined; const transports: TunnelTransports = { max_connections: this.max_connections, @@ -106,12 +106,16 @@ class TransportService { try { _baseUrl = new URL(baseUrl); } catch (e: any) { - return transports; + _baseUrl = undefined; } } else { _baseUrl = baseUrl; } + if (_baseUrl == undefined) { + return transports; + } + if (this.transports.ws instanceof WebSocketEndpoint) { transports.ws = { enabled: tunnel.config.transport?.ws?.enabled || false, diff --git a/src/transport/transport.ts b/src/transport/transport.ts index 185dbf5..c426a5d 100644 --- a/src/transport/transport.ts +++ b/src/transport/transport.ts @@ -1,11 +1,18 @@ -import { randomUUID } from 'crypto'; +import crypto from 'crypto'; import { EventEmitter } from 'events'; import { Duplex } from 'stream'; +type TransportConnectTlsOptions = { + enabled: boolean, + servername?: string, + cert?: Buffer, +}; + export type TransportConnectionOptions = { remoteAddr: string, tunnelId?: string, port?: number, + tls?: TransportConnectTlsOptions, }; export interface TransportOptions { @@ -20,7 +27,7 @@ export default abstract class Transport extends EventEmitter { constructor(opts: TransportOptions) { super(); this.max_connections = opts.max_connections || 1; - this.id = randomUUID(); + this.id = crypto.randomUUID(); } public abstract createConnection(opts: TransportConnectionOptions, callback: (err: Error | undefined, sock: Duplex) => void): Duplex; diff --git a/src/transport/ws/ws-endpoint.ts b/src/transport/ws/ws-endpoint.ts index a0947d9..e570727 100644 --- a/src/transport/ws/ws-endpoint.ts +++ b/src/transport/ws/ws-endpoint.ts @@ -1,7 +1,7 @@ import net from 'net'; import querystring from 'querystring'; import { WebSocket, WebSocketServer } from 'ws'; -import Listener from '../../listener/index.js'; +import Listener from '../../listener/listener.js'; import { Logger } from '../../logger.js'; import TunnelService from '../../tunnel/tunnel-service.js'; import { @@ -9,10 +9,11 @@ import { } from '../../utils/errors.js'; import WebSocketTransport from './ws-transport.js'; import TransportEndpoint, { EndpointResult, TransportEndpointOptions } from '../transport-endpoint.js'; -import HttpListener from '../../listener/http-listener.js'; +import HttpListener, { HttpRequestType, HttpUpgradeCallback } from '../../listener/http-listener.js'; import Tunnel from '../../tunnel/tunnel.js'; import { URL } from 'url'; import { IncomingMessage } from 'http'; +import { Duplex } from 'stream'; export type WebSocketEndpointOptions = { enabled: boolean, @@ -48,23 +49,25 @@ export default class WebSocketEndpoint extends TransportEndpoint { private httpListener: HttpListener; private tunnelService: TunnelService; private wss: WebSocketServer; - private _upgradeHandler: any; + private _upgradeHandler: HttpUpgradeCallback; private connections: Array; constructor(opts: _WebSocketEndpointOptions) { super(opts); this.opts = opts; this.logger = Logger("ws-endpoint"); - this.httpListener = Listener.acquire('http', opts.port); + this.httpListener = Listener.acquire(HttpListener, opts.port); this.tunnelService = new TunnelService(); this.wss = new WebSocketServer({ noServer: true }); this.connections = []; - this._upgradeHandler = this.httpListener.use('upgrade', { logger: this.logger }, async (ctx: any, next: any) => { + this._upgradeHandler = async (ctx, next) => { if (!await this.handleUpgrade(ctx.req, ctx.sock, ctx.head)) { return next(); } - }); + }; + + this.httpListener.use(HttpRequestType.upgrade, { logger: this.logger }, this._upgradeHandler); this.httpListener.listen() .then(() => { @@ -92,7 +95,7 @@ export default class WebSocketEndpoint extends TransportEndpoint { } protected async _destroy(): Promise { - this.httpListener.removeHandler('upgrade', this._upgradeHandler); + this.httpListener.removeHandler(HttpRequestType.upgrade, this._upgradeHandler); for (const connection of this.connections) { const {wst, ws} = connection; await wst.destroy(); @@ -102,7 +105,7 @@ export default class WebSocketEndpoint extends TransportEndpoint { this.wss.close(); await Promise.allSettled([ this.tunnelService.destroy(), - Listener.release('http', this.opts.port), + Listener.release(this.opts.port), ]); } @@ -135,7 +138,7 @@ export default class WebSocketEndpoint extends TransportEndpoint { }; } - private _unauthorized(sock: net.Socket, request: IncomingMessage): RawHttpResponse { + private _unauthorized(sock: Duplex, request: IncomingMessage): RawHttpResponse { const response = { status: 401, statusLine: 'Unauthorized' @@ -143,7 +146,7 @@ export default class WebSocketEndpoint extends TransportEndpoint { return this._rawHttpResponse(sock, request, response); }; - private _rawHttpResponse(sock: net.Socket, request: IncomingMessage, response: RawHttpResponse): RawHttpResponse { + private _rawHttpResponse(sock: Duplex, request: IncomingMessage, response: RawHttpResponse): RawHttpResponse { sock.write(`HTTP/${request.httpVersion} ${response.status} ${response.statusLine}\r\n`); sock.write('\r\n'); response.body && sock.write(response.body); @@ -151,12 +154,7 @@ export default class WebSocketEndpoint extends TransportEndpoint { return response; } - async handleUpgrade(req: IncomingMessage, sock: net.Socket, head: Buffer) { - - //if (req.upgrade !== true) { - // this.logger.trace("upgrade called on non-upgrade request"); - // return undefined; - //} + async handleUpgrade(req: IncomingMessage, sock: Duplex, head: Buffer) { const parsed = this._parseRequest(req); if (parsed == undefined) { diff --git a/src/ingress/altname-service.js b/src/tunnel/altname-service.js similarity index 100% rename from src/ingress/altname-service.js rename to src/tunnel/altname-service.js diff --git a/src/tunnel/tunnel-config.ts b/src/tunnel/tunnel-config.ts index dcce0cb..9823280 100644 --- a/src/tunnel/tunnel-config.ts +++ b/src/tunnel/tunnel-config.ts @@ -16,17 +16,17 @@ export type TunnelIngressConfig = { sni: TunnelIngressTypeConfig, } -type TunnelIngressTypeConfig = { +export type TunnelIngressTypeConfig = { enabled: boolean, url: string | undefined, urls: Array, } -type TunnelHttpIngressConfig = TunnelIngressTypeConfig & { +export type TunnelHttpIngressConfig = TunnelIngressTypeConfig & { alt_names: Array, } -type TunnelTargetConfig = { +export type TunnelTargetConfig = { url: string | undefined } diff --git a/src/tunnel/tunnel-service.ts b/src/tunnel/tunnel-service.ts index 25e9b5b..714f8e7 100644 --- a/src/tunnel/tunnel-service.ts +++ b/src/tunnel/tunnel-service.ts @@ -1,29 +1,37 @@ 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 { TunnelConfig, TunnelHttpIngressConfig, TunnelIngressConfig, TunnelIngressTypeConfig, cloneTunnelConfig } from "./tunnel-config.js"; +import { Tunnel, TunnelConnection, TunnelState } from "./tunnel.js"; import Account from '../account/account.js'; -import Ingress from '../ingress/index.js'; -import { safeEqual } from '../utils/misc.js'; +import { difference, safeEqual, symDifference } 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'; +import { IngressType } from '../ingress/ingress-manager.js'; +import AltNameService from './altname-service.js'; +import CustomError, { ERROR_TUNNEL_INGRESS_BAD_ALT_NAMES } from '../utils/errors.js'; +import IngressService from '../ingress/ingress-service.js'; export type ConnectOptions = { peer: string, } +type CreateConnectionIngressTlsContext = { + enabled: boolean, + servername?: string, + cert?: Buffer, +}; + export type CreateConnectionContext = { remoteAddr: string, ingress: { port: number, - tls?: boolean, + tls?: CreateConnectionIngressTlsContext, } }; @@ -80,10 +88,11 @@ export default class TunnelService { private destroyed: boolean = false; private logger: any; private storage!: Storage; - private ingress!: Ingress; + private ingressService!: IngressService; private eventBus!: EventBus; private clusterService!: ClusterService; private accountService!: AccountService; + private altNameService!: AltNameService; private connectedTunnels!: { [ id: string ]: TunnelState }; private lastConnection!: { [ id: string]: string }; @@ -98,10 +107,11 @@ export default class TunnelService { this.logger = Logger("tunnel-service"); this.storage = new Storage("tunnel"); - this.ingress = new Ingress(); + this.ingressService = new IngressService(); this.eventBus = new EventBus(); this.clusterService = new ClusterService(); this.accountService = new AccountService(); + this.altNameService = new AltNameService(); this.connectedTunnels = {} this.lastConnection = {}; @@ -147,7 +157,8 @@ export default class TunnelService { this.eventBus.destroy(), this.clusterService.destroy(), this.accountService.destroy(), - this.ingress.destroy(), + this.ingressService.destroy(), + this.altNameService.destroy(), ]); TunnelService.instance = undefined; } @@ -404,7 +415,12 @@ export default class TunnelService { try { await Promise.all([ - this.ingress.deleteIngress(tunnel.config), + this.altNameService.update( + 'http', + tunnel.config.id, + [], + tunnel.config.ingress.http.alt_names, + ), this.storage.delete(tunnelId), updateAccount, ]); @@ -427,6 +443,89 @@ export default class TunnelService { return true; } + private async updateIngressConfig(tunnelConfig: TunnelConfig, prevTunnelConfig: TunnelConfig): Promise { + + const updateHttp = async (): Promise => { + if (!this.ingressService.enabled(IngressType.INGRESS_HTTP)) { + return { + enabled: false, + url: undefined, + urls: [], + alt_names: [], + } + } + + const baseUrl = this.ingressService.getIngressURL(IngressType.INGRESS_HTTP, tunnelConfig.id); + + let altNames = tunnelConfig.ingress.http.alt_names || []; + const prevAltNames = prevTunnelConfig.ingress.http.alt_names || []; + if (symDifference(altNames, prevAltNames).length != 0) { + const resolvedAltNames = await AltNameService.resolve(baseUrl.hostname, altNames); + const diff = symDifference(resolvedAltNames, altNames); + if (diff.length > 0) { + throw new CustomError(ERROR_TUNNEL_INGRESS_BAD_ALT_NAMES, diff.join(', ')); + } + + const updatedAltNames = await this.altNameService.update( + 'http', + tunnelConfig.id, + difference(resolvedAltNames, prevAltNames), + difference(prevAltNames, resolvedAltNames) + ); + altNames = updatedAltNames; + } + + const altUrls = altNames.map((an) => { + const url = new URL(baseUrl); + url.hostname = an; + return url.href; + }); + + if (tunnelConfig.ingress.http.enabled) { + return { + enabled: true, + url: baseUrl.href, + urls: [ + baseUrl.href, + ...altUrls, + ], + alt_names: altNames + } + } else { + return { + enabled: false, + url: undefined, + urls: [], + alt_names: altNames + } + } + } + + const updateSni = async (): Promise => { + if (!this.ingressService.enabled(IngressType.INGRESS_SNI) || !tunnelConfig.ingress.sni.enabled) { + return { + enabled: false, + url: undefined, + urls: [], + } + } + + const baseUrl = this.ingressService.getIngressURL(IngressType.INGRESS_SNI, tunnelConfig.id); + return { + enabled: true, + url: baseUrl.href, + urls: [ + baseUrl.href + ], + } + } + + return { + http: await updateHttp(), + sni: await updateSni(), + } + } + public async update(tunnelId: string, accountId: string, callback: (tunnelConfig: TunnelConfig) => void): Promise { let tunnel = await this._get(tunnelId); if (!this._isPermitted(tunnel, accountId)) { @@ -438,24 +537,22 @@ export default class TunnelService { 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; + let ingressConfig; + + ingressConfig = await this.updateIngressConfig(tunnelConfig, origConfig); + + if (tunnelConfig.ingress.http.enabled && !ingressConfig.http.enabled) { + throw new Error('ingress_administratively_disabled'); + } else if (tunnelConfig.ingress.sni.enabled && !ingressConfig.sni.enabled) { + throw new Error('ingress_administratively_disabled'); } - tunnelConfig.ingress = updatedIngress; + + tunnelConfig.ingress = ingressConfig; tunnelConfig.updated_at = new Date().toISOString(); return true; }); + tunnel.config = updatedConfig; return tunnel; } @@ -499,7 +596,8 @@ export default class TunnelService { if (!(account instanceof Account)) { return result; } - const correctToken = safeEqual(token, tunnel.config.transport.token) + const correctToken = tunnel.config.transport.token != undefined && + safeEqual(token, tunnel.config.transport.token) result.authorized = correctToken && !account.status.disabled; if (result.authorized) { @@ -704,6 +802,11 @@ export default class TunnelService { tunnelId, remoteAddr: ctx.remoteAddr, port: ctx.ingress.port, + tls: { + enabled: ctx.ingress.tls?.enabled == true, + servername: ctx.ingress.tls?.servername, + cert: ctx.ingress.tls?.cert, + } }, callback); return sock; } diff --git a/src/utils/misc.js b/src/utils/misc.ts similarity index 70% rename from src/utils/misc.js rename to src/utils/misc.ts index 503173a..5bc2f47 100644 --- a/src/utils/misc.js +++ b/src/utils/misc.ts @@ -1,6 +1,6 @@ import crypto from 'crypto'; -export function symDifference(a, b) { +export function symDifference(a: Array, b: Array): Array { const as = new Set(a); const bs = new Set(b); @@ -10,14 +10,14 @@ export function symDifference(a, b) { ]; } -export function difference(a, b) { +export function difference(a: Array, b: Array): Array { const bs = new Set(b); return [ ...a.filter(x => !bs.has(x)), ] } -export function safeEqual(input, allowed) { +export function safeEqual(input: string, allowed: string): boolean { const autoReject = (input.length !== allowed.length); if (autoReject) { allowed = input; diff --git a/test/e2e/test_cluster.js b/test/e2e/test_cluster.js index eacef51..55b1cfc 100644 --- a/test/e2e/test_cluster.js +++ b/test/e2e/test_cluster.js @@ -1,8 +1,11 @@ import child_process from 'child_process'; import crypto from 'crypto'; import assert from 'assert/strict'; +import http from 'http'; +import https from 'https'; import { setTimeout } from 'timers/promises'; -import { createAccount, createEchoServer, exposrCliImageTag, getAuthToken, getTunnel, putTunnel } from './e2e-utils.js'; +import { createAccount, exposrCliImageTag, getAuthToken, getTunnel, putTunnel } from './e2e-utils.js'; +import { createEchoHttpServer } from '../unit/test-utils.js'; const startExposrd = (name = "", network, args = [], dockerargs = []) => { const obj = child_process.spawn("docker", [ @@ -79,39 +82,46 @@ describe('Cluster E2E', () => { }); const clusterModes = [ - {mode: "UDP/multicast", args: ["--cluster", "udp"]}, - {mode: "Redis pub/sub", args: ["--cluster", "redis", "--cluster-redis-url", redisUrl, ]} + {mode: "UDP/multicast", ingress: 'http', args: ["--cluster", "udp"]}, + {mode: "UDP/multicast", ingress: 'sni', args: ["--cluster", "udp"]}, + {mode: "Redis pub/sub", ingress: 'http', args: ["--cluster", "redis", "--cluster-redis-url", redisUrl ]}, + {mode: "Redis pub/sub", ingress: 'sni', args: ["--cluster", "redis", "--cluster-redis-url", redisUrl ]}, ]; // Test will // Spawn two nodes, with the given cluster method // Connect a tunnel client to the second node - // Perform a http ingress request to the first node + // Perform a http ingress request to the first node // Assert that a http response is received - clusterModes.forEach(({mode, args}) => { - it(`Cluster mode ${mode} w/ redis storage`, async () => { + clusterModes.forEach(({mode, ingress, args}) => { + it(`Cluster mode ${mode} w/ redis storage, ingress ${ingress}`, async () => { const node1 = startExposrd("node-1", network, [ "--log-level", "debug", - "--storage-url", redisUrl, + "--storage-url", redisUrl, "--allow-registration", - "--ingress", "http", + "--ingress", "http,sni", "--ingress-http-url", "http://localhost:8080", + "--ingress-sni-cert", "test/unit/fixtures/cn-public-cert.pem", + "--ingress-sni-key", "test/unit/fixtures/cn-private-key.pem", ].concat(args), [ - "-p", "8080:8080" + "-p", "8080:8080", + "-p", "4430:4430" ]); const node2 = startExposrd("node-2", network, [ "--log-level", "debug", - "--storage-url", redisUrl, + "--storage-url", redisUrl, "--allow-registration", - "--ingress", "http", + "--ingress", "http,sni", "--ingress-http-url", "http://localhost:8080", + "--ingress-sni-cert", "test/unit/fixtures/cn-public-cert.pem", + "--ingress-sni-key", "test/unit/fixtures/cn-private-key.pem", ].concat(args)); - const echoServerTerminate = await createEchoServer(); + const echoServer = await createEchoHttpServer(); const apiEndpoint = "http://localhost:8080"; - const echoServerUrl = "http://host.docker.internal:10000"; + const echoServerUrl = "http://host.docker.internal:20000"; let retries = 60; do { @@ -130,7 +140,9 @@ describe('Cluster E2E', () => { const exposrCliTerminator = startExposr( 'http://node-2:8080', network, [ "-a", `${account.account_id}`, - "tunnel", "connect", `${tunnelId}`, `${echoServerUrl}` + "tunnel", "connect", `${tunnelId}`, `${echoServerUrl}`, + "ingress-http", "enable", + "ingress-sni", "enable", ]); authToken = await getAuthToken(account.account_id, apiEndpoint); @@ -138,30 +150,64 @@ describe('Cluster E2E', () => { do { await setTimeout(1000); res = await getTunnel(authToken, tunnelId, apiEndpoint); - data = await res.json(); + data = await res.json(); } while (data?.connection?.connected == false); assert(data?.connection?.connected == true, "tunnel not connected"); const ingressUrl = new URL(data.ingress.http.url); - - res = await fetch("http://localhost:8080", { - method: 'POST', - headers: { - "Host": `${ingressUrl.hostname}:8080` - }, - body: "echo" - }) - - data = await res.text() + const sniIngressUrl = new URL(data.ingress.sni.url); + + let status; + ([status, data] = await new Promise((resolve, reject) => { + const onRes = (res) => { + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('close', () => { resolve([res.statusCode, data])}); + }; + + let req; + if (ingress == 'sni') { + req = https.request({ + hostname: 'localhost', + port: 4430, + method: 'POST', + path: '/', + headers: { + "Host": sniIngressUrl.hostname + }, + servername: sniIngressUrl.hostname, + rejectUnauthorized: false, + }, onRes); + } else { + req = http.request({ + hostname: 'localhost', + port: 8080, + method: 'POST', + path: '/', + headers: { + "Host": ingressUrl.hostname + }, + rejectUnauthorized: false, + }, onRes); + } + req.on('error', (err) => { + console.log(err); + reject(err) + }) + req.end('echo'); + })); exposrCliTerminator(); - await echoServerTerminate(); node1.terminate(); node2.terminate(); + await echoServer.destroy(); - assert(res.status == 200, `expected status code 200, got ${res.status}`); + assert(status == 200, `expected status code 200, got ${status}`); assert(data == "echo", `did not get response from echo server through WS tunnel, got ${data}`); }).timeout(120000); - }); + }); }); \ No newline at end of file diff --git a/test/e2e/test_ssh.js b/test/e2e/test_ssh.js index cce5945..8385566 100644 --- a/test/e2e/test_ssh.js +++ b/test/e2e/test_ssh.js @@ -1,5 +1,6 @@ import assert from 'assert/strict'; import crypto from 'crypto'; +import http from 'node:http'; import { setTimeout } from 'timers/promises'; import { createAccount, createEchoServer, getAuthToken, getTunnel, putTunnel, sshClient } from './e2e-utils.js'; @@ -26,7 +27,7 @@ describe('SSH transport E2E', () => { after(async () => { process.env.NODE_ENV = "test"; - await terminator(undefined, {gracefulTimeout: 1000, drainTimeout: 500}); + await terminator(undefined, {gracefulTimeout: 1000, drainTimeout: 500}); await echoServerTerminator() }); @@ -54,7 +55,7 @@ describe('SSH transport E2E', () => { assert(res.status == 200, "could not create tunnel") res = await getTunnel(authToken, tunnelId); - let data = await res.json(); + let data = await res.json(); assert(data?.transport?.ssh?.enabled == true, "SSH transport not enabled"); assert(typeof data?.transport?.ssh?.url == 'string', "No SSH connect URL available"); @@ -66,32 +67,45 @@ describe('SSH transport E2E', () => { data?.transport?.ssh?.username, data?.transport?.ssh?.password, targetUrl, - ); + ); authToken = await getAuthToken(account.account_id); do { await setTimeout(1000); res = await getTunnel(authToken, tunnelId); - data = await res.json(); + data = await res.json(); } while (data?.connection?.connected == false); assert(data?.connection?.connected == true, "tunnel not connected"); const ingressUrl = new URL(data.ingress.http.url); - res = await fetch("http://localhost:8080", { - method: 'POST', - headers: { - "Host": ingressUrl.hostname - }, - body: "echo" - }) + let status; + ([status, data] = await new Promise((resolve) => { + const req = http.request({ + hostname: 'localhost', + port: 8080, + method: 'POST', + path: '/', + headers: { + "Host": ingressUrl.hostname + } + }, (res) => { + let data = ''; - assert(res.status == 200, `expected status code 200, got ${res.status}`); - data = await res.text() - assert(data == "echo", `did not get response from echo server through WS tunnel, got ${data}`) + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('close', () => { resolve([res.statusCode, data])}); + }); + req.end('echo'); + })); terminateClient(); + assert(status == 200, `expected status code 200, got ${status}`); + assert(data == "echo", `did not get response from echo server through WS tunnel, got ${data}`); + }).timeout(60000); }); \ No newline at end of file diff --git a/test/e2e/test_ws.js b/test/e2e/test_ws.js index 8c16f44..f3f9f74 100644 --- a/test/e2e/test_ws.js +++ b/test/e2e/test_ws.js @@ -1,5 +1,6 @@ import assert from 'assert/strict'; import crypto from 'crypto'; +import http from 'node:http'; import { setTimeout } from 'timers/promises'; import { createAccount, createEchoServer, getAuthToken, getTunnel, putTunnel, startExposr } from './e2e-utils.js'; import { PGSQL_URL, REDIS_URL } from '../env.js'; @@ -61,20 +62,33 @@ describe('Websocket E2E', () => { const ingressUrl = new URL(data.ingress.http.url); - res = await fetch("http://localhost:8080", { - method: 'POST', - headers: { - "Host": ingressUrl.hostname - }, - body: "echo" - }) + let status; + ([status, data] = await new Promise((resolve) => { + const req = http.request({ + hostname: 'localhost', + port: 8080, + method: 'POST', + path: '/', + headers: { + "Host": ingressUrl.hostname + } + }, (res) => { + let data = ''; - assert(res.status == 200, `expected status code 200, got ${res.status}`); - data = await res.text() - assert(data == "echo", `did not get response from echo server through WS tunnel, got ${data}`); + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('close', () => { resolve([res.statusCode, data])}); + }); + req.end('echo'); + })); exposrCliTerminator(); await terminator(undefined, {gracefulTimeout: 10000, drainTimeout: 500}); + + assert(status == 200, `expected status code 200, got ${status}`); + assert(data == "echo", `did not get response from echo server through WS tunnel, got ${data}`); }).timeout(60000); }); }); \ No newline at end of file diff --git a/test/system/ingress/test_http_ingress.js b/test/system/ingress/test_http_ingress.js deleted file mode 100644 index 9af9a38..0000000 --- a/test/system/ingress/test_http_ingress.js +++ /dev/null @@ -1,196 +0,0 @@ -import assert from 'assert/strict'; -import crypto from 'crypto'; -import AccountService from "../../../src/account/account-service.js"; -import EventBus from "../../../src/cluster/eventbus.js"; -import Config from "../../../src/config.js"; -import Ingress from "../../../src/ingress/index.js"; -import TunnelService from "../../../src/tunnel/tunnel-service.js"; -import { createEchoHttpServer, initClusterService, initStorageService, wsSocketPair, wsmPair } from "../../unit/test-utils.ts"; -import { setTimeout } from 'timers/promises'; -import sinon from 'sinon'; -import net from 'net' -import http from 'http'; - -describe('http ingress', () => { - let clock; - let storageService; - let clusterService; - let config; - let ingress; - - before(async () => { - config = new Config(); - clusterService = initClusterService(); - storageService = await initStorageService(); - - clock = sinon.useFakeTimers({shouldAdvanceTime: true}); - await new Promise((resolve, reject) => { - ingress = new Ingress({ - callback: (err) => { - err ? reject(err) : resolve(ingress); - }, - http: { - enabled: true, - port: 10000, - httpAgentTTL: 5, - subdomainUrl: new URL("http://localhost.example") - }, - }); - }); - assert(ingress instanceof Ingress); - }); - - after(async () => { - await ingress.destroy(); - await clusterService.destroy(); - await storageService.destroy(); - await config.destroy(); - clock.restore(); - }); - - let accountService; - let tunnelService; - let bus; - let account - let tunnel; - - - beforeEach(async () => { - tunnelService = new TunnelService(); - bus = new EventBus(); - accountService = new AccountService(); - - account = await accountService.create(); - const tunnelId = crypto.randomBytes(20).toString('hex'); - tunnel = await tunnelService.create(tunnelId, account.id); - tunnel = await tunnelService.update(tunnel.id, account.id, (tunnel) => { - tunnel.ingress.http.enabled = true; - }); - }); - - afterEach(async () => { - await bus.destroy(); - await tunnelService.destroy(); - await accountService.destroy(); - account = undefined; - tunnel = undefined; - }); - - it('agent does not timeout during transfer', async () => { - const sockPair = await wsSocketPair.create(9000) - - const [client, transport] = wsmPair(sockPair) - - let res = await tunnelService.connect(tunnel.id, account.id, transport, {peer: "127.0.0.1"}); - assert(res == true, "failed to connect tunnel"); - - let i = 0; - let tun; - 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") - - client.on('connection', (sock) => { - sock.on('data', async (chunk) => { - //console.log(chunk.toString()); - sock.write("HTTP/1.1 200\r\nContent-Length: 2\r\n\r\n"); - sock.write("A"); - await clock.tickAsync(12500); - sock.write("A"); - sock.end(); - }); - }); - - res = await fetch("http://127.0.0.1:10000", { - method: "GET", - headers: { - Host: `${tunnel.id}.localhost.example` - } - }); - - const data = await res.text(); - assert(data == "AA", `did not get expected reply, got ${data}`); - - await client.destroy(); - await transport.destroy(); - await sockPair.terminate(); - - }).timeout(2000); - - it(`http ingress can handle websocket upgrades`, async () => { - const sockPair = await wsSocketPair.create(9000) - const [sock1, sock2] = wsmPair(sockPair) - const echoServer = await createEchoHttpServer(20000); - - sock2.on('connection', (sock) => { - const targetSock = new net.Socket(); - targetSock.connect({ - host: 'localhost', - port: 20000 - }, () => { - targetSock.pipe(sock); - sock.pipe(targetSock); - }); - - const close = () => { - targetSock.unpipe(sock); - sock.unpipe(targetSock); - sock.destroy(); - targetSock.destroy(); - }; - - targetSock.on('close', close); - sock.on('close', close); - sock.on('error', () => { - close(); - }); - targetSock.on('error', () => { - close(); - }); - }); - - let res = await tunnelService.connect(tunnel.id, account.id, sock1, {peer: "127.0.0.1"}); - assert(res == true, "failed to connect tunnel"); - - let i = 0; - let tun; - 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") - - const req = http.request({ - hostname: 'localhost', - port: 10000, - method: 'GET', - path: '/ws', - headers: { - "Host": `${tunnel.id}.localhost.example`, - "Connection": 'Upgrade', - "Upgrade": 'websocket', - "Origin": `http://${tunnel.id}.localhost.example`, - "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==", - "Sec-WebSocket-Version": "13" - } - }); - - const done = (resolve) => { - req.on('upgrade', (res, socket, head) => { - const body = head.subarray(2); - resolve(body); - }); - }; - req.end(); - - const wsRes = await new Promise(done); - assert(wsRes.equals(Buffer.from("ws echo connected")), `did not get ws echo, got ${wsRes}`); - - await sock1.destroy(); - await sock2.destroy(); - await sockPair.terminate(); - await echoServer.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 index 7088726..a9e1cfc 100644 --- a/test/unit/cluster/test_cluster-transport.ts +++ b/test/unit/cluster/test_cluster-transport.ts @@ -1,4 +1,7 @@ +import assert from 'assert/strict'; import net from 'net'; +import tls from 'tls'; +import fs from 'fs'; import sinon from 'sinon'; import ClusterTransport from '../../../src/transport/cluster/cluster-transport.js'; import ClusterService, { ClusterNode } from '../../../src/cluster/index.js'; @@ -8,8 +11,13 @@ 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 server = net.createServer((socket: net.Socket) => { + socket.end('success'); + }); + + await new Promise((resolve) => { + server.listen(10000, () => { resolve(undefined); }); + }); const clusterService = new ClusterService("mem"); @@ -28,12 +36,18 @@ describe('cluster transport', () => { const sock: Duplex = await new Promise((resolve) => { const sock = clusterTransport.createConnection({ port: 10000, - remoteAddr: "127.0.0.1" + remoteAddr: "127.0.0.2" }, () => { resolve(sock); }); }); + const data = await new Promise((resolve) => { + sock.once('data', (chunk: Buffer) => { + resolve(chunk.toString()); + }); + }); + await clusterService.destroy(); sock.destroy(); await new Promise((resolve) => { @@ -42,5 +56,73 @@ describe('cluster transport', () => { }); }); config.destroy(); + sinon.restore(); + + assert(data == 'success'); + }); + + it('can connect to tls', async () => { + const config = new Config(); + + const key = fs.readFileSync(new URL('../fixtures/cn-private-key.pem', import.meta.url).pathname); + const cert = fs.readFileSync(new URL('../fixtures/cn-public-cert.pem', import.meta.url).pathname); + + const server = tls.createServer({ + key, + cert, + }, (socket: tls.TLSSocket) => { + const servername = (socket).servername; + socket.end(servername); + }); + + await new Promise((resolve) => { + server.listen(11000, () => { resolve(undefined); }); + }); + + 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: 11000, + remoteAddr: "127.0.0.2", + tls: { + enabled: true, + servername: 'test.example.com', + cert: cert, + } + }, () => { + resolve(sock); + }); + }); + + const data = await new Promise((resolve) => { + sock.once('data', (chunk: Buffer) => { + resolve(chunk.toString()); + }); + }); + + await clusterService.destroy(); + sock.destroy(); + await new Promise((resolve) => { + server.close(() => { + resolve(undefined); + }); + }); + config.destroy(); + sinon.restore(); + + assert(data == 'test.example.com'); }); }); \ No newline at end of file diff --git a/test/unit/ingress/test_http_ingress.ts b/test/unit/ingress/test_http_ingress.ts new file mode 100644 index 0000000..fd918a9 --- /dev/null +++ b/test/unit/ingress/test_http_ingress.ts @@ -0,0 +1,695 @@ +import assert from 'assert/strict'; +import crypto from 'crypto'; +import dns from 'dns/promises'; +import AccountService from "../../../src/account/account-service.js"; +import EventBus from "../../../src/cluster/eventbus.js"; +import Config from "../../../src/config.js"; +import IngressManager, { IngressType } from "../../../src/ingress/ingress-manager.js"; +import TunnelService from "../../../src/tunnel/tunnel-service.js"; +import { createEchoHttpServer, initClusterService, initStorageService, wsSocketPair, wsmPair } from "../test-utils.js"; +import sinon from 'sinon'; +import net from 'net' +import http from 'http'; +import Tunnel from '../../../src/tunnel/tunnel.js'; +import Account from '../../../src/account/account.js'; +import { StorageService } from '../../../src/storage/index.js'; +import ClusterService from '../../../src/cluster/index.js'; +import { WebSocketMultiplex } from '@exposr/ws-multiplex'; +import WebSocketTransport from '../../../src/transport/ws/ws-transport.js'; +import { Duplex } from 'stream'; +import CustomError, { ERROR_TUNNEL_INGRESS_BAD_ALT_NAMES } from '../../../src/utils/errors.js'; +import HttpIngress from '../../../src/ingress/http-ingress.js'; +import { httpRequest } from './utils.js'; + +describe('http ingress', () => { + let clock: sinon.SinonFakeTimers; + let storageService: StorageService; + let clusterService: ClusterService; + let config: Config; + + before(async () => { + config = new Config(); + clusterService = initClusterService(); + storageService = await initStorageService(); + + clock = sinon.useFakeTimers({shouldAdvanceTime: true}); + await IngressManager.listen({ + http: { + enabled: true, + port: 10000, + httpAgentTTL: 5, + subdomainUrl: new URL("http://localhost.example") + }, + }); + echoServer = await createEchoHttpServer(20000); + }); + + after(async () => { + await IngressManager.close(); + await clusterService.destroy(); + await storageService.destroy(); + await config.destroy(); + await echoServer.destroy(); + clock.restore(); + }); + + let accountService: AccountService; + let tunnelService: TunnelService; + let account: Account | undefined; + let tunnel: Tunnel | undefined; + let sockPair: wsSocketPair; + let client: WebSocketMultiplex; + let transport: WebSocketTransport; + let echoServer: { destroy: () => Promise; }; + + beforeEach(async () => { + tunnelService = new TunnelService(); + accountService = new AccountService(); + + account = await accountService.create(); + const tunnelId = crypto.randomBytes(20).toString('hex'); + tunnel = await tunnelService.create(tunnelId, account?.id); + tunnel = await tunnelService.update(tunnel.id, account?.id, (tunnel) => { + tunnel.ingress.http.enabled = true; + }); + + sockPair = await wsSocketPair.create(9000) + assert(sockPair != undefined); + ({client, transport} = wsmPair(sockPair)); + }); + + afterEach(async () => { + await client.destroy(); + await transport.destroy(); + await sockPair.terminate(); + await tunnelService.destroy(); + await accountService.destroy(); + account = undefined; + tunnel = undefined; + }); + + const forwardTo = (host: string, port: number): void => { + client.on('connection', (sock: Duplex) => { + const targetSock = new net.Socket(); + targetSock.connect({ + host, + port + }, () => { + targetSock.pipe(sock); + sock.pipe(targetSock); + }); + + const close = () => { + targetSock.unpipe(sock); + sock.unpipe(targetSock); + sock.destroy(); + targetSock.destroy(); + }; + + targetSock.on('close', close); + sock.on('close', close); + sock.on('error', () => { + close(); + }); + targetSock.on('error', () => { + close(); + }); + }); + }; + + const connectTunnel = async (): Promise => { + assert(tunnel != undefined); + assert(account != undefined); + + let res = await tunnelService.connect(tunnel.id, account.id, transport, {peer: "127.0.0.1"}); + assert(res == true, "failed to connect tunnel"); + + let i = 0; + let tun: Tunnel; + do { + await clock.tickAsync(1000); + tun = await tunnelService.lookup(tunnel.id) + } while (tun.state.connected == false && i++ < 10); + assert(tun.state.connected == true, "tunnel not connected"); + } + + it('can send traffic', async () => { + assert(tunnel != undefined); + assert(account != undefined); + + client.on('connection', (sock: Duplex) => { + const targetSock = new net.Socket(); + targetSock.connect({ + host: 'localhost', + port: 10000 + }, () => { + targetSock.pipe(sock); + sock.pipe(targetSock); + }); + + const close = () => { + targetSock.unpipe(sock); + sock.unpipe(targetSock); + sock.destroy(); + targetSock.destroy(); + }; + + targetSock.on('close', close); + sock.on('close', close); + sock.on('error', () => { + close(); + }); + targetSock.on('error', () => { + close(); + }); + }); + + let res = await tunnelService.connect(tunnel.id, account.id, transport, {peer: "127.0.0.1"}); + assert(res == true, "failed to connect tunnel"); + + let i = 0; + let tun: Tunnel; + do { + await clock.tickAsync(1000); + tun = await tunnelService.lookup(tunnel.id) + } while (tun.state.connected == false && i++ < 10); + assert(tun.state.connected == true, "tunnel not connected"); + + let {status, data}: {status: number | undefined, data: any} = await new Promise((resolve) => { + const req = http.request({ + hostname: 'localhost', + port: 20000, + method: 'GET', + path: '/file?size=1048576', + headers: { + "Host": `${tunnel?.id}.localhost.example` + } + }, (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('close', () => { resolve({status: res.statusCode, data})}); + }); + req.end(); + }); + + await tunnelService.disconnect(tunnel.id, account.id); + + assert(status == 200, `expected 200 status, got ${status}`); + assert(data.length == 1048576, `did not receive expected data, got data length ${data.length}`); + }); + + it('agent does not timeout during transfer', async () => { + assert(tunnel != undefined); + assert(account != undefined); + + let res = await tunnelService.connect(tunnel.id, account.id, transport, {peer: "127.0.0.1"}); + assert(res == true, "failed to connect tunnel"); + + let i = 0; + let tun: Tunnel; + do { + await clock.tickAsync(1000); + tun = await tunnelService.lookup(tunnel.id) + } while (tun.state.connected == false && i++ < 10); + assert(tun.state.connected == true, "tunnel not connected") + + client.on('connection', (sock) => { + sock.on('data', async (chunk: any) => { + //console.log(chunk.toString()); + sock.write("HTTP/1.1 200\r\nContent-Length: 2\r\n\r\n"); + sock.write("A"); + await clock.tickAsync(12500); + sock.write("A"); + sock.end(); + }); + }); + + let {status, data}: {status: number, data: string} = await new Promise((resolve: (res: {status: number, data: string}) => void) => { + const req = http.request({ + hostname: 'localhost', + port: 10000, + method: 'GET', + path: '/', + headers: { + "Host": `${tunnel?.id}.localhost.example` + } + }, (res) => { + let data: string = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('close', () => { resolve({status: res.statusCode, data})}); + }); + req.end(); + }); + + assert(status == 200, `expected status code 200, got ${status}`); + assert(data == "AA", `did not get expected reply, got ${data}`); + + await tunnelService.disconnect(tunnel.id, account.id); + }); + + it('agent timeout on idle', async () => { + assert(tunnel != undefined); + assert(account != undefined); + + forwardTo("localhost", 20000); + await connectTunnel(); + + const {status, data} = await httpRequest({ + hostname: 'localhost', + port: 10000, + method: 'GET', + path: '/', + headers: { + "Host": `${tunnel.id}.localhost.example`, + } + }); + + const instance: HttpIngress = IngressManager.getIngress(IngressType.INGRESS_HTTP) as HttpIngress; + let agent = instance["_agentCache"].get(tunnel.id); + assert(agent != undefined); + + await clock.tickAsync(10000); + + let agent2 = instance["_agentCache"].get(tunnel.id); + assert(agent2 == undefined); + + await tunnelService.disconnect(tunnel.id, account.id); + }); + + it(`http ingress can handle websocket upgrades`, async () => { + assert(tunnel != undefined); + assert(account != undefined); + + client.on('connection', (sock: Duplex) => { + const targetSock = new net.Socket(); + targetSock.connect({ + host: 'localhost', + port: 20000 + }, () => { + targetSock.pipe(sock); + sock.pipe(targetSock); + }); + + const close = () => { + targetSock.unpipe(sock); + sock.unpipe(targetSock); + sock.destroy(); + targetSock.destroy(); + }; + + targetSock.on('close', close); + sock.on('close', close); + sock.on('error', () => { + close(); + }); + targetSock.on('error', () => { + close(); + }); + }); + + let res = await tunnelService.connect(tunnel.id, account.id, transport, {peer: "127.0.0.1"}); + assert(res == true, "failed to connect tunnel"); + + let i = 0; + let tun: Tunnel; + do { + await clock.tickAsync(1000); + tun = await tunnelService.lookup(tunnel?.id) + } while (tun.state.connected == false && i++ < 10); + assert(tun.state.connected == true, "tunnel not connected") + + const req = http.request({ + hostname: 'localhost', + port: 10000, + method: 'GET', + path: '/ws', + headers: { + "Host": `${tunnel.id}.localhost.example`, + "Connection": 'Upgrade', + "Upgrade": 'websocket', + "Origin": `http://${tunnel.id}.localhost.example`, + "Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==", + "Sec-WebSocket-Version": "13" + } + }); + + const wsWait = new Promise((resolve: (value: any) => void) => { + req.on('upgrade', (res, socket, head) => { + const body = head.subarray(2); + resolve(body); + }); + req.end(); + }); + + const wsRes = await wsWait; + + assert(wsRes.equals(Buffer.from("ws echo connected")), `did not get ws echo, got ${wsRes}`); + }); + + it('handles ingress altname', async () => { + assert(tunnel != undefined); + assert(account != undefined); + + sinon.stub(dns, 'resolveCname') + .withArgs('custom-name.example') + .resolves([`${tunnel.id}.localhost.example`]); + + tunnel = await tunnelService.update(tunnel.id, account?.id, (tunnel) => { + tunnel.ingress.http.alt_names = [ + "custom-name.example" + ] + }); + + forwardTo("localhost", 20000); + await connectTunnel(); + + const {status, data} = await httpRequest({ + hostname: 'localhost', + port: 10000, + method: 'POST', + path: '/', + headers: { + "Host": `custom-name.example` + } + }, "echo"); + + sinon.restore(); + await tunnelService.disconnect(tunnel.id, account.id); + + assert(status == 200, `expected status code 200, got ${status}`); + assert(data == "echo", `did not get expected reply, got ${data}`); + }); + + it('adding altname without cname throws error', async () => { + assert(tunnel != undefined); + + let error: CustomError | undefined; + try { + tunnel = await tunnelService.update(tunnel.id, account?.id, (tunnel) => { + tunnel.ingress.http.alt_names = [ + "custom-name.example" + ] + }); + } catch (e: any) { + error = e; + } + + assert(error != undefined, "error not thrown"); + assert(error.code == ERROR_TUNNEL_INGRESS_BAD_ALT_NAMES); + }); + + it('adding altname with wrong cname throws error', async () => { + assert(tunnel != undefined); + + sinon.stub(dns, 'resolveCname') + .withArgs('custom-name.example') + .resolves([`localhost.example`]); + + let error: CustomError | undefined; + try { + tunnel = await tunnelService.update(tunnel.id, account?.id, (tunnel) => { + tunnel.ingress.http.alt_names = [ + "custom-name.example" + ] + }); + } catch (e: any) { + error = e; + } + + sinon.restore(); + + assert(error != undefined, "error not thrown"); + assert(error.code == ERROR_TUNNEL_INGRESS_BAD_ALT_NAMES); + }); + + it('request headers are rewritten with the target host for http', async () => { + assert(tunnel != undefined); + assert(account != undefined); + + tunnel = await tunnelService.update(tunnel.id, account?.id, (config) => { + config.target.url = "https://echo.localhost.example" + }); + + forwardTo("localhost", 20000); + await connectTunnel(); + + const {status, data} = await httpRequest({ + hostname: 'localhost', + port: 10000, + method: 'GET', + path: '/headers', + headers: { + "Host": `${tunnel.id}.localhost.example`, + "Origin": `${tunnel.id}.localhost.example`, + "Referer": `https://${tunnel.id}.localhost.example/page`, + "X-Forwarded-For": "192.168.0.1", + "X-Forwarded-proto": "https" + } + }); + + sinon.restore(); + await tunnelService.disconnect(tunnel.id, account.id); + + const headers = JSON.parse(data); + assert(headers.host == "echo.localhost.example", `expected host echo.localhost.example, got ${headers.host}`); + assert(headers.origin == "echo.localhost.example", `expected origin echo.localhost.example, got ${headers.origin}`); + assert(headers.referer == "https://echo.localhost.example/page", `expected referer https://echo.localhost.example/page, got ${headers.referer}`); + }); + + it('request headers are not rewritten with the target host for non-http', async () => { + assert(tunnel != undefined); + assert(account != undefined); + + tunnel = await tunnelService.update(tunnel.id, account?.id, (config) => { + config.target.url = "tcps://echo.localhost.example" + }); + + forwardTo("localhost", 20000); + await connectTunnel(); + + const {status, data} = await httpRequest({ + hostname: 'localhost', + port: 10000, + method: 'GET', + path: '/headers', + headers: { + "Host": `${tunnel.id}.localhost.example`, + "Origin": `${tunnel.id}.localhost.example`, + "Referer": `https://${tunnel.id}.localhost.example/page`, + "X-Forwarded-For": "192.168.0.1", + "X-Forwarded-proto": "https" + } + }); + + sinon.restore(); + await tunnelService.disconnect(tunnel.id, account.id); + + const headers = JSON.parse(data); + assert(headers.host == `${tunnel.id}.localhost.example`, `expected host ${tunnel.id}.localhost.example, got ${headers.host}`); + assert(headers.origin == `${tunnel.id}.localhost.example`, `expected origin ${tunnel.id}.localhost.example, got ${headers.origin}`); + assert(headers.referer == `https://${tunnel.id}.localhost.example/page`, `expected referer https://${tunnel.id}.localhost.example/page, got ${headers.referer}`); + }); + + it('forwarded headers are added to request', async () => { + assert(tunnel != undefined); + assert(account != undefined); + + forwardTo("localhost", 20000); + await connectTunnel(); + + sinon.stub(net.Socket.prototype, '_getpeername').returns({ + address: "127.0.0.2" + }); + + const {status, data} = await httpRequest({ + hostname: 'localhost', + port: 10000, + method: 'GET', + path: '/headers', + headers: { + "Host": `${tunnel.id}.localhost.example`, + } + }); + + sinon.restore(); + await tunnelService.disconnect(tunnel.id, account.id); + + const headers = JSON.parse(data); + assert(headers['x-forwarded-for'] == "127.0.0.2", `unexpected x-forwarded-for, got ${headers['x-forwarded-for']}`); + assert(headers['x-real-ip'] == "127.0.0.2", `unexpected x-real-ip, got ${headers['x-real-ip']}`); + assert(headers['x-forwarded-proto'] == "http", `unexpected x-forwarded-proto, got ${headers['x-forwarded-proto']}`); + const forwarded = `by=_exposr;for=127.0.0.2;host=${tunnel.id}.localhost.example;proto=http` + assert(headers['forwarded'] == forwarded, `unexpected forwarded, got ${headers['forwarded']}`); + assert(headers['x-forwarded-host'] == `${tunnel.id}.localhost.example`, `${headers['x-forwarded-host']}`); + }); + + it('x-forwarded headers from request are read', async () => { + assert(tunnel != undefined); + assert(account != undefined); + + forwardTo("localhost", 20000); + await connectTunnel(); + + sinon.stub(net.Socket.prototype, '_getpeername').returns({ + address: "127.0.0.2" + }); + + const {status, data} = await httpRequest({ + hostname: 'localhost', + port: 10000, + method: 'GET', + path: '/headers', + headers: { + "Host": `${tunnel.id}.localhost.example`, + "x-forwarded-for": "127.0.0.3", + "x-forwarded-proto": "https", + } + }); + + sinon.restore(); + await tunnelService.disconnect(tunnel.id, account.id); + + const headers = JSON.parse(data); + assert(headers['x-forwarded-for'] == "127.0.0.3", `unexpected x-forwarded-for, got ${headers['x-forwarded-for']}`); + assert(headers['x-real-ip'] == "127.0.0.3", `unexpected x-real-ip, got ${headers['x-real-ip']}`); + assert(headers['x-forwarded-proto'] == "https", `unexpected x-forwarded-proto, got ${headers['x-forwarded-proto']}`); + const forwarded = `by=_exposr;for=127.0.0.3;host=${tunnel.id}.localhost.example;proto=https` + assert(headers['forwarded'] == forwarded, `unexpected forwarded, got ${headers['forwarded']}`); + }); + + it('exposr via header is added to request', async () => { + assert(tunnel != undefined); + assert(account != undefined); + + forwardTo("localhost", 20000); + await connectTunnel(); + + const {status, data} = await httpRequest({ + hostname: 'localhost', + port: 10000, + method: 'GET', + path: '/headers', + headers: { + "Host": `${tunnel.id}.localhost.example`, + } + }); + + await tunnelService.disconnect(tunnel.id, account.id); + + const headers = JSON.parse(data); + assert(headers['exposr-via']?.length > 0, `via header not set`); + }); + + it('request loops returns 508', async () => { + assert(tunnel != undefined); + assert(account != undefined); + + forwardTo("localhost", 10000); + await connectTunnel(); + + const {status, data} = await httpRequest({ + hostname: 'localhost', + port: 10000, + method: 'GET', + path: '/headers', + headers: { + "Host": `${tunnel.id}.localhost.example`, + } + }); + + await tunnelService.disconnect(tunnel.id, account.id); + + assert(status == 508, `expected status 508, got ${status}`); + }); + + it('un-responsive target returns 502', async () => { + assert(tunnel != undefined); + assert(account != undefined); + + forwardTo("localhost", 20001); + await connectTunnel(); + + const {status, data} = await httpRequest({ + hostname: 'localhost', + port: 10000, + method: 'GET', + path: '/headers', + headers: { + "Host": `${tunnel.id}.localhost.example`, + } + }); + + await tunnelService.disconnect(tunnel.id, account.id); + + assert(status == 502, `expected status 502, got ${status}`); + }); + + it('connection to non-existing tunnel returns 404', async () => { + assert(tunnel != undefined); + assert(account != undefined); + + const {status, data} = await httpRequest({ + hostname: 'localhost', + port: 10000, + method: 'GET', + path: '/headers', + headers: { + "Host": `does-not-exist.localhost.example`, + } + }); + + assert(status == 404, `expected status 404, got ${status}`); + }); + + it('non-connected tunnel returns 503', async () => { + assert(tunnel != undefined); + assert(account != undefined); + + const {status, data} = await httpRequest({ + hostname: 'localhost', + port: 10000, + method: 'GET', + path: '/headers', + headers: { + "Host": `${tunnel.id}.localhost.example`, + } + }); + + assert(status == 503, `expected status 503, got ${status}`); + }); + + it('disabled ingress returns 403', async () => { + assert(tunnel != undefined); + assert(account != undefined); + + tunnel = await tunnelService.update(tunnel.id, account?.id, (config) => { + config.ingress.http.enabled = false; + }); + + forwardTo("localhost", 20000); + await connectTunnel(); + + const {status, data} = await httpRequest({ + hostname: 'localhost', + port: 10000, + method: 'GET', + path: '/headers', + headers: { + "Host": `${tunnel.id}.localhost.example`, + } + }); + + await tunnelService.disconnect(tunnel.id, account.id); + + assert(status == 403, `expected status 403, got ${status}`); + }); + +}); \ No newline at end of file diff --git a/test/unit/ingress/test_sni-ingress.js b/test/unit/ingress/test_sni-ingress.js deleted file mode 100644 index ed7d87b..0000000 --- a/test/unit/ingress/test_sni-ingress.js +++ /dev/null @@ -1,129 +0,0 @@ -import SNIIngress from '../../../src/ingress/sni-ingress.js'; -import Tunnel from '../../../src/tunnel/tunnel.js'; -import assert from 'assert/strict'; -import { X509Certificate } from 'crypto'; -import fs from 'fs'; -import { initClusterService, initStorageService } from '../test-utils.ts' -import Config from '../../../src/config.js'; -import Ingress from '../../../src/ingress/index.js'; -import TunnelService from '../../../src/tunnel/tunnel-service.js'; - -describe('sni ingress', () => { - let storageService; - let clusterService; - let tunnelService; - let config; - let ingress; - - before(async () => { - config = new Config(); - storageService = await initStorageService(); - clusterService = initClusterService(); - ingress = new Ingress({ - tunnelService, - http: { - enabled: true, - subdomainUrl: new URL("https://example.com"), - port: 8080, - } - }); - }); - - after(async () => { - await storageService.destroy(); - await clusterService.destroy(); - await ingress.destroy(); - config.destroy() - }); - - it("_getWildcardSubjects parses CN correctly", () => { - const cert = fs.readFileSync(new URL('../fixtures/cn-public-cert.pem', import.meta.url)); - const wild = SNIIngress._getWildcardSubjects(new X509Certificate(cert)); - - assert(wild.length == 1); - assert(wild[0] == '*.example.com'); - }); - - it("_getWildcardSubjects parses SAN correctly", () => { - const cert = fs.readFileSync(new URL('../fixtures/san-public-cert.pem', import.meta.url)); - const wild = SNIIngress._getWildcardSubjects(new X509Certificate(cert)); - - assert(wild.length == 2); - assert(wild[0] == '*.example.com'); - assert(wild[1] == '*.localhost'); - }); - - it("_getWildcardSubjects returns nothing for non-wildcard cert", () => { - const cert = fs.readFileSync(new URL('../fixtures/no-wildcard-public-cert.pem', import.meta.url)); - const wild = SNIIngress._getWildcardSubjects(new X509Certificate(cert)); - - assert(wild.length == 0); - }); - - it("construct instance with valid certificates", async () => { - const tunnelService = new TunnelService(); - const sni = new SNIIngress({ - tunnelService, - cert: new URL('../fixtures/cn-public-cert.pem', import.meta.url), - key: new URL('../fixtures/cn-private-key.pem', import.meta.url), - }); - await tunnelService.destroy(); - return sni.destroy(); - }); - - const urlTests = [ - { - args: { - cert: new URL('../fixtures/cn-public-cert.pem', import.meta.url), - key: new URL('../fixtures/cn-private-key.pem', import.meta.url), - }, - expected: "tcps://test.example.com:4430", - }, - { - args: { - cert: new URL('../fixtures/cn-public-cert.pem', import.meta.url), - key: new URL('../fixtures/cn-private-key.pem', import.meta.url), - port: 44300, - }, - expected: "tcps://test.example.com:44300", - }, - { - args: { - cert: new URL('../fixtures/cn-public-cert.pem', import.meta.url), - key: new URL('../fixtures/cn-private-key.pem', import.meta.url), - port: 4430, - host: 'example.com:443', - }, - expected: "tcps://test.example.com:443", - }, - { - args: { - cert: new URL('../fixtures/cn-public-cert.pem', import.meta.url), - key: new URL('../fixtures/cn-private-key.pem', import.meta.url), - port: 4430, - host: 'tcp://example.com:443', - }, - expected: "tcps://test.example.com:443", - }, - ]; - - urlTests.forEach(({args, expected}) => { - it(`getIngress() for ${JSON.stringify(args)} returns ${expected}`, async () => { - const tunnelService = new TunnelService(); - const tunnel = new Tunnel(); - tunnel.id = 'test'; - - const ingress = new SNIIngress({ - tunnelService, - ...args - }); - const ing = ingress.getIngress(tunnel); - await ingress.destroy(); - - assert(ing.url == expected, `got ${ing.url}`); - - await tunnelService.destroy(); - return true; - }); - }); -}); \ No newline at end of file diff --git a/test/unit/ingress/test_sni-ingress.ts b/test/unit/ingress/test_sni-ingress.ts new file mode 100644 index 0000000..f9f2d33 --- /dev/null +++ b/test/unit/ingress/test_sni-ingress.ts @@ -0,0 +1,339 @@ +import SNIIngress, { SniIngressOptions } from '../../../src/ingress/sni-ingress.js'; +import Tunnel from '../../../src/tunnel/tunnel.js'; +import assert from 'assert/strict'; +import { X509Certificate } from 'crypto'; +import fs from 'fs'; +import crypto from 'crypto'; +import net from 'net'; +import tls, { TLSSocket } from 'tls'; +import { createEchoHttpServer, initClusterService, initStorageService, wsSocketPair, wsmPair } from '../test-utils.js' +import Config from '../../../src/config.js'; +import IngressManager, { IngressType } from '../../../src/ingress/ingress-manager.js'; +import { StorageService } from '../../../src/storage/index.js'; +import ClusterService from '../../../src/cluster/index.js'; +import TunnelService from '../../../src/tunnel/tunnel-service.js'; +import Account from '../../../src/account/account.js'; +import AccountService from '../../../src/account/account-service.js'; +import sinon from 'sinon'; +import { WebSocketMultiplex } from '@exposr/ws-multiplex'; +import WebSocketTransport from '../../../src/transport/ws/ws-transport.js'; +import { Duplex } from 'stream'; +import { httpRequest } from './utils.js'; +import { setTimeout } from 'timers/promises'; + +describe('sni', () => { + + describe('cert parser', () => { + it("_getWildcardSubjects parses CN correctly", () => { + const cert = fs.readFileSync(new URL('../fixtures/cn-public-cert.pem', import.meta.url)); + const wild = SNIIngress['_getWildcardSubjects'](new X509Certificate(cert)); + + assert(wild.length == 1); + assert(wild[0] == '*.example.com'); + }); + + it("_getWildcardSubjects parses SAN correctly", () => { + const cert = fs.readFileSync(new URL('../fixtures/san-public-cert.pem', import.meta.url)); + const wild = SNIIngress['_getWildcardSubjects'](new X509Certificate(cert)); + + assert(wild.length == 2); + assert(wild[0] == '*.example.com'); + assert(wild[1] == '*.localhost'); + }); + + it("_getWildcardSubjects returns nothing for non-wildcard cert", () => { + const cert = fs.readFileSync(new URL('../fixtures/no-wildcard-public-cert.pem', import.meta.url)); + const wild = SNIIngress['_getWildcardSubjects'](new X509Certificate(cert)); + + assert(wild.length == 0); + }); + }); + + describe('baseUrl', () => { + const urlTests = [ + { + args: { + cert: new URL('../fixtures/cn-public-cert.pem', import.meta.url).pathname, + key: new URL('../fixtures/cn-private-key.pem', import.meta.url).pathname, + }, + expected: "tcps://test.example.com:4430", + }, + { + args: { + cert: new URL('../fixtures/cn-public-cert.pem', import.meta.url).pathname, + key: new URL('../fixtures/cn-private-key.pem', import.meta.url).pathname, + port: 44300, + }, + expected: "tcps://test.example.com:44300", + }, + { + args: { + cert: new URL('../fixtures/cn-public-cert.pem', import.meta.url).pathname, + key: new URL('../fixtures/cn-private-key.pem', import.meta.url).pathname, + port: 4430, + host: 'example.com:443', + }, + expected: "tcps://test.example.com:443", + }, + { + args: { + cert: new URL('../fixtures/cn-public-cert.pem', import.meta.url).pathname, + key: new URL('../fixtures/cn-private-key.pem', import.meta.url).pathname, + port: 4430, + host: 'tcp://example.com:443', + }, + expected: "tcps://test.example.com:443", + }, + ]; + + urlTests.forEach(({args, expected}) => { + it(`baseurl for ${JSON.stringify(args)} returns ${expected}`, async () => { + let storageService: StorageService; + let clusterService: ClusterService; + let tunnelService: TunnelService; + let accountService: AccountService; + let config: Config; + let account: Account | undefined; + let tunnel: Tunnel; + + config = new Config(); + storageService = await initStorageService(); + clusterService = initClusterService(); + await IngressManager.listen({ + sni: { + enabled: true, + ...(args as SniIngressOptions) + } + }); + + tunnelService = new TunnelService(); + accountService = new AccountService(); + + account = await accountService.create(); + const tunnelId = 'test'; + tunnel = await tunnelService.create(tunnelId, account?.id); + tunnel = await tunnelService.update(tunnel.id, account?.id, (tunnel) => { + tunnel.ingress.sni.enabled = true; + }); + + const url = IngressManager.getIngress(IngressType.INGRESS_SNI).getBaseUrl(tunnel.id); + + await tunnelService.delete(tunnelId, account?.id); + + await accountService.destroy(); + await tunnelService.destroy(); + await IngressManager.close(); + await storageService.destroy(); + await clusterService.destroy(); + config.destroy(); + + assert(url?.href == expected, `expected ${expected}, got ${url?.href}`); + + return true; + }); + }); + }); + + describe('ingress', () => { + let storageService: StorageService; + let clusterService: ClusterService; + let config: Config; + let echoServer: { destroy: () => Promise; }; + + let sockPair: wsSocketPair; + let client: WebSocketMultiplex; + let transport: WebSocketTransport; + + before(async () => { + clock = sinon.useFakeTimers({shouldAdvanceTime: true}); + config = new Config(); + storageService = await initStorageService(); + clusterService = initClusterService(); + await IngressManager.listen({ + sni: { + enabled: true, + port: 4430, + cert: new URL('../fixtures/cn-public-cert.pem', import.meta.url).pathname, + key: new URL('../fixtures/cn-private-key.pem', import.meta.url).pathname, + } + }); + + echoServer = await createEchoHttpServer(20000); + }); + + after(async () => { + await storageService.destroy(); + await clusterService.destroy(); + await IngressManager.close(); + config.destroy() + clock.restore(); + await echoServer.destroy(); + }); + + let clock: sinon.SinonFakeTimers; + let accountService: AccountService; + let tunnelService: TunnelService; + let account: Account | undefined; + let tunnel: Tunnel | undefined; + + beforeEach(async () => { + tunnelService = new TunnelService(); + accountService = new AccountService(); + + account = await accountService.create(); + const tunnelId = crypto.randomBytes(20).toString('hex'); + tunnel = await tunnelService.create(tunnelId, account?.id); + tunnel = await tunnelService.update(tunnel.id, account?.id, (tunnel) => { + tunnel.ingress.sni.enabled = true; + }); + + sockPair = await wsSocketPair.create(9000) + assert(sockPair != undefined); + ({client, transport} = wsmPair(sockPair)); + }); + + afterEach(async () => { + await client.destroy(); + await transport.destroy(); + await sockPair.terminate(); + await tunnelService.destroy(); + await accountService.destroy(); + account = undefined; + tunnel = undefined; + }); + + const connectTunnel = async (): Promise => { + assert(tunnel != undefined); + assert(account != undefined); + + let res = await tunnelService.connect(tunnel.id, account.id, transport, {peer: "127.0.0.1"}); + assert(res == true, "failed to connect tunnel"); + + let i = 0; + let tun: Tunnel; + do { + await clock.tickAsync(1000); + tun = await tunnelService.lookup(tunnel.id) + } while (tun.state.connected == false && i++ < 10); + assert(tun.state.connected == true, "tunnel not connected"); + } + + const forwardTo = (host: string, port: number): void => { + client.on('connection', (sock: Duplex) => { + const targetSock = new net.Socket(); + targetSock.connect({ + host, + port + }, () => { + targetSock.pipe(sock); + sock.pipe(targetSock); + }); + + const close = () => { + targetSock.unpipe(sock); + sock.unpipe(targetSock); + sock.destroy(); + targetSock.destroy(); + }; + + targetSock.on('close', close); + sock.on('close', close); + sock.on('error', () => { + close(); + }); + targetSock.on('error', () => { + close(); + }); + }); + }; + + it(`can send traffic`, async () => { + assert(tunnel != undefined); + assert(account != undefined); + + forwardTo("localhost", 20000); + await connectTunnel(); + + const url = new URL(`${tunnel.config.ingress.sni.url}`); + + const tlsSock = await new Promise((resolve: (sock: TLSSocket) => void) => { + const sock = tls.connect({ + servername: url.hostname, + host: 'localhost', + port: Number.parseInt(url.port), + checkServerIdentity: () => undefined, + rejectUnauthorized: false, + }, () => { + resolve(sock); + }); + }); + + const {status, data} = await httpRequest({ + method: 'GET', + path: '/file?size=1048576', + createConnection: () => { return tlsSock; }, + }); + + await tunnelService.disconnect(tunnel.id, account.id); + + assert(status == 200, `expected 200 status, got ${status}`); + assert(data.length == 1048576, `did not receive expected data, got data length ${data.length}`); + }); + + it(`tls handshake fails for non-connected tunnel`, async () => { + + const err = await new Promise((resolve: (result: Error | undefined) => void) => { + const sock = tls.connect({ + servername: `test.example`, + host: 'localhost', + port: 4430, + }, () => { + resolve(undefined); + }); + + sock.once('error', (err: Error) => { + resolve(err) + }); + }); + + assert(err != undefined); + assert((err).code == 'ECONNRESET'); + }); + + it(`tls handshake fails for disabled ingress`, async () => { + assert(tunnel != undefined); + assert(account != undefined); + + const url = new URL(`${tunnel.config.ingress.sni.url}`); + + tunnel = await tunnelService.update(tunnel.id, account?.id, (config) => { + config.ingress.sni.enabled = false; + }); + + forwardTo("localhost", 20000); + await connectTunnel(); + + const err = await new Promise((resolve: (result: Error | undefined) => void) => { + const sock = tls.connect({ + servername: url.hostname, + host: 'localhost', + port: Number.parseInt(url.port), + checkServerIdentity: () => undefined, + rejectUnauthorized: false, + }, () => { + resolve(undefined); + }); + + sock.once('error', (err: Error) => { + resolve(err) + }); + }); + + await tunnelService.disconnect(tunnel.id, account.id); + + assert(err != undefined); + assert((err).code == 'ECONNRESET'); + }); + + }); +}); \ No newline at end of file diff --git a/test/unit/ingress/utils.ts b/test/unit/ingress/utils.ts new file mode 100644 index 0000000..17724b0 --- /dev/null +++ b/test/unit/ingress/utils.ts @@ -0,0 +1,18 @@ +import http from 'http'; + +export const httpRequest = async (opts: http.RequestOptions, buffer?: string | undefined): Promise<{status: number | undefined, data: any}> => { + const result: {status: number | undefined, data: any} = await new Promise((resolve) => { + const req = http.request(opts, + (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('close', () => { resolve({status: res.statusCode, data})}); + }); + req.end(buffer); + }); + return result; +} \ No newline at end of file diff --git a/test/unit/listener/test_http-listener.ts b/test/unit/listener/test_http-listener.ts new file mode 100644 index 0000000..62fd40d --- /dev/null +++ b/test/unit/listener/test_http-listener.ts @@ -0,0 +1,255 @@ +import assert from "assert"; +import HttpListener, { HttpRequestCallback, HttpRequestType, HttpUpgradeCallback } from "../../../src/listener/http-listener.js"; +import Listener from "../../../src/listener/listener.js"; +import http from 'http'; +import { setTimeout } from "timers/promises"; +import { Socket } from "net"; + +describe('HTTP listener', () => { + + it(`can listen on port`, async () => { + const httpListener = Listener.acquire(HttpListener, 8080); + + const requestHandler: HttpRequestCallback = async (ctx, next): Promise => { + ctx.res.statusCode = 201; + ctx.res.end('foo') + }; + + httpListener.use(HttpRequestType.request, requestHandler); + assert(httpListener["callbacks"]["request"].length == 1); + + await httpListener.listen() + + let res = await fetch("http://localhost:8080"); + assert(res.status == 201); + + let data = await res.text(); + assert(data == 'foo'); + + httpListener.removeHandler(HttpRequestType.request, requestHandler); + assert(httpListener["callbacks"]["request"].length == 0); + + + await Listener.release(8080); + assert(httpListener["_destroyed"] == true); + + try { + await fetch("http://localhost:8080"); + assert(false, "listener is still listening"); + } catch (e:any) { + assert(true); + } + }); + + it(`destroy removes installed handlers`, async () => { + const httpListener = Listener.acquire(HttpListener, 8080); + + const requestHandler: HttpRequestCallback = async (ctx, next): Promise => { + ctx.res.statusCode = 201; + ctx.res.end('foo') + }; + + httpListener.use(HttpRequestType.request, requestHandler); + await httpListener.listen() + + await httpListener.close() + + await Listener.release(8080); + + assert(httpListener["_destroyed"] == true); + assert(httpListener["callbacks"]["request"].length == 0, "handler still installed"); + }); + + it(`listener can be acquired multiple times`, async () => { + const httpListener = Listener.acquire(HttpListener, 8080); + const httpListener2 = Listener.acquire(HttpListener, 8080); + assert(httpListener == httpListener2) + + const requestHandler: HttpRequestCallback = async (ctx, next): Promise => { + ctx.res.statusCode = 201; + ctx.res.end('foo') + }; + httpListener.use(HttpRequestType.request, requestHandler); + await httpListener.listen() + + let res = await fetch("http://localhost:8080"); + assert(res.status == 201); + let data = await res.text(); + assert(data == 'foo'); + + const requestHandler2: HttpRequestCallback = async (ctx, next): Promise => { + ctx.res.statusCode = 200; + ctx.res.end('bar') + }; + httpListener2.use(HttpRequestType.request, requestHandler2); + await httpListener2.listen() + + res = await fetch("http://localhost:8080"); + assert(res.status == 201); + data = await res.text(); + assert(data == 'foo', `got ${data}`); + + httpListener.removeHandler(HttpRequestType.request, requestHandler); + await httpListener.close(); + + res = await fetch("http://localhost:8080"); + assert(res.status == 200); + data = await res.text(); + assert(data == 'bar'); + + await Listener.release(8080); + assert(httpListener["_destroyed"] == false); + await Listener.release(8080); + assert(httpListener["_destroyed"] == true); + assert(httpListener["callbacks"]["request"].length == 0, "handler still installed"); + }); + + it(`callback can pass request to next handler`, async () => { + const httpListener = Listener.acquire(HttpListener, 8080); + const httpListener2 = Listener.acquire(HttpListener, 8080); + assert(httpListener == httpListener2) + + await Promise.all([httpListener.listen(), httpListener2.listen()]); + + const requestHandler: HttpRequestCallback = async (ctx, next): Promise => { + next(); + }; + httpListener.use(HttpRequestType.request, requestHandler); + + const requestHandler2: HttpRequestCallback = async (ctx, next): Promise => { + ctx.res.statusCode = 200; + ctx.res.end('bar') + }; + httpListener2.use(HttpRequestType.request, requestHandler2); + + let res = await fetch("http://localhost:8080"); + assert(res.status == 200); + let data = await res.text(); + assert(data == 'bar'); + + await Listener.release(8080); + assert(httpListener["_destroyed"] == false); + await Listener.release(8080); + assert(httpListener["_destroyed"] == true); + }); + + it(`listener on different ports return different instances`, async () => { + const httpListener = Listener.acquire(HttpListener, 8080); + const httpListener2 = Listener.acquire(HttpListener, 9090); + assert(httpListener != httpListener2) + + await Promise.all([httpListener.listen(), httpListener2.listen()]); + + const requestHandler: HttpRequestCallback = async (ctx, next): Promise => { + ctx.res.statusCode = 201; + ctx.res.end('foo') + }; + httpListener.use(HttpRequestType.request, requestHandler); + + const requestHandler2: HttpRequestCallback = async (ctx, next): Promise => { + ctx.res.statusCode = 200; + ctx.res.end('bar') + }; + httpListener2.use(HttpRequestType.request, requestHandler2); + + let res = await fetch("http://localhost:8080"); + assert(res.status == 201); + let data = await res.text(); + assert(data == 'foo', `got ${data}`); + + httpListener.removeHandler(HttpRequestType.request, requestHandler); + await httpListener.close(); + + res = await fetch("http://localhost:9090"); + assert(res.status == 200); + data = await res.text(); + assert(data == 'bar'); + + await Listener.release(8080); + await Listener.release(9090); + }); + + it(`callback handlers can be added with different priorities`, async () => { + const httpListener = Listener.acquire(HttpListener, 8080); + const httpListener2 = Listener.acquire(HttpListener, 8080); + assert(httpListener == httpListener2) + + await Promise.all([httpListener.listen(), httpListener2.listen()]); + + const requestHandler: HttpRequestCallback = async (ctx, next): Promise => { + ctx.res.statusCode = 201; + ctx.res.end('foo') + }; + httpListener.use(HttpRequestType.request, requestHandler); + + const requestHandler2: HttpRequestCallback = async (ctx, next): Promise => { + ctx.res.statusCode = 200; + ctx.res.end('bar') + }; + httpListener2.use(HttpRequestType.request, {prio: 1}, requestHandler2); + + let res = await fetch("http://localhost:8080"); + assert(res.status == 200); + let data = await res.text(); + assert(data == 'bar'); + + await Listener.release(8080); + assert(httpListener["_destroyed"] == false); + await Listener.release(8080); + assert(httpListener["_destroyed"] == true); + }); + + it(`can install an upgrade handler`, async () => { + const httpListener = Listener.acquire(HttpListener, 8080); + await httpListener.listen(); + + const upgradeHandler: HttpUpgradeCallback = async (ctx, next): Promise => { + ctx.sock.write(`HTTP/${ctx.req.httpVersion} 101 ${http.STATUS_CODES[101]}\r\n`); + ctx.sock.write('Upgrade: someprotocol\r\n'); + ctx.sock.write('Connection: Upgrade\r\n'); + ctx.sock.write('\r\n'); + + ctx.sock.write("upgraded"); + ctx.sock.end(); + }; + httpListener.use(HttpRequestType.upgrade, upgradeHandler); + + const req = http.request({ + hostname: 'localhost', + port: 8080, + method: 'GET', + path: '/', + headers: { + "Host": "localhost", + "Connection": 'Upgrade', + "Upgrade": 'someprotocol', + "Origin": `http://localhost`, + } + }); + + const done = (resolve: (value: any) => void) => { + req.on('upgrade', (res, socket, head) => { + resolve(head.toString()); + }); + }; + req.end(); + + let data = await new Promise(done); + assert(data == 'upgraded'); + + httpListener.removeHandler(HttpRequestType.upgrade, upgradeHandler); + await httpListener.close(); + await Listener.release(8080); + }); + + it(`request without a handler returns 500`, async () => { + const httpListener = Listener.acquire(HttpListener, 8080); + await httpListener.listen(); + + let res = await fetch("http://localhost:8080"); + assert(res.status == 500); + + await httpListener.close(); + await Listener.release(8080); + }); +}); \ No newline at end of file diff --git a/test/unit/test-utils.ts b/test/unit/test-utils.ts index e8b89f3..2441c31 100644 --- a/test/unit/test-utils.ts +++ b/test/unit/test-utils.ts @@ -6,6 +6,7 @@ import * as url from 'node:url'; import { WebSocket, WebSocketServer } from "ws"; import ClusterService from "../../src/cluster/index.js"; import { StorageService } from "../../src/storage/index.js"; +import WebSocketTransport from "../../src/transport/ws/ws-transport.js"; import { WebSocketMultiplex } from "@exposr/ws-multiplex"; export const initStorageService = async (): Promise => { @@ -83,16 +84,19 @@ export class wsSocketPair { } } -export const wsmPair = (socketPair: wsSocketPair, options?: Object): Array => { - const wsm1 = new WebSocketMultiplex(socketPair.sock1, { - ...options, - reference: "wsm1" +export const wsmPair = (socketPair: wsSocketPair, options?: Object): {transport: WebSocketTransport, client: WebSocketMultiplex} => { + + const transport = new WebSocketTransport({ + tunnelId: "wsm1", + socket: socketPair.sock1, }); - const wsm2 = new WebSocketMultiplex(socketPair.sock2, { + + const client = new WebSocketMultiplex(socketPair.sock2, { ...options, reference: "wsm2" }); - return [wsm1, wsm2]; + + return {transport, client}; }; export const createEchoHttpServer = async (port: number = 20000, crtPath?: string | undefined, keyPath?: string | undefined) => { @@ -108,6 +112,11 @@ export const createEchoHttpServer = async (port: number = 20000, crtPath?: strin }); }; + const echoHeaders = (request: http.IncomingMessage, response: http.ServerResponse) => { + response.statusCode = 200; + response.end(JSON.stringify(request.headers, undefined, 2)); + }; + const fileGenerator = (size: number, chunkSize: number, response: http.ServerResponse) => { let sentBytes: number = 0; @@ -122,11 +131,17 @@ export const createEchoHttpServer = async (port: number = 20000, crtPath?: strin const chunkToSend = Math.min(chunkSize, remainingBytes); const buffer = Buffer.alloc(chunkToSend); - response.write(buffer); - + buffer.fill('A'); + const resp = response.write(buffer); sentBytes += chunkToSend; - setTimeout(writeChunk, 0); + if (!resp) { + response.once('drain', () => { + setTimeout(writeChunk, 0); + }); + } else { + setTimeout(writeChunk, 0); + } } else { response.end(); } @@ -160,6 +175,8 @@ export const createEchoHttpServer = async (port: number = 20000, crtPath?: strin const size = Number(parsedUrl.query["size"] || "32"); const chunkSize = Number(parsedUrl.query["chunk"] || "262144"); return fileGenerator(size, chunkSize, response); + } else if (request.method == "GET" && parsedUrl.pathname == '/headers') { + return echoHeaders(request, response); } else { return echoRequest(request, response); } @@ -177,11 +194,16 @@ export const createEchoHttpServer = async (port: number = 20000, crtPath?: strin server.on('request', handleRequest); server.on('upgrade', handleUpgrade); - server.listen(port); + await new Promise((resolve) => { + server.listen(port, () => { + resolve(undefined); + }); + }); return { destroy: async () => { await new Promise((resolve) => { server.close(resolve); + server.closeAllConnections(); server.removeAllListeners('request'); server.removeAllListeners('upgrade'); }); diff --git a/test/unit/transport/test_ssh-endpoint.ts b/test/unit/transport/test_ssh-endpoint.ts index 3c7d4e8..3f4873e 100644 --- a/test/unit/transport/test_ssh-endpoint.ts +++ b/test/unit/transport/test_ssh-endpoint.ts @@ -3,7 +3,7 @@ import Tunnel from '../../../src/tunnel/tunnel.js'; import SSHEndpoint from '../../../src/transport/ssh/ssh-endpoint.js'; import { initClusterService, initStorageService } from '../test-utils.js' import Config from '../../../src/config.js'; -import Ingress from '../../../src/ingress/index.js'; +import IngressManager from '../../../src/ingress/ingress-manager.js'; import { TunnelConfig } from '../../../src/tunnel/tunnel-config.js'; describe('ssh endpoint', () => { @@ -41,7 +41,7 @@ describe('ssh endpoint', () => { const config = new Config(); const storageService = await initStorageService(); const clusterService = initClusterService(); - const ingress = new Ingress({ + await IngressManager.listen({ http: { enabled: true, subdomainUrl: new URL("https://example.com"), @@ -61,13 +61,13 @@ describe('ssh endpoint', () => { allowInsecureTarget: true, }); const ep = endpoint.getEndpoint(tunnel, baseUrl); - endpoint.destroy(); + await endpoint.destroy(); assert(ep.url == expected, `got ${ep.url}`); await storageService.destroy(); await clusterService.destroy(); await config.destroy(); - await ingress.destroy(); + await IngressManager.close(); }); }); }); \ No newline at end of file diff --git a/test/unit/transport/test_ssh_transport.ts b/test/unit/transport/test_ssh_transport.ts index 6c05c98..7ab8a4e 100644 --- a/test/unit/transport/test_ssh_transport.ts +++ b/test/unit/transport/test_ssh_transport.ts @@ -1,6 +1,7 @@ import assert from 'assert/strict'; import crypto from 'crypto'; import net from 'net'; +import http from 'node:http'; import Config from '../../../src/config.js'; import TransportService from '../../../src/transport/transport-service.js' import { createEchoHttpServer, initStorageService } from '../test-utils.js'; @@ -9,10 +10,10 @@ import ClusterService from '../../../src/cluster/index.js'; import { StorageService } from '../../../src/storage/index.js'; import AccountService from '../../../src/account/account-service.js'; import TunnelService from '../../../src/tunnel/tunnel-service.js'; -import Ingress from '../../../src/ingress/index.js'; import Tunnel from '../../../src/tunnel/tunnel.js'; import Account from '../../../src/account/account.js'; import sinon from 'sinon'; +import IngressManager from '../../../src/ingress/ingress-manager.js'; describe('SSH transport', () => { let clock: sinon.SinonFakeTimers; @@ -22,7 +23,6 @@ describe('SSH transport', () => { let accountService: AccountService; let tunnelService: TunnelService; let echoServer: any; - let ingress: Ingress; let account: Account; let tunnel: Tunnel; let tunnelId: string; @@ -34,16 +34,12 @@ describe('SSH transport', () => { ]); storageservice = await initStorageService(); clusterservice = new ClusterService('mem', {}); - ingress = await new Promise((resolve, reject) => { - const i = new Ingress({ - callback: (e: any) => { - e ? reject(e) : resolve(i) }, - http: { - enabled: true, - subdomainUrl: new URL("https://example.com"), - port: 8080, - } - }); + await IngressManager.listen({ + http: { + enabled: true, + subdomainUrl: new URL("https://example.com"), + port: 8080, + } }); accountService = new AccountService(); tunnelService = new TunnelService(); @@ -58,7 +54,7 @@ describe('SSH transport', () => { afterEach(async () => { await tunnelService.destroy(); await accountService.destroy(); - await ingress.destroy(); + await IngressManager.close(); await clusterservice.destroy(); await storageservice.destroy(); await config.destroy(); @@ -149,28 +145,58 @@ describe('SSH transport', () => { }); }); - let res = await fetch("http://localhost:8080", { - method: 'POST', - headers: { - "Host": `${tunnel.id}.example.com` - }, - body: "echo" + do { + await clock.tickAsync(1000); + tunnel = await tunnelService.lookup(tunnelId); + } while (tunnel.state.connected == false); + + let {status, data}: {status: number | undefined, data: any} = await new Promise((resolve) => { + const req = http.request({ + hostname: 'localhost', + port: 8080, + method: 'POST', + path: '/', + headers: { + "Host": `${tunnel.id}.example.com` + } + }, (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('close', () => { resolve({status: res.statusCode, data})}); + }); + req.end('echo'); }); - assert(res.status == 200, "did not get response from echo server"); - let data = await res.text(); + assert(status == 200, "did not get response from echo server"); assert(data == 'echo', "did not get response from echo server"); - res = await fetch("http://localhost:8080/file?size=1048576", { - method: 'GET', - headers: { - "Host": `${tunnel.id}.example.com` - }, + let {status: status2, data: data2}: {status: number | undefined, data: any} = await new Promise((resolve) => { + const req = http.request({ + hostname: 'localhost', + port: 8080, + method: 'GET', + path: '/file?size=1048576', + headers: { + "Host": `${tunnel.id}.example.com` + } + }, (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('close', () => { resolve({status: res.statusCode, data})}); + }); + req.end(); }); - assert(res.status == 200, "did not get response from echo server"); - let data2 = await res.blob(); - assert(data2.size == 1048576, "did not receive large file") + assert(status2 == 200, `did not get 200 response from echo server, got ${status2}`); + assert(data2.length == 1048576, "did not receive large file"); conn.destroy(); await transportService.destroy(); @@ -247,16 +273,32 @@ describe('SSH transport', () => { }); }); - let res = await fetch("http://localhost:8080", { - method: 'POST', - headers: { - "Host": `${tunnel.id}.example.com` - }, - body: "echo" - }); - assert(res.status == 200, "did not get response from echo server"); - let data = await res.text(); + do { + await clock.tickAsync(1000); + tunnel = await tunnelService.lookup(tunnelId); + } while (tunnel.state.connected == false); + + let {status, data}: {status: number | undefined, data: any} = await new Promise((resolve) => { + const req = http.request({ + hostname: 'localhost', + port: 8080, + method: 'POST', + path: '/', + headers: { + "Host": `${tunnel.id}.example.com` + } + }, (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + res.on('close', () => { resolve({status: res.statusCode, data})}); + }); + req.end('echo'); + }); + assert(status == 200, `did not get 200 response from echo server, ${status}`); assert(data == 'echo', "did not get response from echo server"); conn.destroy(); @@ -352,29 +394,62 @@ describe('SSH transport', () => { transports = transportService.getTransports(tunnel2, "http://localhost"); const conn2 = await sshConn(transports.ssh!?.host, transports.ssh!?.port, transports.ssh!?.username, transports.ssh!?.password); - let res = await fetch("http://localhost:8080", { - method: 'POST', - headers: { - "Host": `${tunnel.id}.example.com` - }, - body: `${tunnel.id}` - }); - assert(res.status == 200, "did not get response from echo server"); - let data = await res.text(); + do { + await clock.tickAsync(1000); + tunnel = await tunnelService.lookup(tunnelId); + } while (tunnel.state.connected == false); - assert(data == tunnel.id, "did not get response from echo server"); + do { + await clock.tickAsync(1000); + tunnel2 = await tunnelService.lookup(tunnelId); + } while (tunnel2.state.connected == false); + + + let {status, data}: {status: number | undefined, data: any} = await new Promise((resolve) => { + const req = http.request({ + hostname: 'localhost', + port: 8080, + method: 'POST', + path: '/', + headers: { + "Host": `${tunnel.id}.example.com` + } + }, (res) => { + let data = ''; - res = await fetch("http://localhost:8080", { - method: 'POST', - headers: { - "Host": `${tunnel2.id}.example.com` - }, - body: `${tunnel2.id}` + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('close', () => { resolve({status: res.statusCode, data})}); + }); + req.end(tunnel.id); }); - assert(res.status == 200, "did not get response from echo server"); - data = await res.text(); + assert(status == 200, `did not get 200 response from echo server, ${status}`); + assert(data == tunnel.id, "did not get response from echo server"); + + let {status: status2, data: data2}: {status: number | undefined, data: any} = await new Promise((resolve) => { + const req = http.request({ + hostname: 'localhost', + port: 8080, + method: 'POST', + path: '/', + headers: { + "Host": `${tunnel.id}.example.com` + } + }, (res) => { + let data = ''; - assert(data == tunnel2.id, "did not get response from echo server"); + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('close', () => { resolve({status: res.statusCode, data})}); + }); + req.end(tunnel2.id); + }); + assert(status2 == 200, `did not get 200 response from echo server, ${status}`); + assert(data2 == tunnel2.id, "did not get response from echo server"); conn.destroy(); let clients: any; @@ -466,26 +541,33 @@ describe('SSH transport', () => { }); await readyWait; - let res = await fetch("http://localhost:8080", { - method: 'POST', - headers: { - "Host": `${tunnel.id}.example.com` - }, - body: "echo" - }); - - assert(res.status == 200, "did not get response from echo server"); - let data = await res.text(); - assert(data == 'echo', "did not get response from echo server"); + do { + await clock.tickAsync(1000); + tunnel = await tunnelService.lookup(tunnelId); + } while (tunnel.state.connected == false); + + let {status, data}: {status: number | undefined, data: any} = await new Promise((resolve) => { + const req = http.request({ + hostname: 'localhost', + port: 8080, + method: 'POST', + path: '/', + headers: { + "Host": `${tunnel.id}.example.com` + } + }, (res) => { + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); - res = await fetch("http://localhost:8080/file?size=1048576", { - method: 'GET', - headers: { - "Host": `${tunnel.id}.example.com` - }, + res.on('close', () => { resolve({status: res.statusCode, data})}); + }); + req.end('echo'); }); - assert(res.status == 200, "did not get response from echo server"); + assert(status == 200, `did not get 200 response from echo server, ${status}`); + assert(data == 'echo', "did not get response from echo server"); conn.destroy(); await transportService.destroy(); diff --git a/test/unit/transport/test_ws_transport.ts b/test/unit/transport/test_ws_transport.ts index 8e69d15..26623b2 100644 --- a/test/unit/transport/test_ws_transport.ts +++ b/test/unit/transport/test_ws_transport.ts @@ -1,6 +1,7 @@ import assert from 'assert/strict'; import crypto from 'crypto'; import net from 'net'; +import http from 'node:http'; import WebSocket from 'ws'; import Config from '../../../src/config.js'; import TransportService from '../../../src/transport/transport-service.js' @@ -9,12 +10,12 @@ import ClusterService from '../../../src/cluster/index.js'; import { StorageService } from '../../../src/storage/index.js'; import AccountService from '../../../src/account/account-service.js'; import TunnelService from '../../../src/tunnel/tunnel-service.js'; -import Ingress from '../../../src/ingress/index.js'; import Tunnel from '../../../src/tunnel/tunnel.js'; import Account from '../../../src/account/account.js'; import sinon from 'sinon'; import { WebSocketMultiplex } from '@exposr/ws-multiplex'; import { Duplex } from 'stream'; +import IngressManager from '../../../src/ingress/ingress-manager.js'; describe('WS transport', () => { let clock: sinon.SinonFakeTimers; @@ -24,7 +25,6 @@ describe('WS transport', () => { let accountService: AccountService; let tunnelService: TunnelService; let echoServer: any; - let ingress: Ingress; let account: Account; let tunnel: Tunnel; let tunnelId: string; @@ -36,16 +36,13 @@ describe('WS transport', () => { ]); storageservice = await initStorageService(); clusterservice = new ClusterService('mem', {}); - ingress = await new Promise((resolve, reject) => { - const i = new Ingress({ - callback: (e: any) => { - e ? reject(e) : resolve(i) }, - http: { - enabled: true, - subdomainUrl: new URL("https://example.com"), - port: 8080, - } - }); + + await IngressManager.listen({ + http: { + enabled: true, + subdomainUrl: new URL("https://example.com"), + port: 8080, + } }); accountService = new AccountService(); tunnelService = new TunnelService(); @@ -60,7 +57,7 @@ describe('WS transport', () => { afterEach(async () => { await tunnelService.destroy(); await accountService.destroy(); - await ingress.destroy(); + await IngressManager.close(); await clusterservice.destroy(); await storageservice.destroy(); await config.destroy(); @@ -137,29 +134,60 @@ describe('WS transport', () => { }); }); - let res = await fetch("http://localhost:8080", { - method: 'POST', - headers: { - "Host": `${tunnel.id}.example.com` - }, - body: "echo" + do { + await clock.tickAsync(1000); + tunnel = await tunnelService.lookup(tunnelId); + } while (tunnel.state.connected == false); + + let {status, data}: {status: number | undefined, data: any} = await new Promise((resolve) => { + const req = http.request({ + hostname: 'localhost', + port: 8080, + method: 'POST', + path: '/', + headers: { + "Host": `${tunnel.id}.example.com` + } + }, (res) => { + let data = ''; + + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('close', () => { resolve({status: res.statusCode, data})}); + }); + req.end('echo'); }); - assert(res.status == 200, "did not get response from echo server"); - let data = await res.text(); + + assert(status == 200, `did not get 200 response from echo server, ${status}`); assert(data == 'echo', "did not get response from echo server"); - res = await fetch("http://localhost:8080/file?size=1048576", { - method: 'GET', - headers: { - "Host": `${tunnel.id}.example.com` - }, - }); - assert(res.status == 200, "did not get response from echo server"); + let {status: status2, data: data2}: {status: number | undefined, data: any} = await new Promise((resolve) => { + const req = http.request({ + hostname: 'localhost', + port: 8080, + method: 'GET', + path: '/file?size=1048576', + headers: { + "Host": `${tunnel.id}.example.com` + } + }, (res) => { + let data = ''; - let data2 = await res.blob(); - assert(data2.size == 1048576, "did not receive large file") + res.on('data', (chunk) => { + data += chunk; + }); + + res.on('close', () => { resolve({status: res.statusCode, data})}); + }); + req.end(); + }); ws.close(); await transportService.destroy(); + + assert(status2 == 200, `did not get 200 response from echo server, got ${status2}`); + assert(data2.length == 1048576, "did not receive large file"); }); }); \ No newline at end of file diff --git a/test/unit/tunnel/test_tunnel-service.js b/test/unit/tunnel/test_tunnel-service.js index 89fde77..12380cd 100644 --- a/test/unit/tunnel/test_tunnel-service.js +++ b/test/unit/tunnel/test_tunnel-service.js @@ -5,20 +5,19 @@ 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'; -import Ingress from '../../../src/ingress/index.js'; import AccountService from '../../../src/account/account-service.js'; 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'; +import IngressManager from '../../../src/ingress/ingress-manager.js'; describe('tunnel service', () => { let clock; let config; let storageservice; let clusterservice; - let ingress; let accountService; beforeEach(async () => { @@ -27,18 +26,13 @@ describe('tunnel service', () => { storageservice = await initStorageService(); clusterservice = new ClusterService('mem', {}); - ingress = await new Promise((resolve, reject) => { - const i = new Ingress({ - callback: (e) => { - e ? reject(e) : resolve(i) }, - http: { - enabled: true, - subdomainUrl: new URL("https://example.com"), - port: 8080, - } - }); + await IngressManager.listen({ + http: { + enabled: true, + subdomainUrl: new URL("https://example.com"), + port: 8080, + } }); - assert(ingress instanceof Ingress); accountService = new AccountService(); }); @@ -47,7 +41,7 @@ describe('tunnel service', () => { await storageservice.destroy(); await clusterservice.destroy(); await accountService.destroy(); - await ingress.destroy(); + await IngressManager.close(); clock.restore(); sinon.restore(); })