Merge pull request #1453 from cdr/proxy

HTTP proxy
This commit is contained in:
Asher 2020-04-08 12:44:29 -05:00 committed by GitHub
commit 5aded14b87
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 373 additions and 47 deletions

View File

@ -65,6 +65,35 @@ only to HTTP requests.
You can use [Let's Encrypt](https://letsencrypt.org/) to get an SSL certificate You can use [Let's Encrypt](https://letsencrypt.org/) to get an SSL certificate
for free. for free.
## How do I securely access web services?
code-server is capable of proxying to any port using either a subdomain or a
subpath which means you can securely access these services using code-server's
built-in authentication.
### Sub-domains
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 `*.<domain>` if your domain
name registrar supports it or you can create one for every port you want to
access (`3000.<domain>`, `8080.<domain>`, etc).
You should also set up TLS certificates for these subdomains, either using a
wildcard certificate for `*.<domain>` or individual certificates for each port.
Start code-server with the `--proxy-domain` flag set to your domain.
```
code-server --proxy-domain <domain>
```
Now you can browse to `<port>.<domain>`. 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/<port>/`.
## x86 releases? ## x86 releases?
node has dropped support for x86 and so we decided to as well. See node has dropped support for x86 and so we decided to as well. See

View File

@ -17,6 +17,7 @@
"devDependencies": { "devDependencies": {
"@types/adm-zip": "^0.4.32", "@types/adm-zip": "^0.4.32",
"@types/fs-extra": "^8.0.1", "@types/fs-extra": "^8.0.1",
"@types/http-proxy": "^1.17.4",
"@types/mocha": "^5.2.7", "@types/mocha": "^5.2.7",
"@types/node": "^12.12.7", "@types/node": "^12.12.7",
"@types/parcel-bundler": "^1.12.1", "@types/parcel-bundler": "^1.12.1",
@ -52,13 +53,14 @@
"@coder/logger": "1.1.11", "@coder/logger": "1.1.11",
"adm-zip": "^0.4.14", "adm-zip": "^0.4.14",
"fs-extra": "^8.1.0", "fs-extra": "^8.1.0",
"http-proxy": "^1.18.0",
"httpolyglot": "^0.1.2", "httpolyglot": "^0.1.2",
"node-pty": "^0.9.0", "node-pty": "^0.9.0",
"pem": "^1.14.2", "pem": "^1.14.2",
"safe-compare": "^1.1.4", "safe-compare": "^1.1.4",
"semver": "^7.1.3", "semver": "^7.1.3",
"tar": "^6.0.1",
"ssh2": "^0.8.7", "ssh2": "^0.8.7",
"tar": "^6.0.1",
"tar-fs": "^2.0.0", "tar-fs": "^2.0.0",
"ws": "^7.2.0" "ws": "^7.2.0"
} }

View File

@ -17,7 +17,7 @@
href="{{BASE}}/static/{{COMMIT}}/src/browser/media/manifest.json" href="{{BASE}}/static/{{COMMIT}}/src/browser/media/manifest.json"
crossorigin="use-credentials" crossorigin="use-credentials"
/> />
<link rel="apple-touch-icon" href="{{BASE}}/static/{{COMMIT}}/src/browser/media/pwa-icon-384.pnggg" /> <link rel="apple-touch-icon" href="{{BASE}}/static/{{COMMIT}}/src/browser/media/pwa-icon-384.png" />
<link href="{{BASE}}/static/{{COMMIT}}/dist/pages/app.css" rel="stylesheet" /> <link href="{{BASE}}/static/{{COMMIT}}/dist/pages/app.css" rel="stylesheet" />
<meta id="coder-options" data-settings="{{OPTIONS}}" /> <meta id="coder-options" data-settings="{{OPTIONS}}" />
</head> </head>

View File

@ -43,7 +43,7 @@ export class ApiHttpProvider extends HttpProvider {
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> { public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
this.ensureAuthenticated(request) this.ensureAuthenticated(request)
if (route.requestPath !== "/index.html") { if (!this.isRoot(route)) {
throw new HttpError("Not found", HttpCode.NotFound) throw new HttpError("Not found", HttpCode.NotFound)
} }

View File

@ -20,7 +20,7 @@ export class DashboardHttpProvider extends HttpProvider {
} }
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> { public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
if (route.requestPath !== "/index.html") { if (!this.isRoot(route)) {
throw new HttpError("Not found", HttpCode.NotFound) throw new HttpError("Not found", HttpCode.NotFound)
} }

View File

@ -18,7 +18,7 @@ interface LoginPayload {
*/ */
export class LoginHttpProvider extends HttpProvider { export class LoginHttpProvider extends HttpProvider {
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> { public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
if (this.options.auth !== AuthType.Password || route.requestPath !== "/index.html") { if (this.options.auth !== AuthType.Password || !this.isRoot(route)) {
throw new HttpError("Not found", HttpCode.NotFound) throw new HttpError("Not found", HttpCode.NotFound)
} }
switch (route.base) { switch (route.base) {

43
src/node/app/proxy.ts Normal file
View File

@ -0,0 +1,43 @@
import * as http from "http"
import { HttpCode, HttpError } from "../../common/http"
import { HttpProvider, HttpResponse, Route, WsResponse } from "../http"
/**
* Proxy HTTP provider.
*/
export class ProxyHttpProvider extends HttpProvider {
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
if (!this.authenticated(request)) {
if (this.isRoot(route)) {
return { redirect: "/login", query: { to: route.fullPath } }
}
throw new HttpError("Unauthorized", HttpCode.Unauthorized)
}
// Ensure there is a trailing slash so relative paths work correctly.
if (this.isRoot(route) && !route.fullPath.endsWith("/")) {
return {
redirect: `${route.fullPath}/`,
}
}
const port = route.base.replace(/^\//, "")
return {
proxy: {
base: `${this.options.base}/${port}`,
port,
},
}
}
public async handleWebSocket(route: Route, request: http.IncomingMessage): Promise<WsResponse> {
this.ensureAuthenticated(request)
const port = route.base.replace(/^\//, "")
return {
proxy: {
base: `${this.options.base}/${port}`,
port,
},
}
}
}

View File

@ -61,7 +61,7 @@ export class UpdateHttpProvider extends HttpProvider {
this.ensureAuthenticated(request) this.ensureAuthenticated(request)
this.ensureMethod(request) this.ensureMethod(request)
if (route.requestPath !== "/index.html") { if (!this.isRoot(route)) {
throw new HttpError("Not found", HttpCode.NotFound) throw new HttpError("Not found", HttpCode.NotFound)
} }

View File

@ -126,7 +126,7 @@ export class VscodeHttpProvider extends HttpProvider {
switch (route.base) { switch (route.base) {
case "/": case "/":
if (route.requestPath !== "/index.html") { if (!this.isRoot(route)) {
throw new HttpError("Not found", HttpCode.NotFound) throw new HttpError("Not found", HttpCode.NotFound)
} else if (!this.authenticated(request)) { } else if (!this.authenticated(request)) {
return { redirect: "/login", query: { to: this.options.base } } return { redirect: "/login", query: { to: this.options.base } }

View File

@ -39,6 +39,7 @@ export interface Args extends VsArgs {
readonly "install-extension"?: string[] readonly "install-extension"?: string[]
readonly "show-versions"?: boolean readonly "show-versions"?: boolean
readonly "uninstall-extension"?: string[] readonly "uninstall-extension"?: string[]
readonly "proxy-domain"?: string[]
readonly locale?: string readonly locale?: string
readonly _: string[] readonly _: string[]
} }
@ -111,6 +112,7 @@ const options: Options<Required<Args>> = {
"install-extension": { type: "string[]", description: "Install or update a VS Code extension by id or vsix." }, "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." }, "uninstall-extension": { type: "string[]", description: "Uninstall a VS Code extension by id." },
"show-versions": { type: "boolean", description: "Show VS Code extension versions." }, "show-versions": { type: "boolean", description: "Show VS Code extension versions." },
"proxy-domain": { type: "string[]", description: "Domain used for proxying ports." },
locale: { type: "string" }, locale: { type: "string" },
log: { type: LogLevel }, log: { type: LogLevel },

View File

@ -5,11 +5,12 @@ import { CliMessage } from "../../lib/vscode/src/vs/server/ipc"
import { ApiHttpProvider } from "./app/api" import { ApiHttpProvider } from "./app/api"
import { DashboardHttpProvider } from "./app/dashboard" import { DashboardHttpProvider } from "./app/dashboard"
import { LoginHttpProvider } from "./app/login" import { LoginHttpProvider } from "./app/login"
import { ProxyHttpProvider } from "./app/proxy"
import { StaticHttpProvider } from "./app/static" import { StaticHttpProvider } from "./app/static"
import { UpdateHttpProvider } from "./app/update" import { UpdateHttpProvider } from "./app/update"
import { VscodeHttpProvider } from "./app/vscode" import { VscodeHttpProvider } from "./app/vscode"
import { Args, optionDescriptions, parse } from "./cli" import { Args, optionDescriptions, parse } from "./cli"
import { AuthType, HttpServer } from "./http" import { AuthType, HttpServer, HttpServerOptions } from "./http"
import { SshProvider } from "./ssh/server" import { SshProvider } from "./ssh/server"
import { generateCertificate, generatePassword, generateSshHostKey, hash, open } from "./util" import { generateCertificate, generatePassword, generateSshHostKey, hash, open } from "./util"
import { ipcMain, wrap } from "./wrapper" import { ipcMain, wrap } from "./wrapper"
@ -36,42 +37,31 @@ const main = async (args: Args): Promise<void> => {
const originalPassword = auth === AuthType.Password && (process.env.PASSWORD || (await generatePassword())) const originalPassword = auth === AuthType.Password && (process.env.PASSWORD || (await generatePassword()))
// Spawn the main HTTP server. // Spawn the main HTTP server.
const options = { const options: HttpServerOptions = {
auth, auth,
cert: args.cert ? args.cert.value : undefined,
certKey: args["cert-key"],
sshHostKey: args["ssh-host-key"],
commit, commit,
host: args.host || (args.auth === AuthType.Password && typeof args.cert !== "undefined" ? "0.0.0.0" : "localhost"), host: args.host || (args.auth === AuthType.Password && typeof args.cert !== "undefined" ? "0.0.0.0" : "localhost"),
password: originalPassword ? hash(originalPassword) : undefined, password: originalPassword ? hash(originalPassword) : undefined,
port: typeof args.port !== "undefined" ? args.port : process.env.PORT ? parseInt(process.env.PORT, 10) : 8080, port: typeof args.port !== "undefined" ? args.port : process.env.PORT ? parseInt(process.env.PORT, 10) : 8080,
proxyDomains: args["proxy-domain"],
socket: args.socket, 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) { if (options.cert && !options.certKey) {
const { cert, certKey } = await generateCertificate()
options.cert = cert
options.certKey = certKey
} else if (args.cert && !args["cert-key"]) {
throw new Error("--cert-key is missing") 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 httpServer = new HttpServer(options)
const vscode = httpServer.registerHttpProvider("/", VscodeHttpProvider, args) const vscode = httpServer.registerHttpProvider("/", VscodeHttpProvider, args)
const api = httpServer.registerHttpProvider("/api", ApiHttpProvider, httpServer, vscode, args["user-data-dir"]) const api = httpServer.registerHttpProvider("/api", ApiHttpProvider, httpServer, vscode, args["user-data-dir"])
const update = httpServer.registerHttpProvider("/update", UpdateHttpProvider, !args["disable-updates"]) const update = httpServer.registerHttpProvider("/update", UpdateHttpProvider, !args["disable-updates"])
httpServer.registerHttpProvider("/proxy", ProxyHttpProvider)
httpServer.registerHttpProvider("/login", LoginHttpProvider) httpServer.registerHttpProvider("/login", LoginHttpProvider)
httpServer.registerHttpProvider("/static", StaticHttpProvider) httpServer.registerHttpProvider("/static", StaticHttpProvider)
httpServer.registerHttpProvider("/dashboard", DashboardHttpProvider, api, update) httpServer.registerHttpProvider("/dashboard", DashboardHttpProvider, api, update)
@ -84,7 +74,7 @@ const main = async (args: Args): Promise<void> => {
if (auth === AuthType.Password && !process.env.PASSWORD) { if (auth === AuthType.Password && !process.env.PASSWORD) {
logger.info(` - Password is ${originalPassword}`) 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) { if (!args.auth) {
logger.info(" - To disable use `--auth none`") logger.info(" - To disable use `--auth none`")
} }
@ -96,7 +86,7 @@ const main = async (args: Args): Promise<void> => {
if (httpServer.protocol === "https") { if (httpServer.protocol === "https") {
logger.info( logger.info(
typeof args.cert === "string" args.cert && args.cert.value
? ` - Using provided certificate and key for HTTPS` ? ` - Using provided certificate and key for HTTPS`
: ` - Using generated certificate and key for HTTPS`, : ` - Using generated certificate and key for HTTPS`,
) )
@ -104,11 +94,25 @@ const main = async (args: Args): Promise<void> => {
logger.info(" - Not serving HTTPS") logger.info(" - Not serving HTTPS")
} }
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"}`) 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 let sshPort: number | undefined
if (!args["disable-ssh"] && options.sshHostKey) { if (!args["disable-ssh"] && sshHostKey) {
const sshProvider = httpServer.registerHttpProvider("/ssh", SshProvider, options.sshHostKey as string) const sshProvider = httpServer.registerHttpProvider("/ssh", SshProvider, sshHostKey)
try { try {
sshPort = await sshProvider.listen() sshPort = await sshProvider.listen()
} catch (error) { } catch (error) {
@ -118,6 +122,7 @@ const main = async (args: Args): Promise<void> => {
if (typeof sshPort !== "undefined") { if (typeof sshPort !== "undefined") {
logger.info(`SSH server listening on localhost:${sshPort}`) logger.info(`SSH server listening on localhost:${sshPort}`)
logger.info(" - To disable use `--disable-ssh`")
} else { } else {
logger.info("SSH server disabled") logger.info("SSH server disabled")
} }

View File

@ -1,6 +1,7 @@
import { field, logger } from "@coder/logger" import { field, logger } from "@coder/logger"
import * as fs from "fs-extra" import * as fs from "fs-extra"
import * as http from "http" import * as http from "http"
import proxy from "http-proxy"
import * as httpolyglot from "httpolyglot" import * as httpolyglot from "httpolyglot"
import * as https from "https" import * as https from "https"
import * as net from "net" import * as net from "net"
@ -18,6 +19,10 @@ import { getMediaMime, xdgLocalDir } from "./util"
export type Cookies = { [key: string]: string[] | undefined } export type Cookies = { [key: string]: string[] | undefined }
export type PostData = { [key: string]: string | string[] | undefined } export type PostData = { [key: string]: string | string[] | undefined }
interface ProxyRequest extends http.IncomingMessage {
base?: string
}
interface AuthPayload extends Cookies { interface AuthPayload extends Cookies {
key?: string[] key?: string[]
} }
@ -29,6 +34,17 @@ export enum AuthType {
export type Query = { [key: string]: string | string[] | undefined } 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<T = string | Buffer | object> { export interface HttpResponse<T = string | Buffer | object> {
/* /*
* Whether to set cache-control headers for this response. * Whether to set cache-control headers for this response.
@ -77,6 +93,17 @@ export interface HttpResponse<T = string | Buffer | object> {
* `undefined` to remove a query variable. * `undefined` to remove a query variable.
*/ */
query?: Query query?: Query
/**
* Indicates the request should be proxied.
*/
proxy?: ProxyOptions
}
export interface WsResponse {
/**
* Indicates the web socket should be proxied.
*/
proxy?: ProxyOptions
} }
/** /**
@ -100,14 +127,31 @@ export interface HttpServerOptions {
readonly host?: string readonly host?: string
readonly password?: string readonly password?: string
readonly port?: number readonly port?: number
readonly proxyDomains?: string[]
readonly socket?: string readonly socket?: string
} }
export interface Route { export interface Route {
/**
* Base path part (in /test/path it would be "/test").
*/
base: string base: string
/**
* Remaining part of the route (in /test/path it would be "/path"). It can be
* blank.
*/
requestPath: string requestPath: string
/**
* Query variables included in the request.
*/
query: querystring.ParsedUrlQuery query: querystring.ParsedUrlQuery
/**
* Normalized version of `originalPath`.
*/
fullPath: string fullPath: string
/**
* Original path of the request without any modifications.
*/
originalPath: string originalPath: string
} }
@ -136,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( public handleWebSocket(
/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */
@ -145,7 +191,7 @@ export abstract class HttpProvider {
_socket: net.Socket, _socket: net.Socket,
_head: Buffer, _head: Buffer,
/* eslint-enable @typescript-eslint/no-unused-vars */ /* eslint-enable @typescript-eslint/no-unused-vars */
): Promise<void> { ): Promise<WsResponse | void> {
throw new HttpError("Not found", HttpCode.NotFound) throw new HttpError("Not found", HttpCode.NotFound)
} }
@ -264,7 +310,7 @@ export abstract class HttpProvider {
* Return the provided password value if the payload contains the right * Return the provided password value if the payload contains the right
* password otherwise return false. If no payload is specified use cookies. * 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) { switch (this.options.auth) {
case AuthType.None: case AuthType.None:
return true return true
@ -335,6 +381,14 @@ export abstract class HttpProvider {
} }
return cookies as T 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"
}
} }
/** /**
@ -407,7 +461,18 @@ export class HttpServer {
private readonly heart: Heart private readonly heart: Heart
private readonly socketProvider = new SocketProxyProvider() private readonly socketProvider = new SocketProxyProvider()
/**
* Proxy domains are stored here without the leading `*.`
*/
public readonly proxyDomains: Set<string>
/**
* Provides the actual proxying functionality.
*/
private readonly proxy = proxy.createProxyServer({})
public constructor(private readonly options: HttpServerOptions) { 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 () => { this.heart = new Heart(path.join(xdgLocalDir, "heartbeat"), async () => {
const connections = await this.getConnections() const connections = await this.getConnections()
logger.trace(`${connections} active connection${plural(connections)}`) logger.trace(`${connections} active connection${plural(connections)}`)
@ -425,6 +490,16 @@ export class HttpServer {
} else { } else {
this.server = http.createServer(this.onRequest) this.server = http.createServer(this.onRequest)
} }
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) {
response.headers.location = request.base + response.headers.location
}
})
} }
public dispose(): void { public dispose(): void {
@ -515,6 +590,9 @@ export class HttpServer {
this.heart.beat() this.heart.beat()
const route = this.parseUrl(request) const route = this.parseUrl(request)
const write = (payload: HttpResponse): void => { 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, { response.writeHead(payload.redirect ? HttpCode.Redirect : payload.code || HttpCode.Ok, {
"Content-Type": payload.mime || getMediaMime(payload.filePath), "Content-Type": payload.mime || getMediaMime(payload.filePath),
...(payload.redirect ? { Location: this.constructRedirect(request, route, payload as RedirectResponse) } : {}), ...(payload.redirect ? { Location: this.constructRedirect(request, route, payload as RedirectResponse) } : {}),
@ -525,9 +603,12 @@ export class HttpServer {
"Set-Cookie": [ "Set-Cookie": [
`${payload.cookie.key}=${payload.cookie.value}`, `${payload.cookie.key}=${payload.cookie.value}`,
`Path=${normalize(payload.cookie.path || "/", true)}`, `Path=${normalize(payload.cookie.path || "/", true)}`,
domain ? `Domain=${this.getCookieDomain(domain)}` : undefined,
// "HttpOnly", // "HttpOnly",
"SameSite=strict", "SameSite=lax",
].join(";"), ]
.filter((l) => !!l)
.join(";"),
} }
: {}), : {}),
...payload.headers, ...payload.headers,
@ -547,20 +628,27 @@ export class HttpServer {
response.end() response.end()
} }
} }
try { try {
const payload = this.maybeRedirect(request, route) || (await route.provider.handleRequest(route, request)) const payload =
if (!payload) { this.maybeRedirect(request, route) ||
throw new HttpError("Not found", HttpCode.NotFound) (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) write(payload)
}
} catch (error) { } catch (error) {
let e = error let e = error
if (error.code === "ENOENT" || error.code === "EISDIR") { if (error.code === "ENOENT" || error.code === "EISDIR") {
e = new HttpError("Not found", HttpCode.NotFound) 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 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) const payload = await route.provider.getErrorRoot(route, code, code, e.message)
write({ write({
code, code,
@ -625,7 +713,14 @@ export class HttpServer {
throw new HttpError("Not found", HttpCode.NotFound) throw new HttpError("Not found", HttpCode.NotFound)
} }
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) { } catch (error) {
socket.destroy(error) socket.destroy(error)
logger.warn(`discarding socket connection: ${error.message}`) logger.warn(`discarding socket connection: ${error.message}`)
@ -647,7 +742,6 @@ export class HttpServer {
// Happens if it's a plain `domain.com`. // Happens if it's a plain `domain.com`.
base = "/" base = "/"
} }
requestPath = requestPath || "/index.html"
return { base, requestPath } return { base, requestPath }
} }
@ -670,4 +764,106 @@ export class HttpServer {
} }
return { base, fullPath, requestPath, query: parsedUrl.query, provider, originalPath } 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,
},
}
}
} }

