mirror of
https://github.com/cdr/code-server.git
synced 2025-12-06 16:34:35 +01:00
* Flesh out fixes to align with upstream. * Update route handlers to better reflect fallback behavior. * Add platform to vscode-reh-web task Our strategy has been to build once and then recompile native modules for individual platforms. It looks like VS Code builds from scratch for each platform. But we can target any platform, grab the pre-packaged folder, then continue with own packaging. In the future we may want to rework to match upstream. * Fix issue where workspace args are not parsed. * Fix issues surrounding opening files within code-server's terminal. * Readd parent wrapper for hot reload. * Allow more errors. * Fix issues surrounding Coder link. * Add dir creation and fix cli It seems VS Code explodes when certain directories do not exist so import the reh agent instead of the server component since it creates the directories (require patching thus the VS Code update). Also the CLI (for installing extensions) did not seem to be working so point that to the same place since it also exports a function for running that part of the CLI. * Remove hardcoded VSCODE_DEV=1 This causes VS Code to use the development HTML file. Move this to the watch command instead. I deleted the other stuff before it as well since in the latest main.js they do not have this code so I figure we should be safe to omit it. * Fix mismatching commit between client and server * Mostly restore command-line parity Restore most everything and remove the added server arguments. This will let us add and remove options after later so we can contain the number of breaking changes. To accomplish this a hard separation is added between the CLI arguments and the server arguments. The separation between user-provided arguments and arguments with defaults is also made more clear. The extra directory flags have been left out as they were buggy and should be implemented upstream although I think there are better solutions anyway. locale and install-source are unsupported with the web remote and are left removed. It is unclear whether they were used before anyway. Some restored flags still need to have their behavior re-implemented. * Fix static endpoint not emitting 404s This fixes the last failing unit test. Fix a missing dependency, add some generic reverse proxy support for the protocol, and add back a missing nfpm fix. * Import missing logError * Fix 403 errors * Add code-server version to about dialog * Use user settings to disable welcome page The workspace setting seems to be recognized but if so it is having no effect. * Update VS Code cache step with new build directories Co-authored-by: Asher <ash@coder.com>
522 lines
15 KiB
TypeScript
522 lines
15 KiB
TypeScript
import { logger } from "@coder/logger"
|
|
import * as argon2 from "argon2"
|
|
import * as cp from "child_process"
|
|
import * as crypto from "crypto"
|
|
import envPaths from "env-paths"
|
|
import { promises as fs } from "fs"
|
|
import * as net from "net"
|
|
import * as os from "os"
|
|
import * as path from "path"
|
|
import safeCompare from "safe-compare"
|
|
import * as util from "util"
|
|
import xdgBasedir from "xdg-basedir"
|
|
import { vsRootPath } from "./constants"
|
|
|
|
export interface Paths {
|
|
data: string
|
|
config: string
|
|
runtime: string
|
|
}
|
|
|
|
// From https://github.com/chalk/ansi-regex
|
|
const pattern = [
|
|
"[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)",
|
|
"(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))",
|
|
].join("|")
|
|
const re = new RegExp(pattern, "g")
|
|
|
|
/**
|
|
* Split stdout on newlines and strip ANSI codes.
|
|
*/
|
|
export const onLine = (proc: cp.ChildProcess, callback: (strippedLine: string, originalLine: string) => void): void => {
|
|
let buffer = ""
|
|
if (!proc.stdout) {
|
|
throw new Error("no stdout")
|
|
}
|
|
proc.stdout.setEncoding("utf8")
|
|
proc.stdout.on("data", (d) => {
|
|
const data = buffer + d
|
|
const split = data.split("\n")
|
|
const last = split.length - 1
|
|
|
|
for (let i = 0; i < last; ++i) {
|
|
callback(split[i].replace(re, ""), split[i])
|
|
}
|
|
|
|
// The last item will either be an empty string (the data ended with a
|
|
// newline) or a partial line (did not end with a newline) and we must
|
|
// wait to parse it until we get a full line.
|
|
buffer = split[last]
|
|
})
|
|
}
|
|
|
|
export const paths = getEnvPaths()
|
|
|
|
/**
|
|
* Gets the config and data paths for the current platform/configuration.
|
|
* On MacOS this function gets the standard XDG directories instead of using the native macOS
|
|
* ones. Most CLIs do this as in practice only GUI apps use the standard macOS directories.
|
|
*/
|
|
export function getEnvPaths(): Paths {
|
|
const paths = envPaths("code-server", { suffix: "" })
|
|
const append = (p: string): string => path.join(p, "code-server")
|
|
switch (process.platform) {
|
|
case "darwin":
|
|
return {
|
|
// envPaths uses native directories so force Darwin to use the XDG spec
|
|
// to align with other CLI tools.
|
|
data: xdgBasedir.data ? append(xdgBasedir.data) : paths.data,
|
|
config: xdgBasedir.config ? append(xdgBasedir.config) : paths.config,
|
|
// Fall back to temp if there is no runtime dir.
|
|
runtime: xdgBasedir.runtime ? append(xdgBasedir.runtime) : paths.temp,
|
|
}
|
|
case "win32":
|
|
return {
|
|
data: paths.data,
|
|
config: paths.config,
|
|
// Windows doesn't have a runtime dir.
|
|
runtime: paths.temp,
|
|
}
|
|
default:
|
|
return {
|
|
data: paths.data,
|
|
config: paths.config,
|
|
// Fall back to temp if there is no runtime dir.
|
|
runtime: xdgBasedir.runtime ? append(xdgBasedir.runtime) : paths.temp,
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* humanPath replaces the home directory in p with ~.
|
|
* Makes it more readable.
|
|
*
|
|
* @param p
|
|
*/
|
|
export function humanPath(p?: string): string {
|
|
if (!p) {
|
|
return ""
|
|
}
|
|
return p.replace(os.homedir(), "~")
|
|
}
|
|
|
|
export const generateCertificate = async (hostname: string): Promise<{ cert: string; certKey: string }> => {
|
|
const certPath = path.join(paths.data, `${hostname.replace(/\./g, "_")}.crt`)
|
|
const certKeyPath = path.join(paths.data, `${hostname.replace(/\./g, "_")}.key`)
|
|
|
|
// Try generating the certificates if we can't access them (which probably
|
|
// means they don't exist).
|
|
try {
|
|
await Promise.all([fs.access(certPath), fs.access(certKeyPath)])
|
|
} catch (error) {
|
|
// Require on demand so openssl isn't required if you aren't going to
|
|
// generate certificates.
|
|
const pem = require("pem") as typeof import("pem")
|
|
const certs = await new Promise<import("pem").CertificateCreationResult>((resolve, reject): void => {
|
|
pem.createCertificate(
|
|
{
|
|
selfSigned: true,
|
|
commonName: hostname,
|
|
config: `
|
|
[req]
|
|
req_extensions = v3_req
|
|
|
|
[ v3_req ]
|
|
basicConstraints = CA:true
|
|
extendedKeyUsage = serverAuth
|
|
subjectAltName = @alt_names
|
|
|
|
[alt_names]
|
|
DNS.1 = ${hostname}
|
|
`,
|
|
},
|
|
(error, result) => {
|
|
return error ? reject(error) : resolve(result)
|
|
},
|
|
)
|
|
})
|
|
await fs.mkdir(paths.data, { recursive: true })
|
|
await Promise.all([fs.writeFile(certPath, certs.certificate), fs.writeFile(certKeyPath, certs.serviceKey)])
|
|
}
|
|
|
|
return {
|
|
cert: certPath,
|
|
certKey: certKeyPath,
|
|
}
|
|
}
|
|
|
|
export const generatePassword = async (length = 24): Promise<string> => {
|
|
const buffer = Buffer.alloc(Math.ceil(length / 2))
|
|
await util.promisify(crypto.randomFill)(buffer)
|
|
return buffer.toString("hex").substring(0, length)
|
|
}
|
|
|
|
/**
|
|
* Used to hash the password.
|
|
*/
|
|
export const hash = async (password: string): Promise<string> => {
|
|
try {
|
|
return await argon2.hash(password)
|
|
} catch (error: any) {
|
|
logger.error(error)
|
|
return ""
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Used to verify if the password matches the hash
|
|
*/
|
|
export const isHashMatch = async (password: string, hash: string) => {
|
|
if (password === "" || hash === "" || !hash.startsWith("$")) {
|
|
return false
|
|
}
|
|
try {
|
|
return await argon2.verify(hash, password)
|
|
} catch (error: any) {
|
|
throw new Error(error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Used to hash the password using the sha256
|
|
* algorithm. We only use this to for checking
|
|
* the hashed-password set in the config.
|
|
*
|
|
* Kept for legacy reasons.
|
|
*/
|
|
export const hashLegacy = (str: string): string => {
|
|
return crypto.createHash("sha256").update(str).digest("hex")
|
|
}
|
|
|
|
/**
|
|
* Used to check if the password matches the hash using
|
|
* the hashLegacy function
|
|
*/
|
|
export const isHashLegacyMatch = (password: string, hashPassword: string) => {
|
|
const hashedWithLegacy = hashLegacy(password)
|
|
return safeCompare(hashedWithLegacy, hashPassword)
|
|
}
|
|
|
|
export type PasswordMethod = "SHA256" | "ARGON2" | "PLAIN_TEXT"
|
|
|
|
/**
|
|
* Used to determine the password method.
|
|
*
|
|
* There are three options for the return value:
|
|
* 1. "SHA256" -> the legacy hashing algorithm
|
|
* 2. "ARGON2" -> the newest hashing algorithm
|
|
* 3. "PLAIN_TEXT" -> regular ol' password with no hashing
|
|
*
|
|
* @returns {PasswordMethod} "SHA256" | "ARGON2" | "PLAIN_TEXT"
|
|
*/
|
|
export function getPasswordMethod(hashedPassword: string | undefined): PasswordMethod {
|
|
if (!hashedPassword) {
|
|
return "PLAIN_TEXT"
|
|
}
|
|
|
|
// This is the new hashing algorithm
|
|
if (hashedPassword.includes("$argon")) {
|
|
return "ARGON2"
|
|
}
|
|
|
|
// This is the legacy hashing algorithm
|
|
return "SHA256"
|
|
}
|
|
|
|
type PasswordValidation = {
|
|
isPasswordValid: boolean
|
|
hashedPassword: string
|
|
}
|
|
|
|
type HandlePasswordValidationArgs = {
|
|
/** The PasswordMethod */
|
|
passwordMethod: PasswordMethod
|
|
/** The password provided by the user */
|
|
passwordFromRequestBody: string
|
|
/** The password set in PASSWORD or config */
|
|
passwordFromArgs: string | undefined
|
|
/** The hashed-password set in HASHED_PASSWORD or config */
|
|
hashedPasswordFromArgs: string | undefined
|
|
}
|
|
|
|
/**
|
|
* Checks if a password is valid and also returns the hash
|
|
* using the PasswordMethod
|
|
*/
|
|
export async function handlePasswordValidation({
|
|
passwordMethod,
|
|
passwordFromArgs,
|
|
passwordFromRequestBody,
|
|
hashedPasswordFromArgs,
|
|
}: HandlePasswordValidationArgs): Promise<PasswordValidation> {
|
|
const passwordValidation = <PasswordValidation>{
|
|
isPasswordValid: false,
|
|
hashedPassword: "",
|
|
}
|
|
|
|
switch (passwordMethod) {
|
|
case "PLAIN_TEXT": {
|
|
const isValid = passwordFromArgs ? safeCompare(passwordFromRequestBody, passwordFromArgs) : false
|
|
passwordValidation.isPasswordValid = isValid
|
|
|
|
const hashedPassword = await hash(passwordFromRequestBody)
|
|
passwordValidation.hashedPassword = hashedPassword
|
|
break
|
|
}
|
|
case "SHA256": {
|
|
const isValid = isHashLegacyMatch(passwordFromRequestBody, hashedPasswordFromArgs || "")
|
|
passwordValidation.isPasswordValid = isValid
|
|
|
|
passwordValidation.hashedPassword = hashedPasswordFromArgs || (await hashLegacy(passwordFromRequestBody))
|
|
break
|
|
}
|
|
case "ARGON2": {
|
|
const isValid = await isHashMatch(passwordFromRequestBody, hashedPasswordFromArgs || "")
|
|
passwordValidation.isPasswordValid = isValid
|
|
|
|
passwordValidation.hashedPassword = hashedPasswordFromArgs || ""
|
|
break
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
|
|
return passwordValidation
|
|
}
|
|
|
|
export type IsCookieValidArgs = {
|
|
passwordMethod: PasswordMethod
|
|
cookieKey: string
|
|
hashedPasswordFromArgs: string | undefined
|
|
passwordFromArgs: string | undefined
|
|
}
|
|
|
|
/** Checks if a req.cookies.key is valid using the PasswordMethod */
|
|
export async function isCookieValid({
|
|
passwordFromArgs = "",
|
|
cookieKey,
|
|
hashedPasswordFromArgs = "",
|
|
passwordMethod,
|
|
}: IsCookieValidArgs): Promise<boolean> {
|
|
let isValid = false
|
|
switch (passwordMethod) {
|
|
case "PLAIN_TEXT":
|
|
isValid = await isHashMatch(passwordFromArgs, cookieKey)
|
|
break
|
|
case "ARGON2":
|
|
case "SHA256":
|
|
isValid = safeCompare(cookieKey, hashedPasswordFromArgs)
|
|
break
|
|
default:
|
|
break
|
|
}
|
|
return isValid
|
|
}
|
|
|
|
/** Ensures that the input is sanitized by checking
|
|
* - it's a string
|
|
* - greater than 0 characters
|
|
* - trims whitespace
|
|
*/
|
|
export function sanitizeString(str: string): string {
|
|
// Very basic sanitization of string
|
|
// Credit: https://stackoverflow.com/a/46719000/3015595
|
|
return typeof str === "string" && str.trim().length > 0 ? str.trim() : ""
|
|
}
|
|
|
|
const mimeTypes: { [key: string]: string } = {
|
|
".aac": "audio/x-aac",
|
|
".avi": "video/x-msvideo",
|
|
".bmp": "image/bmp",
|
|
".css": "text/css",
|
|
".flv": "video/x-flv",
|
|
".gif": "image/gif",
|
|
".html": "text/html",
|
|
".ico": "image/x-icon",
|
|
".jpe": "image/jpg",
|
|
".jpeg": "image/jpg",
|
|
".jpg": "image/jpg",
|
|
".js": "application/javascript",
|
|
".json": "application/json",
|
|
".m1v": "video/mpeg",
|
|
".m2a": "audio/mpeg",
|
|
".m2v": "video/mpeg",
|
|
".m3a": "audio/mpeg",
|
|
".mid": "audio/midi",
|
|
".midi": "audio/midi",
|
|
".mk3d": "video/x-matroska",
|
|
".mks": "video/x-matroska",
|
|
".mkv": "video/x-matroska",
|
|
".mov": "video/quicktime",
|
|
".movie": "video/x-sgi-movie",
|
|
".mp2": "audio/mpeg",
|
|
".mp2a": "audio/mpeg",
|
|
".mp3": "audio/mpeg",
|
|
".mp4": "video/mp4",
|
|
".mp4a": "audio/mp4",
|
|
".mp4v": "video/mp4",
|
|
".mpe": "video/mpeg",
|
|
".mpeg": "video/mpeg",
|
|
".mpg": "video/mpeg",
|
|
".mpg4": "video/mp4",
|
|
".mpga": "audio/mpeg",
|
|
".oga": "audio/ogg",
|
|
".ogg": "audio/ogg",
|
|
".ogv": "video/ogg",
|
|
".png": "image/png",
|
|
".psd": "image/vnd.adobe.photoshop",
|
|
".qt": "video/quicktime",
|
|
".spx": "audio/ogg",
|
|
".svg": "image/svg+xml",
|
|
".tga": "image/x-tga",
|
|
".tif": "image/tiff",
|
|
".tiff": "image/tiff",
|
|
".txt": "text/plain",
|
|
".wav": "audio/x-wav",
|
|
".wasm": "application/wasm",
|
|
".webm": "video/webm",
|
|
".webp": "image/webp",
|
|
".wma": "audio/x-ms-wma",
|
|
".wmv": "video/x-ms-wmv",
|
|
".woff": "application/font-woff",
|
|
}
|
|
|
|
export const getMediaMime = (filePath?: string): string => {
|
|
return (filePath && mimeTypes[path.extname(filePath)]) || "text/plain"
|
|
}
|
|
|
|
export const isWsl = async (): Promise<boolean> => {
|
|
return (
|
|
(process.platform === "linux" && os.release().toLowerCase().indexOf("microsoft") !== -1) ||
|
|
(await fs.readFile("/proc/version", "utf8")).toLowerCase().indexOf("microsoft") !== -1
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Try opening an address using whatever the system has set for opening URLs.
|
|
*/
|
|
export const open = async (address: URL | string): Promise<void> => {
|
|
if (typeof address === "string") {
|
|
throw new Error("Cannot open socket paths")
|
|
}
|
|
// Web sockets do not seem to work if browsing with 0.0.0.0.
|
|
const url = new URL(address)
|
|
if (url.hostname === "0.0.0.0") {
|
|
url.hostname = "localhost"
|
|
}
|
|
const args = [] as string[]
|
|
const options = {} as cp.SpawnOptions
|
|
const platform = (await isWsl()) ? "wsl" : process.platform
|
|
let command = platform === "darwin" ? "open" : "xdg-open"
|
|
if (platform === "win32" || platform === "wsl") {
|
|
command = platform === "wsl" ? "cmd.exe" : "cmd"
|
|
args.push("/c", "start", '""', "/b")
|
|
url.search = url.search.replace(/&/g, "^&")
|
|
}
|
|
const proc = cp.spawn(command, [...args, url.toString()], options)
|
|
await new Promise<void>((resolve, reject) => {
|
|
proc.on("error", reject)
|
|
proc.on("close", (code) => {
|
|
return code !== 0 ? reject(new Error(`Failed to open with code ${code}`)) : resolve()
|
|
})
|
|
})
|
|
}
|
|
|
|
/**
|
|
* For iterating over an enum's values.
|
|
*/
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
export const enumToArray = (t: any): string[] => {
|
|
const values = [] as string[]
|
|
for (const k in t) {
|
|
values.push(t[k])
|
|
}
|
|
return values
|
|
}
|
|
|
|
/**
|
|
* For displaying all allowed options in an enum.
|
|
*/
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
export const buildAllowedMessage = (t: any): string => {
|
|
const values = enumToArray(t)
|
|
return `Allowed value${values.length === 1 ? " is" : "s are"} ${values.map((t) => `'${t}'`).join(", ")}`
|
|
}
|
|
|
|
export const isObject = <T extends object>(obj: T): obj is T => {
|
|
return !Array.isArray(obj) && typeof obj === "object" && obj !== null
|
|
}
|
|
|
|
/**
|
|
* Return a promise that resolves with whether the socket path is active.
|
|
*/
|
|
export function canConnect(path: string): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
const socket = net.connect(path)
|
|
socket.once("error", () => resolve(false))
|
|
socket.once("connect", () => {
|
|
socket.destroy()
|
|
resolve(true)
|
|
})
|
|
})
|
|
}
|
|
|
|
export const isFile = async (path: string): Promise<boolean> => {
|
|
try {
|
|
const stat = await fs.stat(path)
|
|
return stat.isFile()
|
|
} catch (error) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Escapes any HTML string special characters, like &, <, >, ", and '.
|
|
*
|
|
* Source: https://stackoverflow.com/a/6234804/3015595
|
|
**/
|
|
export function escapeHtml(unsafe: string): string {
|
|
return unsafe
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'")
|
|
}
|
|
|
|
/**
|
|
* A helper function which returns a boolean indicating whether
|
|
* the given error is a NodeJS.ErrnoException by checking if
|
|
* it has a .code property.
|
|
*/
|
|
export function isNodeJSErrnoException(error: unknown): error is NodeJS.ErrnoException {
|
|
return error !== undefined && (error as NodeJS.ErrnoException).code !== undefined
|
|
}
|
|
|
|
// TODO: Replace with proper templating system.
|
|
export const escapeJSON = (value: cp.Serializable) => JSON.stringify(value).replace(/"/g, """)
|
|
|
|
type AMDModule<T> = { [exportName: string]: T }
|
|
|
|
/**
|
|
* Loads AMD module, typically from a compiled VSCode bundle.
|
|
*
|
|
* @deprecated This should be gradually phased out as code-server migrates to lib/vscode
|
|
* @param amdPath Path to module relative to lib/vscode
|
|
* @param exportName Given name of export in the file
|
|
*/
|
|
export const loadAMDModule = async <T>(amdPath: string, exportName: string): Promise<T> => {
|
|
// Set default remote native node modules path, if unset
|
|
process.env["VSCODE_INJECT_NODE_MODULE_LOOKUP_PATH"] =
|
|
process.env["VSCODE_INJECT_NODE_MODULE_LOOKUP_PATH"] || path.join(vsRootPath, "remote", "node_modules")
|
|
|
|
require(path.join(vsRootPath, "out/bootstrap-node")).injectNodeModuleLookupPath(
|
|
process.env["VSCODE_INJECT_NODE_MODULE_LOOKUP_PATH"],
|
|
)
|
|
|
|
const module = await new Promise<AMDModule<T>>((resolve, reject) => {
|
|
require(path.join(vsRootPath, "out/bootstrap-amd")).load(amdPath, resolve, reject)
|
|
})
|
|
|
|
return module[exportName] as T
|
|
}
|