http.ts 7.6 KB
Newer Older
A
Asher 已提交
1
import { field, logger } from "@coder/logger"
A
Asher 已提交
2 3
import * as express from "express"
import * as expressCore from "express-serve-static-core"
4 5
import * as http from "http"
import * as net from "net"
6
import path from "path"
A
Asher 已提交
7
import qs from "qs"
8
import { Disposable } from "../common/emitter"
A
Asher 已提交
9
import { HttpCode, HttpError } from "../common/http"
10
import { normalize } from "../common/util"
A
Asher 已提交
11
import { AuthType, DefaultedArgs } from "./cli"
12
import { version as codeServerVersion } from "./constants"
A
Asher 已提交
13
import { Heart } from "./heart"
14
import { getPasswordMethod, IsCookieValidArgs, isCookieValid, sanitizeString, escapeHtml, escapeJSON } from "./util"
A
Asher 已提交
15

16 17 18 19 20 21 22 23 24
/**
 * Base options included on every page.
 */
export interface ClientConfiguration {
  codeServerVersion: string
  base: string
  csStaticBase: string
}

A
Asher 已提交
25 26 27 28 29 30 31 32 33 34
declare global {
  // eslint-disable-next-line @typescript-eslint/no-namespace
  namespace Express {
    export interface Request {
      args: DefaultedArgs
      heart: Heart
    }
  }
}

35
export const createClientConfiguration = (req: express.Request): ClientConfiguration => {
36 37 38 39
  const base = relativeRoot(req)

  return {
    base,
40
    csStaticBase: normalize(path.posix.join(base, "_static/")),
41 42 43 44
    codeServerVersion,
  }
}

A
Asher 已提交
45
/**
A
Asher 已提交
46
 * Replace common variable strings in HTML templates.
A
Asher 已提交
47
 */
A
Asher 已提交
48 49 50 51 52
export const replaceTemplates = <T extends object>(
  req: express.Request,
  content: string,
  extraOpts?: Omit<T, "base" | "csStaticBase" | "logLevel">,
): string => {
53
  const serverOptions: ClientConfiguration = {
54
    ...createClientConfiguration(req),
A
Asher 已提交
55 56
    ...extraOpts,
  }
57

A
Asher 已提交
58
  return content
59
    .replace(/{{TO}}/g, (typeof req.query.to === "string" && escapeHtml(req.query.to)) || "/")
60 61 62
    .replace(/{{BASE}}/g, serverOptions.base)
    .replace(/{{CS_STATIC_BASE}}/g, serverOptions.csStaticBase)
    .replace("{{OPTIONS}}", () => escapeJSON(serverOptions))
A
Asher 已提交
63 64 65
}

/**
A
Asher 已提交
66
 * Throw an error if not authorized. Call `next` if provided.
A
Asher 已提交
67
 */
68 69 70 71 72 73 74
export const ensureAuthenticated = async (
  req: express.Request,
  _?: express.Response,
  next?: express.NextFunction,
): Promise<void> => {
  const isAuthenticated = await authenticated(req)
  if (!isAuthenticated) {
A
Asher 已提交
75
    throw new HttpError("Unauthorized", HttpCode.Unauthorized)
76
  }
A
Asher 已提交
77 78 79
  if (next) {
    next()
  }
A
Asher 已提交
80 81
}

A
Asher 已提交
82 83 84
/**
 * Return true if authenticated via cookies.
 */
85
export const authenticated = async (req: express.Request): Promise<boolean> => {
A
Asher 已提交
86
  switch (req.args.auth) {
87
    case AuthType.None: {
A
Asher 已提交
88
      return true
89 90
    }
    case AuthType.Password: {
A
Asher 已提交
91
      // The password is stored in the cookie after being hashed.
92 93 94 95
      const hashedPasswordFromArgs = req.args["hashed-password"]
      const passwordMethod = getPasswordMethod(hashedPasswordFromArgs)
      const isCookieValidArgs: IsCookieValidArgs = {
        passwordMethod,
96
        cookieKey: sanitizeString(req.cookies.key),
97 98 99 100 101
        passwordFromArgs: req.args.password || "",
        hashedPasswordFromArgs: req.args["hashed-password"],
      }

      return await isCookieValid(isCookieValidArgs)
102 103
    }
    default: {
A
Asher 已提交
104
      throw new Error(`Unsupported auth type ${req.args.auth}`)
105
    }
A
Asher 已提交
106
  }
A
Asher 已提交
107 108
}

A
Asher 已提交
109 110 111 112 113 114 115 116 117 118 119 120
/**
 * Get the relative path that will get us to the root of the page. For each
 * slash we need to go up a directory. For example:
 * / => .
 * /foo => .
 * /foo/ => ./..
 * /foo/bar => ./..
 * /foo/bar/ => ./../..
 */
