util.ts 13.2 KB
Newer Older
A
Asher 已提交
1 2
import * as cp from "child_process"
import * as crypto from "crypto"
3
import * as argon2 from "argon2"
A
Asher 已提交
4
import envPaths from "env-paths"
A
Asher 已提交
5
import { promises as fs } from "fs"
A
Asher 已提交
6
import * as net from "net"
A
Asher 已提交
7 8 9
import * as os from "os"
import * as path from "path"
import * as util from "util"
10
import xdgBasedir from "xdg-basedir"
11
import safeCompare from "safe-compare"
12
import { logger } from "@coder/logger"
A
Asher 已提交
13

J
Joe Previte 已提交
14
export interface Paths {
15 16
  data: string
  config: string
J
Joe Previte 已提交
17
  runtime: string
18 19 20 21
}

export const paths = getEnvPaths()

22 23 24 25 26
/**
 * 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.
 */
J
Joe Previte 已提交
27
export function getEnvPaths(): Paths {
J
Joe Previte 已提交
28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
  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,
      }
A
Asher 已提交
54
  }
A
Asher 已提交
55
}
A
Asher 已提交
56

57 58 59 60 61 62 63 64 65 66
/**
 * humanPath replaces the home directory in p with ~.
 * Makes it more readable.
 *
 * @param p
 */
export function humanPath(p?: string): string {
  if (!p) {
    return ""
  }
67 68
  return p.replace(os.homedir(), "~")
}
A
Asher 已提交
69

70 71 72
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`)
73

A
Asher 已提交
74 75 76 77 78
  // 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) {
A
Asher 已提交
79 80 81 82
    // 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 => {
83 84 85
      pem.createCertificate(
        {
          selfSigned: true,
86
          commonName: hostname,
87 88 89 90 91
          config: `
[req]
req_extensions = v3_req

[ v3_req ]
92
basicConstraints = CA:true
93 94 95 96
extendedKeyUsage = serverAuth
subjectAltName = @alt_names

