Move proxy logic into main HTTP server
This makes the code much more internally consistent (providers just return payloads, include the proxy provider).
This commit is contained in:
parent
aaa6c279a1
commit
a5d1d3b90e
|
@ -1,46 +1,12 @@
|
||||||
import { logger } from "@coder/logger"
|
|
||||||
import * as http from "http"
|
import * as http from "http"
|
||||||
import proxy from "http-proxy"
|
|
||||||
import * as net from "net"
|
|
||||||
import * as querystring from "querystring"
|
|
||||||
import { HttpCode, HttpError } from "../../common/http"
|
import { HttpCode, HttpError } from "../../common/http"
|
||||||
import { HttpProvider, HttpProviderOptions, HttpProxyProvider, HttpResponse, Route } from "../http"
|
import { HttpProvider, HttpResponse, Route, WsResponse } from "../http"
|
||||||
|
|
||||||
interface Request extends http.IncomingMessage {
|
|
||||||
base?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Proxy HTTP provider.
|
* Proxy HTTP provider.
|
||||||
*/
|
*/
|
||||||
export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider {
|
export class ProxyHttpProvider extends HttpProvider {
|
||||||
/**
|
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
|
||||||
* Proxy domains are stored here without the leading `*.`
|
|
||||||
*/
|
|
||||||
public readonly proxyDomains: Set<string>
|
|
||||||
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)
|
|
||||||
this.proxyDomains = new Set(proxyDomains.map((d) => d.replace(/^\*\./, "")))
|
|
||||||
this.proxy.on("error", (error) => logger.warn(error.message))
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
public async handleRequest(
|
|
||||||
route: Route,
|
|
||||||
request: http.IncomingMessage,
|
|
||||||
response: http.ServerResponse,
|
|
||||||
): Promise<HttpResponse> {
|
|
||||||
if (!this.authenticated(request)) {
|
if (!this.authenticated(request)) {
|
||||||
if (this.isRoot(route)) {
|
if (this.isRoot(route)) {
|
||||||
return { redirect: "/login", query: { to: route.fullPath } }
|
return { redirect: "/login", query: { to: route.fullPath } }
|
||||||
|
@ -56,133 +22,22 @@ export class ProxyHttpProvider extends HttpProvider implements HttpProxyProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
const port = route.base.replace(/^\//, "")
|
const port = route.base.replace(/^\//, "")
|
||||||
const base = `${this.options.base}/${port}`
|
return {
|
||||||
const payload = this.doProxy(route, request, response, port, base)
|
proxy: {
|
||||||
if (payload) {
|
base: `${this.options.base}/${port}`,
|
||||||
return payload
|
port,
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new HttpError("Not found", HttpCode.NotFound)
|
public async handleWebSocket(route: Route, request: http.IncomingMessage): Promise<WsResponse> {
|
||||||
}
|
|
||||||
|
|
||||||
public async handleWebSocket(
|
|
||||||
route: Route,
|
|
||||||
request: http.IncomingMessage,
|
|
||||||
socket: net.Socket,
|
|
||||||
head: Buffer,
|
|
||||||
): Promise<void> {
|
|
||||||
this.ensureAuthenticated(request)
|
this.ensureAuthenticated(request)
|
||||||
const port = route.base.replace(/^\//, "")
|
const port = route.base.replace(/^\//, "")
|
||||||
const base = `${this.options.base}/${port}`
|
|
||||||
this.doProxy(route, request, { socket, head }, port, base)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
public maybeProxyRequest(
|
|
||||||
route: Route,
|
|
||||||
request: http.IncomingMessage,
|
|
||||||
response: http.ServerResponse,
|
|
||||||
): HttpResponse | undefined {
|
|
||||||
const port = this.getPort(request)
|
|
||||||
return port ? this.doProxy(route, 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, 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)) {
|
|
||||||
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(".")
|
|
||||||
if (!port || !this.proxyDomains.has(proxyDomain)) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
return port
|
|
||||||
}
|
|
||||||
|
|
||||||
private doProxy(
|
|
||||||
route: Route,
|
|
||||||
request: http.IncomingMessage,
|
|
||||||
response: http.ServerResponse,
|
|
||||||
portStr: string,
|
|
||||||
base?: string,
|
|
||||||
): HttpResponse
|
|
||||||
private doProxy(
|
|
||||||
route: Route,
|
|
||||||
request: http.IncomingMessage,
|
|
||||||
response: { socket: net.Socket; head: Buffer },
|
|
||||||
portStr: string,
|
|
||||||
base?: string,
|
|
||||||
): HttpResponse
|
|
||||||
private doProxy(
|
|
||||||
route: Route,
|
|
||||||
request: http.IncomingMessage,
|
|
||||||
response: http.ServerResponse | { socket: net.Socket; head: Buffer },
|
|
||||||
portStr: string,
|
|
||||||
base?: string,
|
|
||||||
): HttpResponse {
|
|
||||||
const port = parseInt(portStr, 10)
|
|
||||||
if (isNaN(port)) {
|
|
||||||
return {
|
return {
|
||||||
code: HttpCode.BadRequest,
|
proxy: {
|
||||||
content: `"${portStr}" is not a valid number`,
|
base: `${this.options.base}/${port}`,
|
||||||
|
port,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 isHttp = response instanceof http.ServerResponse
|
|
||||||
const path = base ? route.fullPath.replace(base, "") : route.fullPath
|
|
||||||
const options: 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, options)
|
|
||||||
} else {
|
|
||||||
this.proxy.ws(request, response.socket, response.head, options)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { handled: true }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -43,6 +43,7 @@ const main = async (args: Args): Promise<void> => {
|
||||||
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
|
...(args.cert && !args.cert.value
|
||||||
? await generateCertificate()
|
? await generateCertificate()
|
||||||
|
@ -60,11 +61,10 @@ const main = async (args: Args): Promise<void> => {
|
||||||
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"])
|
||||||
const proxy = httpServer.registerHttpProvider("/proxy", ProxyHttpProvider, args["proxy-domain"])
|
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)
|
||||||
httpServer.registerProxy(proxy)
|
|
||||||
|
|
||||||
ipcMain().onDispose(() => httpServer.dispose())
|
ipcMain().onDispose(() => httpServer.dispose())
|
||||||
|
|
||||||
|
@ -94,9 +94,9 @@ const main = async (args: Args): Promise<void> => {
|
||||||
logger.info(" - Not serving HTTPS")
|
logger.info(" - Not serving HTTPS")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (proxy.proxyDomains.size > 0) {
|
if (httpServer.proxyDomains.size > 0) {
|
||||||
logger.info(` - Proxying the following domain${proxy.proxyDomains.size === 1 ? "" : "s"}:`)
|
logger.info(` - Proxying the following domain${httpServer.proxyDomains.size === 1 ? "" : "s"}:`)
|
||||||
proxy.proxyDomains.forEach((domain) => logger.info(` - *.${domain}`))
|
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"}`)
|
||||||
|
|
223
src/node/http.ts
223
src/node/http.ts
|
@ -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.
|
||||||
|
@ -78,9 +94,16 @@ export interface HttpResponse<T = string | Buffer | object> {
|
||||||
*/
|
*/
|
||||||
query?: Query
|
query?: Query
|
||||||
/**
|
/**
|
||||||
* Indicates the request was handled and nothing else needs to be done.
|
* Indicates the request should be proxied.
|
||||||
*/
|
*/
|
||||||
handled?: boolean
|
proxy?: ProxyOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WsResponse {
|
||||||
|
/**
|
||||||
|
* Indicates the web socket should be proxied.
|
||||||
|
*/
|
||||||
|
proxy?: ProxyOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -104,6 +127,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -156,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 */
|
||||||
|
@ -165,18 +191,14 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle requests to the registered endpoint.
|
* Handle requests to the registered endpoint.
|
||||||
*/
|
*/
|
||||||
public abstract handleRequest(
|
public abstract handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse>
|
||||||
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
|
* Get the base relative to the provided route. For each slash we need to go
|
||||||
|
@ -288,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
|
||||||
|
@ -426,39 +448,6 @@ export interface HttpProvider3<A1, A2, A3, T> {
|
||||||
new (options: HttpProviderOptions, a1: A1, a2: A2, a3: A3): T
|
new (options: HttpProviderOptions, a1: A1, a2: A2, a3: A3): T
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HttpProxyProvider {
|
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
* 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).
|
|
||||||
*/
|
|
||||||
getCookieDomain(host: string): string | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An HTTP server. Its main role is to route incoming HTTP requests to the
|
* 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
|
* appropriate provider for that endpoint then write out the response. It also
|
||||||
|
@ -471,9 +460,19 @@ export class HttpServer {
|
||||||
private readonly providers = new Map<string, HttpProvider>()
|
private readonly providers = new Map<string, HttpProvider>()
|
||||||
private readonly heart: Heart
|
private readonly heart: Heart
|
||||||
private readonly socketProvider = new SocketProxyProvider()
|
private readonly socketProvider = new SocketProxyProvider()
|
||||||
private proxy?: HttpProxyProvider
|
|
||||||
|
/**
|
||||||
|
* 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)}`)
|
||||||
|
@ -491,6 +490,13 @@ export class HttpServer {
|
||||||
} else {
|
} else {
|
||||||
this.server = http.createServer(this.onRequest)
|
this.server = http.createServer(this.onRequest)
|
||||||
}
|
}
|
||||||
|
this.proxy.on("error", (error) => logger.warn(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 {
|
||||||
|
@ -546,14 +552,6 @@ export class HttpServer {
|
||||||
return p
|
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.
|
* Start listening on the specified port.
|
||||||
*/
|
*/
|
||||||
|
@ -602,7 +600,7 @@ 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.proxy && this.proxy.getCookieDomain(domain)) || domain}` : undefined,
|
domain ? `Domain=${this.getCookieDomain(domain)}` : undefined,
|
||||||
// "HttpOnly",
|
// "HttpOnly",
|
||||||
"SameSite=lax",
|
"SameSite=lax",
|
||||||
]
|
]
|
||||||
|
@ -631,9 +629,11 @@ export class HttpServer {
|
||||||
try {
|
try {
|
||||||
const payload =
|
const payload =
|
||||||
this.maybeRedirect(request, route) ||
|
this.maybeRedirect(request, route) ||
|
||||||
(this.proxy && this.proxy.maybeProxyRequest(route, request, response)) ||
|
(route.provider.authenticated(request) && this.maybeProxy(request)) ||
|
||||||
(await route.provider.handleRequest(route, request, response))
|
(await route.provider.handleRequest(route, request))
|
||||||
if (!payload.handled) {
|
if (payload.proxy) {
|
||||||
|
this.doProxy(route, request, response, payload.proxy)
|
||||||
|
} else {
|
||||||
write(payload)
|
write(payload)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -710,8 +710,13 @@ export class HttpServer {
|
||||||
throw new HttpError("Not found", HttpCode.NotFound)
|
throw new HttpError("Not found", HttpCode.NotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.proxy || !this.proxy.maybeProxyWebSocket(route, request, socket, head)) {
|
// The socket proxy is so we can pass them to child processes (TLS sockets
|
||||||
await route.provider.handleWebSocket(route, request, await this.socketProvider.createProxy(socket), head)
|
// 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)
|
||||||
|
@ -756,4 +761,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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue