2020-03-25 04:34:31 +07:00
|
|
|
import { logger } from "@coder/logger"
|
2020-03-24 01:47:01 +07:00
|
|
|
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"
|
2020-03-24 01:47:01 +07:00
|
|
|
import { HttpCode, HttpError } from "../../common/http"
|
2020-03-24 02:26:47 +07:00
|
|
|
import { HttpProvider, HttpProviderOptions, HttpProxyProvider, HttpResponse, Route } from "../http"
|
2020-03-24 01:47:01 +07:00
|
|
|
|
2020-04-01 02:56:01 +07:00
|
|
|
interface Request extends http.IncomingMessage {
|
|
|
|
base?: string
|
|
|
|
}
|
|
|
|
|
2020-03-24 01:47:01 +07:00
|
|
|
/**
|
|
|
|
* Proxy HTTP provider.
|
|
|
|
*/
|
2020-03-24 02:26:47 +07:00
|
|
|
export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider {
|
2020-03-24 02:51:58 +07:00
|
|
|
/**
|
|
|
|
* Proxy domains are stored here without the leading `*.`
|
|
|
|
*/
|
2020-03-24 02:26:47 +07:00
|
|
|
public readonly proxyDomains: string[]
|
2020-03-24 06:02:31 +07:00
|
|
|
private readonly proxy = proxy.createProxyServer({})
|
2020-03-24 02:26:47 +07:00
|
|
|
|
2020-03-24 02:51:58 +07:00
|
|
|
/**
|
|
|
|
* Domains can be provided in the form `coder.com` or `*.coder.com`. Either
|
|
|
|
* way, `<number>.coder.com` will be proxied to `number`.
|
|
|
|
*/
|
2020-03-24 02:26:47 +07:00
|
|
|
public constructor(options: HttpProviderOptions, proxyDomains: string[] = []) {
|
2020-03-24 01:47:01 +07:00
|
|
|
super(options)
|
2020-03-24 02:26:47 +07:00
|
|
|
this.proxyDomains = proxyDomains.map((d) => d.replace(/^\*\./, "")).filter((d, i, arr) => arr.indexOf(d) === i)
|
2020-03-25 04:34:31 +07:00
|
|
|
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 01:47:01 +07:00
|
|
|
}
|
|
|
|
|
2020-03-24 06:02:31 +07:00
|
|
|
public async handleRequest(
|
|
|
|
route: Route,
|
|
|
|
request: http.IncomingMessage,
|
|
|
|
response: http.ServerResponse,
|
|
|
|
): Promise<HttpResponse> {
|
2020-04-01 00:59:07 +07:00
|
|
|
const isRoot = !route.requestPath || route.requestPath === "/index.html"
|
2020-03-24 02:26:47 +07:00
|
|
|
if (!this.authenticated(request)) {
|
2020-03-24 06:02:31 +07:00
|
|
|
// Only redirect from the root. Other requests get an unauthorized error.
|
2020-04-01 00:59:07 +07:00
|
|
|
if (isRoot) {
|
|
|
|
return { redirect: "/login", query: { to: route.fullPath } }
|
2020-03-24 02:26:47 +07:00
|
|
|
}
|
2020-04-01 00:59:07 +07:00
|
|
|
throw new HttpError("Unauthorized", HttpCode.Unauthorized)
|
2020-03-24 01:47:01 +07:00
|
|
|
}
|
2020-03-24 02:26:47 +07:00
|
|
|
|
2020-04-01 00:59:07 +07:00
|
|
|
// Ensure there is a trailing slash so relative paths work correctly.
|
2020-04-01 02:56:01 +07:00
|
|
|
const port = route.base.replace(/^\//, "")
|
|
|
|
const base = `${this.options.base}/${port}`
|
|
|
|
if (isRoot && !route.fullPath.endsWith("/")) {
|
2020-04-01 00:59:07 +07:00
|
|
|
return {
|
2020-04-01 02:56:01 +07:00
|
|
|
redirect: `${base}/`,
|
2020-04-01 00:59:07 +07:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-04-01 02:56:01 +07:00
|
|
|
const payload = this.doProxy(route, request, response, port, base)
|
2020-03-24 02:26:47 +07:00
|
|
|
if (payload) {
|
|
|
|
return payload
|
2020-03-24 01:47:01 +07:00
|
|
|
}
|
2020-03-24 02:26:47 +07:00
|
|
|
|
|
|
|
throw new HttpError("Not found", HttpCode.NotFound)
|
2020-03-24 01:47:01 +07:00
|
|
|
}
|
|
|
|
|
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
|
|
|
}
|
|
|
|
|
2020-03-24 02:51:58 +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
|
|
|
|
}
|
|
|
|
})
|
2020-03-25 02:29:48 +07:00
|
|
|
// 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 01:47:01 +07:00
|
|
|
}
|
|
|
|
|
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 {
|
2020-03-24 02:26:47 +07:00
|
|
|
// 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)) {
|
2020-03-24 01:47:01 +07:00
|
|
|
return undefined
|
|
|
|
}
|
|
|
|
|
2020-03-25 02:29:48 +07:00
|
|
|
// 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(".")
|
2020-03-24 01:47:01 +07:00
|
|
|
|
2020-03-24 02:51:58 +07:00
|
|
|
// There must be an exact match.
|
|
|
|
const port = parts.shift()
|
|
|
|
const proxyDomain = parts.join(".")
|
|
|
|
if (!port || !this.proxyDomains.includes(proxyDomain)) {
|
2020-03-24 01:47:01 +07:00
|
|
|
return undefined
|
|
|
|
}
|
|
|
|
|
2020-03-24 06:02:31 +07:00
|
|
|
return port
|
2020-03-24 01:47:01 +07:00
|
|
|
}
|
|
|
|
|
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)
|
2020-03-24 01:47:01 +07:00
|
|
|
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 01:47:01 +07:00
|
|
|
}
|
|
|
|
}
|
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
|
|
|
|
|
|
|
|
const hxxp = response instanceof http.ServerResponse
|
|
|
|
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 02:56:01 +07:00
|
|
|
target: `${hxxp ? "http" : "ws"}://127.0.0.1:${port}${path}${
|
|
|
|
Object.keys(route.query).length > 0 ? `?${querystring.stringify(route.query)}` : ""
|
2020-04-01 01:11:35 +07:00
|
|
|
}`,
|
2020-04-01 02:56:01 +07:00
|
|
|
ws: !hxxp,
|
2020-03-24 01:47:01 +07:00
|
|
|
}
|
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 }
|
2020-03-24 01:47:01 +07:00
|
|
|
}
|
|
|
|
}
|