[alt_names]
97
DNS.1 = ${hostname}
98 99 100 101 102 103
`,
        },
        (error, result) => {
          return error ? reject(error) : resolve(result)
        },
      )
A
Asher 已提交
104
    })
A
Asher 已提交
105
    await fs.mkdir(paths.data, { recursive: true })
106 107
    await Promise.all([fs.writeFile(certPath, certs.certificate), fs.writeFile(certKeyPath, certs.serviceKey)])
  }
A
Asher 已提交
108

109 110 111
  return {
    cert: certPath,
    certKey: certKeyPath,
A
Asher 已提交
112
  }
A
Asher 已提交
113 114
}

A
Asher 已提交
115 116 117 118 119
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)
}
A
Asher 已提交
120

121 122 123
/**
 * Used to hash the password.
 */
124 125 126 127 128 129 130
export const hash = async (password: string): Promise<string> => {
  try {
    return await argon2.hash(password)
  } catch (error) {
    logger.error(error)
    return ""
  }
131 132 133 134 135
}

/**
 * Used to verify if the password matches the hash
 */
136 137 138 139 140 141 142
export const isHashMatch = async (password: string, hash: string) => {
  try {
    return await argon2.verify(hash, password)
  } catch (error) {
    logger.error(error)
    return false
  }
143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162
}

/**
 * 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)
A
Asher 已提交
163 164
}

165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191
const passwordMethods = ["SHA256", "ARGON2", "PLAIN_TEXT"] as const
export type PasswordMethod = typeof passwordMethods[number]

/**
 * 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"
}

192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251
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(
  passwordValidationArgs: HandlePasswordValidationArgs,
): Promise<PasswordValidation> {
  const { passwordMethod, passwordFromArgs, passwordFromRequestBody, hashedPasswordFromArgs } = passwordValidationArgs
  // TODO implement
  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
}

252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276
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(isCookieValidArgs: IsCookieValidArgs): Promise<boolean> {
  let isValid = false
  const { passwordFromArgs = "", cookieKey, hashedPasswordFromArgs = "" } = isCookieValidArgs
  switch (isCookieValidArgs.passwordMethod) {
    case "PLAIN_TEXT":
      isValid = await isHashMatch(passwordFromArgs, cookieKey)
      break
    case "ARGON2":
    case "SHA256":
      isValid = safeCompare(cookieKey, hashedPasswordFromArgs)
      break
    default:
      break
  }
  return isValid
}

A
Asher 已提交
277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332
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",
}
A
Asher 已提交
333

A
Asher 已提交
334
export const getMediaMime = (filePath?: string): string => {
A
Asher 已提交
335 336
  return (filePath && mimeTypes[path.extname(filePath)]) || "text/plain"
}
A
Asher 已提交
337 338

export const isWsl = async (): Promise<boolean> => {
A
Asher 已提交
339
  return (
A
Anmol Sethi 已提交
340
    (process.platform === "linux" && os.release().toLowerCase().indexOf("microsoft") !== -1) ||
A
Asher 已提交
341 342 343
    (await fs.readFile("/proc/version", "utf8")).toLowerCase().indexOf("microsoft") !== -1
  )
}
A
Asher 已提交
344

A
Asher 已提交
345 346 347
/**
 * Try opening a URL using whatever the system has set for opening URLs.
 */
A
Asher 已提交
348
export const open = async (url: string): Promise<void> => {
A
Asher 已提交
349 350 351 352 353 354 355 356 357 358
  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 = url.replace(/&/g, "^&")
  }
  const proc = cp.spawn(command, [...args, url], options)
359
  await new Promise<void>((resolve, reject) => {
A
Asher 已提交
360 361 362 363 364 365
    proc.on("error", reject)
    proc.on("close", (code) => {
      return code !== 0 ? reject(new Error(`Failed to open with code ${code}`)) : resolve()
    })
  })
}
A
Asher 已提交
366

A
Asher 已提交
367 368 369 370
/**
 * For iterating over an enum's values.
 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
A
Asher 已提交
371
export const enumToArray = (t: any): string[] => {
A
Asher 已提交
372 373 374 375 376 377
  const values = [] as string[]
  for (const k in t) {
    values.push(t[k])
  }
  return values
}
A
Asher 已提交
378

A
Asher 已提交
379 380 381 382
/**
 * For displaying all allowed options in an enum.
 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
A
Asher 已提交
383
export const buildAllowedMessage = (t: any): string => {
A
Asher 已提交
384 385 386 387 388 389 390
  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
}
391

392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438
/**
 * Taken from vs/base/common/charCode.ts. Copied for now instead of importing so
 * we don't have to set up a `vs` alias to be able to import with types (since
 * the alternative is to directly import from `out`).
 */
const enum CharCode {
  Slash = 47,
  A = 65,
  Z = 90,
  a = 97,
  z = 122,
  Colon = 58,
}

/**
 * Compute `fsPath` for the given uri.
 * Taken from vs/base/common/uri.ts. It's not imported to avoid also importing
 * everything that file imports.
 */
export function pathToFsPath(path: string, keepDriveLetterCasing = false): string {
  const isWindows = process.platform === "win32"
  const uri = { authority: undefined, path, scheme: "file" }
  let value: string
  if (uri.authority && uri.path.length > 1 && uri.scheme === "file") {
    // unc path: file://shares/c$/far/boo
    value = `//${uri.authority}${uri.path}`
  } else if (
    uri.path.charCodeAt(0) === CharCode.Slash &&
    ((uri.path.charCodeAt(1) >= CharCode.A && uri.path.charCodeAt(1) <= CharCode.Z) ||
      (uri.path.charCodeAt(1) >= CharCode.a && uri.path.charCodeAt(1) <= CharCode.z)) &&
    uri.path.charCodeAt(2) === CharCode.Colon
  ) {
    if (!keepDriveLetterCasing) {
      // windows drive letter: file:///c:/far/boo
      value = uri.path[1].toLowerCase() + uri.path.substr(2)
    } else {
      value = uri.path.substr(1)
    }
  } else {
    // other path
    value = uri.path
  }
  if (isWindows) {
    value = value.replace(/\//g, "\\")
  }
  return value
}
A
Asher 已提交
439 440 441 442 443 444 445 446 447 448 449 450 451 452

/**
 * 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)
    })
  })
}
A
Asher 已提交
453 454 455 456 457 458 459 460 461

export const isFile = async (path: string): Promise<boolean> => {
  try {
    const stat = await fs.stat(path)
    return stat.isFile()
  } catch (error) {
    return false
  }
}