View File

@ -117,6 +117,7 @@ describe("cli", () => {
assert.throws(() => parse(["--auth=", "--log=debug"]), /--auth requires a value/) assert.throws(() => parse(["--auth=", "--log=debug"]), /--auth requires a value/)
assert.throws(() => parse(["--auth", "--log"]), /--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(["--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", () => { it("should error if value is invalid", () => {
@ -160,4 +161,19 @@ describe("cli", () => {
auth: "none", 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"],
})
})
}) })

View File

@ -871,6 +871,13 @@
dependencies: dependencies:
"@types/node" "*" "@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": "@types/json-schema@^7.0.3":
version "7.0.4" version "7.0.4"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.4.tgz#38fd73ddfd9b55abb1e1b2ed578cb55bd7b7d339" 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: dependencies:
ms "2.0.0" ms "2.0.0"
debug@3.2.6: debug@3.2.6, debug@^3.0.0:
version "3.2.6" version "3.2.6"
resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b"
integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==
@ -2745,6 +2752,11 @@ etag@~1.8.1:
resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= 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: events@^3.0.0:
version "3.1.0" version "3.1.0"
resolved "https://registry.yarnpkg.com/events/-/events-3.1.0.tgz#84279af1b34cb75aa88bf5ff291f6d0bd9b31a59" 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" resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.1.tgz#69e57caa8f0eacbc281d2e2cb458d46fdb449e08"
integrity sha512-a1hQMktqW9Nmqr5aktAux3JMNqaucxGcjtjWnZLHX7yyPCmlSV3M54nGYbqT8K+0GhF3NBgmJCc3ma+WOgX8Jg== 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: for-in@^1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" 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" statuses ">= 1.5.0 < 2"
toidentifier "1.0.0" 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: http-signature@~1.2.0:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" 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" resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b"
integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== 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: resolve-from@^3.0.0:
version "3.0.0" version "3.0.0"
resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748"