diff --git a/.eslintrc.yaml b/.eslintrc.yaml index 92657d62..231c2dbc 100644 --- a/.eslintrc.yaml +++ b/.eslintrc.yaml @@ -31,6 +31,8 @@ rules: import/order: [error, { alphabetize: { order: "asc" }, groups: [["builtin", "external", "internal"], "parent", "sibling"] }] no-async-promise-executor: off + # This isn't a real module, just types, which apparently doesn't resolve. + import/no-unresolved: [error, { ignore: ["express-serve-static-core"] }] settings: # Does not work with CommonJS unfortunately. diff --git a/ci/dev/vscode.patch b/ci/dev/vscode.patch index b205aa83..9d759649 100644 --- a/ci/dev/vscode.patch +++ b/ci/dev/vscode.patch @@ -1318,7 +1318,7 @@ index 0000000000000000000000000000000000000000..56331ff1fc32bbd82e769aaecb551e42 +require('../../bootstrap-amd').load('vs/server/entry'); diff --git a/src/vs/server/ipc.d.ts b/src/vs/server/ipc.d.ts new file mode 100644 -index 0000000000000000000000000000000000000000..33b28cf2d53746ee9c50c056ac2e087dcee0a4e2 +index 0000000000000000000000000000000000000000..6ce56bec114a6d8daf5dd3ded945ea78fc72a5c6 --- /dev/null +++ b/src/vs/server/ipc.d.ts @@ -0,0 +1,131 @@ @@ -1336,7 +1336,7 @@ index 0000000000000000000000000000000000000000..33b28cf2d53746ee9c50c056ac2e087d + options: VscodeOptions; +} + -+export type Query = { [key: string]: string | string[] | undefined }; ++export type Query = { [key: string]: string | string[] | undefined | Query | Query[] }; + +export interface SocketMessage { + type: 'socket'; diff --git a/package.json b/package.json index e6197065..187052f8 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,8 @@ }, "main": "out/node/entry.js", "devDependencies": { + "@types/body-parser": "^1.19.0", + "@types/cookie-parser": "^1.4.2", "@types/express": "^4.17.8", "@types/fs-extra": "^8.0.1", "@types/http-proxy": "^1.17.4", @@ -67,6 +69,8 @@ }, "dependencies": { "@coder/logger": "1.1.16", + "body-parser": "^1.19.0", + "cookie-parser": "^1.4.5", "env-paths": "^2.2.0", "express": "^4.17.1", "fs-extra": "^9.0.1", @@ -75,6 +79,7 @@ "js-yaml": "^3.13.1", "limiter": "^1.1.5", "pem": "^1.14.2", + "qs": "6.7.0", "rotating-file-stream": "^2.1.1", "safe-buffer": "^5.1.1", "safe-compare": "^1.1.4", diff --git a/src/common/http.ts b/src/common/http.ts index 4749247d..5279bf44 100644 --- a/src/common/http.ts +++ b/src/common/http.ts @@ -9,7 +9,7 @@ export enum HttpCode { } export class HttpError extends Error { - public constructor(message: string, public readonly code: number, public readonly details?: object) { + public constructor(message: string, public readonly status: number, public readonly details?: object) { super(message) this.name = this.constructor.name } diff --git a/src/node/app.ts b/src/node/app.ts index 7e5e7a2e..5fde2767 100644 --- a/src/node/app.ts +++ b/src/node/app.ts @@ -4,6 +4,7 @@ import { promises as fs } from "fs" import http from "http" import * as httpolyglot from "httpolyglot" import { DefaultedArgs } from "./cli" +import { handleUpgrade } from "./http" /** * Create an Express app and an HTTP/S server to serve it. @@ -38,6 +39,8 @@ export const createApp = async (args: DefaultedArgs): Promise<[Express, http.Ser } }) + handleUpgrade(app, server) + return [app, server] } diff --git a/src/node/entry.ts b/src/node/entry.ts index ce0d2980..30dd93d8 100644 --- a/src/node/entry.ts +++ b/src/node/entry.ts @@ -18,7 +18,7 @@ import { } from "./cli" import { coderCloudBind } from "./coder-cloud" import { commit, version } from "./constants" -import { loadPlugins } from "./plugin" +import { register } from "./routes" import { humanPath, open } from "./util" import { ipcMain, WrapperProcess } from "./wrapper" @@ -111,15 +111,14 @@ const main = async (args: DefaultedArgs): Promise => { if (args.auth === AuthType.Password && !args.password) { throw new Error("Please pass in a password via the config file or $PASSWORD") } + ipcMain.onDispose(() => { // TODO: register disposables }) const [app, server] = await createApp(args) const serverAddress = ensureAddress(server) - - // TODO: register routes - await loadPlugins(app, args) + await register(app, server, args) logger.info(`Using config file ${humanPath(args.config)}`) logger.info(`HTTP server listening on ${serverAddress} ${args.link ? "(randomized by --link)" : ""}`) diff --git a/src/node/http.ts b/src/node/http.ts index 4aa6dc06..96066910 100644 --- a/src/node/http.ts +++ b/src/node/http.ts @@ -1,885 +1,253 @@ import { field, logger } from "@coder/logger" -import * as fs from "fs-extra" +import * as express from "express" +import * as expressCore from "express-serve-static-core" import * as http from "http" -import proxy from "http-proxy" -import * as httpolyglot from "httpolyglot" -import * as https from "https" import * as net from "net" -import * as path from "path" -import * as querystring from "querystring" +import qs from "qs" import safeCompare from "safe-compare" -import { Readable } from "stream" -import * as tls from "tls" -import * as url from "url" import { HttpCode, HttpError } from "../common/http" -import { arrayify, normalize, Options, plural, split, trimSlashes } from "../common/util" +import { normalize, Options } from "../common/util" import { AuthType } from "./cli" +import { commit, rootPath } from "./constants" import { Heart } from "./heart" -import { SocketProxyProvider } from "./socket" -import { getMediaMime, paths } from "./util" +import { hash } from "./util" -export type Cookies = { [key: string]: string[] | undefined } -export type PostData = { [key: string]: string | string[] | undefined } - -interface ProxyRequest extends http.IncomingMessage { - base?: string -} - -interface AuthPayload extends Cookies { - key?: string[] -} - -export type Query = { [key: string]: string | string[] | undefined } - -export interface ProxyOptions { - /** - * A path to strip from from the beginning of the request before proxying - */ - strip?: string - /** - * A path to add to the beginning of the request before proxying. - */ - prepend?: string - /** - * The port to proxy. - */ - port: string -} - -export interface HttpResponse { - /* - * Whether to set cache-control headers for this response. - */ - cache?: boolean - /** - * If the code cannot be determined automatically set it here. The - * defaults are 302 for redirects and 200 for successful requests. For errors - * you should throw an HttpError and include the code there. If you - * use Error it will default to 404 for ENOENT and EISDIR and 500 otherwise. - */ - code?: number - /** - * Content to write in the response. Mutually exclusive with stream. - */ - content?: T - /** - * Cookie to write with the response. - * NOTE: Cookie paths must be absolute. The default is /. - */ - cookie?: { key: string; value: string; path?: string } - /** - * Used to automatically determine the appropriate mime type. - */ - filePath?: string - /** - * Additional headers to include. - */ - headers?: http.OutgoingHttpHeaders - /** - * If the mime type cannot be determined automatically set it here. - */ - mime?: string - /** - * Redirect to this path. This is constructed against the site base (not the - * provider's base). - */ - redirect?: string - /** - * Stream this to the response. Mutually exclusive with content. - */ - stream?: Readable - /** - * Query variables to add in addition to current ones when redirecting. Use - * `undefined` to remove a query variable. - */ - query?: Query - /** - * Indicates the request should be proxied. - */ - proxy?: ProxyOptions -} - -export interface WsResponse { - /** - * Indicates the web socket should be proxied. - */ - proxy?: ProxyOptions +export interface Locals { + heart: Heart } /** - * Use when you need to run search and replace on a file's content before - * sending it. + * Replace common variable strings in HTML templates. */ -export interface HttpStringFileResponse extends HttpResponse { - content: string - filePath: string -} - -export interface RedirectResponse extends HttpResponse { - redirect: string -} - -export interface HttpServerOptions { - readonly auth?: AuthType - readonly cert?: string - readonly certKey?: string - readonly commit?: string - readonly host?: string - readonly password?: string - readonly port?: number - readonly proxyDomains: string[] - readonly socket?: string -} - -export interface Route { - /** - * Provider base path part (for /provider/base/path it would be /provider). - */ - providerBase: string - /** - * Base path part (for /provider/base/path it would be /base). - */ - base: string - /** - * Remaining part of the route after factoring out the base and provider base - * (for /provider/base/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 -} - -interface ProviderRoute extends Route { - provider: HttpProvider -} - -export interface HttpProviderOptions { - readonly auth: AuthType - readonly commit: string - readonly password?: string +export const replaceTemplates = ( + req: express.Request, + content: string, + extraOpts?: Omit, +): string => { + const base = relativeRoot(req) + const options: Options = { + base, + csStaticBase: base + "/static/" + commit + rootPath, + logLevel: logger.level, + ...extraOpts, + } + return content + .replace(/{{TO}}/g, (typeof req.query.to === "string" && req.query.to) || "/") + .replace(/{{BASE}}/g, options.base) + .replace(/{{CS_STATIC_BASE}}/g, options.csStaticBase) + .replace(/"{{OPTIONS}}"/, `'${JSON.stringify(options)}'`) } /** - * Provides HTTP responses. This abstract class provides some helpers for - * interpreting, creating, and authenticating responses. + * Throw an error if not authorized. */ -export abstract class HttpProvider { - protected readonly rootPath = path.resolve(__dirname, "../..") - - public constructor(protected readonly options: HttpProviderOptions) {} - - public async dispose(): Promise { - // No default behavior. +export const ensureAuthenticated = (req: express.Request): void => { + if (!authenticated(req)) { + throw new HttpError("Unauthorized", HttpCode.Unauthorized) } - - /** - * 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( - /* eslint-disable @typescript-eslint/no-unused-vars */ - _route: Route, - _request: http.IncomingMessage, - _socket: net.Socket, - _head: Buffer, - /* eslint-enable @typescript-eslint/no-unused-vars */ - ): Promise { - throw new HttpError("Not found", HttpCode.NotFound) - } - - /** - * Handle requests to the registered endpoint. - */ - public abstract handleRequest(route: Route, request: http.IncomingMessage): Promise - - /** - * Get the base relative to the provided route. For each slash we need to go - * up a directory. For example: - * / => . - * /foo => . - * /foo/ => ./.. - * /foo/bar => ./.. - * /foo/bar/ => ./../.. - */ - public base(route: Route): string { - const depth = (route.originalPath.match(/\//g) || []).length - return normalize("./" + (depth > 1 ? "../".repeat(depth - 1) : "")) - } - - /** - * Get error response. - */ - public async getErrorRoot(route: Route, title: string, header: string, body: string): Promise { - const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/error.html") - response.content = response.content - .replace(/{{ERROR_TITLE}}/g, title) - .replace(/{{ERROR_HEADER}}/g, header) - .replace(/{{ERROR_BODY}}/g, body) - return this.replaceTemplates(route, response) - } - - /** - * Replace common templates strings. - */ - protected replaceTemplates( - route: Route, - response: HttpStringFileResponse, - extraOptions?: Omit, - ): HttpStringFileResponse { - const base = this.base(route) - const options: Options = { - base, - csStaticBase: base + "/static/" + this.options.commit + this.rootPath, - logLevel: logger.level, - ...extraOptions, - } - response.content = response.content - .replace(/{{TO}}/g, Array.isArray(route.query.to) ? route.query.to[0] : route.query.to || "/dashboard") - .replace(/{{BASE}}/g, options.base) - .replace(/{{CS_STATIC_BASE}}/g, options.csStaticBase) - .replace(/"{{OPTIONS}}"/, `'${JSON.stringify(options)}'`) - return response - } - - protected get isDev(): boolean { - return this.options.commit === "development" - } - - /** - * Get a file resource. - * TODO: Would a stream be faster, at least for large files? - */ - protected async getResource(...parts: string[]): Promise { - const filePath = path.join(...parts) - return { content: await fs.readFile(filePath), filePath } - } - - /** - * Get a file resource as a string. - */ - protected async getUtf8Resource(...parts: string[]): Promise { - const filePath = path.join(...parts) - return { content: await fs.readFile(filePath, "utf8"), filePath } - } - - /** - * Helper to error on invalid methods (default GET). - */ - protected ensureMethod(request: http.IncomingMessage, method?: string | string[]): void { - const check = arrayify(method || "GET") - if (!request.method || !check.includes(request.method)) { - throw new HttpError(`Unsupported method ${request.method}`, HttpCode.BadRequest) - } - } - - /** - * Helper to error if not authorized. - */ - public ensureAuthenticated(request: http.IncomingMessage): void { - if (!this.authenticated(request)) { - throw new HttpError("Unauthorized", HttpCode.Unauthorized) - } - } - - /** - * Use the first query value or the default if there isn't one. - */ - protected queryOrDefault(value: string | string[] | undefined, def: string): string { - if (Array.isArray(value)) { - value = value[0] - } - return typeof value !== "undefined" ? value : def - } - - /** - * Return the provided password value if the payload contains the right - * password otherwise return false. If no payload is specified use cookies. - */ - public authenticated(request: http.IncomingMessage, payload?: AuthPayload): string | boolean { - switch (this.options.auth) { - case AuthType.None: - return true - case AuthType.Password: - if (typeof payload === "undefined") { - payload = this.parseCookies(request) - } - if (this.options.password && payload.key) { - for (let i = 0; i < payload.key.length; ++i) { - if (safeCompare(payload.key[i], this.options.password)) { - return payload.key[i] - } - } - } - return false - default: - throw new Error(`Unsupported auth type ${this.options.auth}`) - } - } - - /** - * Parse POST data. - */ - protected getData(request: http.IncomingMessage): Promise { - return request.method === "POST" || request.method === "DELETE" - ? new Promise((resolve, reject) => { - let body = "" - const onEnd = (): void => { - off() // eslint-disable-line @typescript-eslint/no-use-before-define - resolve(body || undefined) - } - const onError = (error: Error): void => { - off() // eslint-disable-line @typescript-eslint/no-use-before-define - reject(error) - } - const onData = (d: Buffer): void => { - body += d - if (body.length > 1e6) { - onError(new HttpError("Payload is too large", HttpCode.LargePayload)) - request.connection.destroy() - } - } - const off = (): void => { - request.off("error", onError) - request.off("data", onError) - request.off("end", onEnd) - } - request.on("error", onError) - request.on("data", onData) - request.on("end", onEnd) - }) - : Promise.resolve(undefined) - } - - /** - * Parse cookies. - */ - protected parseCookies(request: http.IncomingMessage): T { - const cookies: { [key: string]: string[] } = {} - if (request.headers.cookie) { - request.headers.cookie.split(";").forEach((keyValue) => { - const [key, value] = split(keyValue, "=") - if (!cookies[key]) { - cookies[key] = [] - } - cookies[key].push(decodeURI(value)) - }) - } - 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" - } -} - -export interface HttpProvider0 { - new (options: HttpProviderOptions): T -} - -export interface HttpProvider1 { - new (options: HttpProviderOptions, a1: A1): T -} - -export interface HttpProvider2 { - new (options: HttpProviderOptions, a1: A1, a2: A2): T -} - -export interface HttpProvider3 { - new (options: HttpProviderOptions, a1: A1, a2: A2, a3: A3): T } /** - * 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 - * covers some common use cases like redirects and caching. + * Return true if authenticated via cookies. */ -export class HttpServer { - protected readonly server: http.Server | https.Server - private listenPromise: Promise | undefined - public readonly protocol: "http" | "https" - private readonly providers = new Map() - public readonly heart: Heart - private readonly socketProvider = new SocketProxyProvider() - - /** - * Provides the actual proxying functionality. - */ - private readonly proxy = proxy.createProxyServer({}) - - public constructor(private readonly options: HttpServerOptions) { - this.heart = new Heart(path.join(paths.data, "heartbeat"), async () => { - const connections = await this.getConnections() - logger.trace(plural(connections, `${connections} active connection`)) - return connections !== 0 - }) - this.protocol = this.options.cert ? "https" : "http" - if (this.protocol === "https") { - this.server = httpolyglot.createServer( - { - cert: this.options.cert && fs.readFileSync(this.options.cert), - key: this.options.certKey && fs.readFileSync(this.options.certKey), - }, - this.onRequest, - ) - } else { - 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 - } - }) - } - - /** - * Stop and dispose everything. Return an array of disposal errors. - */ - public async dispose(): Promise { - this.socketProvider.stop() - const providers = Array.from(this.providers.values()) - // Catch so all the errors can be seen rather than just the first one. - const responses = await Promise.all(providers.map((p) => p.dispose().catch((e) => e))) - return responses.filter((r): r is Error => typeof r !== "undefined") - } - - public async getConnections(): Promise { - return new Promise((resolve, reject) => { - this.server.getConnections((error, count) => { - return error ? reject(error) : resolve(count) - }) - }) - } - - /** - * Register a provider for a top-level endpoint. - */ - public registerHttpProvider(endpoint: string | string[], provider: HttpProvider0): T - public registerHttpProvider( - endpoint: string | string[], - provider: HttpProvider1, - a1: A1, - ): T - public registerHttpProvider( - endpoint: string | string[], - provider: HttpProvider2, - a1: A1, - a2: A2, - ): T - public registerHttpProvider( - endpoint: string | string[], - provider: HttpProvider3, - a1: A1, - a2: A2, - a3: A3, - ): T - // eslint-disable-next-line @typescript-eslint/no-explicit-any - public registerHttpProvider(endpoint: string | string[], provider: any, ...args: any[]): void { - const p = new provider( - { - auth: this.options.auth || AuthType.None, - commit: this.options.commit, - password: this.options.password, - }, - ...args, - ) - const endpoints = arrayify(endpoint).map(trimSlashes) - endpoints.forEach((endpoint) => { - if (/\//.test(endpoint)) { - throw new Error(`Only top-level endpoints are supported (got ${endpoint})`) - } - const existingProvider = this.providers.get(`/${endpoint}`) - this.providers.set(`/${endpoint}`, p) - if (existingProvider) { - logger.debug(`Overridding existing /${endpoint} provider`) - // If the existing provider isn't registered elsewhere we can dispose. - if (!Array.from(this.providers.values()).find((p) => p === existingProvider)) { - logger.debug(`Disposing existing /${endpoint} provider`) - existingProvider.dispose() - } - } - }) - } - - /** - * Start listening on the specified port. - */ - public listen(): Promise { - if (!this.listenPromise) { - this.listenPromise = new Promise(async (resolve, reject) => { - this.server.on("error", reject) - this.server.on("upgrade", this.onUpgrade) - const onListen = (): void => resolve(this.address()) - if (this.options.socket) { - try { - await fs.unlink(this.options.socket) - } catch (err) { - if (err.code !== "ENOENT") { - logger.warn(err.message) - } - } - this.server.listen(this.options.socket, onListen) - } else if (this.options.host) { - // [] is the correct format when using :: but Node errors with them. - this.server.listen(this.options.port, this.options.host.replace(/^\[|\]$/g, ""), onListen) - } else { - this.server.listen(this.options.port, onListen) - } - }) - } - return this.listenPromise - } - - /** - * The *local* address of the server. - */ - public address(): string | null { - const address = this.server.address() - const endpoint = - typeof address !== "string" && address !== null - ? (address.address === "::" ? "localhost" : address.address) + ":" + address.port - : address - return endpoint && `${this.protocol}://${endpoint}` - } - - private onRequest = async (request: http.IncomingMessage, response: http.ServerResponse): Promise => { - const route = this.parseUrl(request) - if (route.providerBase !== "/healthz") { - this.heart.beat() - } - const write = (payload: HttpResponse): void => { - response.writeHead(payload.redirect ? HttpCode.Redirect : payload.code || HttpCode.Ok, { - "Content-Type": payload.mime || getMediaMime(payload.filePath), - ...(payload.redirect ? { Location: this.constructRedirect(request, route, payload as RedirectResponse) } : {}), - ...(request.headers["service-worker"] ? { "Service-Worker-Allowed": route.provider.base(route) } : {}), - ...(payload.cache ? { "Cache-Control": "public, max-age=31536000" } : {}), - ...(payload.cookie - ? { - "Set-Cookie": [ - `${payload.cookie.key}=${payload.cookie.value}`, - `Path=${normalize(payload.cookie.path || "/", true)}`, - this.getCookieDomain(request.headers.host || ""), - // "HttpOnly", - "SameSite=lax", - ] - .filter((l) => !!l) - .join(";"), - } - : {}), - ...payload.headers, - }) - if (payload.stream) { - payload.stream.on("error", (error: NodeJS.ErrnoException) => { - response.writeHead(error.code === "ENOENT" ? HttpCode.NotFound : HttpCode.ServerError) - response.end(error.message) - }) - payload.stream.on("close", () => response.end()) - payload.stream.pipe(response) - } else if (typeof payload.content === "string" || payload.content instanceof Buffer) { - response.end(payload.content) - } else if (payload.content && typeof payload.content === "object") { - response.end(JSON.stringify(payload.content)) - } else { - response.end() - } - } - - try { - const payload = (await this.handleRequest(route, request)) || (await route.provider.handleRequest(route, request)) - if (payload.proxy) { - this.doProxy(route, request, response, payload.proxy) - } else { - write(payload) - } - } catch (error) { - let e = error - if (error.code === "ENOENT" || error.code === "EISDIR") { - e = new HttpError("Not found", HttpCode.NotFound) - } - const code = typeof e.code === "number" ? e.code : HttpCode.ServerError - logger.debug("Request error", field("url", request.url), field("code", code), field("error", error)) - if (code >= HttpCode.ServerError) { - logger.error(error.stack) - } - if (request.headers["content-type"] === "application/json") { - write({ - code, - mime: "application/json", - content: { - error: e.message, - ...(e.details || {}), - }, - }) - } else { - write({ - code, - ...(await route.provider.getErrorRoot(route, code, code, e.message)), - }) - } - } - } - - /** - * Handle requests that are always in effect no matter what provider is - * registered at the route. - */ - private async handleRequest(route: ProviderRoute, request: http.IncomingMessage): Promise { - // If we're handling TLS ensure all requests are redirected to HTTPS. - if (this.options.cert && !(request.connection as tls.TLSSocket).encrypted) { - return { redirect: route.fullPath } - } - - // Return robots.txt. - if (route.fullPath === "/robots.txt") { - const filePath = path.resolve(__dirname, "../../src/browser/robots.txt") - return { content: await fs.readFile(filePath), filePath } - } - - // Handle proxy domains. - return this.maybeProxy(route, request) - } - - /** - * Given a path that goes from the base, construct a relative redirect URL - * that will get you there considering that the app may be served from an - * unknown base path. If handling TLS, also ensure HTTPS. - */ - private constructRedirect(request: http.IncomingMessage, route: ProviderRoute, payload: RedirectResponse): string { - const query = { - ...route.query, - ...(payload.query || {}), - } - - Object.keys(query).forEach((key) => { - if (typeof query[key] === "undefined") { - delete query[key] - } - }) - - const secure = (request.connection as tls.TLSSocket).encrypted - const redirect = - (this.options.cert && !secure ? `${this.protocol}://${request.headers.host}/` : "") + - normalize(`${route.provider.base(route)}/${payload.redirect}`, true) + - (Object.keys(query).length > 0 ? `?${querystring.stringify(query)}` : "") - logger.debug("redirecting", field("secure", !!secure), field("from", request.url), field("to", redirect)) - return redirect - } - - private onUpgrade = async (request: http.IncomingMessage, socket: net.Socket, head: Buffer): Promise => { - try { - this.heart.beat() - socket.on("error", () => socket.destroy()) - - if (this.options.cert && !(socket as tls.TLSSocket).encrypted) { - throw new HttpError("HTTP websocket", HttpCode.BadRequest) - } - - if (!request.headers.upgrade || request.headers.upgrade.toLowerCase() !== "websocket") { - throw new HttpError("HTTP/1.1 400 Bad Request", HttpCode.BadRequest) - } - - const route = this.parseUrl(request) - if (!route.provider) { - throw new HttpError("Not found", HttpCode.NotFound) - } - - // 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(route, request) || (await route.provider.handleWebSocket(route, request, socketProxy, head)) - if (payload && payload.proxy) { - this.doProxy(route, request, { socket: socketProxy, head }, payload.proxy) - } - } catch (error) { - socket.destroy(error) - logger.warn(`discarding socket connection: ${error.message}`) - } - } - - /** - * Parse a request URL so we can route it. - */ - private parseUrl(request: http.IncomingMessage): ProviderRoute { - const parse = (fullPath: string): { base: string; requestPath: string } => { - const match = fullPath.match(/^(\/?[^/]*)(.*)$/) - let [, /* ignore */ base, requestPath] = match ? match.map((p) => p.replace(/\/+$/, "")) : ["", "", ""] - if (base.indexOf(".") !== -1) { - // Assume it's a file at the root. - requestPath = base - base = "/" - } else if (base === "") { - // Happens if it's a plain `domain.com`. - base = "/" - } - return { base, requestPath } - } - - const parsedUrl = request.url ? url.parse(request.url, true) : { query: {}, pathname: "" } - const originalPath = parsedUrl.pathname || "/" - const fullPath = normalize(originalPath, true) - const { base, requestPath } = parse(fullPath) - - // Providers match on the path after their base so we need to account for - // that by shifting the next base out of the request path. - let provider = this.providers.get(base) - if (base !== "/" && provider) { - return { ...parse(requestPath), providerBase: base, fullPath, query: parsedUrl.query, provider, originalPath } - } - - // Fall back to the top-level provider. - provider = this.providers.get("/") - if (!provider) { - throw new Error(`No provider for ${base}`) - } - return { base, providerBase: "/", 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.strip - - const isHttp = response instanceof http.ServerResponse - const base = options.strip ? route.fullPath.replace(options.strip, "") : route.fullPath - const path = normalize("/" + (options.prepend || "") + "/" + base, true) - 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 value that should be used for setting a cookie domain. This will - * allow the user to authenticate only once. This will use the highest level - * domain (e.g. `coder.com` over `test.coder.com` if both are specified). - */ - private getCookieDomain(host: string): string | undefined { - const idx = host.lastIndexOf(":") - host = idx !== -1 ? host.substring(0, idx) : host - if ( - // Might be blank/missing, so there's nothing more to do. - !host || - // IP addresses can't have subdomains so there's no value in setting the - // domain for them. Assume anything with a : is ipv6 (valid domain name - // characters are alphanumeric or dashes). - host.includes(":") || - // Assume anything entirely numbers and dots is ipv4 (currently tlds - // cannot be entirely numbers). - !/[^0-9.]/.test(host) || - // localhost subdomains don't seem to work at all (browser bug?). - host.endsWith(".localhost") || - // It might be localhost (or an IP, see above) if it's a proxy and it - // isn't setting the host header to match the access domain. - host === "localhost" - ) { - logger.debug("no valid cookie doman", field("host", host)) - return undefined - } - - this.options.proxyDomains.forEach((domain) => { - if (host.endsWith(domain) && domain.length < host.length) { - host = domain - } - }) - - logger.debug("got cookie doman", field("host", host)) - return host ? `Domain=${host}` : undefined - } - - /** - * 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. - * - * Throw an error if proxying but the user isn't authenticated. - */ - public maybeProxy(route: ProviderRoute, 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.options.proxyDomains.includes(proxyDomain)) { - return undefined - } - - // Must be authenticated to use the proxy. - route.provider.ensureAuthenticated(request) - - return { - proxy: { - port, - }, - } +export const authenticated = (req: express.Request): boolean => { + switch (req.args.auth) { + case AuthType.None: + return true + case AuthType.Password: + // The password is stored in the cookie after being hashed. + return req.args.password && req.cookies.key && safeCompare(req.cookies.key, hash(req.args.password)) + default: + throw new Error(`Unsupported auth type ${req.args.auth}`) } } + +/** + * Get the relative path that will get us to the root of the page. For each + * slash we need to go up a directory. For example: + * / => . + * /foo => . + * /foo/ => ./.. + * /foo/bar => ./.. + * /foo/bar/ => ./../.. + */ +export const relativeRoot = (req: express.Request): string => { + const depth = (req.originalUrl.split("?", 1)[0].match(/\//g) || []).length + return normalize("./" + (depth > 1 ? "../".repeat(depth - 1) : "")) +} + +/** + * Redirect relatively to `/${to}`. Query variables will be preserved. + * `override` will merge with the existing query (use `undefined` to unset). + */ +export const redirect = ( + req: express.Request, + res: express.Response, + to: string, + override: expressCore.Query = {}, +): void => { + const query = Object.assign({}, req.query, override) + Object.keys(override).forEach((key) => { + if (typeof override[key] === "undefined") { + delete query[key] + } + }) + + const relativePath = normalize(`${relativeRoot(req)}/${to}`, true) + const queryString = qs.stringify(query) + const redirectPath = `${relativePath}${queryString ? `?${queryString}` : ""}` + logger.debug(`redirecting from ${req.originalUrl} to ${redirectPath}`) + res.redirect(redirectPath) +} + +/** + * Get the value that should be used for setting a cookie domain. This will + * allow the user to authenticate only once. This will use the highest level + * domain (e.g. `coder.com` over `test.coder.com` if both are specified). + */ +export const getCookieDomain = (host: string, proxyDomains: string[]): string | undefined => { + const idx = host.lastIndexOf(":") + host = idx !== -1 ? host.substring(0, idx) : host + if ( + // Might be blank/missing, so there's nothing more to do. + !host || + // IP addresses can't have subdomains so there's no value in setting the + // domain for them. Assume anything with a : is ipv6 (valid domain name + // characters are alphanumeric or dashes). + host.includes(":") || + // Assume anything entirely numbers and dots is ipv4 (currently tlds + // cannot be entirely numbers). + !/[^0-9.]/.test(host) || + // localhost subdomains don't seem to work at all (browser bug?). + host.endsWith(".localhost") || + // It might be localhost (or an IP, see above) if it's a proxy and it + // isn't setting the host header to match the access domain. + host === "localhost" + ) { + logger.debug("no valid cookie doman", field("host", host)) + return undefined + } + + proxyDomains.forEach((domain) => { + if (host.endsWith(domain) && domain.length < host.length) { + host = domain + } + }) + + logger.debug("got cookie doman", field("host", host)) + return host ? `Domain=${host}` : undefined +} + +declare module "express" { + function Router(options?: express.RouterOptions): express.Router & WithWebsocketMethod + + type WebsocketRequestHandler = ( + socket: net.Socket, + head: Buffer, + req: express.Request, + next: express.NextFunction, + ) => void | Promise + + type WebsocketMethod = (route: expressCore.PathParams, ...handlers: WebsocketRequestHandler[]) => T + + interface WithWebsocketMethod { + ws: WebsocketMethod + } +} + +export const handleUpgrade = (app: express.Express, server: http.Server): void => { + server.on("upgrade", (req, socket, head) => { + socket.on("error", () => socket.destroy()) + + req.ws = socket + req.head = head + req._ws_handled = false + + const res = new http.ServerResponse(req) + res.writeHead = function writeHead(statusCode: number) { + if (statusCode > 200) { + socket.destroy(new Error(`${statusCode}`)) + } + return res + } + + // Send the request off to be handled by Express. + ;(app as any).handle(req, res, () => { + if (!req._ws_handled) { + socket.destroy(new Error("Not found")) + } + }) + }) +} + +/** + * Patch Express routers to handle web sockets and async routes (since we have + * to patch `get` anyway). + * + * Not using express-ws since the ws-wrapped sockets don't work with the proxy + * and wildcards don't work correctly. + */ +function patchRouter(): void { + // Apparently this all works because Router is also the prototype assigned to + // the routers it returns. + + // Store these since the original methods will be overridden. + const originalGet = (express.Router as any).get + const originalPost = (express.Router as any).post + + // Inject the `ws` method. + ;(express.Router as any).ws = function ws( + route: expressCore.PathParams, + ...handlers: express.WebsocketRequestHandler[] + ) { + originalGet.apply(this, [ + route, + ...handlers.map((handler) => { + const wrapped: express.Handler = (req, _, next) => { + if ((req as any).ws) { + ;(req as any)._ws_handled = true + Promise.resolve(handler((req as any).ws, (req as any).head, req, next)).catch(next) + } else { + next() + } + } + return wrapped + }), + ]) + return this + } + // Overwrite `get` so we can distinguish between websocket and non-websocket + // routes. While we're at it handle async responses. + ;(express.Router as any).get = function get(route: expressCore.PathParams, ...handlers: express.Handler[]) { + originalGet.apply(this, [ + route, + ...handlers.map((handler) => { + const wrapped: express.Handler = (req, res, next) => { + if (!(req as any).ws) { + Promise.resolve(handler(req, res, next)).catch(next) + } else { + next() + } + } + return wrapped + }), + ]) + return this + } + // Handle async responses for `post` as well since we're in here anyway. + ;(express.Router as any).post = function post(route: expressCore.PathParams, ...handlers: express.Handler[]) { + originalPost.apply(this, [ + route, + ...handlers.map((handler) => { + const wrapped: express.Handler = (req, res, next) => { + Promise.resolve(handler(req, res, next)).catch(next) + } + return wrapped + }), + ]) + return this + } +} + +// This needs to happen before anything uses the router. +patchRouter() diff --git a/src/node/proxy.ts b/src/node/proxy.ts new file mode 100644 index 00000000..8f5dd684 --- /dev/null +++ b/src/node/proxy.ts @@ -0,0 +1,73 @@ +import { Request, Router } from "express" +import proxyServer from "http-proxy" +import { HttpCode } from "../common/http" +import { ensureAuthenticated } from "./http" + +export const proxy = proxyServer.createProxyServer({}) +proxy.on("error", (error, _, res) => { + res.writeHead(HttpCode.ServerError) + res.end(error.message) +}) + +// Intercept the response to rewrite absolute redirects against the base path. +proxy.on("proxyRes", (res, req) => { + if (res.headers.location && res.headers.location.startsWith("/") && (req as any).base) { + res.headers.location = (req as any).base + res.headers.location + } +}) + +export const router = Router() + +/** + * Return the port 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. + * + * Throw an error if proxying but the user isn't authenticated. + */ +const maybeProxy = (req: Request): string | undefined => { + // Split into parts. + const host = req.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 || !req.args["proxy-domain"].includes(proxyDomain)) { + return undefined + } + + // Must be authenticated to use the proxy. + ensureAuthenticated(req) + + return port +} + +router.all("*", (req, res, next) => { + const port = maybeProxy(req) + if (!port) { + return next() + } + + proxy.web(req, res, { + ignorePath: true, + target: `http://127.0.0.1:${port}${req.originalUrl}`, + }) +}) + +router.ws("*", (socket, head, req, next) => { + const port = maybeProxy(req) + if (!port) { + return next() + } + + proxy.ws(req, socket, head, { + ignorePath: true, + target: `http://127.0.0.1:${port}${req.originalUrl}`, + }) +}) diff --git a/src/node/routes/health.ts b/src/node/routes/health.ts index 4e505f57..20dab71a 100644 --- a/src/node/routes/health.ts +++ b/src/node/routes/health.ts @@ -1,22 +1,10 @@ -import { Heart } from "../heart" -import { HttpProvider, HttpProviderOptions, HttpResponse } from "../http" +import { Router } from "express" -/** - * Check the heartbeat. - */ -export class HealthHttpProvider extends HttpProvider { - public constructor(options: HttpProviderOptions, private readonly heart: Heart) { - super(options) - } +export const router = Router() - public async handleRequest(): Promise { - return { - cache: false, - mime: "application/json", - content: { - status: this.heart.alive() ? "alive" : "expired", - lastHeartbeat: this.heart.lastHeartbeat, - }, - } - } -} +router.get("/", (req, res) => { + res.json({ + status: req.heart.alive() ? "alive" : "expired", + lastHeartbeat: req.heart.lastHeartbeat, + }) +}) diff --git a/src/node/routes/index.ts b/src/node/routes/index.ts new file mode 100644 index 00000000..82a68866 --- /dev/null +++ b/src/node/routes/index.ts @@ -0,0 +1,123 @@ +import { logger } from "@coder/logger" +import bodyParser from "body-parser" +import cookieParser from "cookie-parser" +import { Express } from "express" +import { promises as fs } from "fs" +import http from "http" +import * as path from "path" +import * as tls from "tls" +import { HttpCode, HttpError } from "../../common/http" +import { plural } from "../../common/util" +import { AuthType, DefaultedArgs } from "../cli" +import { rootPath } from "../constants" +import { Heart } from "../heart" +import { replaceTemplates } from "../http" +import { loadPlugins } from "../plugin" +import * as domainProxy from "../proxy" +import { getMediaMime, paths } from "../util" +import * as health from "./health" +import * as login from "./login" +import * as proxy from "./proxy" +// static is a reserved keyword. +import * as _static from "./static" +import * as update from "./update" +import * as vscode from "./vscode" + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + export interface Request { + args: DefaultedArgs + heart: Heart + } + } +} + +/** + * Register all routes and middleware. + */ +export const register = async (app: Express, server: http.Server, args: DefaultedArgs): Promise => { + const heart = new Heart(path.join(paths.data, "heartbeat"), async () => { + return new Promise((resolve, reject) => { + server.getConnections((error, count) => { + if (error) { + return reject(error) + } + logger.trace(plural(count, `${count} active connection`)) + resolve(count > 0) + }) + }) + }) + + app.disable("x-powered-by") + + app.use(cookieParser()) + app.use(bodyParser.json()) + app.use(bodyParser.urlencoded({ extended: true })) + + server.on("upgrade", () => { + heart.beat() + }) + + app.use(async (req, res, next) => { + heart.beat() + + // If we're handling TLS ensure all requests are redirected to HTTPS. + // TODO: This does *NOT* work if you have a base path since to specify the + // protocol we need to specify the whole path. + if (args.cert && !(req.connection as tls.TLSSocket).encrypted) { + return res.redirect(`https://${req.headers.host}${req.originalUrl}`) + } + + // Return robots.txt. + if (req.originalUrl === "/robots.txt") { + const resourcePath = path.resolve(rootPath, "src/browser/robots.txt") + res.set("Content-Type", getMediaMime(resourcePath)) + return res.send(await fs.readFile(resourcePath)) + } + + // Add common variables routes can use. + req.args = args + req.heart = heart + + return next() + }) + + app.use("/", domainProxy.router) + app.use("/", vscode.router) + app.use("/healthz", health.router) + if (args.auth === AuthType.Password) { + app.use("/login", login.router) + } + app.use("/proxy", proxy.router) + app.use("/static", _static.router) + app.use("/update", update.router) + app.use("/vscode", vscode.router) + + await loadPlugins(app, args) + + app.use(() => { + throw new HttpError("Not Found", HttpCode.NotFound) + }) + + // Handle errors. + // TODO: The types are broken; says they're all implicitly `any`. + app.use(async (err: any, req: any, res: any, next: any) => { + const resourcePath = path.resolve(rootPath, "src/browser/pages/error.html") + res.set("Content-Type", getMediaMime(resourcePath)) + try { + const content = await fs.readFile(resourcePath, "utf8") + if (err.code === "ENOENT" || err.code === "EISDIR") { + err.status = HttpCode.NotFound + } + res.status(err.status || 500).send( + replaceTemplates(req, content) + .replace(/{{ERROR_TITLE}}/g, err.status || "Error") + .replace(/{{ERROR_HEADER}}/g, err.status || "Error") + .replace(/{{ERROR_BODY}}/g, err.message), + ) + } catch (error) { + next(error) + } + }) +} diff --git a/src/node/routes/login.ts b/src/node/routes/login.ts index 58bd55e0..bf1058e5 100644 --- a/src/node/routes/login.ts +++ b/src/node/routes/login.ts @@ -1,140 +1,21 @@ -import * as http from "http" -import * as limiter from "limiter" -import * as querystring from "querystring" -import { HttpCode, HttpError } from "../../common/http" -import { AuthType } from "../cli" -import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http" +import { Router, Request } from "express" +import { promises as fs } from "fs" +import { RateLimiter as Limiter } from "limiter" +import * as path from "path" +import safeCompare from "safe-compare" +import { rootPath } from "../constants" +import { authenticated, getCookieDomain, redirect, replaceTemplates } from "../http" import { hash, humanPath } from "../util" -interface LoginPayload { - password?: string - /** - * Since we must set a cookie with an absolute path, we need to know the full - * base path. - */ - base?: string -} - -/** - * Login HTTP provider. - */ -export class LoginHttpProvider extends HttpProvider { - public constructor( - options: HttpProviderOptions, - private readonly configFile: string, - private readonly envPassword: boolean, - ) { - super(options) - } - - public async handleRequest(route: Route, request: http.IncomingMessage): Promise { - if (this.options.auth !== AuthType.Password || !this.isRoot(route)) { - throw new HttpError("Not found", HttpCode.NotFound) - } - switch (route.base) { - case "/": - switch (request.method) { - case "POST": - this.ensureMethod(request, ["GET", "POST"]) - return this.tryLogin(route, request) - default: - this.ensureMethod(request) - if (this.authenticated(request)) { - return { - redirect: (Array.isArray(route.query.to) ? route.query.to[0] : route.query.to) || "/", - query: { to: undefined }, - } - } - return this.getRoot(route) - } - } - - throw new HttpError("Not found", HttpCode.NotFound) - } - - public async getRoot(route: Route, error?: Error): Promise { - const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/login.html") - response.content = response.content.replace(/{{ERROR}}/, error ? `
${error.message}
` : "") - let passwordMsg = `Check the config file at ${humanPath(this.configFile)} for the password.` - if (this.envPassword) { - passwordMsg = "Password was set from $PASSWORD." - } - response.content = response.content.replace(/{{PASSWORD_MSG}}/g, passwordMsg) - return this.replaceTemplates(route, response) - } - - private readonly limiter = new RateLimiter() - - /** - * Try logging in. On failure, show the login page with an error. - */ - private async tryLogin(route: Route, request: http.IncomingMessage): Promise { - // Already authenticated via cookies? - const providedPassword = this.authenticated(request) - if (providedPassword) { - return { code: HttpCode.Ok } - } - - try { - if (!this.limiter.try()) { - throw new Error("Login rate limited!") - } - - const data = await this.getData(request) - const payload = data ? querystring.parse(data) : {} - return await this.login(payload, route, request) - } catch (error) { - return this.getRoot(route, error) - } - } - - /** - * Return a cookie if the user is authenticated otherwise throw an error. - */ - private async login(payload: LoginPayload, route: Route, request: http.IncomingMessage): Promise { - const password = this.authenticated(request, { - key: typeof payload.password === "string" ? [hash(payload.password)] : undefined, - }) - - if (password) { - return { - redirect: (Array.isArray(route.query.to) ? route.query.to[0] : route.query.to) || "/", - query: { to: undefined }, - cookie: - typeof password === "string" - ? { - key: "key", - value: password, - path: payload.base, - } - : undefined, - } - } - - // Only log if it was an actual login attempt. - if (payload && payload.password) { - console.error( - "Failed login attempt", - JSON.stringify({ - xForwardedFor: request.headers["x-forwarded-for"], - remoteAddress: request.connection.remoteAddress, - userAgent: request.headers["user-agent"], - timestamp: Math.floor(new Date().getTime() / 1000), - }), - ) - - throw new Error("Incorrect password") - } - - throw new Error("Missing password") - } +enum Cookie { + Key = "key", } // RateLimiter wraps around the limiter library for logins. // It allows 2 logins every minute and 12 logins every hour. class RateLimiter { - private readonly minuteLimiter = new limiter.RateLimiter(2, "minute") - private readonly hourLimiter = new limiter.RateLimiter(12, "hour") + private readonly minuteLimiter = new Limiter(2, "minute") + private readonly hourLimiter = new Limiter(12, "hour") public try(): boolean { if (this.minuteLimiter.tryRemoveTokens(1)) { @@ -143,3 +24,72 @@ class RateLimiter { return this.hourLimiter.tryRemoveTokens(1) } } + +const getRoot = async (req: Request, error?: Error): Promise => { + const content = await fs.readFile(path.join(rootPath, "src/browser/pages/login.html"), "utf8") + let passwordMsg = `Check the config file at ${humanPath(req.args.config)} for the password.` + if (req.args.usingEnvPassword) { + passwordMsg = "Password was set from $PASSWORD." + } + return replaceTemplates( + req, + content + .replace(/{{PASSWORD_MSG}}/g, passwordMsg) + .replace(/{{ERROR}}/, error ? `
${error.message}
` : ""), + ) +} + +const limiter = new RateLimiter() + +export const router = Router() + +router.use((req, res, next) => { + const to = (typeof req.query.to === "string" && req.query.to) || "/" + if (authenticated(req)) { + return redirect(req, res, to, { to: undefined }) + } + next() +}) + +router.get("/", async (req, res) => { + res.send(await getRoot(req)) +}) + +router.post("/", async (req, res) => { + try { + if (!limiter.try()) { + throw new Error("Login rate limited!") + } + + if (!req.body.password) { + throw new Error("Missing password") + } + + if (req.args.password && safeCompare(req.body.password, req.args.password)) { + // The hash does not add any actual security but we do it for + // obfuscation purposes (and as a side effect it handles escaping). + res.cookie(Cookie.Key, hash(req.body.password), { + domain: getCookieDomain(req.headers.host || "", req.args["proxy-domain"]), + path: req.body.base || "/", + sameSite: "lax", + }) + + const to = (typeof req.query.to === "string" && req.query.to) || "/" + return redirect(req, res, to, { to: undefined }) + } + + console.error( + "Failed login attempt", + JSON.stringify({ + xForwardedFor: req.headers["x-forwarded-for"], + remoteAddress: req.connection.remoteAddress, + userAgent: req.headers["user-agent"], + timestamp: Math.floor(new Date().getTime() / 1000), + }), + ) + + throw new Error("Incorrect password") + } catch (error) { + res.send(await getRoot(req, error)) + } +}) diff --git a/src/node/routes/proxy.ts b/src/node/routes/proxy.ts index a332cc05..8c83827a 100644 --- a/src/node/routes/proxy.ts +++ b/src/node/routes/proxy.ts @@ -1,43 +1,43 @@ -import * as http from "http" +import { Request, Router } from "express" +import qs from "qs" import { HttpCode, HttpError } from "../../common/http" -import { HttpProvider, HttpResponse, Route, WsResponse } from "../http" +import { authenticated, redirect } from "../http" +import { proxy } from "../proxy" -/** - * Proxy HTTP provider. - */ -export class ProxyHttpProvider extends HttpProvider { - public async handleRequest(route: Route, request: http.IncomingMessage): Promise { - if (!this.authenticated(request)) { - if (this.isRoot(route)) { - return { redirect: "/login", query: { to: route.fullPath } } - } - throw new HttpError("Unauthorized", HttpCode.Unauthorized) - } +export const router = Router() - // 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: { - strip: `${route.providerBase}/${port}`, - port, - }, - } - } - - public async handleWebSocket(route: Route, request: http.IncomingMessage): Promise { - this.ensureAuthenticated(request) - const port = route.base.replace(/^\//, "") - return { - proxy: { - strip: `${route.providerBase}/${port}`, - port, - }, - } +const getProxyTarget = (req: Request, rewrite: boolean): string => { + if (rewrite) { + const query = qs.stringify(req.query) + return `http://127.0.0.1:${req.params.port}/${req.params[0] || ""}${query ? `?${query}` : ""}` } + return `http://127.0.0.1:${req.params.port}/${req.originalUrl}` } + +router.all("/(:port)(/*)?", (req, res) => { + if (!authenticated(req)) { + // If visiting the root (/proxy/:port and nothing else) redirect to the + // login page. + if (!req.params[0] || req.params[0] === "/") { + return redirect(req, res, "login", { + to: `${req.baseUrl}${req.path}` || "/", + }) + } + throw new HttpError("Unauthorized", HttpCode.Unauthorized) + } + + // Absolute redirects need to be based on the subpath when rewriting. + ;(req as any).base = `${req.baseUrl}/${req.params.port}` + + proxy.web(req, res, { + ignorePath: true, + target: getProxyTarget(req, true), + }) +}) + +router.ws("/(:port)(/*)?", (socket, head, req) => { + proxy.ws(req, socket, head, { + ignorePath: true, + target: getProxyTarget(req, true), + }) +}) diff --git a/src/node/routes/static.ts b/src/node/routes/static.ts index 471d0c98..0678c231 100644 --- a/src/node/routes/static.ts +++ b/src/node/routes/static.ts @@ -1,73 +1,61 @@ import { field, logger } from "@coder/logger" -import * as http from "http" +import { Router } from "express" +import { promises as fs } from "fs" import * as path from "path" import { Readable } from "stream" import * as tarFs from "tar-fs" import * as zlib from "zlib" -import { HttpProvider, HttpResponse, Route } from "../http" -import { pathToFsPath } from "../util" +import { HttpCode, HttpError } from "../../common/http" +import { rootPath } from "../constants" +import { authenticated, replaceTemplates } from "../http" +import { getMediaMime, pathToFsPath } from "../util" -/** - * Static file HTTP provider. Static requests do not require authentication if - * the resource is in the application's directory except requests to serve a - * directory as a tar which always requires authentication. - */ -export class StaticHttpProvider extends HttpProvider { - public async handleRequest(route: Route, request: http.IncomingMessage): Promise { - this.ensureMethod(request) +export const router = Router() - if (typeof route.query.tar === "string") { - this.ensureAuthenticated(request) - return this.getTarredResource(request, pathToFsPath(route.query.tar)) - } - - const response = await this.getReplacedResource(request, route) - if (!this.isDev) { - response.cache = true - } - return response +// The commit is for caching. +router.get("/(:commit)(/*)?", async (req, res) => { + if (!req.params[0]) { + throw new HttpError("Not Found", HttpCode.NotFound) } - /** - * Return a resource with variables replaced where necessary. - */ - protected async getReplacedResource(request: http.IncomingMessage, route: Route): Promise { - // The first part is always the commit (for caching purposes). - const split = route.requestPath.split("/").slice(1) + const resourcePath = path.resolve(req.params[0]) - const resourcePath = path.resolve("/", ...split) - - // Make sure it's in code-server or a plugin. - const validPaths = [this.rootPath, process.env.PLUGIN_DIR] - if (!validPaths.find((p) => p && resourcePath.startsWith(p))) { - this.ensureAuthenticated(request) - } - - switch (split[split.length - 1]) { - case "manifest.json": { - const response = await this.getUtf8Resource(resourcePath) - return this.replaceTemplates(route, response) - } - } - return this.getResource(resourcePath) + // Make sure it's in code-server if you aren't authenticated. This lets + // unauthenticated users load the login assets. + if (!resourcePath.startsWith(rootPath) && !authenticated(req)) { + throw new HttpError("Unauthorized", HttpCode.Unauthorized) } - /** - * Tar up and stream a directory. - */ - private async getTarredResource(request: http.IncomingMessage, ...parts: string[]): Promise { - const filePath = path.join(...parts) - let stream: Readable = tarFs.pack(filePath) - const headers: http.OutgoingHttpHeaders = {} - if (request.headers["accept-encoding"] && request.headers["accept-encoding"].includes("gzip")) { - logger.debug("gzipping tar", field("filePath", filePath)) + // Don't cache during development. - can also be used if you want to make a + // static request without caching. + if (req.params.commit !== "development" && req.params.commit !== "-") { + res.header("Cache-Control", "public, max-age=31536000") + } + + const tar = Array.isArray(req.query.tar) ? req.query.tar[0] : req.query.tar + if (typeof tar === "string") { + let stream: Readable = tarFs.pack(pathToFsPath(tar)) + if (req.headers["accept-encoding"] && req.headers["accept-encoding"].includes("gzip")) { + logger.debug("gzipping tar", field("path", resourcePath)) const compress = zlib.createGzip() stream.pipe(compress) stream.on("error", (error) => compress.destroy(error)) stream.on("close", () => compress.end()) stream = compress - headers["content-encoding"] = "gzip" + res.header("content-encoding", "gzip") } - return { stream, filePath, mime: "application/x-tar", cache: true, headers } + res.set("Content-Type", "application/x-tar") + stream.on("close", () => res.end()) + return stream.pipe(res) } -} + + res.set("Content-Type", getMediaMime(resourcePath)) + + if (resourcePath.endsWith("manifest.json")) { + const content = await fs.readFile(resourcePath, "utf8") + return res.send(replaceTemplates(req, content)) + } + + const content = await fs.readFile(resourcePath) + return res.send(content) +}) diff --git a/src/node/routes/update.ts b/src/node/routes/update.ts index a83f578e..ea7479fe 100644 --- a/src/node/routes/update.ts +++ b/src/node/routes/update.ts @@ -1,172 +1,34 @@ -import { field, logger } from "@coder/logger" -import * as http from "http" -import * as https from "https" -import * as path from "path" -import * as semver from "semver" -import * as url from "url" -import { HttpCode, HttpError } from "../../common/http" -import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http" -import { settings as globalSettings, SettingsProvider, UpdateSettings } from "../settings" +import { Router } from "express" +import { version } from "../constants" +import { ensureAuthenticated } from "../http" +import { UpdateProvider } from "../update" -export interface Update { - checked: number - version: string -} +export const router = Router() -export interface LatestResponse { - name: string -} +const provider = new UpdateProvider() -/** - * HTTP provider for checking updates (does not download/install them). - */ -export class UpdateHttpProvider extends HttpProvider { - private update?: Promise - private updateInterval = 1000 * 60 * 60 * 24 // Milliseconds between update checks. +router.use((req, _, next) => { + ensureAuthenticated(req) + next() +}) - public constructor( - options: HttpProviderOptions, - public readonly enabled: boolean, - /** - * The URL for getting the latest version of code-server. Should return JSON - * that fulfills `LatestResponse`. - */ - private readonly latestUrl = "https://api.github.com/repos/cdr/code-server/releases/latest", - /** - * Update information will be stored here. If not provided, the global - * settings will be used. - */ - private readonly settings: SettingsProvider = globalSettings, - ) { - super(options) - } +router.get("/", async (_, res) => { + const update = await provider.getUpdate() + res.json({ + checked: update.checked, + latest: update.version, + current: version, + isLatest: provider.isLatestVersion(update), + }) +}) - public async handleRequest(route: Route, request: http.IncomingMessage): Promise { - this.ensureAuthenticated(request) - this.ensureMethod(request) - - if (!this.isRoot(route)) { - throw new HttpError("Not found", HttpCode.NotFound) - } - - if (!this.enabled) { - throw new Error("update checks are disabled") - } - - switch (route.base) { - case "/check": - case "/": { - const update = await this.getUpdate(route.base === "/check") - return { - content: { - ...update, - isLatest: this.isLatestVersion(update), - }, - } - } - } - - throw new HttpError("Not found", HttpCode.NotFound) - } - - /** - * Query for and return the latest update. - */ - public async getUpdate(force?: boolean): Promise { - // Don't run multiple requests at a time. - if (!this.update) { - this.update = this._getUpdate(force) - this.update.then(() => (this.update = undefined)) - } - - return this.update - } - - private async _getUpdate(force?: boolean): Promise { - const now = Date.now() - try { - let { update } = !force ? await this.settings.read() : { update: undefined } - if (!update || update.checked + this.updateInterval < now) { - const buffer = await this.request(this.latestUrl) - const data = JSON.parse(buffer.toString()) as LatestResponse - update = { checked: now, version: data.name } - await this.settings.write({ update }) - } - logger.debug("got latest version", field("latest", update.version)) - return update - } catch (error) { - logger.error("Failed to get latest version", field("error", error.message)) - return { - checked: now, - version: "unknown", - } - } - } - - public get currentVersion(): string { - return require(path.resolve(__dirname, "../../../package.json")).version - } - - /** - * Return true if the currently installed version is the latest. - */ - public isLatestVersion(latest: Update): boolean { - const version = this.currentVersion - logger.debug("comparing versions", field("current", version), field("latest", latest.version)) - try { - return latest.version === version || semver.lt(latest.version, version) - } catch (error) { - return true - } - } - - private async request(uri: string): Promise { - const response = await this.requestResponse(uri) - return new Promise((resolve, reject) => { - const chunks: Buffer[] = [] - let bufferLength = 0 - response.on("data", (chunk) => { - bufferLength += chunk.length - chunks.push(chunk) - }) - response.on("error", reject) - response.on("end", () => { - resolve(Buffer.concat(chunks, bufferLength)) - }) - }) - } - - private async requestResponse(uri: string): Promise { - let redirects = 0 - const maxRedirects = 10 - return new Promise((resolve, reject) => { - const request = (uri: string): void => { - logger.debug("Making request", field("uri", uri)) - const httpx = uri.startsWith("https") ? https : http - const client = httpx.get(uri, { headers: { "User-Agent": "code-server" } }, (response) => { - if ( - response.statusCode && - response.statusCode >= 300 && - response.statusCode < 400 && - response.headers.location - ) { - ++redirects - if (redirects > maxRedirects) { - return reject(new Error("reached max redirects")) - } - response.destroy() - return request(url.resolve(uri, response.headers.location)) - } - - if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 400) { - return reject(new Error(`${uri}: ${response.statusCode || "500"}`)) - } - - resolve(response) - }) - client.on("error", reject) - } - request(uri) - }) - } -} +// This route will force a check. +router.get("/check", async (_, res) => { + const update = await provider.getUpdate(true) + res.json({ + checked: update.checked, + latest: update.version, + current: version, + isLatest: provider.isLatestVersion(update), + }) +}) diff --git a/src/node/routes/vscode.ts b/src/node/routes/vscode.ts index ed4f714e..5f8dd758 100644 --- a/src/node/routes/vscode.ts +++ b/src/node/routes/vscode.ts @@ -1,237 +1,99 @@ -import { field, logger } from "@coder/logger" -import * as cp from "child_process" import * as crypto from "crypto" -import * as fs from "fs-extra" -import * as http from "http" -import * as net from "net" +import { Router } from "express" +import { promises as fs } from "fs" import * as path from "path" -import { - CodeServerMessage, - Options, - StartPath, - VscodeMessage, - VscodeOptions, - WorkbenchOptions, -} from "../../../lib/vscode/src/vs/server/ipc" -import { HttpCode, HttpError } from "../../common/http" -import { arrayify, generateUuid } from "../../common/util" -import { Args } from "../cli" -import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http" -import { settings } from "../settings" -import { pathToFsPath } from "../util" +import { commit, rootPath, version } from "../constants" +import { authenticated, ensureAuthenticated, redirect, replaceTemplates } from "../http" +import { getMediaMime, pathToFsPath } from "../util" +import { VscodeProvider } from "../vscode" -export class VscodeHttpProvider extends HttpProvider { - private readonly serverRootPath: string - private readonly vsRootPath: string - private _vscode?: Promise +export const router = Router() - public constructor(options: HttpProviderOptions, private readonly args: Args) { - super(options) - this.vsRootPath = path.resolve(this.rootPath, "lib/vscode") - this.serverRootPath = path.join(this.vsRootPath, "out/vs/server") - } +const vscode = new VscodeProvider() - public get running(): boolean { - return !!this._vscode - } - - public async dispose(): Promise { - if (this._vscode) { - const vscode = await this._vscode - vscode.removeAllListeners() - this._vscode = undefined - vscode.kill() - } - } - - private async initialize(options: VscodeOptions): Promise { - const id = generateUuid() - const vscode = await this.fork() - - logger.debug("setting up vs code...") - return new Promise((resolve, reject) => { - vscode.once("message", (message: VscodeMessage) => { - logger.debug("got message from vs code", field("message", message)) - return message.type === "options" && message.id === id - ? resolve(message.options) - : reject(new Error("Unexpected response during initialization")) - }) - vscode.once("error", reject) - vscode.once("exit", (code) => reject(new Error(`VS Code exited unexpectedly with code ${code}`))) - this.send({ type: "init", id, options }, vscode) +router.get("/", async (req, res) => { + if (!authenticated(req)) { + return redirect(req, res, "login", { + to: req.baseUrl || "/", }) } - private fork(): Promise { - if (!this._vscode) { - logger.debug("forking vs code...") - const vscode = cp.fork(path.join(this.serverRootPath, "fork")) - vscode.on("error", (error) => { - logger.error(error.message) - this._vscode = undefined - }) - vscode.on("exit", (code) => { - logger.error(`VS Code exited unexpectedly with code ${code}`) - this._vscode = undefined - }) - - this._vscode = new Promise((resolve, reject) => { - vscode.once("message", (message: VscodeMessage) => { - logger.debug("got message from vs code", field("message", message)) - return message.type === "ready" - ? resolve(vscode) - : reject(new Error("Unexpected response waiting for ready response")) - }) - vscode.once("error", reject) - vscode.once("exit", (code) => reject(new Error(`VS Code exited unexpectedly with code ${code}`))) - }) - } - - return this._vscode - } - - public async handleWebSocket(route: Route, request: http.IncomingMessage, socket: net.Socket): Promise { - if (!this.authenticated(request)) { - throw new Error("not authenticated") - } - - // VS Code expects a raw socket. It will handle all the web socket frames. - // We just need to handle the initial upgrade. - // This magic value is specified by the websocket spec. - const magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" - const reply = crypto - .createHash("sha1") - .update(request.headers["sec-websocket-key"] + magic) - .digest("base64") - socket.write( - [ - "HTTP/1.1 101 Switching Protocols", - "Upgrade: websocket", - "Connection: Upgrade", - `Sec-WebSocket-Accept: ${reply}`, - ].join("\r\n") + "\r\n\r\n", - ) - - const vscode = await this._vscode - this.send({ type: "socket", query: route.query }, vscode, socket) - } - - private send(message: CodeServerMessage, vscode?: cp.ChildProcess, socket?: net.Socket): void { - if (!vscode || vscode.killed) { - throw new Error("vscode is not running") - } - vscode.send(message, socket) - } - - public async handleRequest(route: Route, request: http.IncomingMessage): Promise { - this.ensureMethod(request) - - switch (route.base) { - case "/": - if (!this.isRoot(route)) { - throw new HttpError("Not found", HttpCode.NotFound) - } else if (!this.authenticated(request)) { - return { redirect: "/login", query: { to: route.providerBase } } - } - try { - return await this.getRoot(request, route) - } catch (error) { - const message = `
VS Code failed to load.
${ - this.isDev - ? `
It might not have finished compiling.
` + - `Check for Finished compilation in the output.` - : "" - }

