Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

HTTP proxy #1453

Merged
merged 20 commits into from
Apr 8, 2020
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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"
}
3 changes: 2 additions & 1 deletion src/node/app/api.ts
Original file line number Diff line number Diff line change
@@ -43,7 +43,8 @@ export class ApiHttpProvider extends HttpProvider {

public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
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)
}

3 changes: 2 additions & 1 deletion src/node/app/dashboard.ts
Original file line number Diff line number Diff line change
@@ -20,7 +20,8 @@ export class DashboardHttpProvider extends HttpProvider {
}

public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
if (route.requestPath !== "/index.html") {
// Only serve root pages.
if (route.requestPath && route.requestPath !== "/index.html") {
throw new HttpError("Not found", HttpCode.NotFound)
}

3 changes: 2 additions & 1 deletion src/node/app/login.ts
Original file line number Diff line number Diff line change
@@ -18,7 +18,8 @@ interface LoginPayload {
*/
export class LoginHttpProvider extends HttpProvider {
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
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) {
97 changes: 83 additions & 14 deletions src/node/app/proxy.ts
Original file line number Diff line number Diff line change
@@ -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,22 +23,37 @@ export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider
this.proxyDomains = proxyDomains.map((d) => d.replace(/^\*\./, "")).filter((d, i, arr) => arr.indexOf(d) === i)
code-asher marked this conversation as resolved.
Show resolved Hide resolved
}

public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
public async handleRequest(
route: Route,
request: http.IncomingMessage,
response: http.ServerResponse,
): Promise<HttpResponse> {
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
}

throw new HttpError("Not found", HttpCode.NotFound)
}

public async handleWebSocket(
route: Route,
request: http.IncomingMessage,
socket: net.Socket,
head: Buffer,
): Promise<void> {
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 }
}
}
3 changes: 2 additions & 1 deletion src/node/app/update.ts
Original file line number Diff line number Diff line change
@@ -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)
}

3 changes: 2 additions & 1 deletion src/node/app/vscode.ts
Original file line number Diff line number Diff line change
@@ -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 } }
56 changes: 47 additions & 9 deletions src/node/http.ts
Original file line number Diff line number Diff line change
@@ -77,6 +77,10 @@ export interface HttpResponse<T = string | Buffer | object> {
* `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<HttpResponse>
public abstract handleRequest(
route: Route,
request: http.IncomingMessage,
response: http.ServerResponse,
): Promise<HttpResponse>

/**
* 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 }
}

Loading