From d47e77b18e83d370e34c5f10452f98c4911e152b Mon Sep 17 00:00:00 2001 From: Fredrik Lindberg Date: Sat, 11 Nov 2023 10:06:15 +0100 Subject: [PATCH] wip9 --- src/ingress/http-ingress.ts | 20 +-- src/tunnel/tunnel-service.ts | 29 ++-- test/unit/ingress/test_http_ingress.ts | 205 ++++++++++++++++++++++++- 3 files changed, 229 insertions(+), 25 deletions(-) diff --git a/src/ingress/http-ingress.ts b/src/ingress/http-ingress.ts index d0e8c1f..a1a24b5 100644 --- a/src/ingress/http-ingress.ts +++ b/src/ingress/http-ingress.ts @@ -119,6 +119,7 @@ export default class HttpIngress implements IngressBase { } this._agentCache.del(key); agent.destroy(); + agent.removeAllListeners(); this.logger.isDebugEnabled() && this.logger.withContext("tunnel", key).debug("http agent destroyed") }); @@ -166,7 +167,8 @@ export default class HttpIngress implements IngressBase { } } try { - return this.tunnelService.lookup(tunnelId); + const tunnel = await this.tunnelService.lookup(tunnelId); + return tunnel; } catch (e) { return false; } @@ -227,7 +229,7 @@ export default class HttpIngress implements IngressBase { 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; } @@ -318,7 +320,7 @@ export default class HttpIngress implements IngressBase { } if (!tunnel.state.connected) { - httpResponse(502, { + httpResponse(503, { error: ERROR_TUNNEL_NOT_CONNECTED, }); return true; @@ -370,10 +372,10 @@ export default class HttpIngress implements IngressBase { 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})); @@ -443,12 +445,12 @@ export default class HttpIngress implements IngressBase { statusLine = 'Too Many Requests'; msg = ERROR_TUNNEL_TRANSPORT_REQUEST_LIMIT; } else if ((err as any).code == 'ECONNRESET') { - statusCode = 503; - statusLine = 'Service Unavailable'; + 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, { diff --git a/src/tunnel/tunnel-service.ts b/src/tunnel/tunnel-service.ts index 2d502c0..1abf588 100644 --- a/src/tunnel/tunnel-service.ts +++ b/src/tunnel/tunnel-service.ts @@ -442,7 +442,7 @@ export default class TunnelService { const updateHttp = async (): Promise => { if (!this.ingressService.enabled(IngressType.INGRESS_HTTP)) { return { - enabled: this.ingressService.enabled(IngressType.INGRESS_HTTP), + enabled: false, url: undefined, urls: [], alt_names: [], @@ -475,19 +475,28 @@ export default class TunnelService { return url.href; }); - return { - enabled: this.ingressService.enabled(IngressType.INGRESS_HTTP), - url: baseUrl.href, - urls: [ - baseUrl.href, - ...altUrls, - ], - alt_names: altNames + 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)) { + if (!this.ingressService.enabled(IngressType.INGRESS_SNI) || !tunnelConfig.ingress.sni.enabled) { return { enabled: this.ingressService.enabled(IngressType.INGRESS_SNI), url: undefined, diff --git a/test/unit/ingress/test_http_ingress.ts b/test/unit/ingress/test_http_ingress.ts index efce714..fdcc649 100644 --- a/test/unit/ingress/test_http_ingress.ts +++ b/test/unit/ingress/test_http_ingress.ts @@ -4,12 +4,12 @@ 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 from "../../../src/ingress/ingress-manager.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, { IncomingMessage } from 'http'; +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'; @@ -18,6 +18,7 @@ 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'; describe('http ingress', () => { let clock: sinon.SinonFakeTimers; @@ -273,6 +274,35 @@ describe('http ingress', () => { 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); @@ -330,15 +360,15 @@ describe('http ingress', () => { } }); - const done = (resolve: (value: any) => void) => { + const wsWait = new Promise((resolve: (value: any) => void) => { req.on('upgrade', (res, socket, head) => { const body = head.subarray(2); resolve(body); }); - }; - req.end(); + req.end(); + }); - const wsRes = await new Promise(done); + const wsRes = await wsWait; assert(wsRes.equals(Buffer.from("ws echo connected")), `did not get ws echo, got ${wsRes}`); }); @@ -517,5 +547,168 @@ describe('http ingress', () => { 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