diff --git a/src/node/app.ts b/src/node/app.ts new file mode 100644 index 000000000..7e5e7a2ef --- /dev/null +++ b/src/node/app.ts @@ -0,0 +1,57 @@ +import { logger } from "@coder/logger" +import express, { Express } from "express" +import { promises as fs } from "fs" +import http from "http" +import * as httpolyglot from "httpolyglot" +import { DefaultedArgs } from "./cli" + +/** + * Create an Express app and an HTTP/S server to serve it. + */ +export const createApp = async (args: DefaultedArgs): Promise<[Express, http.Server]> => { + const app = express() + + const server = args.cert + ? httpolyglot.createServer( + { + cert: args.cert && (await fs.readFile(args.cert.value)), + key: args["cert-key"] && (await fs.readFile(args["cert-key"])), + }, + app, + ) + : http.createServer(app) + + await new Promise(async (resolve, reject) => { + server.on("error", reject) + if (args.socket) { + try { + await fs.unlink(args.socket) + } catch (error) { + if (error.code !== "ENOENT") { + logger.error(error.message) + } + } + server.listen(args.socket, resolve) + } else { + // [] is the correct format when using :: but Node errors with them. + server.listen(args.port, args.host.replace(/^\[|\]$/g, ""), resolve) + } + }) + + return [app, server] +} + +/** + * Get the address of a server as a string (protocol not included) while + * ensuring there is one (will throw if there isn't). + */ +export const ensureAddress = (server: http.Server): string => { + const addr = server.address() + if (!addr) { + throw new Error("server has no address") + } + if (typeof addr !== "string") { + return `${addr.address}:${addr.port}` + } + return addr +} diff --git a/src/node/entry.ts b/src/node/entry.ts index 630e4b0b7..e8c6b4e1f 100644 --- a/src/node/entry.ts +++ b/src/node/entry.ts @@ -5,13 +5,9 @@ import http from "http" import * as path from "path" import { CliMessage, OpenCommandPipeArgs } from "../../lib/vscode/src/vs/server/ipc" import { plural } from "../common/util" -import { HealthHttpProvider } from "./routes/health" -import { LoginHttpProvider } from "./routes/login" -import { ProxyHttpProvider } from "./routes/proxy" -import { StaticHttpProvider } from "./routes/static" -import { UpdateHttpProvider } from "./routes/update" -import { VscodeHttpProvider } from "./routes/vscode" +import { createApp, ensureAddress } from "./app" import { + AuthType, DefaultedArgs, optionDescriptions, parse, @@ -21,9 +17,8 @@ import { shouldRunVsCodeCli, } from "./cli" import { coderCloudBind } from "./coder-cloud" -import { AuthType, HttpServer, HttpServerOptions } from "./http" import { loadPlugins } from "./plugin" -import { hash, humanPath, open } from "./util" +import { humanPath, open } from "./util" import { ipcMain, WrapperProcess } from "./wrapper" let pkg: { version?: string; commit?: string } = {} @@ -117,65 +112,39 @@ export const openInExistingInstance = async (args: DefaultedArgs, socketPath: st } const main = async (args: DefaultedArgs): Promise => { + logger.info(`code-server ${version} ${commit}`) + logger.info(`Using user-data-dir ${humanPath(args["user-data-dir"])}`) logger.trace(`Using extensions-dir ${humanPath(args["extensions-dir"])}`) if (args.auth === AuthType.Password && !args.password) { throw new Error("Please pass in a password via the config file or $PASSWORD") } - - // Spawn the main HTTP server. - const options: HttpServerOptions = { - auth: args.auth, - commit, - host: args.host, - // The hash does not add any actual security but we do it for obfuscation purposes. - password: args.password ? hash(args.password) : undefined, - port: args.port, - proxyDomains: args["proxy-domain"], - socket: args.socket, - cert: args.cert && args.cert.value, - certKey: args["cert-key"], - } - - if (options.cert && !options.certKey) { - throw new Error("--cert-key is missing") - } - - const httpServer = new HttpServer(options) - httpServer.registerHttpProvider(["/", "/vscode"], VscodeHttpProvider, args) - httpServer.registerHttpProvider("/update", UpdateHttpProvider, false) - httpServer.registerHttpProvider("/proxy", ProxyHttpProvider) - httpServer.registerHttpProvider("/login", LoginHttpProvider, args.config!, args.usingEnvPassword) - httpServer.registerHttpProvider("/static", StaticHttpProvider) - httpServer.registerHttpProvider("/healthz", HealthHttpProvider, httpServer.heart) - - await loadPlugins(httpServer, args) - ipcMain.onDispose(() => { - httpServer.dispose().then((errors) => { - errors.forEach((error) => logger.error(error.message)) - }) + // TODO: register disposables }) - logger.info(`code-server ${version} ${commit}`) - logger.info(`Using config file ${humanPath(args.config)}`) + const [app, server] = await createApp(args) + const serverAddress = ensureAddress(server) - const serverAddress = await httpServer.listen() + // TODO: register routes + await loadPlugins(app, args) + + logger.info(`Using config file ${humanPath(args.config)}`) logger.info(`HTTP server listening on ${serverAddress} ${args.link ? "(randomized by --link)" : ""}`) if (args.auth === AuthType.Password) { + logger.info(" - Authentication is enabled") if (args.usingEnvPassword) { logger.info(" - Using password from $PASSWORD") } else { logger.info(` - Using password from ${humanPath(args.config)}`) } - logger.info(" - To disable use `--auth none`") } else { - logger.info(` - No authentication ${args.link ? "(disabled by --link)" : ""}`) + logger.info(` - Authentication is disabled ${args.link ? "(disabled by --link)" : ""}`) } - if (httpServer.protocol === "https") { + if (args.cert) { logger.info( args.cert && args.cert.value ? ` - Using provided certificate and key for HTTPS` @@ -192,7 +161,7 @@ const main = async (args: DefaultedArgs): Promise => { if (args.link) { try { - await coderCloudBind(serverAddress!, args.link.value) + await coderCloudBind(serverAddress, args.link.value) logger.info(" - Connected to cloud agent") } catch (err) { logger.error(err.message) @@ -200,7 +169,7 @@ const main = async (args: DefaultedArgs): Promise => { } } - if (serverAddress && !options.socket && args.open) { + if (serverAddress && !args.socket && args.open) { // The web socket doesn't seem to work if browsing with 0.0.0.0. const openAddress = serverAddress.replace(/:\/\/0.0.0.0/, "://localhost") await open(openAddress).catch((error: Error) => { diff --git a/src/node/plugin.ts b/src/node/plugin.ts index 7469f317d..20c19d3e7 100644 --- a/src/node/plugin.ts +++ b/src/node/plugin.ts @@ -1,14 +1,14 @@ import { field, logger } from "@coder/logger" +import { Express } from "express" import * as fs from "fs" import * as path from "path" import * as util from "util" import { Args } from "./cli" -import { HttpServer } from "./http" import { paths } from "./util" /* eslint-disable @typescript-eslint/no-var-requires */ -export type Activate = (httpServer: HttpServer, args: Args) => void +export type Activate = (app: Express, args: Args) => void /** * Plugins must implement this interface. @@ -30,10 +30,10 @@ require("module")._load = function (request: string, parent: object, isMain: boo /** * Load a plugin and run its activation function. */ -const loadPlugin = async (pluginPath: string, httpServer: HttpServer, args: Args): Promise => { +const loadPlugin = async (pluginPath: string, app: Express, args: Args): Promise => { try { const plugin: Plugin = require(pluginPath) - plugin.activate(httpServer, args) + plugin.activate(app, args) const packageJson = require(path.join(pluginPath, "package.json")) logger.debug( @@ -50,12 +50,12 @@ const loadPlugin = async (pluginPath: string, httpServer: HttpServer, args: Args /** * Load all plugins in the specified directory. */ -const _loadPlugins = async (pluginDir: string, httpServer: HttpServer, args: Args): Promise => { +const _loadPlugins = async (pluginDir: string, app: Express, args: Args): Promise => { try { const files = await util.promisify(fs.readdir)(pluginDir, { withFileTypes: true, }) - await Promise.all(files.map((file) => loadPlugin(path.join(pluginDir, file.name), httpServer, args))) + await Promise.all(files.map((file) => loadPlugin(path.join(pluginDir, file.name), app, args))) } catch (error) { if (error.code !== "ENOENT") { logger.warn(error.message) @@ -68,17 +68,17 @@ const _loadPlugins = async (pluginDir: string, httpServer: HttpServer, args: Arg * `CS_PLUGIN_PATH` (colon-separated), and individual plugins specified by * `CS_PLUGIN` (also colon-separated). */ -export const loadPlugins = async (httpServer: HttpServer, args: Args): Promise => { +export const loadPlugins = async (app: Express, args: Args): Promise => { const pluginPath = process.env.CS_PLUGIN_PATH || `${path.join(paths.data, "plugins")}:/usr/share/code-server/plugins` const plugin = process.env.CS_PLUGIN || "" await Promise.all([ // Built-in plugins. - _loadPlugins(path.resolve(__dirname, "../../plugins"), httpServer, args), + _loadPlugins(path.resolve(__dirname, "../../plugins"), app, args), // User-added plugins. ...pluginPath .split(":") .filter((p) => !!p) - .map((dir) => _loadPlugins(path.resolve(dir), httpServer, args)), + .map((dir) => _loadPlugins(path.resolve(dir), app, args)), // Individual plugins so you don't have to symlink or move them into a // directory specifically for plugins. This lets you load plugins that are // on the same level as other directories that are not plugins (if you tried @@ -87,6 +87,6 @@ export const loadPlugins = async (httpServer: HttpServer, args: Args): Promise !!p) - .map((dir) => loadPlugin(path.resolve(dir), httpServer, args)), + .map((dir) => loadPlugin(path.resolve(dir), app, args)), ]) }