code-server/packages/server/src/cli.ts
Dean Sheather a65773338c
add failed authentication attempt logger
When `isAuthed()` is called and the password cookie is not what we
expected, the failed login attempt is logged with the provided password,
remote address and user agent.

To allow for logging failed attempts with a reverse proxy, the
`--trust-proxy` argument has been added to trust the `X-Forwarded-For`
header. This implementation of an `X-Forwarded-For` parser uses the last
value in the list, therefore only trusting the nearest proxy.
2019-07-07 16:50:43 +10:00

357 lines
13 KiB
TypeScript

import { field, logger } from "@coder/logger";
import { ServerMessage, SharedProcessActive } from "@coder/protocol/src/proto";
import { withEnv } from "@coder/protocol";
import { ChildProcess, fork, ForkOptions } from "child_process";
import { randomFillSync } from "crypto";
import * as fs from "fs";
import * as fse from "fs-extra";
import * as os from "os";
import * as path from "path";
import * as WebSocket from "ws";
import { buildDir, cacheHome, dataHome, isCli, serveStatic } from "./constants";
import { createApp } from "./server";
import { forkModule, requireModule } from "./vscode/bootstrapFork";
import { SharedProcess, SharedProcessState } from "./vscode/sharedProcess";
import opn = require("opn");
import * as commander from "commander";
const collect = <T>(value: T, previous: T[]): T[] => {
return previous.concat(value);
};
commander.version(process.env.VERSION || "development")
.name("code-server")
.description("Run VS Code on a remote server.")
.option("--cert <value>")
.option("--cert-key <value>")
.option("-e, --extensions-dir <dir>", "Override the main default path for user extensions.")
.option("--extra-extensions-dir [dir]", "Path to an extra user extension directory (repeatable).", collect, [])
.option("--extra-builtin-extensions-dir [dir]", "Path to an extra built-in extension directory (repeatable).", collect, [])
.option("-d --user-data-dir <dir>", "Specifies the directory that user data is kept in, useful when running as root.")
.option("--data-dir <value>", "DEPRECATED: Use '--user-data-dir' instead. Customize where user-data is stored.")
.option("-h, --host <value>", "Customize the hostname.", "0.0.0.0")
.option("-o, --open", "Open in the browser on startup.", false)
.option("-p, --port <number>", "Port to bind on.", parseInt(process.env.PORT!, 10) || 8443)
.option("-N, --no-auth", "Start without requiring authentication.", false)
.option("-H, --allow-http", "Allow http connections.", false)
.option("-P, --password <value>", "DEPRECATED: Use the PASSWORD environment variable instead. Specify a password for authentication.")
.option("--disable-telemetry", "Disables ALL telemetry.", false)
.option("--socket <value>", "Listen on a UNIX socket. Host and port will be ignored when set.")
.option("--trust-proxy", "Trust the X-Forwarded-For header, useful when using a reverse proxy.", false)
.option("--install-extension <value>", "Install an extension by its ID.")
.option("--bootstrap-fork <name>", "Used for development. Never set.")
.option("--extra-args <args>", "Used for development. Never set.")
.arguments("Specify working directory.")
.parse(process.argv);
Error.stackTraceLimit = Infinity;
if (isCli) {
require("nbin").shimNativeFs(buildDir);
require("nbin").shimNativeFs("/node_modules");
}
// Makes strings or numbers bold in stdout
const bold = (text: string | number): string | number => {
return `\u001B[1m${text}\u001B[0m`;
};
(async (): Promise<void> => {
const args = commander.args;
const options = commander.opts() as {
noAuth: boolean;
readonly allowHttp: boolean;
readonly host: string;
readonly port: number;
readonly disableTelemetry: boolean;
readonly userDataDir?: string;
readonly extensionsDir?: string;
readonly extraExtensionsDir?: string[];
readonly extraBuiltinExtensionsDir?: string[];
readonly dataDir?: string;
readonly password?: string;
readonly open?: boolean;
readonly cert?: string;
readonly certKey?: string;
readonly socket?: string;
readonly trustProxy?: boolean;
readonly installExtension?: string;
readonly bootstrapFork?: string;
readonly extraArgs?: string;
};
if (options.disableTelemetry) {
process.env.DISABLE_TELEMETRY = "true";
}
// Commander has an exception for `--no` prefixes. Here we'll adjust that.
// tslint:disable-next-line:no-any
const noAuthValue = (commander as any).auth;
options.noAuth = !noAuthValue;
const dataDir = path.resolve(options.userDataDir || options.dataDir || path.join(dataHome, "code-server"));
const extensionsDir = options.extensionsDir ? path.resolve(options.extensionsDir) : path.resolve(dataDir, "extensions");
const builtInExtensionsDir = path.resolve(buildDir || path.join(__dirname, ".."), "build/extensions");
const extraExtensionDirs = options.extraExtensionsDir ? options.extraExtensionsDir.map((p) => path.resolve(p)) : [];
const extraBuiltinExtensionDirs = options.extraBuiltinExtensionsDir ? options.extraBuiltinExtensionsDir.map((p) => path.resolve(p)) : [];
const workingDir = path.resolve(args[0] || process.cwd());
const dependenciesDir = path.join(os.tmpdir(), "code-server/dependencies");
if (!fs.existsSync(dataDir)) {
const oldDataDir = path.resolve(path.join(os.homedir(), ".code-server"));
if (fs.existsSync(oldDataDir)) {
await fse.move(oldDataDir, dataDir);
logger.info(`Moved data directory from ${oldDataDir} to ${dataDir}`);
}
}
await Promise.all([
fse.mkdirp(cacheHome),
fse.mkdirp(dataDir),
fse.mkdirp(extensionsDir),
fse.mkdirp(workingDir),
fse.mkdirp(dependenciesDir),
...extraExtensionDirs.map((p) => fse.mkdirp(p)),
...extraBuiltinExtensionDirs.map((p) => fse.mkdirp(p)),
]);
const unpackExecutable = (binaryName: string): void => {
const memFile = path.join(isCli ? buildDir! : path.join(__dirname, ".."), "build/dependencies", binaryName);
const diskFile = path.join(dependenciesDir, binaryName);
if (!fse.existsSync(diskFile)) {
fse.writeFileSync(diskFile, fse.readFileSync(memFile));
}
fse.chmodSync(diskFile, "755");
};
unpackExecutable("rg");
// tslint:disable-next-line no-any
(<any>global).RIPGREP_LOCATION = path.join(dependenciesDir, "rg");
if (options.bootstrapFork) {
const modulePath = options.bootstrapFork;
if (!modulePath) {
logger.error("No module path specified to fork!");
process.exit(1);
}
process.argv = [
process.argv[0],
process.argv[1],
...(options.extraArgs ? JSON.parse(options.extraArgs) : []),
];
return requireModule(modulePath, builtInExtensionsDir);
}
const logDir = path.join(cacheHome, "code-server/logs", new Date().toISOString().replace(/[-:.TZ]/g, ""));
process.env.VSCODE_LOGS = logDir;
const certPath = options.cert ? path.resolve(options.cert) : undefined;
const certKeyPath = options.certKey ? path.resolve(options.certKey) : undefined;
if (certPath && !certKeyPath) {
logger.error("'--cert-key' flag is required when specifying a certificate!");
process.exit(1);
}
if (!certPath && certKeyPath) {
logger.error("'--cert' flag is required when specifying certificate key!");
process.exit(1);
}
let certData: Buffer | undefined;
let certKeyData: Buffer | undefined;
if (typeof certPath !== "undefined" && typeof certKeyPath !== "undefined") {
try {
certData = fs.readFileSync(certPath);
} catch (ex) {
logger.error(`Failed to read certificate: ${ex.message}`);
process.exit(1);
}
try {
certKeyData = fs.readFileSync(certKeyPath);
} catch (ex) {
logger.error(`Failed to read certificate key: ${ex.message}`);
process.exit(1);
}
}
logger.info(`\u001B[1mcode-server ${process.env.VERSION ? `v${process.env.VERSION}` : "development"}`);
if (options.dataDir) {
logger.warn('"--data-dir" is deprecated. Use "--user-data-dir" instead.');
}
if (options.installExtension) {
const fork = forkModule("vs/code/node/cli", [
"--user-data-dir", dataDir,
"--builtin-extensions-dir", builtInExtensionsDir,
"--extensions-dir", extensionsDir,
"--install-extension", options.installExtension,
], withEnv({ env: { VSCODE_ALLOW_IO: "true" } }), dataDir);
fork.stdout.on("data", (d: Buffer) => d.toString().split("\n").forEach((l) => logger.info(l)));
fork.stderr.on("data", (d: Buffer) => d.toString().split("\n").forEach((l) => logger.error(l)));
fork.on("exit", () => process.exit());
return;
}
// TODO: fill in appropriate doc url
logger.info("Additional documentation: http://github.com/cdr/code-server");
logger.info("Initializing", field("data-dir", dataDir), field("extensions-dir", extensionsDir), field("working-dir", workingDir), field("log-dir", logDir));
const sharedProcess = new SharedProcess(dataDir, extensionsDir, builtInExtensionsDir, extraExtensionDirs, extraBuiltinExtensionDirs);
const sendSharedProcessReady = (socket: WebSocket): void => {
const active = new SharedProcessActive();
active.setSocketPath(sharedProcess.socketPath);
active.setLogPath(logDir);
const serverMessage = new ServerMessage();
serverMessage.setSharedProcessActive(active);
socket.send(serverMessage.serializeBinary());
};
sharedProcess.onState((event) => {
if (event.state === SharedProcessState.Ready) {
app.wss.clients.forEach((c) => sendSharedProcessReady(c));
}
});
if (options.password) {
logger.warn('"--password" is deprecated. Use the PASSWORD environment variable instead.');
}
let password = options.password || process.env.PASSWORD;
const usingCustomPassword = !!password;
if (!password) {
// Generate a random password with a length of 24.
const buffer = Buffer.alloc(12);
randomFillSync(buffer);
password = buffer.toString("hex");
}
const hasCustomHttps = certData && certKeyData;
const app = await createApp({
allowHttp: options.allowHttp,
bypassAuth: options.noAuth,
registerMiddleware: (app): void => {
// If we're not running from the binary and we aren't serving the static
// pre-built version, use webpack to serve the web files.
if (!isCli && !serveStatic) {
const webpackConfig = require(path.resolve(__dirname, "..", "..", "web", "webpack.config.js"));
const compiler = require("webpack")(webpackConfig);
app.use(require("webpack-dev-middleware")(compiler, {
logger: {
trace: (m: string): void => logger.trace("webpack", field("message", m)),
debug: (m: string): void => logger.debug("webpack", field("message", m)),
info: (m: string): void => logger.info("webpack", field("message", m)),
warn: (m: string): void => logger.warn("webpack", field("message", m)),
error: (m: string): void => logger.error("webpack", field("message", m)),
},
publicPath: webpackConfig.output.publicPath,
stats: webpackConfig.stats,
}));
app.use(require("webpack-hot-middleware")(compiler));
}
},
serverOptions: {
extensionsDirectory: extensionsDir,
builtInExtensionsDirectory: builtInExtensionsDir,
extraExtensionDirectories: extraExtensionDirs,
extraBuiltinExtensionDirectories: extraBuiltinExtensionDirs,
dataDirectory: dataDir,
workingDirectory: workingDir,
cacheDirectory: cacheHome,
fork: (modulePath: string, args?: string[], options?: ForkOptions): ChildProcess => {
if (options && options.env && options.env.AMD_ENTRYPOINT) {
return forkModule(options.env.AMD_ENTRYPOINT, args, options, dataDir);
}
return fork(modulePath, args, options);
},
},
password,
trustProxy: options.trustProxy,
httpsOptions: hasCustomHttps ? {
key: certKeyData,
cert: certData,
} : undefined,
});
if (options.socket) {
logger.info("Starting webserver via socket...", field("socket", options.socket));
app.server.listen(options.socket, () => {
logger.info(" ");
logger.info("Started on socket address:");
logger.info(options.socket!);
logger.info(" ");
});
} else {
logger.info("Starting webserver...", field("host", options.host), field("port", options.port));
app.server.listen(options.port, options.host, async () => {
const protocol = options.allowHttp ? "http" : "https";
const address = app.server.address();
const port = typeof address === "string" ? options.port : address.port;
const url = `${protocol}://localhost:${port}/`;
logger.info(" ");
logger.info("Started (click the link below to open):");
logger.info(url);
logger.info(" ");
if (options.open) {
try {
await opn(url);
} catch (e) {
logger.warn("Url couldn't be opened automatically.", field("url", url), field("error", e.message));
}
}
});
}
let clientId = 1;
app.wss.on("connection", (ws, req) => {
const id = clientId++;
if (sharedProcess.state === SharedProcessState.Ready) {
sendSharedProcessReady(ws);
}
logger.info(`WebSocket opened \u001B[0m${req.url}`, field("client", id), field("ip", req.socket.remoteAddress));
ws.on("close", (code) => {
logger.info(`WebSocket closed \u001B[0m${req.url}`, field("client", id), field("code", code));
});
});
app.wss.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
if (options.socket) {
logger.error(`Socket ${bold(options.socket)} is in use. Please specify a different socket.`);
} else {
logger.error(`Port ${bold(options.port)} is in use. Please free up port ${options.port} or specify a different port with the -p flag`);
}
process.exit(1);
}
});
if (!options.certKey && !options.cert) {
logger.warn("No certificate specified. \u001B[1mThis could be insecure.");
// TODO: fill in appropriate doc url
logger.warn("Documentation on securing your setup: https://github.com/cdr/code-server/blob/master/doc/security/ssl.md");
}
if (!options.noAuth) {
logger.info(" ");
logger.info(usingCustomPassword ? "Using custom password." : `Password:\u001B[1m ${password}`);
} else {
logger.warn(" ");
logger.warn("Launched without authentication.");
}
if (options.disableTelemetry) {
logger.info(" ");
logger.info("Telemetry is disabled.");
}
})().catch((ex) => {
logger.error(ex);
});