export const relativeRoot = (req: express.Request): string => {
  const depth = (req.originalUrl.split("?", 1)[0].match(/\//g) || []).length
  return normalize("./" + (depth > 1 ? "../".repeat(depth - 1) : ""))
A
Asher 已提交
121 122
}

A
Asher 已提交
123
/**
124 125
 * Redirect relatively to `/${to}`. Query variables on the current URI will be preserved.
 * `to` should be a simple path without any query parameters
A
Asher 已提交
126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145
 * `override` will merge with the existing query (use `undefined` to unset).
 */
export const redirect = (
  req: express.Request,
  res: express.Response,
  to: string,
  override: expressCore.Query = {},
): void => {
  const query = Object.assign({}, req.query, override)
  Object.keys(override).forEach((key) => {
    if (typeof override[key] === "undefined") {
      delete query[key]
    }
  })

  const relativePath = normalize(`${relativeRoot(req)}/${to}`, true)
  const queryString = qs.stringify(query)
  const redirectPath = `${relativePath}${queryString ? `?${queryString}` : ""}`
  logger.debug(`redirecting from ${req.originalUrl} to ${redirectPath}`)
  res.redirect(redirectPath)
146 147
}

A
Asher 已提交
148
/**
A
Asher 已提交
149
 * Get the value that should be used for setting a cookie domain. This will
150 151 152
 * allow the user to authenticate once no matter what sub-domain they use to log
 * in. This will use the highest level proxy domain (e.g. `coder.com` over
 * `test.coder.com` if both are specified).
A
Asher 已提交
153
 */
A
Asher 已提交
154 155 156
export const getCookieDomain = (host: string, proxyDomains: string[]): string | undefined => {
  const idx = host.lastIndexOf(":")
  host = idx !== -1 ? host.substring(0, idx) : host
157 158
  // If any of these are true we will still set cookies but without an explicit
  // `Domain` attribute on the cookie.
A
Asher 已提交
159
  if (
160
    // The host can be be blank or missing so there's nothing we can set.
A
Asher 已提交
161 162
    !host ||
    // IP addresses can't have subdomains so there's no value in setting the
163 164
    // domain for them. Assume that anything with a : is ipv6 (valid domain name
    // characters are alphanumeric or dashes)...
A
Asher 已提交
165
    host.includes(":") ||
166
    // ...and that anything entirely numbers and dots is ipv4 (currently tlds
A
Asher 已提交
167 168
    // cannot be entirely numbers).
    !/[^0-9.]/.test(host) ||
169 170
    // localhost subdomains don't seem to work at all (browser bug?). A cookie
    // set at dev.localhost cannot be read by 8080.dev.localhost.
A
Asher 已提交
171
    host.endsWith(".localhost") ||
172 173 174 175 176 177 178 179
    // Domains without at least one dot (technically two since domain.tld will
    // become .domain.tld) are considered invalid according to the spec so don't
    // set the domain for them. In my testing though localhost is the only
    // problem (the browser just doesn't store the cookie at all). localhost has
    // an additional problem which is that a reverse proxy might give
    // code-server localhost even though the domain is really domain.tld (by
    // default NGINX does this).
    !host.includes(".")
A
Asher 已提交
180 181 182 183 184 185 186 187 188 189 190 191
  ) {
    logger.debug("no valid cookie doman", field("host", host))
    return undefined
  }

  proxyDomains.forEach((domain) => {
    if (host.endsWith(domain) && domain.length < host.length) {
      host = domain
    }
  })

  logger.debug("got cookie doman", field("host", host))
A
Asher 已提交
192
  return host || undefined
A
Asher 已提交
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

/**
 * Return a function capable of fully disposing an HTTP server.
 */
export function disposer(server: http.Server): Disposable["dispose"] {
  const sockets = new Set<net.Socket>()
  let cleanupTimeout: undefined | NodeJS.Timeout

  server.on("connection", (socket) => {
    sockets.add(socket)

    socket.on("close", () => {
      sockets.delete(socket)

      if (cleanupTimeout && sockets.size === 0) {
        clearTimeout(cleanupTimeout)
        cleanupTimeout = undefined
      }
    })
  })

  return () => {
    return new Promise<void>((resolve, reject) => {
      // The whole reason we need this disposer is because close will not
      // actually close anything; it only prevents future connections then waits
      // until everything is closed.
      server.close((err) => {
        if (err) {
          return reject(err)
        }

        resolve()
      })

      // If there are sockets remaining we might need to force close them or
      // this promise might never resolve.
      if (sockets.size > 0) {
        // Give sockets a chance to close up shop.
        cleanupTimeout = setTimeout(() => {
          cleanupTimeout = undefined

          for (const socket of sockets.values()) {
            console.warn("a socket was left hanging")
            socket.destroy()
          }
        }, 1000)
      }
    })
  }
}