From 37299abcc9233017af60b78e58e7ebed1f1c504d Mon Sep 17 00:00:00 2001 From: Asher Date: Mon, 23 Mar 2020 12:07:23 -0500 Subject: [PATCH 01/20] Minor startup code improvements - Add type to HTTP options. - Fix certificate message always saying it was generated. - Dedent output not directly related to the HTTP server. - Remove unnecessary comma. --- src/node/entry.ts | 49 ++++++++++++++++++++++------------------------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/src/node/entry.ts b/src/node/entry.ts index a784338f..81e40a21 100644 --- a/src/node/entry.ts +++ b/src/node/entry.ts @@ -9,7 +9,7 @@ import { StaticHttpProvider } from "./app/static" import { UpdateHttpProvider } from "./app/update" import { VscodeHttpProvider } from "./app/vscode" import { Args, optionDescriptions, parse } from "./cli" -import { AuthType, HttpServer } from "./http" +import { AuthType, HttpServer, HttpServerOptions } from "./http" import { SshProvider } from "./ssh/server" import { generateCertificate, generatePassword, generateSshHostKey, hash, open } from "./util" import { ipcMain, wrap } from "./wrapper" @@ -36,38 +36,25 @@ const main = async (args: Args): Promise => { const originalPassword = auth === AuthType.Password && (process.env.PASSWORD || (await generatePassword())) // Spawn the main HTTP server. - const options = { + const options: HttpServerOptions = { auth, - cert: args.cert ? args.cert.value : undefined, - certKey: args["cert-key"], - sshHostKey: args["ssh-host-key"], commit, host: args.host || (args.auth === AuthType.Password && typeof args.cert !== "undefined" ? "0.0.0.0" : "localhost"), password: originalPassword ? hash(originalPassword) : undefined, port: typeof args.port !== "undefined" ? args.port : process.env.PORT ? parseInt(process.env.PORT, 10) : 8080, socket: args.socket, + ...(args.cert && !args.cert.value + ? await generateCertificate() + : { + cert: args.cert && args.cert.value, + certKey: args["cert-key"], + }), } - if (!options.cert && args.cert) { - const { cert, certKey } = await generateCertificate() - options.cert = cert - options.certKey = certKey - } else if (args.cert && !args["cert-key"]) { + if (options.cert && !options.certKey) { throw new Error("--cert-key is missing") } - if (!args["disable-ssh"]) { - if (!options.sshHostKey && typeof options.sshHostKey !== "undefined") { - throw new Error("--ssh-host-key cannot be blank") - } else if (!options.sshHostKey) { - try { - options.sshHostKey = await generateSshHostKey() - } catch (error) { - logger.error("Unable to start SSH server", field("error", error.message)) - } - } - } - const httpServer = new HttpServer(options) const vscode = httpServer.registerHttpProvider("/", VscodeHttpProvider, args) const api = httpServer.registerHttpProvider("/api", ApiHttpProvider, httpServer, vscode, args["user-data-dir"]) @@ -84,7 +71,7 @@ const main = async (args: Args): Promise => { if (auth === AuthType.Password && !process.env.PASSWORD) { logger.info(` - Password is ${originalPassword}`) - logger.info(" - To use your own password, set the PASSWORD environment variable") + logger.info(" - To use your own password set the PASSWORD environment variable") if (!args.auth) { logger.info(" - To disable use `--auth none`") } @@ -96,7 +83,7 @@ const main = async (args: Args): Promise => { if (httpServer.protocol === "https") { logger.info( - typeof args.cert === "string" + args.cert && args.cert.value ? ` - Using provided certificate and key for HTTPS` : ` - Using generated certificate and key for HTTPS`, ) @@ -106,9 +93,18 @@ const main = async (args: Args): Promise => { logger.info(`Automatic updates are ${update.enabled ? "enabled" : "disabled"}`) + let sshHostKey = args["ssh-host-key"] + if (!args["disable-ssh"] && !sshHostKey) { + try { + sshHostKey = await generateSshHostKey() + } catch (error) { + logger.error("Unable to start SSH server", field("error", error.message)) + } + } + let sshPort: number | undefined - if (!args["disable-ssh"] && options.sshHostKey) { - const sshProvider = httpServer.registerHttpProvider("/ssh", SshProvider, options.sshHostKey as string) + if (!args["disable-ssh"] && sshHostKey) { + const sshProvider = httpServer.registerHttpProvider("/ssh", SshProvider, sshHostKey) try { sshPort = await sshProvider.listen() } catch (error) { @@ -118,6 +114,7 @@ const main = async (args: Args): Promise => { if (typeof sshPort !== "undefined") { logger.info(`SSH server listening on localhost:${sshPort}`) + logger.info(" - To disable use `--disable-ssh`") } else { logger.info("SSH server disabled") } From 13534fa0c0aa3d5e917919e97dc3f2730b9b453e Mon Sep 17 00:00:00 2001 From: Asher Date: Mon, 23 Mar 2020 12:08:50 -0500 Subject: [PATCH 02/20] Add proxy-domain flag This will be used for proxying ports. --- src/node/cli.ts | 2 ++ src/node/entry.ts | 18 ++++++++++++++++++ src/node/http.ts | 1 + test/cli.test.ts | 16 ++++++++++++++++ 4 files changed, 37 insertions(+) diff --git a/src/node/cli.ts b/src/node/cli.ts index 23006147..8feaf982 100644 --- a/src/node/cli.ts +++ b/src/node/cli.ts @@ -39,6 +39,7 @@ export interface Args extends VsArgs { readonly "install-extension"?: string[] readonly "show-versions"?: boolean readonly "uninstall-extension"?: string[] + readonly "proxy-domain"?: string[] readonly locale?: string readonly _: string[] } @@ -111,6 +112,7 @@ const options: Options> = { "install-extension": { type: "string[]", description: "Install or update a VS Code extension by id or vsix." }, "uninstall-extension": { type: "string[]", description: "Uninstall a VS Code extension by id." }, "show-versions": { type: "boolean", description: "Show VS Code extension versions." }, + "proxy-domain": { type: "string[]", description: "Domain used for proxying ports." }, locale: { type: "string" }, log: { type: LogLevel }, diff --git a/src/node/entry.ts b/src/node/entry.ts index 81e40a21..ed5d41ea 100644 --- a/src/node/entry.ts +++ b/src/node/entry.ts @@ -35,6 +35,14 @@ const main = async (args: Args): Promise => { const auth = args.auth || AuthType.Password const originalPassword = auth === AuthType.Password && (process.env.PASSWORD || (await generatePassword())) + /** + * Domains can be in the form `coder.com` or `*.coder.com`. Either way, + * `[number].coder.com` will be proxied to `number`. + */ + const normalizeProxyDomains = (domains?: string[]): string[] => { + return domains ? domains.map((d) => d.replace(/^\*\./, "")).filter((d, i) => domains.indexOf(d) === i) : [] + } + // Spawn the main HTTP server. const options: HttpServerOptions = { auth, @@ -42,6 +50,7 @@ const main = async (args: Args): Promise => { host: args.host || (args.auth === AuthType.Password && typeof args.cert !== "undefined" ? "0.0.0.0" : "localhost"), password: originalPassword ? hash(originalPassword) : undefined, port: typeof args.port !== "undefined" ? args.port : process.env.PORT ? parseInt(process.env.PORT, 10) : 8080, + proxyDomains: normalizeProxyDomains(args["proxy-domain"]), socket: args.socket, ...(args.cert && !args.cert.value ? await generateCertificate() @@ -91,6 +100,15 @@ const main = async (args: Args): Promise => { logger.info(" - Not serving HTTPS") } + if (options.proxyDomains && options.proxyDomains.length === 1) { + logger.info(` - Proxying *.${options.proxyDomains[0]}`) + } else if (options.proxyDomains && options.proxyDomains.length > 1) { + logger.info(" - Proxying the following domains:") + options.proxyDomains.forEach((domain) => { + logger.info(` - *.${domain}`) + }) + } + logger.info(`Automatic updates are ${update.enabled ? "enabled" : "disabled"}`) let sshHostKey = args["ssh-host-key"] diff --git a/src/node/http.ts b/src/node/http.ts index 06b7a167..dd25ec72 100644 --- a/src/node/http.ts +++ b/src/node/http.ts @@ -99,6 +99,7 @@ export interface HttpServerOptions { readonly commit?: string readonly host?: string readonly password?: string + readonly proxyDomains?: string[] readonly port?: number readonly socket?: string } diff --git a/test/cli.test.ts b/test/cli.test.ts index 9de3900e..aab12684 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -117,6 +117,7 @@ describe("cli", () => { assert.throws(() => parse(["--auth=", "--log=debug"]), /--auth requires a value/) assert.throws(() => parse(["--auth", "--log"]), /--auth requires a value/) assert.throws(() => parse(["--auth", "--invalid"]), /--auth requires a value/) + assert.throws(() => parse(["--ssh-host-key"]), /--ssh-host-key requires a value/) }) it("should error if value is invalid", () => { @@ -160,4 +161,19 @@ describe("cli", () => { auth: "none", }) }) + + it("should support repeatable flags", () => { + assert.deepEqual(parse(["--proxy-domain", "*.coder.com"]), { + _: [], + "extensions-dir": path.join(xdgLocalDir, "extensions"), + "user-data-dir": xdgLocalDir, + "proxy-domain": ["*.coder.com"], + }) + assert.deepEqual(parse(["--proxy-domain", "*.coder.com", "--proxy-domain", "test.com"]), { + _: [], + "extensions-dir": path.join(xdgLocalDir, "extensions"), + "user-data-dir": xdgLocalDir, + "proxy-domain": ["*.coder.com", "test.com"], + }) + }) }) From 77ad73d57953c83f00fb56712641595f75cc513b Mon Sep 17 00:00:00 2001 From: Asher Date: Mon, 23 Mar 2020 12:48:10 -0500 Subject: [PATCH 03/20] Set domain on cookie This allows it to be used in subdomains. --- src/node/http.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/node/http.ts b/src/node/http.ts index dd25ec72..a45bc73f 100644 --- a/src/node/http.ts +++ b/src/node/http.ts @@ -526,9 +526,12 @@ export class HttpServer { "Set-Cookie": [ `${payload.cookie.key}=${payload.cookie.value}`, `Path=${normalize(payload.cookie.path || "/", true)}`, + request.headers.host ? `Domain=${request.headers.host}` : undefined, // "HttpOnly", "SameSite=strict", - ].join(";"), + ] + .filter((l) => !!l) + .join(";"), } : {}), ...payload.headers, From 90fd1f7dd1fe4a4b778ecc7ae3531943830a69e5 Mon Sep 17 00:00:00 2001 From: Asher Date: Mon, 23 Mar 2020 13:47:01 -0500 Subject: [PATCH 04/20] Add proxy provider It'll be able to handle /proxy requests as well as subdomains. --- src/node/app/proxy.ts | 83 +++++++++++++++++++++++++++++++++++++++++++ src/node/entry.ts | 30 ++++++++-------- src/node/http.ts | 20 +++++++++-- 3 files changed, 116 insertions(+), 17 deletions(-) create mode 100644 src/node/app/proxy.ts diff --git a/src/node/app/proxy.ts b/src/node/app/proxy.ts new file mode 100644 index 00000000..757d0083 --- /dev/null +++ b/src/node/app/proxy.ts @@ -0,0 +1,83 @@ +import * as http from "http" +import { HttpCode, HttpError } from "../../common/http" +import { AuthType, HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http" + +/** + * Proxy HTTP provider. + */ +export class ProxyHttpProvider extends HttpProvider { + public constructor(options: HttpProviderOptions, private readonly proxyDomains: string[]) { + super(options) + } + + public async handleRequest(route: Route): Promise { + if (this.options.auth !== AuthType.Password || route.requestPath !== "/index.html") { + throw new HttpError("Not found", HttpCode.NotFound) + } + const payload = this.proxy(route.base.replace(/^\//, "")) + if (!payload) { + throw new HttpError("Not found", HttpCode.NotFound) + } + return payload + } + + public async getRoot(route: Route, error?: Error): Promise { + const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/login.html") + response.content = response.content.replace(/{{ERROR}}/, error ? `
${error.message}
` : "") + return this.replaceTemplates(route, response) + } + + /** + * Return a response if the request should be proxied. Anything that ends in a + * proxy domain and has a subdomain should be proxied. The port is found in + * the top-most subdomain. + * + * For example, if the proxy domain is `coder.com` then `8080.coder.com` and + * `test.8080.coder.com` will both proxy to `8080` but `8080.test.coder.com` + * will have an error because `test` isn't a port. If the proxy domain was + * `test.coder.com` then it would work. + */ + public maybeProxy(request: http.IncomingMessage): HttpResponse | undefined { + const host = request.headers.host + if (!host || !this.proxyDomains) { + return undefined + } + + const proxyDomain = this.proxyDomains.find((d) => host.endsWith(d)) + if (!proxyDomain) { + return undefined + } + + const proxyDomainLength = proxyDomain.split(".").length + const portStr = host + .split(".") + .slice(0, -proxyDomainLength) + .pop() + + if (!portStr) { + return undefined + } + + return this.proxy(portStr) + } + + private proxy(portStr: string): HttpResponse { + if (!portStr) { + return { + code: HttpCode.BadRequest, + content: "Port must be provided", + } + } + const port = parseInt(portStr, 10) + if (isNaN(port)) { + return { + code: HttpCode.BadRequest, + content: `"${portStr}" is not a valid number`, + } + } + return { + code: HttpCode.Ok, + content: `will proxy this to ${port}`, + } + } +} diff --git a/src/node/entry.ts b/src/node/entry.ts index ed5d41ea..4e667293 100644 --- a/src/node/entry.ts +++ b/src/node/entry.ts @@ -5,6 +5,7 @@ import { CliMessage } from "../../lib/vscode/src/vs/server/ipc" import { ApiHttpProvider } from "./app/api" import { DashboardHttpProvider } from "./app/dashboard" import { LoginHttpProvider } from "./app/login" +import { ProxyHttpProvider } from "./app/proxy" import { StaticHttpProvider } from "./app/static" import { UpdateHttpProvider } from "./app/update" import { VscodeHttpProvider } from "./app/vscode" @@ -35,14 +36,6 @@ const main = async (args: Args): Promise => { const auth = args.auth || AuthType.Password const originalPassword = auth === AuthType.Password && (process.env.PASSWORD || (await generatePassword())) - /** - * Domains can be in the form `coder.com` or `*.coder.com`. Either way, - * `[number].coder.com` will be proxied to `number`. - */ - const normalizeProxyDomains = (domains?: string[]): string[] => { - return domains ? domains.map((d) => d.replace(/^\*\./, "")).filter((d, i) => domains.indexOf(d) === i) : [] - } - // Spawn the main HTTP server. const options: HttpServerOptions = { auth, @@ -50,7 +43,6 @@ const main = async (args: Args): Promise => { host: args.host || (args.auth === AuthType.Password && typeof args.cert !== "undefined" ? "0.0.0.0" : "localhost"), password: originalPassword ? hash(originalPassword) : undefined, port: typeof args.port !== "undefined" ? args.port : process.env.PORT ? parseInt(process.env.PORT, 10) : 8080, - proxyDomains: normalizeProxyDomains(args["proxy-domain"]), socket: args.socket, ...(args.cert && !args.cert.value ? await generateCertificate() @@ -64,13 +56,23 @@ const main = async (args: Args): Promise => { throw new Error("--cert-key is missing") } + /** + * Domains can be in the form `coder.com` or `*.coder.com`. Either way, + * `[number].coder.com` will be proxied to `number`. + */ + const proxyDomains = args["proxy-domain"] + ? args["proxy-domain"].map((d) => d.replace(/^\*\./, "")).filter((d, i, arr) => arr.indexOf(d) === i) + : [] + const httpServer = new HttpServer(options) const vscode = httpServer.registerHttpProvider("/", VscodeHttpProvider, args) const api = httpServer.registerHttpProvider("/api", ApiHttpProvider, httpServer, vscode, args["user-data-dir"]) const update = httpServer.registerHttpProvider("/update", UpdateHttpProvider, !args["disable-updates"]) + const proxy = httpServer.registerHttpProvider("/proxy", ProxyHttpProvider, proxyDomains) httpServer.registerHttpProvider("/login", LoginHttpProvider) httpServer.registerHttpProvider("/static", StaticHttpProvider) httpServer.registerHttpProvider("/dashboard", DashboardHttpProvider, api, update) + httpServer.registerProxy(proxy) ipcMain().onDispose(() => httpServer.dispose()) @@ -100,13 +102,11 @@ const main = async (args: Args): Promise => { logger.info(" - Not serving HTTPS") } - if (options.proxyDomains && options.proxyDomains.length === 1) { - logger.info(` - Proxying *.${options.proxyDomains[0]}`) - } else if (options.proxyDomains && options.proxyDomains.length > 1) { + if (proxyDomains.length === 1) { + logger.info(` - Proxying *.${proxyDomains[0]}`) + } else if (proxyDomains && proxyDomains.length > 1) { logger.info(" - Proxying the following domains:") - options.proxyDomains.forEach((domain) => { - logger.info(` - *.${domain}`) - }) + proxyDomains.forEach((domain) => logger.info(` - *.${domain}`)) } logger.info(`Automatic updates are ${update.enabled ? "enabled" : "disabled"}`) diff --git a/src/node/http.ts b/src/node/http.ts index a45bc73f..49621693 100644 --- a/src/node/http.ts +++ b/src/node/http.ts @@ -99,7 +99,6 @@ export interface HttpServerOptions { readonly commit?: string readonly host?: string readonly password?: string - readonly proxyDomains?: string[] readonly port?: number readonly socket?: string } @@ -395,6 +394,10 @@ export interface HttpProvider3 { new (options: HttpProviderOptions, a1: A1, a2: A2, a3: A3): T } +export interface HttpProxyProvider { + maybeProxy(request: http.IncomingMessage): HttpResponse | undefined +} + /** * An HTTP server. Its main role is to route incoming HTTP requests to the * appropriate provider for that endpoint then write out the response. It also @@ -407,6 +410,7 @@ export class HttpServer { private readonly providers = new Map() private readonly heart: Heart private readonly socketProvider = new SocketProxyProvider() + private proxy?: HttpProxyProvider public constructor(private readonly options: HttpServerOptions) { this.heart = new Heart(path.join(xdgLocalDir, "heartbeat"), async () => { @@ -481,6 +485,14 @@ export class HttpServer { return p } + /** + * Register a provider as a proxy. It will be consulted before any other + * provider. + */ + public registerProxy(proxy: HttpProxyProvider): void { + this.proxy = proxy + } + /** * Start listening on the specified port. */ @@ -551,8 +563,12 @@ export class HttpServer { response.end() } } + try { - const payload = this.maybeRedirect(request, route) || (await route.provider.handleRequest(route, request)) + const payload = + (this.proxy && this.proxy.maybeProxy(request)) || + this.maybeRedirect(request, route) || + (await route.provider.handleRequest(route, request)) if (!payload) { throw new HttpError("Not found", HttpCode.NotFound) } From 3a98d856a50f4013be066c270a1118cb37623553 Mon Sep 17 00:00:00 2001 From: Asher Date: Mon, 23 Mar 2020 14:26:47 -0500 Subject: [PATCH 05/20] Handle authentication with proxy The cookie will be set for the proxy domain so it'll work for all of its subdomains. --- src/node/app/proxy.ts | 60 ++++++++++++++++++++++--------------------- src/node/entry.ts | 18 ++++--------- src/node/http.ts | 32 +++++++++++++++++++++-- 3 files changed, 66 insertions(+), 44 deletions(-) diff --git a/src/node/app/proxy.ts b/src/node/app/proxy.ts index 757d0083..dd8f6bda 100644 --- a/src/node/app/proxy.ts +++ b/src/node/app/proxy.ts @@ -1,50 +1,52 @@ import * as http from "http" import { HttpCode, HttpError } from "../../common/http" -import { AuthType, HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http" +import { HttpProvider, HttpProviderOptions, HttpProxyProvider, HttpResponse, Route } from "../http" /** * Proxy HTTP provider. */ -export class ProxyHttpProvider extends HttpProvider { - public constructor(options: HttpProviderOptions, private readonly proxyDomains: string[]) { +export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider { + public readonly proxyDomains: string[] + + public constructor(options: HttpProviderOptions, proxyDomains: string[] = []) { super(options) + this.proxyDomains = proxyDomains.map((d) => d.replace(/^\*\./, "")).filter((d, i, arr) => arr.indexOf(d) === i) } - public async handleRequest(route: Route): Promise { - if (this.options.auth !== AuthType.Password || route.requestPath !== "/index.html") { - throw new HttpError("Not found", HttpCode.NotFound) + public async handleRequest(route: Route, request: http.IncomingMessage): Promise { + if (!this.authenticated(request)) { + if (route.requestPath === "/index.html") { + return { redirect: "/login", query: { to: route.fullPath } } + } + throw new HttpError("Unauthorized", HttpCode.Unauthorized) } + const payload = this.proxy(route.base.replace(/^\//, "")) - if (!payload) { - throw new HttpError("Not found", HttpCode.NotFound) + if (payload) { + return payload } - return payload + + throw new HttpError("Not found", HttpCode.NotFound) } - public async getRoot(route: Route, error?: Error): Promise { - const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/login.html") - response.content = response.content.replace(/{{ERROR}}/, error ? `
${error.message}
` : "") - return this.replaceTemplates(route, response) - } - - /** - * Return a response if the request should be proxied. Anything that ends in a - * proxy domain and has a subdomain should be proxied. The port is found in - * the top-most subdomain. - * - * For example, if the proxy domain is `coder.com` then `8080.coder.com` and - * `test.8080.coder.com` will both proxy to `8080` but `8080.test.coder.com` - * will have an error because `test` isn't a port. If the proxy domain was - * `test.coder.com` then it would work. - */ - public maybeProxy(request: http.IncomingMessage): HttpResponse | undefined { - const host = request.headers.host + public getProxyDomain(host?: string): string | undefined { if (!host || !this.proxyDomains) { return undefined } - const proxyDomain = this.proxyDomains.find((d) => host.endsWith(d)) - if (!proxyDomain) { + return this.proxyDomains.find((d) => host.endsWith(d)) + } + + public maybeProxy(request: http.IncomingMessage): HttpResponse | 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)) { + return undefined + } + + const host = request.headers.host + const proxyDomain = this.getProxyDomain(host) + if (!host || !proxyDomain) { return undefined } diff --git a/src/node/entry.ts b/src/node/entry.ts index 4e667293..e4f59834 100644 --- a/src/node/entry.ts +++ b/src/node/entry.ts @@ -56,19 +56,11 @@ const main = async (args: Args): Promise => { throw new Error("--cert-key is missing") } - /** - * Domains can be in the form `coder.com` or `*.coder.com`. Either way, - * `[number].coder.com` will be proxied to `number`. - */ - const proxyDomains = args["proxy-domain"] - ? args["proxy-domain"].map((d) => d.replace(/^\*\./, "")).filter((d, i, arr) => arr.indexOf(d) === i) - : [] - const httpServer = new HttpServer(options) const vscode = httpServer.registerHttpProvider("/", VscodeHttpProvider, args) const api = httpServer.registerHttpProvider("/api", ApiHttpProvider, httpServer, vscode, args["user-data-dir"]) const update = httpServer.registerHttpProvider("/update", UpdateHttpProvider, !args["disable-updates"]) - const proxy = httpServer.registerHttpProvider("/proxy", ProxyHttpProvider, proxyDomains) + const proxy = httpServer.registerHttpProvider("/proxy", ProxyHttpProvider, args["proxy-domain"]) httpServer.registerHttpProvider("/login", LoginHttpProvider) httpServer.registerHttpProvider("/static", StaticHttpProvider) httpServer.registerHttpProvider("/dashboard", DashboardHttpProvider, api, update) @@ -102,11 +94,11 @@ const main = async (args: Args): Promise => { logger.info(" - Not serving HTTPS") } - if (proxyDomains.length === 1) { - logger.info(` - Proxying *.${proxyDomains[0]}`) - } else if (proxyDomains && proxyDomains.length > 1) { + if (proxy.proxyDomains.length === 1) { + logger.info(` - Proxying *.${proxy.proxyDomains[0]}`) + } else if (proxy.proxyDomains.length > 1) { logger.info(" - Proxying the following domains:") - proxyDomains.forEach((domain) => logger.info(` - *.${domain}`)) + proxy.proxyDomains.forEach((domain) => logger.info(` - *.${domain}`)) } logger.info(`Automatic updates are ${update.enabled ? "enabled" : "disabled"}`) diff --git a/src/node/http.ts b/src/node/http.ts index 49621693..4303ae02 100644 --- a/src/node/http.ts +++ b/src/node/http.ts @@ -395,7 +395,29 @@ export interface HttpProvider3 { } export interface HttpProxyProvider { + /** + * Return a response if the request should be proxied. Anything that ends in a + * proxy domain and has a subdomain should be proxied. The port is found in + * the top-most subdomain. + * + * For example, if the proxy domain is `coder.com` then `8080.coder.com` and + * `test.8080.coder.com` will both proxy to `8080` but `8080.test.coder.com` + * will have an error because `test` isn't a port. If the proxy domain was + * `test.coder.com` then it would work. + */ maybeProxy(request: http.IncomingMessage): HttpResponse | undefined + + /** + * Get the matching proxy domain based on the provided host. + */ + getProxyDomain(host: string): string | undefined + + /** + * Domains can be provided in the form `coder.com` or `*.coder.com`. Either + * way, `.coder.com` will be proxied to `number`. The domains are + * stored here without the `*.`. + */ + readonly proxyDomains: string[] } /** @@ -538,7 +560,13 @@ export class HttpServer { "Set-Cookie": [ `${payload.cookie.key}=${payload.cookie.value}`, `Path=${normalize(payload.cookie.path || "/", true)}`, - request.headers.host ? `Domain=${request.headers.host}` : undefined, + // Set the cookie against the host so it can be used in + // subdomains. Use a matching proxy domain if possible so + // requests to any of those subdomains will already be + // authenticated. + request.headers.host + ? `Domain=${(this.proxy && this.proxy.getProxyDomain(request.headers.host)) || request.headers.host}` + : undefined, // "HttpOnly", "SameSite=strict", ] @@ -566,8 +594,8 @@ export class HttpServer { try { const payload = - (this.proxy && this.proxy.maybeProxy(request)) || 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) From 2086648c874173ea649cee70e56b392515619cd2 Mon Sep 17 00:00:00 2001 From: Asher Date: Mon, 23 Mar 2020 14:51:58 -0500 Subject: [PATCH 06/20] Only handle exact domain matches This simplifies the logic a bit. --- src/node/app/proxy.ts | 39 +++++++++++++++++++++++---------------- src/node/http.ts | 29 +++++++++-------------------- 2 files changed, 32 insertions(+), 36 deletions(-) diff --git a/src/node/app/proxy.ts b/src/node/app/proxy.ts index dd8f6bda..912d94e1 100644 --- a/src/node/app/proxy.ts +++ b/src/node/app/proxy.ts @@ -6,8 +6,15 @@ import { HttpProvider, HttpProviderOptions, HttpProxyProvider, HttpResponse, Rou * Proxy HTTP provider. */ export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider { + /** + * Proxy domains are stored here without the leading `*.` + */ public readonly proxyDomains: string[] + /** + * Domains can be provided in the form `coder.com` or `*.coder.com`. Either + * way, `.coder.com` will be proxied to `number`. + */ public constructor(options: HttpProviderOptions, proxyDomains: string[] = []) { super(options) this.proxyDomains = proxyDomains.map((d) => d.replace(/^\*\./, "")).filter((d, i, arr) => arr.indexOf(d) === i) @@ -29,12 +36,14 @@ export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider throw new HttpError("Not found", HttpCode.NotFound) } - public getProxyDomain(host?: string): string | undefined { - if (!host || !this.proxyDomains) { - return undefined - } - - return this.proxyDomains.find((d) => host.endsWith(d)) + public getCookieDomain(host: string): string { + let current: string | undefined + this.proxyDomains.forEach((domain) => { + if (host.endsWith(domain) && (!current || domain.length < current.length)) { + current = domain + } + }) + return current || host } public maybeProxy(request: http.IncomingMessage): HttpResponse | undefined { @@ -44,23 +53,21 @@ export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider return undefined } + // At minimum there needs to be sub.domain.tld. const host = request.headers.host - const proxyDomain = this.getProxyDomain(host) - if (!host || !proxyDomain) { + const parts = host && host.split(".") + if (!parts || parts.length < 3) { return undefined } - const proxyDomainLength = proxyDomain.split(".").length - const portStr = host - .split(".") - .slice(0, -proxyDomainLength) - .pop() - - if (!portStr) { + // There must be an exact match. + const port = parts.shift() + const proxyDomain = parts.join(".") + if (!port || !this.proxyDomains.includes(proxyDomain)) { return undefined } - return this.proxy(portStr) + return this.proxy(port) } private proxy(portStr: string): HttpResponse { diff --git a/src/node/http.ts b/src/node/http.ts index 4303ae02..411e3ada 100644 --- a/src/node/http.ts +++ b/src/node/http.ts @@ -397,27 +397,20 @@ export interface HttpProvider3 { export interface HttpProxyProvider { /** * Return a response if the request should be proxied. Anything that ends in a - * proxy domain and has a subdomain should be proxied. The port is found in - * the top-most subdomain. + * proxy domain and has a *single* subdomain should be proxied. Anything else + * should return `undefined` and will be handled as normal. * - * For example, if the proxy domain is `coder.com` then `8080.coder.com` and - * `test.8080.coder.com` will both proxy to `8080` but `8080.test.coder.com` - * will have an error because `test` isn't a port. If the proxy domain was - * `test.coder.com` then it would work. + * 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 /** - * Get the matching proxy domain based on the provided host. + * Get the domain that should be used for setting a cookie. This will allow + * the user to authenticate only once. This will return the highest level + * domain (e.g. `coder.com` over `test.coder.com` if both are specified). */ - getProxyDomain(host: string): string | undefined - - /** - * Domains can be provided in the form `coder.com` or `*.coder.com`. Either - * way, `.coder.com` will be proxied to `number`. The domains are - * stored here without the `*.`. - */ - readonly proxyDomains: string[] + getCookieDomain(host: string): string | undefined } /** @@ -560,12 +553,8 @@ export class HttpServer { "Set-Cookie": [ `${payload.cookie.key}=${payload.cookie.value}`, `Path=${normalize(payload.cookie.path || "/", true)}`, - // Set the cookie against the host so it can be used in - // subdomains. Use a matching proxy domain if possible so - // requests to any of those subdomains will already be - // authenticated. request.headers.host - ? `Domain=${(this.proxy && this.proxy.getProxyDomain(request.headers.host)) || request.headers.host}` + ? `Domain=${(this.proxy && this.proxy.getCookieDomain(request.headers.host)) || request.headers.host}` : undefined, // "HttpOnly", "SameSite=strict", From 8aa5675ba2668602ddb09d4a942607e7bdd8a5f7 Mon Sep 17 00:00:00 2001 From: Asher Date: Mon, 23 Mar 2020 18:02:31 -0500 Subject: [PATCH 07/20] Implement the actual proxy --- package.json | 4 +- src/node/app/api.ts | 3 +- src/node/app/dashboard.ts | 3 +- src/node/app/login.ts | 3 +- src/node/app/proxy.ts | 97 +++++++++++++++++++++++++++++++++------ src/node/app/update.ts | 3 +- src/node/app/vscode.ts | 3 +- src/node/http.ts | 56 ++++++++++++++++++---- yarn.lock | 35 +++++++++++++- 9 files changed, 177 insertions(+), 30 deletions(-) diff --git a/package.json b/package.json index 7856054e..842aed16 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 78375fb6..ce3d1b80 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 21721495..ea0b2b33 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 598b13ab..c67a8714 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 912d94e1..e069d2e6 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 9ae64e5c..02766808 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 5759213c..7d9406a2 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 411e3ada..5b62eef6 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 e14037d7..07042359 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" From c0dd29c59183461c3f2fbb32813690346a08bb6a Mon Sep 17 00:00:00 2001 From: Asher Date: Tue, 24 Mar 2020 14:29:48 -0500 Subject: [PATCH 08/20] Fix domains with ports & localhost subdomains --- src/node/app/proxy.ts | 15 ++++++++------- src/node/http.ts | 7 ++++--- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/node/app/proxy.ts b/src/node/app/proxy.ts index e069d2e6..7b79d96f 100644 --- a/src/node/app/proxy.ts +++ b/src/node/app/proxy.ts @@ -61,7 +61,9 @@ export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider current = domain } }) - return current || host + // Setting the domain to localhost doesn't seem to work for subdomains (for + // example dev.localhost). + return current && current !== "localhost" ? current : host } public maybeProxyRequest( @@ -90,12 +92,11 @@ export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider return undefined } - // At minimum there needs to be sub.domain.tld. - const host = request.headers.host - const parts = host && host.split(".") - if (!parts || parts.length < 3) { - return undefined - } + // Split into parts. + const host = request.headers.host || "" + const idx = host.indexOf(":") + const domain = idx !== -1 ? host.substring(0, idx) : host + const parts = domain.split(".") // There must be an exact match. const port = parts.shift() diff --git a/src/node/http.ts b/src/node/http.ts index 5b62eef6..52503308 100644 --- a/src/node/http.ts +++ b/src/node/http.ts @@ -581,6 +581,9 @@ export class HttpServer { this.heart.beat() const route = this.parseUrl(request) const write = (payload: HttpResponse): void => { + const host = request.headers.host || "" + const idx = host.indexOf(":") + const domain = idx !== -1 ? host.substring(0, idx) : host response.writeHead(payload.redirect ? HttpCode.Redirect : payload.code || HttpCode.Ok, { "Content-Type": payload.mime || getMediaMime(payload.filePath), ...(payload.redirect ? { Location: this.constructRedirect(request, route, payload as RedirectResponse) } : {}), @@ -591,9 +594,7 @@ export class HttpServer { "Set-Cookie": [ `${payload.cookie.key}=${payload.cookie.value}`, `Path=${normalize(payload.cookie.path || "/", true)}`, - request.headers.host - ? `Domain=${(this.proxy && this.proxy.getCookieDomain(request.headers.host)) || request.headers.host}` - : undefined, + domain ? `Domain=${(this.proxy && this.proxy.getCookieDomain(domain)) || domain}` : undefined, // "HttpOnly", "SameSite=strict", ] From 737a8f5965b2150f86d120135bef26fb6e3e6f48 Mon Sep 17 00:00:00 2001 From: Asher Date: Tue, 24 Mar 2020 16:34:31 -0500 Subject: [PATCH 09/20] Catch proxy errors Otherwise they'll crash code-server. --- src/node/app/proxy.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/node/app/proxy.ts b/src/node/app/proxy.ts index 7b79d96f..8f551244 100644 --- a/src/node/app/proxy.ts +++ b/src/node/app/proxy.ts @@ -1,3 +1,4 @@ +import { logger } from "@coder/logger" import * as http from "http" import proxy from "http-proxy" import * as net from "net" @@ -21,6 +22,7 @@ export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider public constructor(options: HttpProviderOptions, proxyDomains: string[] = []) { super(options) this.proxyDomains = proxyDomains.map((d) => d.replace(/^\*\./, "")).filter((d, i, arr) => arr.indexOf(d) === i) + this.proxy.on("error", (error) => logger.warn(error.message)) } public async handleRequest( From e68d72c4d6dbee4d1c72a928a5051bbd24099b7e Mon Sep 17 00:00:00 2001 From: Asher Date: Mon, 30 Mar 2020 18:59:29 -0500 Subject: [PATCH 10/20] Add documentation for proxying --- doc/FAQ.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/doc/FAQ.md b/doc/FAQ.md index 0fd08732..8dbd12f0 100644 --- a/doc/FAQ.md +++ b/doc/FAQ.md @@ -65,6 +65,26 @@ only to HTTP requests. You can use [Let's Encrypt](https://letsencrypt.org/) to get an SSL certificate for free. +## How do I access web services? +code-server is capable of proxying to any port using either a subdomain or a +subpath. + +### Sub-domains +Set up a wildcard certificate for your domain and a wildcard DNS entry (or you +can configure each subdomain individually for the ports you expect to use). + +Start code-server with the `--proxy-domain` flag set to your domain. + +``` +code-server --proxy-domain coder.com +``` + +Now you can browse to `.coder.com`. Note that this uses the host header so +ensure your reverse proxy forwards that information if you are using one. + +### Sub-paths +Just browse to `/proxy/`. + ## x86 releases? node has dropped support for x86 and so we decided to as well. See From 561b6343c8cc478f6997728a538374db221fdd8b Mon Sep 17 00:00:00 2001 From: Asher Date: Tue, 31 Mar 2020 12:59:07 -0500 Subject: [PATCH 11/20] Ensure a trailing slash on subpath proxy --- doc/FAQ.md | 2 +- src/node/app/proxy.ts | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/doc/FAQ.md b/doc/FAQ.md index 8dbd12f0..73a43007 100644 --- a/doc/FAQ.md +++ b/doc/FAQ.md @@ -83,7 +83,7 @@ Now you can browse to `.coder.com`. Note that this uses the host header so ensure your reverse proxy forwards that information if you are using one. ### Sub-paths -Just browse to `/proxy/`. +Just browse to `/proxy//`. ## x86 releases? diff --git a/src/node/app/proxy.ts b/src/node/app/proxy.ts index 8f551244..80aa7a7b 100644 --- a/src/node/app/proxy.ts +++ b/src/node/app/proxy.ts @@ -30,15 +30,24 @@ export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider request: http.IncomingMessage, response: http.ServerResponse, ): Promise { + const isRoot = !route.requestPath || route.requestPath === "/index.html" if (!this.authenticated(request)) { // Only redirect from the root. Other requests get an unauthorized error. - if (route.requestPath && route.requestPath !== "/index.html") { - throw new HttpError("Unauthorized", HttpCode.Unauthorized) + if (isRoot) { + return { redirect: "/login", query: { to: route.fullPath } } } - return { redirect: "/login", query: { to: route.fullPath } } + throw new HttpError("Unauthorized", HttpCode.Unauthorized) } - const payload = this.doProxy(route.requestPath, request, response, route.base.replace(/^\//, "")) + // Ensure there is a trailing slash so relative paths work correctly. + const base = route.base.replace(/^\//, "") + if (isRoot && !route.originalPath.endsWith("/")) { + return { + redirect: `/proxy/${base}/`, + } + } + + const payload = this.doProxy(route.requestPath, request, response, base) if (payload) { return payload } From fd339a74333e63f6ea9aa9ec5c0945e35ec1d9dd Mon Sep 17 00:00:00 2001 From: Asher Date: Tue, 31 Mar 2020 13:11:35 -0500 Subject: [PATCH 12/20] Include query parameters when proxying --- doc/FAQ.md | 3 +++ src/node/app/proxy.ts | 16 +++++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/doc/FAQ.md b/doc/FAQ.md index 73a43007..29e5c505 100644 --- a/doc/FAQ.md +++ b/doc/FAQ.md @@ -66,10 +66,12 @@ You can use [Let's Encrypt](https://letsencrypt.org/) to get an SSL certificate for free. ## How do I access web services? + code-server is capable of proxying to any port using either a subdomain or a subpath. ### Sub-domains + Set up a wildcard certificate for your domain and a wildcard DNS entry (or you can configure each subdomain individually for the ports you expect to use). @@ -83,6 +85,7 @@ Now you can browse to `.coder.com`. Note that this uses the host header so ensure your reverse proxy forwards that information if you are using one. ### Sub-paths + Just browse to `/proxy//`. ## x86 releases? diff --git a/src/node/app/proxy.ts b/src/node/app/proxy.ts index 80aa7a7b..1afdd1ce 100644 --- a/src/node/app/proxy.ts +++ b/src/node/app/proxy.ts @@ -2,6 +2,7 @@ import { logger } from "@coder/logger" import * as http from "http" import proxy from "http-proxy" import * as net from "net" +import * as querystring from "querystring" import { HttpCode, HttpError } from "../../common/http" import { HttpProvider, HttpProviderOptions, HttpProxyProvider, HttpResponse, Route } from "../http" @@ -47,7 +48,7 @@ export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider } } - const payload = this.doProxy(route.requestPath, request, response, base) + const payload = this.doProxy(route.requestPath, route.query, request, response, base) if (payload) { return payload } @@ -62,7 +63,7 @@ export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider head: Buffer, ): Promise { this.ensureAuthenticated(request) - this.doProxy(route.requestPath, request, socket, head, route.base.replace(/^\//, "")) + this.doProxy(route.requestPath, route.query, request, socket, head, route.base.replace(/^\//, "")) } public getCookieDomain(host: string): string { @@ -83,7 +84,7 @@ export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider response: http.ServerResponse, ): HttpResponse | undefined { const port = this.getPort(request) - return port ? this.doProxy(route.fullPath, request, response, port) : undefined + return port ? this.doProxy(route.fullPath, route.query, request, response, port) : undefined } public maybeProxyWebSocket( @@ -93,7 +94,7 @@ export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider head: Buffer, ): HttpResponse | undefined { const port = this.getPort(request) - return port ? this.doProxy(route.fullPath, request, socket, head, port) : undefined + return port ? this.doProxy(route.fullPath, route.query, request, socket, head, port) : undefined } private getPort(request: http.IncomingMessage): string | undefined { @@ -121,12 +122,14 @@ export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider private doProxy( path: string, + query: querystring.ParsedUrlQuery, request: http.IncomingMessage, response: http.ServerResponse, portStr: string, ): HttpResponse private doProxy( path: string, + query: querystring.ParsedUrlQuery, request: http.IncomingMessage, socket: net.Socket, head: Buffer, @@ -134,6 +137,7 @@ export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider ): HttpResponse private doProxy( path: string, + query: querystring.ParsedUrlQuery, request: http.IncomingMessage, responseOrSocket: http.ServerResponse | net.Socket, headOrPortStr: Buffer | string, @@ -159,7 +163,9 @@ export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider autoRewrite: true, changeOrigin: true, ignorePath: true, - target: `http://127.0.0.1:${port}${path}`, + target: `http://127.0.0.1:${port}${path}${ + Object.keys(query).length > 0 ? `?${querystring.stringify(query)}` : "" + }`, } if (responseOrSocket instanceof net.Socket) { From e7e7b0ffb7c8273125c9661aeae04ffb85fcdd5a Mon Sep 17 00:00:00 2001 From: Asher Date: Tue, 31 Mar 2020 14:56:01 -0500 Subject: [PATCH 13/20] Fix redirects through subpath proxy --- src/browser/pages/home.html | 2 +- src/node/app/proxy.ts | 79 +++++++++++++++++++++---------------- src/node/http.ts | 8 ++-- 3 files changed, 51 insertions(+), 38 deletions(-) diff --git a/src/browser/pages/home.html b/src/browser/pages/home.html index 08643f48..542f3b6e 100644 --- a/src/browser/pages/home.html +++ b/src/browser/pages/home.html @@ -17,7 +17,7 @@ href="{{BASE}}/static/{{COMMIT}}/src/browser/media/manifest.json" crossorigin="use-credentials" /> - + diff --git a/src/node/app/proxy.ts b/src/node/app/proxy.ts index 1afdd1ce..3023ad79 100644 --- a/src/node/app/proxy.ts +++ b/src/node/app/proxy.ts @@ -6,6 +6,10 @@ import * as querystring from "querystring" import { HttpCode, HttpError } from "../../common/http" import { HttpProvider, HttpProviderOptions, HttpProxyProvider, HttpResponse, Route } from "../http" +interface Request extends http.IncomingMessage { + base?: string +} + /** * Proxy HTTP provider. */ @@ -24,6 +28,12 @@ export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider super(options) this.proxyDomains = proxyDomains.map((d) => d.replace(/^\*\./, "")).filter((d, i, arr) => arr.indexOf(d) === i) this.proxy.on("error", (error) => logger.warn(error.message)) + // Intercept the response to rewrite absolute redirects against the base path. + this.proxy.on("proxyRes", (response, request: Request) => { + if (response.headers.location && response.headers.location.startsWith("/") && request.base) { + response.headers.location = request.base + response.headers.location + } + }) } public async handleRequest( @@ -41,14 +51,15 @@ export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider } // Ensure there is a trailing slash so relative paths work correctly. - const base = route.base.replace(/^\//, "") - if (isRoot && !route.originalPath.endsWith("/")) { + const port = route.base.replace(/^\//, "") + const base = `${this.options.base}/${port}` + if (isRoot && !route.fullPath.endsWith("/")) { return { - redirect: `/proxy/${base}/`, + redirect: `${base}/`, } } - const payload = this.doProxy(route.requestPath, route.query, request, response, base) + const payload = this.doProxy(route, request, response, port, base) if (payload) { return payload } @@ -63,7 +74,9 @@ export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider head: Buffer, ): Promise { this.ensureAuthenticated(request) - this.doProxy(route.requestPath, route.query, request, socket, head, route.base.replace(/^\//, "")) + const port = route.base.replace(/^\//, "") + const base = `${this.options.base}/${port}` + this.doProxy(route, request, { socket, head }, port, base) } public getCookieDomain(host: string): string { @@ -84,7 +97,7 @@ export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider response: http.ServerResponse, ): HttpResponse | undefined { const port = this.getPort(request) - return port ? this.doProxy(route.fullPath, route.query, request, response, port) : undefined + return port ? this.doProxy(route, request, response, port) : undefined } public maybeProxyWebSocket( @@ -94,7 +107,7 @@ export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider head: Buffer, ): HttpResponse | undefined { const port = this.getPort(request) - return port ? this.doProxy(route.fullPath, route.query, request, socket, head, port) : undefined + return port ? this.doProxy(route, request, { socket, head }, port) : undefined } private getPort(request: http.IncomingMessage): string | undefined { @@ -121,57 +134,55 @@ export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider } private doProxy( - path: string, - query: querystring.ParsedUrlQuery, + route: Route, request: http.IncomingMessage, response: http.ServerResponse, portStr: string, + base?: string, ): HttpResponse private doProxy( - path: string, - query: querystring.ParsedUrlQuery, + route: Route, request: http.IncomingMessage, - socket: net.Socket, - head: Buffer, + response: { socket: net.Socket; head: Buffer }, portStr: string, + base?: string, ): HttpResponse private doProxy( - path: string, - query: querystring.ParsedUrlQuery, + route: Route, request: http.IncomingMessage, - responseOrSocket: http.ServerResponse | net.Socket, - headOrPortStr: Buffer | string, - portStr?: string, + response: http.ServerResponse | { socket: net.Socket; head: Buffer }, + portStr: string, + base?: 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`, } } + // REVIEW: Absolute redirects need to be based on the subpath but I'm not + // sure how best to get this information to the `proxyRes` event handler. + // For now I'm sticking it on the request object which is passed through to + // the event. + ;(request as Request).base = base + + const hxxp = response instanceof http.ServerResponse + const path = base ? route.fullPath.replace(base, "") : route.fullPath const options: proxy.ServerOptions = { - autoRewrite: true, changeOrigin: true, ignorePath: true, - target: `http://127.0.0.1:${port}${path}${ - Object.keys(query).length > 0 ? `?${querystring.stringify(query)}` : "" + target: `${hxxp ? "http" : "ws"}://127.0.0.1:${port}${path}${ + Object.keys(route.query).length > 0 ? `?${querystring.stringify(route.query)}` : "" }`, + ws: !hxxp, } - if (responseOrSocket instanceof net.Socket) { - this.proxy.ws(request, responseOrSocket, headOrPortStr, options) + if (response instanceof http.ServerResponse) { + this.proxy.web(request, response, options) } else { - this.proxy.web(request, responseOrSocket, options) + this.proxy.ws(request, response.socket, response.head, options) } return { handled: true } diff --git a/src/node/http.ts b/src/node/http.ts index 52503308..a3a6ed93 100644 --- a/src/node/http.ts +++ b/src/node/http.ts @@ -596,7 +596,7 @@ export class HttpServer { `Path=${normalize(payload.cookie.path || "/", true)}`, domain ? `Domain=${(this.proxy && this.proxy.getCookieDomain(domain)) || domain}` : undefined, // "HttpOnly", - "SameSite=strict", + "SameSite=lax", ] .filter((l) => !!l) .join(";"), @@ -633,9 +633,11 @@ export class HttpServer { if (error.code === "ENOENT" || error.code === "EISDIR") { e = new HttpError("Not found", HttpCode.NotFound) } - logger.debug("Request error", field("url", request.url)) - logger.debug(error.stack) const code = typeof e.code === "number" ? e.code : HttpCode.ServerError + logger.debug("Request error", field("url", request.url), field("code", code)) + if (code >= HttpCode.ServerError) { + logger.error(error.stack) + } const payload = await route.provider.getErrorRoot(route, code, code, e.message) write({ code, From 74a0bacdcf6cb4785c10c3b8c4fad80563229f95 Mon Sep 17 00:00:00 2001 From: Asher Date: Wed, 1 Apr 2020 11:05:10 -0500 Subject: [PATCH 14/20] Rename hxxp to isHttp --- src/node/app/proxy.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/node/app/proxy.ts b/src/node/app/proxy.ts index 3023ad79..2082e6ef 100644 --- a/src/node/app/proxy.ts +++ b/src/node/app/proxy.ts @@ -168,15 +168,15 @@ export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider // the event. ;(request as Request).base = base - const hxxp = response instanceof http.ServerResponse + const isHttp = response instanceof http.ServerResponse const path = base ? route.fullPath.replace(base, "") : route.fullPath const options: proxy.ServerOptions = { changeOrigin: true, ignorePath: true, - target: `${hxxp ? "http" : "ws"}://127.0.0.1:${port}${path}${ + target: `${isHttp ? "http" : "ws"}://127.0.0.1:${port}${path}${ Object.keys(route.query).length > 0 ? `?${querystring.stringify(route.query)}` : "" }`, - ws: !hxxp, + ws: !isHttp, } if (response instanceof http.ServerResponse) { From 411c61fb028f0a4714f4747c419f43f5dfc48fe8 Mon Sep 17 00:00:00 2001 From: Asher Date: Wed, 1 Apr 2020 11:28:09 -0500 Subject: [PATCH 15/20] Create helper for determining if route is the root --- src/node/app/api.ts | 3 +-- src/node/app/dashboard.ts | 3 +-- src/node/app/login.ts | 3 +-- src/node/app/proxy.ts | 6 ++---- src/node/app/update.ts | 3 +-- src/node/app/vscode.ts | 3 +-- src/node/http.ts | 8 ++++++++ 7 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/node/app/api.ts b/src/node/app/api.ts index ce3d1b80..88519ee3 100644 --- a/src/node/app/api.ts +++ b/src/node/app/api.ts @@ -43,8 +43,7 @@ export class ApiHttpProvider extends HttpProvider { public async handleRequest(route: Route, request: http.IncomingMessage): Promise { this.ensureAuthenticated(request) - // Only serve root pages. - if (route.requestPath && route.requestPath !== "/index.html") { + if (!this.isRoot(route)) { throw new HttpError("Not found", HttpCode.NotFound) } diff --git a/src/node/app/dashboard.ts b/src/node/app/dashboard.ts index ea0b2b33..261e93c5 100644 --- a/src/node/app/dashboard.ts +++ b/src/node/app/dashboard.ts @@ -20,8 +20,7 @@ export class DashboardHttpProvider extends HttpProvider { } public async handleRequest(route: Route, request: http.IncomingMessage): Promise { - // Only serve root pages. - if (route.requestPath && route.requestPath !== "/index.html") { + if (!this.isRoot(route)) { throw new HttpError("Not found", HttpCode.NotFound) } diff --git a/src/node/app/login.ts b/src/node/app/login.ts index c67a8714..b55f5503 100644 --- a/src/node/app/login.ts +++ b/src/node/app/login.ts @@ -18,8 +18,7 @@ interface LoginPayload { */ export class LoginHttpProvider extends HttpProvider { public async handleRequest(route: Route, request: http.IncomingMessage): Promise { - // Only serve root pages and only if password authentication is enabled. - if (this.options.auth !== AuthType.Password || (route.requestPath && route.requestPath !== "/index.html")) { + if (this.options.auth !== AuthType.Password || !this.isRoot(route)) { 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 2082e6ef..42e73a60 100644 --- a/src/node/app/proxy.ts +++ b/src/node/app/proxy.ts @@ -41,10 +41,8 @@ export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider request: http.IncomingMessage, response: http.ServerResponse, ): Promise { - const isRoot = !route.requestPath || route.requestPath === "/index.html" if (!this.authenticated(request)) { - // Only redirect from the root. Other requests get an unauthorized error. - if (isRoot) { + if (this.isRoot(route)) { return { redirect: "/login", query: { to: route.fullPath } } } throw new HttpError("Unauthorized", HttpCode.Unauthorized) @@ -53,7 +51,7 @@ export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider // Ensure there is a trailing slash so relative paths work correctly. const port = route.base.replace(/^\//, "") const base = `${this.options.base}/${port}` - if (isRoot && !route.fullPath.endsWith("/")) { + if (this.isRoot(route) && !route.fullPath.endsWith("/")) { return { redirect: `${base}/`, } diff --git a/src/node/app/update.ts b/src/node/app/update.ts index 02766808..d469a9ba 100644 --- a/src/node/app/update.ts +++ b/src/node/app/update.ts @@ -61,8 +61,7 @@ export class UpdateHttpProvider extends HttpProvider { this.ensureAuthenticated(request) this.ensureMethod(request) - // Only serve root pages. - if (route.requestPath && route.requestPath !== "/index.html") { + if (!this.isRoot(route)) { throw new HttpError("Not found", HttpCode.NotFound) } diff --git a/src/node/app/vscode.ts b/src/node/app/vscode.ts index 7d9406a2..79b62847 100644 --- a/src/node/app/vscode.ts +++ b/src/node/app/vscode.ts @@ -128,8 +128,7 @@ export class VscodeHttpProvider extends HttpProvider { switch (route.base) { case "/": - // Only serve this at the root. - if (route.requestPath && route.requestPath !== "/index.html") { + if (!this.isRoot(route)) { 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 a3a6ed93..1656e0a2 100644 --- a/src/node/http.ts +++ b/src/node/http.ts @@ -359,6 +359,14 @@ export abstract class HttpProvider { } return cookies as T } + + /** + * Return true if the route is for the root page. For example /base, /base/, + * or /base/index.html but not /base/path or /base/file.js. + */ + protected isRoot(route: Route): boolean { + return !route.requestPath || route.requestPath === "/index.html" + } } /** From 498becd11fbaaed4802214ba2374103448e982dd Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 2 Apr 2020 11:32:19 -0500 Subject: [PATCH 16/20] Use route.fullPath when adding trailing slash There's no need to specially construct the path. --- src/node/app/proxy.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/node/app/proxy.ts b/src/node/app/proxy.ts index 42e73a60..19812cc1 100644 --- a/src/node/app/proxy.ts +++ b/src/node/app/proxy.ts @@ -49,14 +49,14 @@ export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider } // Ensure there is a trailing slash so relative paths work correctly. - const port = route.base.replace(/^\//, "") - const base = `${this.options.base}/${port}` if (this.isRoot(route) && !route.fullPath.endsWith("/")) { return { - redirect: `${base}/`, + redirect: `${route.fullPath}/`, } } + const port = route.base.replace(/^\//, "") + const base = `${this.options.base}/${port}` const payload = this.doProxy(route, request, response, port, base) if (payload) { return payload From aaa6c279a116c22c5f06d52521de14f9aafbe588 Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 2 Apr 2020 11:49:23 -0500 Subject: [PATCH 17/20] Use Set for proxy domains --- src/node/app/proxy.ts | 6 +++--- src/node/entry.ts | 6 ++---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/node/app/proxy.ts b/src/node/app/proxy.ts index 19812cc1..35421913 100644 --- a/src/node/app/proxy.ts +++ b/src/node/app/proxy.ts @@ -17,7 +17,7 @@ export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider /** * Proxy domains are stored here without the leading `*.` */ - public readonly proxyDomains: string[] + public readonly proxyDomains: Set private readonly proxy = proxy.createProxyServer({}) /** @@ -26,7 +26,7 @@ export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider */ public constructor(options: HttpProviderOptions, proxyDomains: string[] = []) { super(options) - this.proxyDomains = proxyDomains.map((d) => d.replace(/^\*\./, "")).filter((d, i, arr) => arr.indexOf(d) === i) + this.proxyDomains = new Set(proxyDomains.map((d) => d.replace(/^\*\./, ""))) this.proxy.on("error", (error) => logger.warn(error.message)) // Intercept the response to rewrite absolute redirects against the base path. this.proxy.on("proxyRes", (response, request: Request) => { @@ -124,7 +124,7 @@ export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider // There must be an exact match. const port = parts.shift() const proxyDomain = parts.join(".") - if (!port || !this.proxyDomains.includes(proxyDomain)) { + if (!port || !this.proxyDomains.has(proxyDomain)) { return undefined } diff --git a/src/node/entry.ts b/src/node/entry.ts index e4f59834..6df34b7c 100644 --- a/src/node/entry.ts +++ b/src/node/entry.ts @@ -94,10 +94,8 @@ const main = async (args: Args): Promise => { logger.info(" - Not serving HTTPS") } - if (proxy.proxyDomains.length === 1) { - logger.info(` - Proxying *.${proxy.proxyDomains[0]}`) - } else if (proxy.proxyDomains.length > 1) { - logger.info(" - Proxying the following domains:") + if (proxy.proxyDomains.size > 0) { + logger.info(` - Proxying the following domain${proxy.proxyDomains.size === 1 ? "" : "s"}:`) proxy.proxyDomains.forEach((domain) => logger.info(` - *.${domain}`)) } From a5d1d3b90e2379c3f64232f6da58afb05ade1f75 Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 2 Apr 2020 13:09:09 -0500 Subject: [PATCH 18/20] Move proxy logic into main HTTP server This makes the code much more internally consistent (providers just return payloads, include the proxy provider). --- src/node/app/proxy.ts | 173 +++----------------------------- src/node/entry.ts | 10 +- src/node/http.ts | 223 +++++++++++++++++++++++++++++++----------- 3 files changed, 184 insertions(+), 222 deletions(-) diff --git a/src/node/app/proxy.ts b/src/node/app/proxy.ts index 35421913..eff5059c 100644 --- a/src/node/app/proxy.ts +++ b/src/node/app/proxy.ts @@ -1,46 +1,12 @@ -import { logger } from "@coder/logger" import * as http from "http" -import proxy from "http-proxy" -import * as net from "net" -import * as querystring from "querystring" import { HttpCode, HttpError } from "../../common/http" -import { HttpProvider, HttpProviderOptions, HttpProxyProvider, HttpResponse, Route } from "../http" - -interface Request extends http.IncomingMessage { - base?: string -} +import { HttpProvider, HttpResponse, Route, WsResponse } from "../http" /** * Proxy HTTP provider. */ -export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider { - /** - * Proxy domains are stored here without the leading `*.` - */ - public readonly proxyDomains: Set - private readonly proxy = proxy.createProxyServer({}) - - /** - * Domains can be provided in the form `coder.com` or `*.coder.com`. Either - * way, `.coder.com` will be proxied to `number`. - */ - public constructor(options: HttpProviderOptions, proxyDomains: string[] = []) { - super(options) - this.proxyDomains = new Set(proxyDomains.map((d) => d.replace(/^\*\./, ""))) - this.proxy.on("error", (error) => logger.warn(error.message)) - // Intercept the response to rewrite absolute redirects against the base path. - this.proxy.on("proxyRes", (response, request: Request) => { - if (response.headers.location && response.headers.location.startsWith("/") && request.base) { - response.headers.location = request.base + response.headers.location - } - }) - } - - public async handleRequest( - route: Route, - request: http.IncomingMessage, - response: http.ServerResponse, - ): Promise { +export class ProxyHttpProvider extends HttpProvider { + public async handleRequest(route: Route, request: http.IncomingMessage): Promise { if (!this.authenticated(request)) { if (this.isRoot(route)) { return { redirect: "/login", query: { to: route.fullPath } } @@ -56,133 +22,22 @@ export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider } const port = route.base.replace(/^\//, "") - const base = `${this.options.base}/${port}` - const payload = this.doProxy(route, request, response, port, base) - if (payload) { - return payload + return { + proxy: { + base: `${this.options.base}/${port}`, + port, + }, } - - throw new HttpError("Not found", HttpCode.NotFound) } - public async handleWebSocket( - route: Route, - request: http.IncomingMessage, - socket: net.Socket, - head: Buffer, - ): Promise { + public async handleWebSocket(route: Route, request: http.IncomingMessage): Promise { this.ensureAuthenticated(request) const port = route.base.replace(/^\//, "") - const base = `${this.options.base}/${port}` - this.doProxy(route, request, { socket, head }, port, base) - } - - public getCookieDomain(host: string): string { - let current: string | undefined - this.proxyDomains.forEach((domain) => { - if (host.endsWith(domain) && (!current || domain.length < current.length)) { - current = domain - } - }) - // Setting the domain to localhost doesn't seem to work for subdomains (for - // example dev.localhost). - return current && current !== "localhost" ? current : host - } - - public maybeProxyRequest( - route: Route, - request: http.IncomingMessage, - response: http.ServerResponse, - ): HttpResponse | undefined { - const port = this.getPort(request) - return port ? this.doProxy(route, 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, 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)) { - return undefined + return { + proxy: { + base: `${this.options.base}/${port}`, + port, + }, } - - // Split into parts. - const host = request.headers.host || "" - const idx = host.indexOf(":") - const domain = idx !== -1 ? host.substring(0, idx) : host - const parts = domain.split(".") - - // There must be an exact match. - const port = parts.shift() - const proxyDomain = parts.join(".") - if (!port || !this.proxyDomains.has(proxyDomain)) { - return undefined - } - - return port - } - - private doProxy( - route: Route, - request: http.IncomingMessage, - response: http.ServerResponse, - portStr: string, - base?: string, - ): HttpResponse - private doProxy( - route: Route, - request: http.IncomingMessage, - response: { socket: net.Socket; head: Buffer }, - portStr: string, - base?: string, - ): HttpResponse - private doProxy( - route: Route, - request: http.IncomingMessage, - response: http.ServerResponse | { socket: net.Socket; head: Buffer }, - portStr: string, - base?: string, - ): HttpResponse { - const port = parseInt(portStr, 10) - if (isNaN(port)) { - return { - code: HttpCode.BadRequest, - content: `"${portStr}" is not a valid number`, - } - } - - // REVIEW: Absolute redirects need to be based on the subpath but I'm not - // sure how best to get this information to the `proxyRes` event handler. - // For now I'm sticking it on the request object which is passed through to - // the event. - ;(request as Request).base = base - - const isHttp = response instanceof http.ServerResponse - const path = base ? route.fullPath.replace(base, "") : route.fullPath - const options: proxy.ServerOptions = { - changeOrigin: true, - ignorePath: true, - target: `${isHttp ? "http" : "ws"}://127.0.0.1:${port}${path}${ - Object.keys(route.query).length > 0 ? `?${querystring.stringify(route.query)}` : "" - }`, - ws: !isHttp, - } - - if (response instanceof http.ServerResponse) { - this.proxy.web(request, response, options) - } else { - this.proxy.ws(request, response.socket, response.head, options) - } - - return { handled: true } } } diff --git a/src/node/entry.ts b/src/node/entry.ts index 6df34b7c..26a235cf 100644 --- a/src/node/entry.ts +++ b/src/node/entry.ts @@ -43,6 +43,7 @@ const main = async (args: Args): Promise => { host: args.host || (args.auth === AuthType.Password && typeof args.cert !== "undefined" ? "0.0.0.0" : "localhost"), password: originalPassword ? hash(originalPassword) : undefined, port: typeof args.port !== "undefined" ? args.port : process.env.PORT ? parseInt(process.env.PORT, 10) : 8080, + proxyDomains: args["proxy-domain"], socket: args.socket, ...(args.cert && !args.cert.value ? await generateCertificate() @@ -60,11 +61,10 @@ const main = async (args: Args): Promise => { const vscode = httpServer.registerHttpProvider("/", VscodeHttpProvider, args) const api = httpServer.registerHttpProvider("/api", ApiHttpProvider, httpServer, vscode, args["user-data-dir"]) const update = httpServer.registerHttpProvider("/update", UpdateHttpProvider, !args["disable-updates"]) - const proxy = httpServer.registerHttpProvider("/proxy", ProxyHttpProvider, args["proxy-domain"]) + httpServer.registerHttpProvider("/proxy", ProxyHttpProvider) httpServer.registerHttpProvider("/login", LoginHttpProvider) httpServer.registerHttpProvider("/static", StaticHttpProvider) httpServer.registerHttpProvider("/dashboard", DashboardHttpProvider, api, update) - httpServer.registerProxy(proxy) ipcMain().onDispose(() => httpServer.dispose()) @@ -94,9 +94,9 @@ const main = async (args: Args): Promise => { logger.info(" - Not serving HTTPS") } - if (proxy.proxyDomains.size > 0) { - logger.info(` - Proxying the following domain${proxy.proxyDomains.size === 1 ? "" : "s"}:`) - proxy.proxyDomains.forEach((domain) => logger.info(` - *.${domain}`)) + if (httpServer.proxyDomains.size > 0) { + logger.info(` - Proxying the following domain${httpServer.proxyDomains.size === 1 ? "" : "s"}:`) + httpServer.proxyDomains.forEach((domain) => logger.info(` - *.${domain}`)) } logger.info(`Automatic updates are ${update.enabled ? "enabled" : "disabled"}`) diff --git a/src/node/http.ts b/src/node/http.ts index 1656e0a2..07c17767 100644 --- a/src/node/http.ts +++ b/src/node/http.ts @@ -1,6 +1,7 @@ import { field, logger } from "@coder/logger" import * as fs from "fs-extra" import * as http from "http" +import proxy from "http-proxy" import * as httpolyglot from "httpolyglot" import * as https from "https" import * as net from "net" @@ -18,6 +19,10 @@ import { getMediaMime, xdgLocalDir } from "./util" export type Cookies = { [key: string]: string[] | undefined } export type PostData = { [key: string]: string | string[] | undefined } +interface ProxyRequest extends http.IncomingMessage { + base?: string +} + interface AuthPayload extends Cookies { key?: string[] } @@ -29,6 +34,17 @@ export enum AuthType { export type Query = { [key: string]: string | string[] | undefined } +export interface ProxyOptions { + /** + * A base path to strip from from the request before proxying if necessary. + */ + base?: string + /** + * The port to proxy. + */ + port: string +} + export interface HttpResponse { /* * Whether to set cache-control headers for this response. @@ -78,9 +94,16 @@ export interface HttpResponse { */ query?: Query /** - * Indicates the request was handled and nothing else needs to be done. + * Indicates the request should be proxied. */ - handled?: boolean + proxy?: ProxyOptions +} + +export interface WsResponse { + /** + * Indicates the web socket should be proxied. + */ + proxy?: ProxyOptions } /** @@ -104,6 +127,7 @@ export interface HttpServerOptions { readonly host?: string readonly password?: string readonly port?: number + readonly proxyDomains?: string[] readonly socket?: string } @@ -156,7 +180,9 @@ export abstract class HttpProvider { } /** - * Handle web sockets on the registered endpoint. + * Handle web sockets on the registered endpoint. Normally the provider + * handles the request itself but it can return a response when necessary. The + * default is to throw a 404. */ public handleWebSocket( /* eslint-disable @typescript-eslint/no-unused-vars */ @@ -165,18 +191,14 @@ export abstract class HttpProvider { _socket: net.Socket, _head: Buffer, /* eslint-enable @typescript-eslint/no-unused-vars */ - ): Promise { + ): Promise { throw new HttpError("Not found", HttpCode.NotFound) } /** * Handle requests to the registered endpoint. */ - public abstract handleRequest( - route: Route, - request: http.IncomingMessage, - response: http.ServerResponse, - ): Promise + public abstract handleRequest(route: Route, request: http.IncomingMessage): Promise /** * Get the base relative to the provided route. For each slash we need to go @@ -288,7 +310,7 @@ export abstract class HttpProvider { * Return the provided password value if the payload contains the right * password otherwise return false. If no payload is specified use cookies. */ - protected authenticated(request: http.IncomingMessage, payload?: AuthPayload): string | boolean { + public authenticated(request: http.IncomingMessage, payload?: AuthPayload): string | boolean { switch (this.options.auth) { case AuthType.None: return true @@ -426,39 +448,6 @@ export interface HttpProvider3 { new (options: HttpProviderOptions, a1: A1, a2: A2, a3: A3): T } -export interface HttpProxyProvider { - /** - * Return a response if the request should be proxied. Anything that ends in a - * proxy domain and has a *single* subdomain should be proxied. Anything else - * should return `undefined` and will be handled as normal. - * - * 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. - */ - 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 - * the user to authenticate only once. This will return the highest level - * domain (e.g. `coder.com` over `test.coder.com` if both are specified). - */ - getCookieDomain(host: string): string | undefined -} - /** * An HTTP server. Its main role is to route incoming HTTP requests to the * appropriate provider for that endpoint then write out the response. It also @@ -471,9 +460,19 @@ export class HttpServer { private readonly providers = new Map() private readonly heart: Heart private readonly socketProvider = new SocketProxyProvider() - private proxy?: HttpProxyProvider + + /** + * Proxy domains are stored here without the leading `*.` + */ + public readonly proxyDomains: Set + + /** + * Provides the actual proxying functionality. + */ + private readonly proxy = proxy.createProxyServer({}) public constructor(private readonly options: HttpServerOptions) { + this.proxyDomains = new Set((options.proxyDomains || []).map((d) => d.replace(/^\*\./, ""))) this.heart = new Heart(path.join(xdgLocalDir, "heartbeat"), async () => { const connections = await this.getConnections() logger.trace(`${connections} active connection${plural(connections)}`) @@ -491,6 +490,13 @@ export class HttpServer { } else { this.server = http.createServer(this.onRequest) } + this.proxy.on("error", (error) => logger.warn(error.message)) + // Intercept the response to rewrite absolute redirects against the base path. + this.proxy.on("proxyRes", (response, request: ProxyRequest) => { + if (response.headers.location && response.headers.location.startsWith("/") && request.base) { + response.headers.location = request.base + response.headers.location + } + }) } public dispose(): void { @@ -546,14 +552,6 @@ export class HttpServer { return p } - /** - * Register a provider as a proxy. It will be consulted before any other - * provider. - */ - public registerProxy(proxy: HttpProxyProvider): void { - this.proxy = proxy - } - /** * Start listening on the specified port. */ @@ -602,7 +600,7 @@ export class HttpServer { "Set-Cookie": [ `${payload.cookie.key}=${payload.cookie.value}`, `Path=${normalize(payload.cookie.path || "/", true)}`, - domain ? `Domain=${(this.proxy && this.proxy.getCookieDomain(domain)) || domain}` : undefined, + domain ? `Domain=${this.getCookieDomain(domain)}` : undefined, // "HttpOnly", "SameSite=lax", ] @@ -631,9 +629,11 @@ export class HttpServer { try { const payload = this.maybeRedirect(request, route) || - (this.proxy && this.proxy.maybeProxyRequest(route, request, response)) || - (await route.provider.handleRequest(route, request, response)) - if (!payload.handled) { + (route.provider.authenticated(request) && this.maybeProxy(request)) || + (await route.provider.handleRequest(route, request)) + if (payload.proxy) { + this.doProxy(route, request, response, payload.proxy) + } else { write(payload) } } catch (error) { @@ -710,8 +710,13 @@ export class HttpServer { throw new HttpError("Not found", HttpCode.NotFound) } - if (!this.proxy || !this.proxy.maybeProxyWebSocket(route, request, socket, head)) { - await route.provider.handleWebSocket(route, request, await this.socketProvider.createProxy(socket), head) + // The socket proxy is so we can pass them to child processes (TLS sockets + // can't be transferred so we need an in-between). + const socketProxy = await this.socketProvider.createProxy(socket) + const payload = + this.maybeProxy(request) || (await route.provider.handleWebSocket(route, request, socketProxy, head)) + if (payload && payload.proxy) { + this.doProxy(route, request, { socket: socketProxy, head }, payload.proxy) } } catch (error) { socket.destroy(error) @@ -756,4 +761,106 @@ export class HttpServer { } return { base, fullPath, requestPath, query: parsedUrl.query, provider, originalPath } } + + /** + * Proxy a request to the target. + */ + private doProxy( + route: Route, + request: http.IncomingMessage, + response: http.ServerResponse, + options: ProxyOptions, + ): void + /** + * Proxy a web socket to the target. + */ + private doProxy( + route: Route, + request: http.IncomingMessage, + response: { socket: net.Socket; head: Buffer }, + options: ProxyOptions, + ): void + /** + * Proxy a request or web socket to the target. + */ + private doProxy( + route: Route, + request: http.IncomingMessage, + response: http.ServerResponse | { socket: net.Socket; head: Buffer }, + options: ProxyOptions, + ): void { + const port = parseInt(options.port, 10) + if (isNaN(port)) { + throw new HttpError(`"${options.port}" is not a valid number`, HttpCode.BadRequest) + } + + // REVIEW: Absolute redirects need to be based on the subpath but I'm not + // sure how best to get this information to the `proxyRes` event handler. + // For now I'm sticking it on the request object which is passed through to + // the event. + ;(request as ProxyRequest).base = options.base + + const isHttp = response instanceof http.ServerResponse + const path = options.base ? route.fullPath.replace(options.base, "") : route.fullPath + const proxyOptions: proxy.ServerOptions = { + changeOrigin: true, + ignorePath: true, + target: `${isHttp ? "http" : "ws"}://127.0.0.1:${port}${path}${ + Object.keys(route.query).length > 0 ? `?${querystring.stringify(route.query)}` : "" + }`, + ws: !isHttp, + } + + if (response instanceof http.ServerResponse) { + this.proxy.web(request, response, proxyOptions) + } else { + this.proxy.ws(request, response.socket, response.head, proxyOptions) + } + } + + /** + * Get the domain that should be used for setting a cookie. This will allow + * the user to authenticate only once. This will return the highest level + * domain (e.g. `coder.com` over `test.coder.com` if both are specified). + */ + private getCookieDomain(host: string): string { + let current: string | undefined + this.proxyDomains.forEach((domain) => { + if (host.endsWith(domain) && (!current || domain.length < current.length)) { + current = domain + } + }) + // Setting the domain to localhost doesn't seem to work for subdomains (for + // example dev.localhost). + return current && current !== "localhost" ? current : host + } + + /** + * Return a response if the request should be proxied. Anything that ends in a + * proxy domain and has a *single* subdomain should be proxied. Anything else + * should return `undefined` and will be handled as normal. + * + * 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. + */ + public maybeProxy(request: http.IncomingMessage): HttpResponse | undefined { + // Split into parts. + const host = request.headers.host || "" + const idx = host.indexOf(":") + const domain = idx !== -1 ? host.substring(0, idx) : host + const parts = domain.split(".") + + // There must be an exact match. + const port = parts.shift() + const proxyDomain = parts.join(".") + if (!port || !this.proxyDomains.has(proxyDomain)) { + return undefined + } + + return { + proxy: { + port, + }, + } + } } From 363cdd02df34e251f3102734a79497cd96324abb Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 2 Apr 2020 13:40:01 -0500 Subject: [PATCH 19/20] Improve proxy documentation --- doc/FAQ.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/doc/FAQ.md b/doc/FAQ.md index 29e5c505..eccfbed3 100644 --- a/doc/FAQ.md +++ b/doc/FAQ.md @@ -65,23 +65,29 @@ only to HTTP requests. You can use [Let's Encrypt](https://letsencrypt.org/) to get an SSL certificate for free. -## How do I access web services? +## How do I securely access web services? code-server is capable of proxying to any port using either a subdomain or a -subpath. +subpath which means you can securely access these services using code-server's +built-in authentication. ### Sub-domains -Set up a wildcard certificate for your domain and a wildcard DNS entry (or you -can configure each subdomain individually for the ports you expect to use). +You will need a DNS entry that points to your server for each port you want to +access. You can either set up a wildcard DNS entry for `*.` if your domain +name registrar supports it or you can create one for every port you want to +access (`3000.`, `8080.`, etc). + +You should also set up TLS certificates for these subdomains, either using a +wildcard certificate for `*.` or individual certificates for each port. Start code-server with the `--proxy-domain` flag set to your domain. ``` -code-server --proxy-domain coder.com +code-server --proxy-domain ``` -Now you can browse to `.coder.com`. Note that this uses the host header so +Now you can browse to `.`. Note that this uses the host header so ensure your reverse proxy forwards that information if you are using one. ### Sub-paths From a288351ad4b28f0ba1697a4eb8d171c51ddfd127 Mon Sep 17 00:00:00 2001 From: Asher Date: Wed, 8 Apr 2020 11:54:18 -0500 Subject: [PATCH 20/20] Respond when proxy errors Otherwise the request will just hang. --- src/node/http.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/node/http.ts b/src/node/http.ts index 07c17767..654a9d79 100644 --- a/src/node/http.ts +++ b/src/node/http.ts @@ -490,7 +490,10 @@ export class HttpServer { } else { this.server = http.createServer(this.onRequest) } - this.proxy.on("error", (error) => logger.warn(error.message)) + this.proxy.on("error", (error, _request, response) => { + response.writeHead(HttpCode.ServerError) + response.end(error.message) + }) // Intercept the response to rewrite absolute redirects against the base path. this.proxy.on("proxyRes", (response, request: ProxyRequest) => { if (response.headers.location && response.headers.location.startsWith("/") && request.base) {