diff --git a/package.json b/package.json index 7856054e8..842aed16b 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "devDependencies": { "@types/adm-zip": "^0.4.32", "@types/fs-extra": "^8.0.1", + "@types/http-proxy": "^1.17.4", "@types/mocha": "^5.2.7", "@types/node": "^12.12.7", "@types/parcel-bundler": "^1.12.1", @@ -52,13 +53,14 @@ "@coder/logger": "1.1.11", "adm-zip": "^0.4.14", "fs-extra": "^8.1.0", + "http-proxy": "^1.18.0", "httpolyglot": "^0.1.2", "node-pty": "^0.9.0", "pem": "^1.14.2", "safe-compare": "^1.1.4", "semver": "^7.1.3", - "tar": "^6.0.1", "ssh2": "^0.8.7", + "tar": "^6.0.1", "tar-fs": "^2.0.0", "ws": "^7.2.0" } diff --git a/src/node/app/api.ts b/src/node/app/api.ts index 78375fb65..ce3d1b808 100644 --- a/src/node/app/api.ts +++ b/src/node/app/api.ts @@ -43,7 +43,8 @@ export class ApiHttpProvider extends HttpProvider { public async handleRequest(route: Route, request: http.IncomingMessage): Promise { this.ensureAuthenticated(request) - if (route.requestPath !== "/index.html") { + // Only serve root pages. + if (route.requestPath && route.requestPath !== "/index.html") { throw new HttpError("Not found", HttpCode.NotFound) } diff --git a/src/node/app/dashboard.ts b/src/node/app/dashboard.ts index 217214954..ea0b2b33d 100644 --- a/src/node/app/dashboard.ts +++ b/src/node/app/dashboard.ts @@ -20,7 +20,8 @@ export class DashboardHttpProvider extends HttpProvider { } public async handleRequest(route: Route, request: http.IncomingMessage): Promise { - if (route.requestPath !== "/index.html") { + // Only serve root pages. + if (route.requestPath && route.requestPath !== "/index.html") { throw new HttpError("Not found", HttpCode.NotFound) } diff --git a/src/node/app/login.ts b/src/node/app/login.ts index 598b13abe..c67a87142 100644 --- a/src/node/app/login.ts +++ b/src/node/app/login.ts @@ -18,7 +18,8 @@ interface LoginPayload { */ export class LoginHttpProvider extends HttpProvider { public async handleRequest(route: Route, request: http.IncomingMessage): Promise { - if (this.options.auth !== AuthType.Password || route.requestPath !== "/index.html") { + // Only serve root pages and only if password authentication is enabled. + if (this.options.auth !== AuthType.Password || (route.requestPath && route.requestPath !== "/index.html")) { throw new HttpError("Not found", HttpCode.NotFound) } switch (route.base) { diff --git a/src/node/app/proxy.ts b/src/node/app/proxy.ts index 912d94e19..e069d2e60 100644 --- a/src/node/app/proxy.ts +++ b/src/node/app/proxy.ts @@ -1,4 +1,6 @@ import * as http from "http" +import proxy from "http-proxy" +import * as net from "net" import { HttpCode, HttpError } from "../../common/http" import { HttpProvider, HttpProviderOptions, HttpProxyProvider, HttpResponse, Route } from "../http" @@ -10,6 +12,7 @@ export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider * Proxy domains are stored here without the leading `*.` */ public readonly proxyDomains: string[] + private readonly proxy = proxy.createProxyServer({}) /** * Domains can be provided in the form `coder.com` or `*.coder.com`. Either @@ -20,15 +23,20 @@ export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider this.proxyDomains = proxyDomains.map((d) => d.replace(/^\*\./, "")).filter((d, i, arr) => arr.indexOf(d) === i) } - public async handleRequest(route: Route, request: http.IncomingMessage): Promise { + public async handleRequest( + route: Route, + request: http.IncomingMessage, + response: http.ServerResponse, + ): Promise { if (!this.authenticated(request)) { - if (route.requestPath === "/index.html") { - return { redirect: "/login", query: { to: route.fullPath } } + // Only redirect from the root. Other requests get an unauthorized error. + if (route.requestPath && route.requestPath !== "/index.html") { + throw new HttpError("Unauthorized", HttpCode.Unauthorized) } - throw new HttpError("Unauthorized", HttpCode.Unauthorized) + return { redirect: "/login", query: { to: route.fullPath } } } - const payload = this.proxy(route.base.replace(/^\//, "")) + const payload = this.doProxy(route.requestPath, request, response, route.base.replace(/^\//, "")) if (payload) { return payload } @@ -36,6 +44,16 @@ export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider throw new HttpError("Not found", HttpCode.NotFound) } + public async handleWebSocket( + route: Route, + request: http.IncomingMessage, + socket: net.Socket, + head: Buffer, + ): Promise { + this.ensureAuthenticated(request) + this.doProxy(route.requestPath, request, socket, head, route.base.replace(/^\//, "")) + } + public getCookieDomain(host: string): string { let current: string | undefined this.proxyDomains.forEach((domain) => { @@ -46,7 +64,26 @@ export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider return current || host } - public maybeProxy(request: http.IncomingMessage): HttpResponse | undefined { + public maybeProxyRequest( + route: Route, + request: http.IncomingMessage, + response: http.ServerResponse, + ): HttpResponse | undefined { + const port = this.getPort(request) + return port ? this.doProxy(route.fullPath, request, response, port) : undefined + } + + public maybeProxyWebSocket( + route: Route, + request: http.IncomingMessage, + socket: net.Socket, + head: Buffer, + ): HttpResponse | undefined { + const port = this.getPort(request) + return port ? this.doProxy(route.fullPath, request, socket, head, port) : undefined + } + + private getPort(request: http.IncomingMessage): string | undefined { // No proxy until we're authenticated. This will cause the login page to // show as well as let our assets keep loading normally. if (!this.authenticated(request)) { @@ -67,26 +104,58 @@ export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider return undefined } - return this.proxy(port) + return port } - private proxy(portStr: string): HttpResponse { - if (!portStr) { + private doProxy( + path: string, + request: http.IncomingMessage, + response: http.ServerResponse, + portStr: string, + ): HttpResponse + private doProxy( + path: string, + request: http.IncomingMessage, + socket: net.Socket, + head: Buffer, + portStr: string, + ): HttpResponse + private doProxy( + path: string, + request: http.IncomingMessage, + responseOrSocket: http.ServerResponse | net.Socket, + headOrPortStr: Buffer | string, + portStr?: string, + ): HttpResponse { + const _portStr = typeof headOrPortStr === "string" ? headOrPortStr : portStr + if (!_portStr) { return { code: HttpCode.BadRequest, content: "Port must be provided", } } - const port = parseInt(portStr, 10) + + const port = parseInt(_portStr, 10) if (isNaN(port)) { return { code: HttpCode.BadRequest, - content: `"${portStr}" is not a valid number`, + content: `"${_portStr}" is not a valid number`, } } - return { - code: HttpCode.Ok, - content: `will proxy this to ${port}`, + + const options: proxy.ServerOptions = { + autoRewrite: true, + changeOrigin: true, + ignorePath: true, + target: `http://127.0.0.1:${port}${path}`, } + + if (responseOrSocket instanceof net.Socket) { + this.proxy.ws(request, responseOrSocket, headOrPortStr, options) + } else { + this.proxy.web(request, responseOrSocket, options) + } + + return { handled: true } } } diff --git a/src/node/app/update.ts b/src/node/app/update.ts index 9ae64e5ce..02766808f 100644 --- a/src/node/app/update.ts +++ b/src/node/app/update.ts @@ -61,7 +61,8 @@ export class UpdateHttpProvider extends HttpProvider { this.ensureAuthenticated(request) this.ensureMethod(request) - if (route.requestPath !== "/index.html") { + // Only serve root pages. + if (route.requestPath && route.requestPath !== "/index.html") { throw new HttpError("Not found", HttpCode.NotFound) } diff --git a/src/node/app/vscode.ts b/src/node/app/vscode.ts index 5759213cf..7d9406a27 100644 --- a/src/node/app/vscode.ts +++ b/src/node/app/vscode.ts @@ -128,7 +128,8 @@ export class VscodeHttpProvider extends HttpProvider { switch (route.base) { case "/": - if (route.requestPath !== "/index.html") { + // Only serve this at the root. + if (route.requestPath && route.requestPath !== "/index.html") { throw new HttpError("Not found", HttpCode.NotFound) } else if (!this.authenticated(request)) { return { redirect: "/login", query: { to: this.options.base } } diff --git a/src/node/http.ts b/src/node/http.ts index 411e3ada5..5b62eef6a 100644 --- a/src/node/http.ts +++ b/src/node/http.ts @@ -77,6 +77,10 @@ export interface HttpResponse { * `undefined` to remove a query variable. */ query?: Query + /** + * Indicates the request was handled and nothing else needs to be done. + */ + handled?: boolean } /** @@ -104,10 +108,26 @@ export interface HttpServerOptions { } export interface Route { + /** + * Base path part (in /test/path it would be "/test"). + */ base: string + /** + * Remaining part of the route (in /test/path it would be "/path"). It can be + * blank. + */ requestPath: string + /** + * Query variables included in the request. + */ query: querystring.ParsedUrlQuery + /** + * Normalized version of `originalPath`. + */ fullPath: string + /** + * Original path of the request without any modifications. + */ originalPath: string } @@ -152,7 +172,11 @@ export abstract class HttpProvider { /** * Handle requests to the registered endpoint. */ - public abstract handleRequest(route: Route, request: http.IncomingMessage): Promise + public abstract handleRequest( + route: Route, + request: http.IncomingMessage, + response: http.ServerResponse, + ): Promise /** * Get the base relative to the provided route. For each slash we need to go @@ -403,7 +427,21 @@ export interface HttpProxyProvider { * For example if `coder.com` is specified `8080.coder.com` will be proxied * but `8080.test.coder.com` and `test.8080.coder.com` will not. */ - maybeProxy(request: http.IncomingMessage): HttpResponse | undefined + maybeProxyRequest( + route: Route, + request: http.IncomingMessage, + response: http.ServerResponse, + ): HttpResponse | undefined + + /** + * Same concept as `maybeProxyRequest` but for web sockets. + */ + maybeProxyWebSocket( + route: Route, + request: http.IncomingMessage, + socket: net.Socket, + head: Buffer, + ): HttpResponse | undefined /** * Get the domain that should be used for setting a cookie. This will allow @@ -584,12 +622,11 @@ export class HttpServer { try { const payload = this.maybeRedirect(request, route) || - (this.proxy && this.proxy.maybeProxy(request)) || - (await route.provider.handleRequest(route, request)) - if (!payload) { - throw new HttpError("Not found", HttpCode.NotFound) + (this.proxy && this.proxy.maybeProxyRequest(route, request, response)) || + (await route.provider.handleRequest(route, request, response)) + if (!payload.handled) { + write(payload) } - write(payload) } catch (error) { let e = error if (error.code === "ENOENT" || error.code === "EISDIR") { @@ -662,7 +699,9 @@ export class HttpServer { throw new HttpError("Not found", HttpCode.NotFound) } - await route.provider.handleWebSocket(route, request, await this.socketProvider.createProxy(socket), head) + if (!this.proxy || !this.proxy.maybeProxyWebSocket(route, request, socket, head)) { + await route.provider.handleWebSocket(route, request, await this.socketProvider.createProxy(socket), head) + } } catch (error) { socket.destroy(error) logger.warn(`discarding socket connection: ${error.message}`) @@ -684,7 +723,6 @@ export class HttpServer { // Happens if it's a plain `domain.com`. base = "/" } - requestPath = requestPath || "/index.html" return { base, requestPath } } diff --git a/yarn.lock b/yarn.lock index e14037d72..070423591 100644 --- a/yarn.lock +++ b/yarn.lock @@ -871,6 +871,13 @@ dependencies: "@types/node" "*" +"@types/http-proxy@^1.17.4": + version "1.17.4" + resolved "https://registry.yarnpkg.com/@types/http-proxy/-/http-proxy-1.17.4.tgz#e7c92e3dbe3e13aa799440ff42e6d3a17a9d045b" + integrity sha512-IrSHl2u6AWXduUaDLqYpt45tLVCtYv7o4Z0s1KghBCDgIIS9oW5K1H8mZG/A2CfeLdEa7rTd1ACOiHBc1EMT2Q== + dependencies: + "@types/node" "*" + "@types/json-schema@^7.0.3": version "7.0.4" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.4.tgz#38fd73ddfd9b55abb1e1b2ed578cb55bd7b7d339" @@ -2240,7 +2247,7 @@ debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.9: dependencies: ms "2.0.0" -debug@3.2.6: +debug@3.2.6, debug@^3.0.0: version "3.2.6" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== @@ -2745,6 +2752,11 @@ etag@~1.8.1: resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= +eventemitter3@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.0.tgz#d65176163887ee59f386d64c82610b696a4a74eb" + integrity sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg== + events@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/events/-/events-3.1.0.tgz#84279af1b34cb75aa88bf5ff291f6d0bd9b31a59" @@ -2980,6 +2992,13 @@ flatted@^2.0.0: resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.1.tgz#69e57caa8f0eacbc281d2e2cb458d46fdb449e08" integrity sha512-a1hQMktqW9Nmqr5aktAux3JMNqaucxGcjtjWnZLHX7yyPCmlSV3M54nGYbqT8K+0GhF3NBgmJCc3ma+WOgX8Jg== +follow-redirects@^1.0.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.10.0.tgz#01f5263aee921c6a54fb91667f08f4155ce169eb" + integrity sha512-4eyLK6s6lH32nOvLLwlIOnr9zrL8Sm+OvW4pVTJNoXeGzYIkHVf+pADQi+OJ0E67hiuSLezPVPyBcIZO50TmmQ== + dependencies: + debug "^3.0.0" + for-in@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" @@ -3403,6 +3422,15 @@ http-errors@~1.7.2: statuses ">= 1.5.0 < 2" toidentifier "1.0.0" +http-proxy@^1.18.0: + version "1.18.0" + resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.0.tgz#dbe55f63e75a347db7f3d99974f2692a314a6a3a" + integrity sha512-84I2iJM/n1d4Hdgc6y2+qY5mDaz2PUVjlg9znE9byl+q0uC3DeByqBGReQu5tpLK0TAqTIXScRUV+dg7+bUPpQ== + dependencies: + eventemitter3 "^4.0.0" + follow-redirects "^1.0.0" + requires-port "^1.0.0" + http-signature@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" @@ -5894,6 +5922,11 @@ require-main-filename@^2.0.0: resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== +requires-port@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" + integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8= + resolve-from@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748"