code-server/src/node/app/proxy.ts

189 lines
5.8 KiB
TypeScript
Raw Normal View History

import { logger } from "@coder/logger"
import * as http from "http"
2020-03-24 06:02:31 +07:00
import proxy from "http-proxy"
import * as net from "net"
2020-04-01 01:11:35 +07:00
import * as querystring from "querystring"
import { HttpCode, HttpError } from "../../common/http"
import { HttpProvider, HttpProviderOptions, HttpProxyProvider, HttpResponse, Route } from "../http"
2020-04-01 02:56:01 +07:00
interface Request extends http.IncomingMessage {
base?: string
}
/**
* Proxy HTTP provider.
*/
export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider {
/**
* Proxy domains are stored here without the leading `*.`
*/
2020-04-02 23:49:23 +07:00
public readonly proxyDomains: Set<string>
2020-03-24 06:02:31 +07:00
private readonly proxy = proxy.createProxyServer({})
/**
* Domains can be provided in the form `coder.com` or `*.coder.com`. Either
* way, `<number>.coder.com` will be proxied to `number`.
*/
public constructor(options: HttpProviderOptions, proxyDomains: string[] = []) {
super(options)
2020-04-02 23:49:23 +07:00
this.proxyDomains = new Set(proxyDomains.map((d) => d.replace(/^\*\./, "")))
this.proxy.on("error", (error) => logger.warn(error.message))
2020-04-01 02:56:01 +07:00
// 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
}
})
}
2020-03-24 06:02:31 +07:00
public async handleRequest(
route: Route,
request: http.IncomingMessage,
response: http.ServerResponse,
): 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(/^\//, "")
const base = `${this.options.base}/${port}`
2020-04-01 02:56:01 +07:00
const payload = this.doProxy(route, request, response, port, base)
if (payload) {
return payload
}
throw new HttpError("Not found", HttpCode.NotFound)
}
2020-03-24 06:02:31 +07:00
public async handleWebSocket(
route: Route,
request: http.IncomingMessage,
socket: net.Socket,
head: Buffer,
): Promise<void> {
this.ensureAuthenticated(request)
2020-04-01 02:56:01 +07:00
const port = route.base.replace(/^\//, "")
const base = `${this.options.base}/${port}`
this.doProxy(route, request, { socket, head }, port, base)
2020-03-24 06:02:31 +07:00
}
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
}
2020-03-24 06:02:31 +07:00
public maybeProxyRequest(
route: Route,
request: http.IncomingMessage,
response: http.ServerResponse,
): HttpResponse | undefined {
const port = this.getPort(request)
2020-04-01 02:56:01 +07:00
return port ? this.doProxy(route, request, response, port) : undefined
2020-03-24 06:02:31 +07:00
}
public maybeProxyWebSocket(
route: Route,
request: http.IncomingMessage,
socket: net.Socket,
head: Buffer,
): HttpResponse | undefined {
const port = this.getPort(request)
2020-04-01 02:56:01 +07:00
return port ? this.doProxy(route, request, { socket, head }, port) : undefined
2020-03-24 06:02:31 +07:00
}
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
}
// 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(".")
2020-04-02 23:49:23 +07:00
if (!port || !this.proxyDomains.has(proxyDomain)) {
return undefined
}
2020-03-24 06:02:31 +07:00
return port
}
2020-03-24 06:02:31 +07:00
private doProxy(
2020-04-01 02:56:01 +07:00
route: Route,
2020-03-24 06:02:31 +07:00
request: http.IncomingMessage,
response: http.ServerResponse,
portStr: string,
2020-04-01 02:56:01 +07:00
base?: string,
2020-03-24 06:02:31 +07:00
): HttpResponse
private doProxy(
2020-04-01 02:56:01 +07:00
route: Route,
2020-03-24 06:02:31 +07:00
request: http.IncomingMessage,
2020-04-01 02:56:01 +07:00
response: { socket: net.Socket; head: Buffer },
2020-03-24 06:02:31 +07:00
portStr: string,
2020-04-01 02:56:01 +07:00
base?: string,
2020-03-24 06:02:31 +07:00
): HttpResponse
private doProxy(
2020-04-01 02:56:01 +07:00
route: Route,
2020-03-24 06:02:31 +07:00
request: http.IncomingMessage,
2020-04-01 02:56:01 +07:00
response: http.ServerResponse | { socket: net.Socket; head: Buffer },
portStr: string,
base?: string,
2020-03-24 06:02:31 +07:00
): HttpResponse {
2020-04-01 02:56:01 +07:00
const port = parseInt(portStr, 10)
if (isNaN(port)) {
return {
code: HttpCode.BadRequest,
2020-04-01 02:56:01 +07:00
content: `"${portStr}" is not a valid number`,
}
}
2020-03-24 06:02:31 +07:00
2020-04-01 02:56:01 +07:00
// 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
2020-04-01 23:05:10 +07:00
const isHttp = response instanceof http.ServerResponse
2020-04-01 02:56:01 +07:00
const path = base ? route.fullPath.replace(base, "") : route.fullPath
2020-03-24 06:02:31 +07:00
const options: proxy.ServerOptions = {
changeOrigin: true,
ignorePath: true,
2020-04-01 23:05:10 +07:00
target: `${isHttp ? "http" : "ws"}://127.0.0.1:${port}${path}${
2020-04-01 02:56:01 +07:00
Object.keys(route.query).length > 0 ? `?${querystring.stringify(route.query)}` : ""
2020-04-01 01:11:35 +07:00
}`,
2020-04-01 23:05:10 +07:00
ws: !isHttp,
}
2020-03-24 06:02:31 +07:00
2020-04-01 02:56:01 +07:00
if (response instanceof http.ServerResponse) {
this.proxy.web(request, response, options)
2020-03-24 06:02:31 +07:00
} else {
2020-04-01 02:56:01 +07:00
this.proxy.ws(request, response.socket, response.head, options)
2020-03-24 06:02:31 +07:00
}
return { handled: true }
}
}