${error}` - return this.getErrorRoot(route, "VS Code failed to load", "500", message) - } - } - - this.ensureAuthenticated(request) - - switch (route.base) { - case "/resource": - case "/vscode-remote-resource": - if (typeof route.query.path === "string") { - return this.getResource(pathToFsPath(route.query.path)) - } - break - case "/webview": - if (/^\/vscode-resource/.test(route.requestPath)) { - return this.getResource(route.requestPath.replace(/^\/vscode-resource(\/file)?/, "")) - } - return this.getResource(this.vsRootPath, "out/vs/workbench/contrib/webview/browser/pre", route.requestPath) - } - - throw new HttpError("Not found", HttpCode.NotFound) - } - - private async getRoot(request: http.IncomingMessage, route: Route): Promise { - const remoteAuthority = request.headers.host as string - const { lastVisited } = await settings.read() - const startPath = await this.getFirstPath([ - { url: route.query.workspace, workspace: true }, - { url: route.query.folder, workspace: false }, - this.args._ && this.args._.length > 0 ? { url: path.resolve(this.args._[this.args._.length - 1]) } : undefined, - lastVisited, - ]) - const [response, options] = await Promise.all([ - await this.getUtf8Resource(this.rootPath, "src/browser/pages/vscode.html"), - this.initialize({ - args: this.args, - remoteAuthority, - startPath, + const [content, options] = await Promise.all([ + await fs.readFile(path.join(rootPath, "src/browser/pages/vscode.html"), "utf8"), + vscode + .initialize( + { + args: req.args, + remoteAuthority: req.headers.host || "", + }, + req.query, + ) + .catch((error) => { + const devMessage = commit === "development" ? "It might not have finished compiling." : "" + throw new Error(`VS Code failed to load. ${devMessage} ${error.message}`) }), - ]) + ]) - settings.write({ - lastVisited: startPath || lastVisited, // If startpath is undefined, then fallback to lastVisited - query: route.query, - }) + options.productConfiguration.codeServerVersion = version - if (!this.isDev) { - response.content = response.content.replace(//g, "") - } - - options.productConfiguration.codeServerVersion = require("../../../package.json").version - - response.content = response.content + res.send( + replaceTemplates( + req, + // Uncomment prod blocks if not in development. TODO: Would this be + // better as a build step? Or maintain two HTML files again? + commit !== "development" ? content.replace(//g, "") : content, + { + disableTelemetry: !!req.args["disable-telemetry"], + }, + ) .replace(`"{{REMOTE_USER_DATA_URI}}"`, `'${JSON.stringify(options.remoteUserDataUri)}'`) .replace(`"{{PRODUCT_CONFIGURATION}}"`, `'${JSON.stringify(options.productConfiguration)}'`) .replace(`"{{WORKBENCH_WEB_CONFIGURATION}}"`, `'${JSON.stringify(options.workbenchWebConfiguration)}'`) - .replace(`"{{NLS_CONFIGURATION}}"`, `'${JSON.stringify(options.nlsConfiguration)}'`) - return this.replaceTemplates(route, response, { - disableTelemetry: !!this.args["disable-telemetry"], - }) - } + .replace(`"{{NLS_CONFIGURATION}}"`, `'${JSON.stringify(options.nlsConfiguration)}'`), + ) +}) - /** - * Choose the first non-empty path. - */ - private async getFirstPath( - startPaths: Array<{ url?: string | string[]; workspace?: boolean } | undefined>, - ): Promise { - const isFile = async (path: string): Promise => { - try { - const stat = await fs.stat(path) - return stat.isFile() - } catch (error) { - logger.warn(error.message) - return false - } - } - for (let i = 0; i < startPaths.length; ++i) { - const startPath = startPaths[i] - const url = arrayify(startPath && startPath.url).find((p) => !!p) - if (startPath && url) { - return { - url, - // The only time `workspace` is undefined is for the command-line - // argument, in which case it's a path (not a URL) so we can stat it - // without having to parse it. - workspace: typeof startPath.workspace !== "undefined" ? startPath.workspace : await isFile(url), - } - } - } - return undefined +router.ws("/", async (socket, _, req) => { + ensureAuthenticated(req) + const magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + const reply = crypto + .createHash("sha1") + .update(req.headers["sec-websocket-key"] + magic) + .digest("base64") + socket.write( + [ + "HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Accept: ${reply}`, + ].join("\r\n") + "\r\n\r\n", + ) + await vscode.sendWebsocket(socket, req.query) +}) + +router.get("/resource(/*)?", async (req, res) => { + ensureAuthenticated(req) + if (typeof req.query.path === "string") { + res.set("Content-Type", getMediaMime(req.query.path)) + res.send(await fs.readFile(pathToFsPath(req.query.path))) } -} +}) + +router.get("/vscode-remote-resource(/*)?", async (req, res) => { + ensureAuthenticated(req) + if (typeof req.query.path === "string") { + res.set("Content-Type", getMediaMime(req.query.path)) + res.send(await fs.readFile(pathToFsPath(req.query.path))) + } +}) + +router.get("/webview/*", async (req, res) => { + ensureAuthenticated(req) + res.set("Content-Type", getMediaMime(req.path)) + if (/^\/vscode-resource/.test(req.path)) { + return res.send(await fs.readFile(req.path.replace(/^\/vscode-resource(\/file)?/, ""))) + } + return res.send( + await fs.readFile(path.join(vscode.vsRootPath, "out/vs/workbench/contrib/webview/browser/pre", req.params[0])), + ) +}) diff --git a/src/node/settings.ts b/src/node/settings.ts index d68e8e3b..5f9427aa 100644 --- a/src/node/settings.ts +++ b/src/node/settings.ts @@ -1,7 +1,7 @@ import { logger } from "@coder/logger" +import { Query } from "express-serve-static-core" import * as fs from "fs-extra" import * as path from "path" -import { Route } from "./http" import { paths } from "./util" export type Settings = { [key: string]: Settings | string | boolean | number } @@ -58,7 +58,7 @@ export interface CoderSettings extends UpdateSettings { url: string workspace: boolean } - query: Route["query"] + query: Query } /** diff --git a/src/node/update.ts b/src/node/update.ts new file mode 100644 index 00000000..42baa184 --- /dev/null +++ b/src/node/update.ts @@ -0,0 +1,133 @@ +import { field, logger } from "@coder/logger" +import * as http from "http" +import * as https from "https" +import * as semver from "semver" +import * as url from "url" +import { version } from "./constants" +import { settings as globalSettings, SettingsProvider, UpdateSettings } from "./settings" + +export interface Update { + checked: number + version: string +} + +export interface LatestResponse { + name: string +} + +/** + * Provide update information. + */ +export class UpdateProvider { + private update?: Promise + private updateInterval = 1000 * 60 * 60 * 24 // Milliseconds between update checks. + + public constructor( + /** + * The URL for getting the latest version of code-server. Should return JSON + * that fulfills `LatestResponse`. + */ + private readonly latestUrl = "https://api.github.com/repos/cdr/code-server/releases/latest", + /** + * Update information will be stored here. If not provided, the global + * settings will be used. + */ + private readonly settings: SettingsProvider = globalSettings, + ) {} + + /** + * Query for and return the latest update. + */ + public async getUpdate(force?: boolean): Promise { + // Don't run multiple requests at a time. + if (!this.update) { + this.update = this._getUpdate(force) + this.update.then(() => (this.update = undefined)) + } + + return this.update + } + + private async _getUpdate(force?: boolean): Promise { + const now = Date.now() + try { + let { update } = !force ? await this.settings.read() : { update: undefined } + if (!update || update.checked + this.updateInterval < now) { + const buffer = await this.request(this.latestUrl) + const data = JSON.parse(buffer.toString()) as LatestResponse + update = { checked: now, version: data.name.replace(/^v/, "") } + await this.settings.write({ update }) + } + logger.debug("got latest version", field("latest", update.version)) + return update + } catch (error) { + logger.error("Failed to get latest version", field("error", error.message)) + return { + checked: now, + version: "unknown", + } + } + } + + /** + * Return true if the currently installed version is the latest. + */ + public isLatestVersion(latest: Update): boolean { + logger.debug("comparing versions", field("current", version), field("latest", latest.version)) + try { + return latest.version === version || semver.lt(latest.version, version) + } catch (error) { + return true + } + } + + private async request(uri: string): Promise { + const response = await this.requestResponse(uri) + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + let bufferLength = 0 + response.on("data", (chunk) => { + bufferLength += chunk.length + chunks.push(chunk) + }) + response.on("error", reject) + response.on("end", () => { + resolve(Buffer.concat(chunks, bufferLength)) + }) + }) + } + + private async requestResponse(uri: string): Promise { + let redirects = 0 + const maxRedirects = 10 + return new Promise((resolve, reject) => { + const request = (uri: string): void => { + logger.debug("Making request", field("uri", uri)) + const httpx = uri.startsWith("https") ? https : http + const client = httpx.get(uri, { headers: { "User-Agent": "code-server" } }, (response) => { + if ( + response.statusCode && + response.statusCode >= 300 && + response.statusCode < 400 && + response.headers.location + ) { + ++redirects + if (redirects > maxRedirects) { + return reject(new Error("reached max redirects")) + } + response.destroy() + return request(url.resolve(uri, response.headers.location)) + } + + if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 400) { + return reject(new Error(`${uri}: ${response.statusCode || "500"}`)) + } + + resolve(response) + }) + client.on("error", reject) + } + request(uri) + }) + } +} diff --git a/src/node/vscode.ts b/src/node/vscode.ts new file mode 100644 index 00000000..5cf4e4b7 --- /dev/null +++ b/src/node/vscode.ts @@ -0,0 +1,150 @@ +import { field, logger } from "@coder/logger" +import * as cp from "child_process" +import * as fs from "fs-extra" +import * as net from "net" +import * as path from "path" +import * as ipc from "../../lib/vscode/src/vs/server/ipc" +import { arrayify, generateUuid } from "../common/util" +import { rootPath } from "./constants" +import { settings } from "./settings" + +export class VscodeProvider { + public readonly serverRootPath: string + public readonly vsRootPath: string + private _vscode?: Promise + + public constructor() { + this.vsRootPath = path.resolve(rootPath, "lib/vscode") + this.serverRootPath = path.join(this.vsRootPath, "out/vs/server") + } + + public async dispose(): Promise { + if (this._vscode) { + const vscode = await this._vscode + vscode.removeAllListeners() + this._vscode = undefined + vscode.kill() + } + } + + public async initialize( + options: Omit, + query: ipc.Query, + ): Promise { + const { lastVisited } = await settings.read() + const startPath = await this.getFirstPath([ + { url: query.workspace, workspace: true }, + { url: query.folder, workspace: false }, + options.args._ && options.args._.length > 0 + ? { url: path.resolve(options.args._[options.args._.length - 1]) } + : undefined, + lastVisited, + ]) + + settings.write({ + lastVisited: startPath, + query, + }) + + const id = generateUuid() + const vscode = await this.fork() + + logger.debug("setting up vs code...") + return new Promise((resolve, reject) => { + vscode.once("message", (message: ipc.VscodeMessage) => { + logger.debug("got message from vs code", field("message", message)) + return message.type === "options" && message.id === id + ? resolve(message.options) + : reject(new Error("Unexpected response during initialization")) + }) + vscode.once("error", reject) + vscode.once("exit", (code) => reject(new Error(`VS Code exited unexpectedly with code ${code}`))) + this.send( + { + type: "init", + id, + options: { + ...options, + startPath, + }, + }, + vscode, + ) + }) + } + + private fork(): Promise { + if (!this._vscode) { + logger.debug("forking vs code...") + const vscode = cp.fork(path.join(this.serverRootPath, "fork")) + vscode.on("error", (error) => { + logger.error(error.message) + this._vscode = undefined + }) + vscode.on("exit", (code) => { + logger.error(`VS Code exited unexpectedly with code ${code}`) + this._vscode = undefined + }) + + this._vscode = new Promise((resolve, reject) => { + vscode.once("message", (message: ipc.VscodeMessage) => { + logger.debug("got message from vs code", field("message", message)) + return message.type === "ready" + ? resolve(vscode) + : reject(new Error("Unexpected response waiting for ready response")) + }) + vscode.once("error", reject) + vscode.once("exit", (code) => reject(new Error(`VS Code exited unexpectedly with code ${code}`))) + }) + } + + return this._vscode + } + + /** + * VS Code expects a raw socket. It will handle all the web socket frames. + */ + public async sendWebsocket(socket: net.Socket, query: ipc.Query): Promise { + // TODO: TLS socket proxy. + const vscode = await this._vscode + this.send({ type: "socket", query }, vscode, socket) + } + + private send(message: ipc.CodeServerMessage, vscode?: cp.ChildProcess, socket?: net.Socket): void { + if (!vscode || vscode.killed) { + throw new Error("vscode is not running") + } + vscode.send(message, socket) + } + + /** + * Choose the first non-empty path. + */ + private async getFirstPath( + startPaths: Array<{ url?: string | string[] | ipc.Query | ipc.Query[]; workspace?: boolean } | undefined>, + ): Promise { + const isFile = async (path: string): Promise => { + try { + const stat = await fs.stat(path) + return stat.isFile() + } catch (error) { + logger.warn(error.message) + return false + } + } + for (let i = 0; i < startPaths.length; ++i) { + const startPath = startPaths[i] + const url = arrayify(startPath && startPath.url).find((p) => !!p) + if (startPath && url && typeof url === "string") { + return { + url, + // The only time `workspace` is undefined is for the command-line + // argument, in which case it's a path (not a URL) so we can stat it + // without having to parse it. + workspace: typeof startPath.workspace !== "undefined" ? startPath.workspace : await isFile(url), + } + } + } + return undefined + } +} diff --git a/test/update.test.ts b/test/update.test.ts index 093429be..29c558f5 100644 --- a/test/update.test.ts +++ b/test/update.test.ts @@ -2,9 +2,8 @@ import * as assert from "assert" import * as fs from "fs-extra" import * as http from "http" import * as path from "path" -import { AuthType } from "../src/node/cli" -import { LatestResponse, UpdateHttpProvider } from "../src/node/routes/update" import { SettingsProvider, UpdateSettings } from "../src/node/settings" +import { LatestResponse, UpdateProvider } from "../src/node/update" import { tmpdir } from "../src/node/util" describe.skip("update", () => { @@ -34,22 +33,14 @@ describe.skip("update", () => { const jsonPath = path.join(tmpdir, "tests/updates/update.json") const settings = new SettingsProvider(jsonPath) - let _provider: UpdateHttpProvider | undefined - const provider = (): UpdateHttpProvider => { + let _provider: UpdateProvider | undefined + const provider = (): UpdateProvider => { if (!_provider) { const address = server.address() if (!address || typeof address === "string" || !address.port) { throw new Error("unexpected address") } - _provider = new UpdateHttpProvider( - { - auth: AuthType.None, - commit: "test", - }, - true, - `http://${address.address}:${address.port}/latest`, - settings, - ) + _provider = new UpdateProvider(`http://${address.address}:${address.port}/latest`, settings) } return _provider } @@ -153,14 +144,10 @@ describe.skip("update", () => { }) it("should not reject if unable to fetch", async () => { - const options = { - auth: AuthType.None, - commit: "test", - } - let provider = new UpdateHttpProvider(options, true, "invalid", settings) + let provider = new UpdateProvider("invalid", settings) await assert.doesNotReject(() => provider.getUpdate(true)) - provider = new UpdateHttpProvider(options, true, "http://probably.invalid.dev.localhost/latest", settings) + provider = new UpdateProvider("http://probably.invalid.dev.localhost/latest", settings) await assert.doesNotReject(() => provider.getUpdate(true)) }) }) diff --git a/yarn.lock b/yarn.lock index d1224a32..1d98caab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1013,7 +1013,7 @@ traverse "^0.6.6" unified "^6.1.6" -"@types/body-parser@*": +"@types/body-parser@*", "@types/body-parser@^1.19.0": version "1.19.0" resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.0.tgz#0685b3c47eb3006ffed117cdd55164b61f80538f" integrity sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ== @@ -1028,6 +1028,13 @@ dependencies: "@types/node" "*" +"@types/cookie-parser@^1.4.2": + version "1.4.2" + resolved "https://registry.yarnpkg.com/@types/cookie-parser/-/cookie-parser-1.4.2.tgz#e4d5c5ffda82b80672a88a4281aaceefb1bd9df5" + integrity sha512-uwcY8m6SDQqciHsqcKDGbo10GdasYsPCYkH3hVegj9qAah6pX5HivOnOuI3WYmyQMnOATV39zv/Ybs0bC/6iVg== + dependencies: + "@types/express" "*" + "@types/eslint-visitor-keys@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d" @@ -1042,7 +1049,7 @@ "@types/qs" "*" "@types/range-parser" "*" -"@types/express@^4.17.8": +"@types/express@*", "@types/express@^4.17.8": version "4.17.8" resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.8.tgz#3df4293293317e61c60137d273a2e96cd8d5f27a" integrity sha512-wLhcKh3PMlyA2cNAB9sjM1BntnhPMiM0JOBwPBqttjHev2428MLEB4AYVN+d8s2iyCVZac+o41Pflm/ZH5vLXQ== @@ -1659,7 +1666,7 @@ bn.js@^5.1.1: resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.1.3.tgz#beca005408f642ebebea80b042b4d18d2ac0ee6b" integrity sha512-GkTiFpjFtUzU9CbMeJ5iazkCzGL3jrhzerzZIuqLABjbwRaFt33I9tUdSNryIptM+RxDet6OKm2WnLXzW51KsQ== -body-parser@1.19.0: +body-parser@1.19.0, body-parser@^1.19.0: version "1.19.0" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a" integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw== @@ -2251,6 +2258,14 @@ convert-source-map@^1.5.1, convert-source-map@^1.7.0: dependencies: safe-buffer "~5.1.1" +cookie-parser@^1.4.5: + version "1.4.5" + resolved "https://registry.yarnpkg.com/cookie-parser/-/cookie-parser-1.4.5.tgz#3e572d4b7c0c80f9c61daf604e4336831b5d1d49" + integrity sha512-f13bPUj/gG/5mDr+xLmSxxDsB9DQiTIfhJS/sqjrmfAWiAN+x2O4i/XguTL9yDZ+/IFDanJ+5x7hC4CXT9Tdzw== + dependencies: + cookie "0.4.0" + cookie-signature "1.0.6" + cookie-signature@1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"