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) }