diff --git a/src/node/cli.ts b/src/node/cli.ts index b3017b666175..d21f6464fd40 100644 --- a/src/node/cli.ts +++ b/src/node/cli.ts @@ -43,6 +43,11 @@ export interface Args extends VsArgs { port?: number "bind-addr"?: string socket?: string + "enable-get-requests"?: boolean + tokens?: string[] + "generate-token"?: boolean + "list-tokens"?: boolean + "revoke-token"?: string version?: boolean force?: boolean "list-extensions"?: boolean @@ -147,6 +152,27 @@ const options: Options<Required<Args>> = { port: { type: "number", description: "" }, socket: { type: "string", path: true, description: "Path to a socket (bind-addr will be ignored)." }, + "enable-get-requests": { + type: "boolean", + description: `Enable authentication via the url with a query parameter. (Usage: ?pass=[password] after the rest of the url.)` + }, + "tokens": { + type: "string[]", + description: "" + }, + "list-tokens": { + type: "boolean", + short: "t", + description: "List currently active tokens." + }, + "generate-token": { + type: "boolean", + description: "Generate a new token for quick access." + }, + "revoke-token": { + type: "string", + description: "Remove and disable a specific token from use." + }, version: { type: "boolean", short: "v", description: "Display version information." }, _: { type: "string[]" }, @@ -541,6 +567,31 @@ export async function readConfigFile(configPath?: string): Promise<ConfigArgs> { } } +export async function writeConfigFile(configPath?: string, data? : object) { + if (!configPath) { + configPath = process.env.CODE_SERVER_CONFIG + if (!configPath) { + configPath = path.join(paths.config, "config.yaml") + } + } + + if (!(await fs.pathExists(configPath))) { + await fs.outputFile(configPath, await defaultConfigFile()) + logger.info(`Wrote default config file to ${humanPath(configPath)}`) + } + + const configFile = await fs.readFile(configPath) + const config = yaml.safeLoad(configFile.toString(), { + filename: configPath, + }) + if (!config || typeof config === "string") { + throw new Error(`invalid config: ${config}`) + } + + const dumpedData = yaml.safeDump({...config, ...data}); + await fs.outputFile(configPath, dumpedData) +} + function parseBindAddr(bindAddr: string): Addr { const u = new URL(`http://${bindAddr}`) return { diff --git a/src/node/entry.ts b/src/node/entry.ts index ac615da68352..50e5345d58a2 100644 --- a/src/node/entry.ts +++ b/src/node/entry.ts @@ -11,6 +11,7 @@ import { optionDescriptions, parse, readConfigFile, + writeConfigFile, setDefaults, shouldOpenInExistingInstance, shouldRunVsCodeCli, @@ -19,7 +20,7 @@ import { coderCloudBind } from "./coder_cloud" import { commit, version } from "./constants" import * as proxyAgent from "./proxy_agent" import { register } from "./routes" -import { humanPath, isFile, open } from "./util" +import { humanPath, isFile, open, generatePassword } from "./util" import { isChild, wrapper } from "./wrapper" export const runVsCodeCli = (args: DefaultedArgs): void => { @@ -125,6 +126,10 @@ const main = async (args: DefaultedArgs): Promise<void> => { logger.info(` - Authentication is disabled ${args.link ? "(disabled by --link)" : ""}`) } + if (args["enable-get-requests"]) { + logger.info(` - Login via GET is enabled ${args.auth === AuthType.None ? "(however auth is disabled)" : ""}`) + } + if (args.cert) { logger.info(` - Using certificate for HTTPS: ${humanPath(args.cert.value)}`) } else { @@ -202,6 +207,55 @@ async function entry(): Promise<void> { return } + if (args.tokens) { + args.tokens = args.tokens[0].split(",") + } + + if (args["list-tokens"]) { + console.log("code-server", version, commit) + console.log("") + if (!args.tokens) { + return console.log("No tokens currently exist") + } + console.log("Tokens") + args.tokens.forEach(token => { + console.log(" -", token) + }) + return + } + + if (args["generate-token"]) { + console.log("code-server", version, commit) + console.log("") + + if (!args.tokens) { + args.tokens = [] + } + + const token = await generatePassword() + args.tokens.push(token) + writeConfigFile(cliArgs.config, { tokens: args.tokens }) + console.log("Generated token:", token) + return + } + + if (args["revoke-token"]) { + console.log("code-server", version, commit) + console.log("") + + if (args.tokens?.includes(args["revoke-token"])) { + args.tokens = args.tokens.filter(token => { + return token != args["revoke-token"] + }) + writeConfigFile(cliArgs.config, { tokens: args.tokens }) + console.log("The token has successfully been revoked") + } + else { + console.log("The token specified does not exist") + } + return + } + if (shouldRunVsCodeCli(args)) { return runVsCodeCli(args) } diff --git a/src/node/routes/login.ts b/src/node/routes/login.ts index 4db7fd825d13..201a7eb602de 100644 --- a/src/node/routes/login.ts +++ b/src/node/routes/login.ts @@ -1,4 +1,4 @@ -import { Router, Request } from "express" +import { Router, Request, Response, NextFunction } from "express" import { promises as fs } from "fs" import { RateLimiter as Limiter } from "limiter" import * as path from "path" @@ -43,45 +43,35 @@ const getRoot = async (req: Request, error?: Error): Promise<string> => { 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) => { +const login = async (req : Request, res : Response, password : string) => { try { if (!limiter.try()) { throw new Error("Login rate limited!") } - if (!req.body.password) { + if (!password) { throw new Error("Missing password") } if ( req.args.hashedPassword - ? safeCompare(hash(req.body.password), req.args.hashedPassword) - : req.args.password && safeCompare(req.body.password, req.args.password) + ? safeCompare(hash(password), req.args.hashedPassword) + : req.args.password && safeCompare(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), { + res.cookie(Cookie.Key, hash(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 }) + return redirect(req, res, to, { + to: undefined, + password: undefined, + pass: undefined, + }) } console.error( @@ -98,4 +88,34 @@ router.post("/", async (req, res) => { } catch (error) { res.send(await getRoot(req, error)) } +} + +export const router = Router() + +router.use((req : Request, res : Response, next : NextFunction) => { + const to = (typeof req.query.to === "string" && req.query.to) || "/" + if (authenticated(req)) { + return redirect(req, res, to, { + to: undefined, + password: undefined, + pass: undefined, + }) + } + next() +}) + +router.get("/", async (req : Request, res : Response) => { + if (req.args["enable-get-requests"]) { + // `?password` overrides `?pass` + if (req.query.password && typeof req.query.password === "string") { + return await login(req, res, req.query.password) +} else if (req.query.pass && typeof req.query.pass === "string") { + return await login(req, res, req.query.pass) + } + } + res.send(await getRoot(req)) +}) + +router.post("/", async (req : Request, res : Response) => { + await login(req, res, req.body.password) })