mirror of
https://git.tuxpa.in/a/code-server.git
synced 2024-12-27 04:35:25 +00:00
Convert routes to Express
This commit is contained in:
parent
4b6cbacbad
commit
112eda4605
@ -31,6 +31,8 @@ rules:
|
|||||||
import/order:
|
import/order:
|
||||||
[error, { alphabetize: { order: "asc" }, groups: [["builtin", "external", "internal"], "parent", "sibling"] }]
|
[error, { alphabetize: { order: "asc" }, groups: [["builtin", "external", "internal"], "parent", "sibling"] }]
|
||||||
no-async-promise-executor: off
|
no-async-promise-executor: off
|
||||||
|
# This isn't a real module, just types, which apparently doesn't resolve.
|
||||||
|
import/no-unresolved: [error, { ignore: ["express-serve-static-core"] }]
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
# Does not work with CommonJS unfortunately.
|
# Does not work with CommonJS unfortunately.
|
||||||
|
@ -1318,7 +1318,7 @@ index 0000000000000000000000000000000000000000..56331ff1fc32bbd82e769aaecb551e42
|
|||||||
+require('../../bootstrap-amd').load('vs/server/entry');
|
+require('../../bootstrap-amd').load('vs/server/entry');
|
||||||
diff --git a/src/vs/server/ipc.d.ts b/src/vs/server/ipc.d.ts
|
diff --git a/src/vs/server/ipc.d.ts b/src/vs/server/ipc.d.ts
|
||||||
new file mode 100644
|
new file mode 100644
|
||||||
index 0000000000000000000000000000000000000000..33b28cf2d53746ee9c50c056ac2e087dcee0a4e2
|
index 0000000000000000000000000000000000000000..6ce56bec114a6d8daf5dd3ded945ea78fc72a5c6
|
||||||
--- /dev/null
|
--- /dev/null
|
||||||
+++ b/src/vs/server/ipc.d.ts
|
+++ b/src/vs/server/ipc.d.ts
|
||||||
@@ -0,0 +1,131 @@
|
@@ -0,0 +1,131 @@
|
||||||
@ -1336,7 +1336,7 @@ index 0000000000000000000000000000000000000000..33b28cf2d53746ee9c50c056ac2e087d
|
|||||||
+ options: VscodeOptions;
|
+ options: VscodeOptions;
|
||||||
+}
|
+}
|
||||||
+
|
+
|
||||||
+export type Query = { [key: string]: string | string[] | undefined };
|
+export type Query = { [key: string]: string | string[] | undefined | Query | Query[] };
|
||||||
+
|
+
|
||||||
+export interface SocketMessage {
|
+export interface SocketMessage {
|
||||||
+ type: 'socket';
|
+ type: 'socket';
|
||||||
|
@ -30,6 +30,8 @@
|
|||||||
},
|
},
|
||||||
"main": "out/node/entry.js",
|
"main": "out/node/entry.js",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/body-parser": "^1.19.0",
|
||||||
|
"@types/cookie-parser": "^1.4.2",
|
||||||
"@types/express": "^4.17.8",
|
"@types/express": "^4.17.8",
|
||||||
"@types/fs-extra": "^8.0.1",
|
"@types/fs-extra": "^8.0.1",
|
||||||
"@types/http-proxy": "^1.17.4",
|
"@types/http-proxy": "^1.17.4",
|
||||||
@ -67,6 +69,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@coder/logger": "1.1.16",
|
"@coder/logger": "1.1.16",
|
||||||
|
"body-parser": "^1.19.0",
|
||||||
|
"cookie-parser": "^1.4.5",
|
||||||
"env-paths": "^2.2.0",
|
"env-paths": "^2.2.0",
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
"fs-extra": "^9.0.1",
|
"fs-extra": "^9.0.1",
|
||||||
@ -75,6 +79,7 @@
|
|||||||
"js-yaml": "^3.13.1",
|
"js-yaml": "^3.13.1",
|
||||||
"limiter": "^1.1.5",
|
"limiter": "^1.1.5",
|
||||||
"pem": "^1.14.2",
|
"pem": "^1.14.2",
|
||||||
|
"qs": "6.7.0",
|
||||||
"rotating-file-stream": "^2.1.1",
|
"rotating-file-stream": "^2.1.1",
|
||||||
"safe-buffer": "^5.1.1",
|
"safe-buffer": "^5.1.1",
|
||||||
"safe-compare": "^1.1.4",
|
"safe-compare": "^1.1.4",
|
||||||
|
@ -9,7 +9,7 @@ export enum HttpCode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class HttpError extends Error {
|
export class HttpError extends Error {
|
||||||
public constructor(message: string, public readonly code: number, public readonly details?: object) {
|
public constructor(message: string, public readonly status: number, public readonly details?: object) {
|
||||||
super(message)
|
super(message)
|
||||||
this.name = this.constructor.name
|
this.name = this.constructor.name
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,7 @@ import { promises as fs } from "fs"
|
|||||||
import http from "http"
|
import http from "http"
|
||||||
import * as httpolyglot from "httpolyglot"
|
import * as httpolyglot from "httpolyglot"
|
||||||
import { DefaultedArgs } from "./cli"
|
import { DefaultedArgs } from "./cli"
|
||||||
|
import { handleUpgrade } from "./http"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an Express app and an HTTP/S server to serve it.
|
* Create an Express app and an HTTP/S server to serve it.
|
||||||
@ -38,6 +39,8 @@ export const createApp = async (args: DefaultedArgs): Promise<[Express, http.Ser
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
handleUpgrade(app, server)
|
||||||
|
|
||||||
return [app, server]
|
return [app, server]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -18,7 +18,7 @@ import {
|
|||||||
} from "./cli"
|
} from "./cli"
|
||||||
import { coderCloudBind } from "./coder-cloud"
|
import { coderCloudBind } from "./coder-cloud"
|
||||||
import { commit, version } from "./constants"
|
import { commit, version } from "./constants"
|
||||||
import { loadPlugins } from "./plugin"
|
import { register } from "./routes"
|
||||||
import { humanPath, open } from "./util"
|
import { humanPath, open } from "./util"
|
||||||
import { ipcMain, WrapperProcess } from "./wrapper"
|
import { ipcMain, WrapperProcess } from "./wrapper"
|
||||||
|
|
||||||
@ -111,15 +111,14 @@ const main = async (args: DefaultedArgs): Promise<void> => {
|
|||||||
if (args.auth === AuthType.Password && !args.password) {
|
if (args.auth === AuthType.Password && !args.password) {
|
||||||
throw new Error("Please pass in a password via the config file or $PASSWORD")
|
throw new Error("Please pass in a password via the config file or $PASSWORD")
|
||||||
}
|
}
|
||||||
|
|
||||||
ipcMain.onDispose(() => {
|
ipcMain.onDispose(() => {
|
||||||
// TODO: register disposables
|
// TODO: register disposables
|
||||||
})
|
})
|
||||||
|
|
||||||
const [app, server] = await createApp(args)
|
const [app, server] = await createApp(args)
|
||||||
const serverAddress = ensureAddress(server)
|
const serverAddress = ensureAddress(server)
|
||||||
|
await register(app, server, args)
|
||||||
// TODO: register routes
|
|
||||||
await loadPlugins(app, args)
|
|
||||||
|
|
||||||
logger.info(`Using config file ${humanPath(args.config)}`)
|
logger.info(`Using config file ${humanPath(args.config)}`)
|
||||||
logger.info(`HTTP server listening on ${serverAddress} ${args.link ? "(randomized by --link)" : ""}`)
|
logger.info(`HTTP server listening on ${serverAddress} ${args.link ? "(randomized by --link)" : ""}`)
|
||||||
|
1092
src/node/http.ts
1092
src/node/http.ts
File diff suppressed because it is too large
Load Diff
73
src/node/proxy.ts
Normal file
73
src/node/proxy.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { Request, Router } from "express"
|
||||||
|
import proxyServer from "http-proxy"
|
||||||
|
import { HttpCode } from "../common/http"
|
||||||
|
import { ensureAuthenticated } from "./http"
|
||||||
|
|
||||||
|
export const proxy = proxyServer.createProxyServer({})
|
||||||
|
proxy.on("error", (error, _, res) => {
|
||||||
|
res.writeHead(HttpCode.ServerError)
|
||||||
|
res.end(error.message)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Intercept the response to rewrite absolute redirects against the base path.
|
||||||
|
proxy.on("proxyRes", (res, req) => {
|
||||||
|
if (res.headers.location && res.headers.location.startsWith("/") && (req as any).base) {
|
||||||
|
res.headers.location = (req as any).base + res.headers.location
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const router = Router()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the port if the request should be proxied. Anything that ends in a
|
||||||
|
* proxy domain and has a *single* subdomain should be proxied. Anything else
|
||||||
|
* should return `undefined` and will be handled as normal.
|
||||||
|
*
|
||||||
|
* For example if `coder.com` is specified `8080.coder.com` will be proxied
|
||||||
|
* but `8080.test.coder.com` and `test.8080.coder.com` will not.
|
||||||
|
*
|
||||||
|
* Throw an error if proxying but the user isn't authenticated.
|
||||||
|
*/
|
||||||
|
const maybeProxy = (req: Request): string | undefined => {
|
||||||
|
// Split into parts.
|
||||||
|
const host = req.headers.host || ""
|
||||||
|
const idx = host.indexOf(":")
|
||||||
|
const domain = idx !== -1 ? host.substring(0, idx) : host
|
||||||
|
const parts = domain.split(".")
|
||||||
|
|
||||||
|
// There must be an exact match.
|
||||||
|
const port = parts.shift()
|
||||||
|
const proxyDomain = parts.join(".")
|
||||||
|
if (!port || !req.args["proxy-domain"].includes(proxyDomain)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be authenticated to use the proxy.
|
||||||
|
ensureAuthenticated(req)
|
||||||
|
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
|
||||||
|
router.all("*", (req, res, next) => {
|
||||||
|
const port = maybeProxy(req)
|
||||||
|
if (!port) {
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy.web(req, res, {
|
||||||
|
ignorePath: true,
|
||||||
|
target: `http://127.0.0.1:${port}${req.originalUrl}`,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
router.ws("*", (socket, head, req, next) => {
|
||||||
|
const port = maybeProxy(req)
|
||||||
|
if (!port) {
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy.ws(req, socket, head, {
|
||||||
|
ignorePath: true,
|
||||||
|
target: `http://127.0.0.1:${port}${req.originalUrl}`,
|
||||||
|
})
|
||||||
|
})
|
@ -1,22 +1,10 @@
|
|||||||
import { Heart } from "../heart"
|
import { Router } from "express"
|
||||||
import { HttpProvider, HttpProviderOptions, HttpResponse } from "../http"
|
|
||||||
|
|
||||||
/**
|
export const router = Router()
|
||||||
* Check the heartbeat.
|
|
||||||
*/
|
|
||||||
export class HealthHttpProvider extends HttpProvider {
|
|
||||||
public constructor(options: HttpProviderOptions, private readonly heart: Heart) {
|
|
||||||
super(options)
|
|
||||||
}
|
|
||||||
|
|
||||||
public async handleRequest(): Promise<HttpResponse> {
|
router.get("/", (req, res) => {
|
||||||
return {
|
res.json({
|
||||||
cache: false,
|
status: req.heart.alive() ? "alive" : "expired",
|
||||||
mime: "application/json",
|
lastHeartbeat: req.heart.lastHeartbeat,
|
||||||
content: {
|
})
|
||||||
status: this.heart.alive() ? "alive" : "expired",
|
})
|
||||||
lastHeartbeat: this.heart.lastHeartbeat,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
123
src/node/routes/index.ts
Normal file
123
src/node/routes/index.ts
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { logger } from "@coder/logger"
|
||||||
|
import bodyParser from "body-parser"
|
||||||
|
import cookieParser from "cookie-parser"
|
||||||
|
import { Express } from "express"
|
||||||
|
import { promises as fs } from "fs"
|
||||||
|
import http from "http"
|
||||||
|
import * as path from "path"
|
||||||
|
import * as tls from "tls"
|
||||||
|
import { HttpCode, HttpError } from "../../common/http"
|
||||||
|
import { plural } from "../../common/util"
|
||||||
|
import { AuthType, DefaultedArgs } from "../cli"
|
||||||
|
import { rootPath } from "../constants"
|
||||||
|
import { Heart } from "../heart"
|
||||||
|
import { replaceTemplates } from "../http"
|
||||||
|
import { loadPlugins } from "../plugin"
|
||||||
|
import * as domainProxy from "../proxy"
|
||||||
|
import { getMediaMime, paths } from "../util"
|
||||||
|
import * as health from "./health"
|
||||||
|
import * as login from "./login"
|
||||||
|
import * as proxy from "./proxy"
|
||||||
|
// static is a reserved keyword.
|
||||||
|
import * as _static from "./static"
|
||||||
|
import * as update from "./update"
|
||||||
|
import * as vscode from "./vscode"
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||||
|
namespace Express {
|
||||||
|
export interface Request {
|
||||||
|
args: DefaultedArgs
|
||||||
|
heart: Heart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register all routes and middleware.
|
||||||
|
*/
|
||||||
|
export const register = async (app: Express, server: http.Server, args: DefaultedArgs): Promise<void> => {
|
||||||
|
const heart = new Heart(path.join(paths.data, "heartbeat"), async () => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
server.getConnections((error, count) => {
|
||||||
|
if (error) {
|
||||||
|
return reject(error)
|
||||||
|
}
|
||||||
|
logger.trace(plural(count, `${count} active connection`))
|
||||||
|
resolve(count > 0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.disable("x-powered-by")
|
||||||
|
|
||||||
|
app.use(cookieParser())
|
||||||
|
app.use(bodyParser.json())
|
||||||
|
app.use(bodyParser.urlencoded({ extended: true }))
|
||||||
|
|
||||||
|
server.on("upgrade", () => {
|
||||||
|
heart.beat()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.use(async (req, res, next) => {
|
||||||
|
heart.beat()
|
||||||
|
|
||||||
|
// If we're handling TLS ensure all requests are redirected to HTTPS.
|
||||||
|
// TODO: This does *NOT* work if you have a base path since to specify the
|
||||||
|
// protocol we need to specify the whole path.
|
||||||
|
if (args.cert && !(req.connection as tls.TLSSocket).encrypted) {
|
||||||
|
return res.redirect(`https://${req.headers.host}${req.originalUrl}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return robots.txt.
|
||||||
|
if (req.originalUrl === "/robots.txt") {
|
||||||
|
const resourcePath = path.resolve(rootPath, "src/browser/robots.txt")
|
||||||
|
res.set("Content-Type", getMediaMime(resourcePath))
|
||||||
|
return res.send(await fs.readFile(resourcePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add common variables routes can use.
|
||||||
|
req.args = args
|
||||||
|
req.heart = heart
|
||||||
|
|
||||||
|
return next()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.use("/", domainProxy.router)
|
||||||
|
app.use("/", vscode.router)
|
||||||
|
app.use("/healthz", health.router)
|
||||||
|
if (args.auth === AuthType.Password) {
|
||||||
|
app.use("/login", login.router)
|
||||||
|
}
|
||||||
|
app.use("/proxy", proxy.router)
|
||||||
|
app.use("/static", _static.router)
|
||||||
|
app.use("/update", update.router)
|
||||||
|
app.use("/vscode", vscode.router)
|
||||||
|
|
||||||
|
await loadPlugins(app, args)
|
||||||
|
|
||||||
|
app.use(() => {
|
||||||
|
throw new HttpError("Not Found", HttpCode.NotFound)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle errors.
|
||||||
|
// TODO: The types are broken; says they're all implicitly `any`.
|
||||||
|
app.use(async (err: any, req: any, res: any, next: any) => {
|
||||||
|
const resourcePath = path.resolve(rootPath, "src/browser/pages/error.html")
|
||||||
|
res.set("Content-Type", getMediaMime(resourcePath))
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(resourcePath, "utf8")
|
||||||
|
if (err.code === "ENOENT" || err.code === "EISDIR") {
|
||||||
|
err.status = HttpCode.NotFound
|
||||||
|
}
|
||||||
|
res.status(err.status || 500).send(
|
||||||
|
replaceTemplates(req, content)
|
||||||
|
.replace(/{{ERROR_TITLE}}/g, err.status || "Error")
|
||||||
|
.replace(/{{ERROR_HEADER}}/g, err.status || "Error")
|
||||||
|
.replace(/{{ERROR_BODY}}/g, err.message),
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
@ -1,140 +1,21 @@
|
|||||||
import * as http from "http"
|
import { Router, Request } from "express"
|
||||||
import * as limiter from "limiter"
|
import { promises as fs } from "fs"
|
||||||
import * as querystring from "querystring"
|
import { RateLimiter as Limiter } from "limiter"
|
||||||
import { HttpCode, HttpError } from "../../common/http"
|
import * as path from "path"
|
||||||
import { AuthType } from "../cli"
|
import safeCompare from "safe-compare"
|
||||||
import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http"
|
import { rootPath } from "../constants"
|
||||||
|
import { authenticated, getCookieDomain, redirect, replaceTemplates } from "../http"
|
||||||
import { hash, humanPath } from "../util"
|
import { hash, humanPath } from "../util"
|
||||||
|
|
||||||
interface LoginPayload {
|
enum Cookie {
|
||||||
password?: string
|
Key = "key",
|
||||||
/**
|
|
||||||
* Since we must set a cookie with an absolute path, we need to know the full
|
|
||||||
* base path.
|
|
||||||
*/
|
|
||||||
base?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Login HTTP provider.
|
|
||||||
*/
|
|
||||||
export class LoginHttpProvider extends HttpProvider {
|
|
||||||
public constructor(
|
|
||||||
options: HttpProviderOptions,
|
|
||||||
private readonly configFile: string,
|
|
||||||
private readonly envPassword: boolean,
|
|
||||||
) {
|
|
||||||
super(options)
|
|
||||||
}
|
|
||||||
|
|
||||||
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
|
|
||||||
if (this.options.auth !== AuthType.Password || !this.isRoot(route)) {
|
|
||||||
throw new HttpError("Not found", HttpCode.NotFound)
|
|
||||||
}
|
|
||||||
switch (route.base) {
|
|
||||||
case "/":
|
|
||||||
switch (request.method) {
|
|
||||||
case "POST":
|
|
||||||
this.ensureMethod(request, ["GET", "POST"])
|
|
||||||
return this.tryLogin(route, request)
|
|
||||||
default:
|
|
||||||
this.ensureMethod(request)
|
|
||||||
if (this.authenticated(request)) {
|
|
||||||
return {
|
|
||||||
redirect: (Array.isArray(route.query.to) ? route.query.to[0] : route.query.to) || "/",
|
|
||||||
query: { to: undefined },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this.getRoot(route)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new HttpError("Not found", HttpCode.NotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getRoot(route: Route, error?: Error): Promise<HttpResponse> {
|
|
||||||
const response = await this.getUtf8Resource(this.rootPath, "src/browser/pages/login.html")
|
|
||||||
response.content = response.content.replace(/{{ERROR}}/, error ? `<div class="error">${error.message}</div>` : "")
|
|
||||||
let passwordMsg = `Check the config file at ${humanPath(this.configFile)} for the password.`
|
|
||||||
if (this.envPassword) {
|
|
||||||
passwordMsg = "Password was set from $PASSWORD."
|
|
||||||
}
|
|
||||||
response.content = response.content.replace(/{{PASSWORD_MSG}}/g, passwordMsg)
|
|
||||||
return this.replaceTemplates(route, response)
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly limiter = new RateLimiter()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Try logging in. On failure, show the login page with an error.
|
|
||||||
*/
|
|
||||||
private async tryLogin(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
|
|
||||||
// Already authenticated via cookies?
|
|
||||||
const providedPassword = this.authenticated(request)
|
|
||||||
if (providedPassword) {
|
|
||||||
return { code: HttpCode.Ok }
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!this.limiter.try()) {
|
|
||||||
throw new Error("Login rate limited!")
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await this.getData(request)
|
|
||||||
const payload = data ? querystring.parse(data) : {}
|
|
||||||
return await this.login(payload, route, request)
|
|
||||||
} catch (error) {
|
|
||||||
return this.getRoot(route, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return a cookie if the user is authenticated otherwise throw an error.
|
|
||||||
*/
|
|
||||||
private async login(payload: LoginPayload, route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
|
|
||||||
const password = this.authenticated(request, {
|
|
||||||
key: typeof payload.password === "string" ? [hash(payload.password)] : undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (password) {
|
|
||||||
return {
|
|
||||||
redirect: (Array.isArray(route.query.to) ? route.query.to[0] : route.query.to) || "/",
|
|
||||||
query: { to: undefined },
|
|
||||||
cookie:
|
|
||||||
typeof password === "string"
|
|
||||||
? {
|
|
||||||
key: "key",
|
|
||||||
value: password,
|
|
||||||
path: payload.base,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only log if it was an actual login attempt.
|
|
||||||
if (payload && payload.password) {
|
|
||||||
console.error(
|
|
||||||
"Failed login attempt",
|
|
||||||
JSON.stringify({
|
|
||||||
xForwardedFor: request.headers["x-forwarded-for"],
|
|
||||||
remoteAddress: request.connection.remoteAddress,
|
|
||||||
userAgent: request.headers["user-agent"],
|
|
||||||
timestamp: Math.floor(new Date().getTime() / 1000),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
throw new Error("Incorrect password")
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error("Missing password")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RateLimiter wraps around the limiter library for logins.
|
// RateLimiter wraps around the limiter library for logins.
|
||||||
// It allows 2 logins every minute and 12 logins every hour.
|
// It allows 2 logins every minute and 12 logins every hour.
|
||||||
class RateLimiter {
|
class RateLimiter {
|
||||||
private readonly minuteLimiter = new limiter.RateLimiter(2, "minute")
|
private readonly minuteLimiter = new Limiter(2, "minute")
|
||||||
private readonly hourLimiter = new limiter.RateLimiter(12, "hour")
|
private readonly hourLimiter = new Limiter(12, "hour")
|
||||||
|
|
||||||
public try(): boolean {
|
public try(): boolean {
|
||||||
if (this.minuteLimiter.tryRemoveTokens(1)) {
|
if (this.minuteLimiter.tryRemoveTokens(1)) {
|
||||||
@ -143,3 +24,72 @@ class RateLimiter {
|
|||||||
return this.hourLimiter.tryRemoveTokens(1)
|
return this.hourLimiter.tryRemoveTokens(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getRoot = async (req: Request, error?: Error): Promise<string> => {
|
||||||
|
const content = await fs.readFile(path.join(rootPath, "src/browser/pages/login.html"), "utf8")
|
||||||
|
let passwordMsg = `Check the config file at ${humanPath(req.args.config)} for the password.`
|
||||||
|
if (req.args.usingEnvPassword) {
|
||||||
|
passwordMsg = "Password was set from $PASSWORD."
|
||||||
|
}
|
||||||
|
return replaceTemplates(
|
||||||
|
req,
|
||||||
|
content
|
||||||
|
.replace(/{{PASSWORD_MSG}}/g, passwordMsg)
|
||||||
|
.replace(/{{ERROR}}/, error ? `<div class="error">${error.message}</div>` : ""),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
try {
|
||||||
|
if (!limiter.try()) {
|
||||||
|
throw new Error("Login rate limited!")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.body.password) {
|
||||||
|
throw new Error("Missing password")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.args.password && safeCompare(req.body.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), {
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(
|
||||||
|
"Failed login attempt",
|
||||||
|
JSON.stringify({
|
||||||
|
xForwardedFor: req.headers["x-forwarded-for"],
|
||||||
|
remoteAddress: req.connection.remoteAddress,
|
||||||
|
userAgent: req.headers["user-agent"],
|
||||||
|
timestamp: Math.floor(new Date().getTime() / 1000),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
throw new Error("Incorrect password")
|
||||||
|
} catch (error) {
|
||||||
|
res.send(await getRoot(req, error))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
@ -1,43 +1,43 @@
|
|||||||
import * as http from "http"
|
import { Request, Router } from "express"
|
||||||
|
import qs from "qs"
|
||||||
import { HttpCode, HttpError } from "../../common/http"
|
import { HttpCode, HttpError } from "../../common/http"
|
||||||
import { HttpProvider, HttpResponse, Route, WsResponse } from "../http"
|
import { authenticated, redirect } from "../http"
|
||||||
|
import { proxy } from "../proxy"
|
||||||
|
|
||||||
/**
|
export const router = Router()
|
||||||
* Proxy HTTP provider.
|
|
||||||
*/
|
|
||||||
export class ProxyHttpProvider extends HttpProvider {
|
|
||||||
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
|
|
||||||
if (!this.authenticated(request)) {
|
|
||||||
if (this.isRoot(route)) {
|
|
||||||
return { redirect: "/login", query: { to: route.fullPath } }
|
|
||||||
}
|
|
||||||
throw new HttpError("Unauthorized", HttpCode.Unauthorized)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure there is a trailing slash so relative paths work correctly.
|
const getProxyTarget = (req: Request, rewrite: boolean): string => {
|
||||||
if (this.isRoot(route) && !route.fullPath.endsWith("/")) {
|
if (rewrite) {
|
||||||
return {
|
const query = qs.stringify(req.query)
|
||||||
redirect: `${route.fullPath}/`,
|
return `http://127.0.0.1:${req.params.port}/${req.params[0] || ""}${query ? `?${query}` : ""}`
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const port = route.base.replace(/^\//, "")
|
|
||||||
return {
|
|
||||||
proxy: {
|
|
||||||
strip: `${route.providerBase}/${port}`,
|
|
||||||
port,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async handleWebSocket(route: Route, request: http.IncomingMessage): Promise<WsResponse> {
|
|
||||||
this.ensureAuthenticated(request)
|
|
||||||
const port = route.base.replace(/^\//, "")
|
|
||||||
return {
|
|
||||||
proxy: {
|
|
||||||
strip: `${route.providerBase}/${port}`,
|
|
||||||
port,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return `http://127.0.0.1:${req.params.port}/${req.originalUrl}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
router.all("/(:port)(/*)?", (req, res) => {
|
||||||
|
if (!authenticated(req)) {
|
||||||
|
// If visiting the root (/proxy/:port and nothing else) redirect to the
|
||||||
|
// login page.
|
||||||
|
if (!req.params[0] || req.params[0] === "/") {
|
||||||
|
return redirect(req, res, "login", {
|
||||||
|
to: `${req.baseUrl}${req.path}` || "/",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
throw new HttpError("Unauthorized", HttpCode.Unauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absolute redirects need to be based on the subpath when rewriting.
|
||||||
|
;(req as any).base = `${req.baseUrl}/${req.params.port}`
|
||||||
|
|
||||||
|
proxy.web(req, res, {
|
||||||
|
ignorePath: true,
|
||||||
|
target: getProxyTarget(req, true),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
router.ws("/(:port)(/*)?", (socket, head, req) => {
|
||||||
|
proxy.ws(req, socket, head, {
|
||||||
|
ignorePath: true,
|
||||||
|
target: getProxyTarget(req, true),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
@ -1,73 +1,61 @@
|
|||||||
import { field, logger } from "@coder/logger"
|
import { field, logger } from "@coder/logger"
|
||||||
import * as http from "http"
|
import { Router } from "express"
|
||||||
|
import { promises as fs } from "fs"
|
||||||
import * as path from "path"
|
import * as path from "path"
|
||||||
import { Readable } from "stream"
|
import { Readable } from "stream"
|
||||||
import * as tarFs from "tar-fs"
|
import * as tarFs from "tar-fs"
|
||||||
import * as zlib from "zlib"
|
import * as zlib from "zlib"
|
||||||
import { HttpProvider, HttpResponse, Route } from "../http"
|
import { HttpCode, HttpError } from "../../common/http"
|
||||||
import { pathToFsPath } from "../util"
|
import { rootPath } from "../constants"
|
||||||
|
import { authenticated, replaceTemplates } from "../http"
|
||||||
|
import { getMediaMime, pathToFsPath } from "../util"
|
||||||
|
|
||||||
/**
|
export const router = Router()
|
||||||
* Static file HTTP provider. Static requests do not require authentication if
|
|
||||||
* the resource is in the application's directory except requests to serve a
|
|
||||||
* directory as a tar which always requires authentication.
|
|
||||||
*/
|
|
||||||
export class StaticHttpProvider extends HttpProvider {
|
|
||||||
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
|
|
||||||
this.ensureMethod(request)
|
|
||||||
|
|
||||||
if (typeof route.query.tar === "string") {
|
// The commit is for caching.
|
||||||
this.ensureAuthenticated(request)
|
router.get("/(:commit)(/*)?", async (req, res) => {
|
||||||
return this.getTarredResource(request, pathToFsPath(route.query.tar))
|
if (!req.params[0]) {
|
||||||
}
|
throw new HttpError("Not Found", HttpCode.NotFound)
|
||||||
|
|
||||||
const response = await this.getReplacedResource(request, route)
|
|
||||||
if (!this.isDev) {
|
|
||||||
response.cache = true
|
|
||||||
}
|
|
||||||
return response
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const resourcePath = path.resolve(req.params[0])
|
||||||
* Return a resource with variables replaced where necessary.
|
|
||||||
*/
|
|
||||||
protected async getReplacedResource(request: http.IncomingMessage, route: Route): Promise<HttpResponse> {
|
|
||||||
// The first part is always the commit (for caching purposes).
|
|
||||||
const split = route.requestPath.split("/").slice(1)
|
|
||||||
|
|
||||||
const resourcePath = path.resolve("/", ...split)
|
// Make sure it's in code-server if you aren't authenticated. This lets
|
||||||
|
// unauthenticated users load the login assets.
|
||||||
// Make sure it's in code-server or a plugin.
|
if (!resourcePath.startsWith(rootPath) && !authenticated(req)) {
|
||||||
const validPaths = [this.rootPath, process.env.PLUGIN_DIR]
|
throw new HttpError("Unauthorized", HttpCode.Unauthorized)
|
||||||
if (!validPaths.find((p) => p && resourcePath.startsWith(p))) {
|
|
||||||
this.ensureAuthenticated(request)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (split[split.length - 1]) {
|
|
||||||
case "manifest.json": {
|
|
||||||
const response = await this.getUtf8Resource(resourcePath)
|
|
||||||
return this.replaceTemplates(route, response)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this.getResource(resourcePath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Don't cache during development. - can also be used if you want to make a
|
||||||
* Tar up and stream a directory.
|
// static request without caching.
|
||||||
*/
|
if (req.params.commit !== "development" && req.params.commit !== "-") {
|
||||||
private async getTarredResource(request: http.IncomingMessage, ...parts: string[]): Promise<HttpResponse> {
|
res.header("Cache-Control", "public, max-age=31536000")
|
||||||
const filePath = path.join(...parts)
|
}
|
||||||
let stream: Readable = tarFs.pack(filePath)
|
|
||||||
const headers: http.OutgoingHttpHeaders = {}
|
const tar = Array.isArray(req.query.tar) ? req.query.tar[0] : req.query.tar
|
||||||
if (request.headers["accept-encoding"] && request.headers["accept-encoding"].includes("gzip")) {
|
if (typeof tar === "string") {
|
||||||
logger.debug("gzipping tar", field("filePath", filePath))
|
let stream: Readable = tarFs.pack(pathToFsPath(tar))
|
||||||
|
if (req.headers["accept-encoding"] && req.headers["accept-encoding"].includes("gzip")) {
|
||||||
|
logger.debug("gzipping tar", field("path", resourcePath))
|
||||||
const compress = zlib.createGzip()
|
const compress = zlib.createGzip()
|
||||||
stream.pipe(compress)
|
stream.pipe(compress)
|
||||||
stream.on("error", (error) => compress.destroy(error))
|
stream.on("error", (error) => compress.destroy(error))
|
||||||
stream.on("close", () => compress.end())
|
stream.on("close", () => compress.end())
|
||||||
stream = compress
|
stream = compress
|
||||||
headers["content-encoding"] = "gzip"
|
res.header("content-encoding", "gzip")
|
||||||
}
|
}
|
||||||
return { stream, filePath, mime: "application/x-tar", cache: true, headers }
|
res.set("Content-Type", "application/x-tar")
|
||||||
|
stream.on("close", () => res.end())
|
||||||
|
return stream.pipe(res)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
res.set("Content-Type", getMediaMime(resourcePath))
|
||||||
|
|
||||||
|
if (resourcePath.endsWith("manifest.json")) {
|
||||||
|
const content = await fs.readFile(resourcePath, "utf8")
|
||||||
|
return res.send(replaceTemplates(req, content))
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await fs.readFile(resourcePath)
|
||||||
|
return res.send(content)
|
||||||
|
})
|
||||||
|
@ -1,172 +1,34 @@
|
|||||||
import { field, logger } from "@coder/logger"
|
import { Router } from "express"
|
||||||
import * as http from "http"
|
import { version } from "../constants"
|
||||||
import * as https from "https"
|
import { ensureAuthenticated } from "../http"
|
||||||
import * as path from "path"
|
import { UpdateProvider } from "../update"
|
||||||
import * as semver from "semver"
|
|
||||||
import * as url from "url"
|
|
||||||
import { HttpCode, HttpError } from "../../common/http"
|
|
||||||
import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http"
|
|
||||||
import { settings as globalSettings, SettingsProvider, UpdateSettings } from "../settings"
|
|
||||||
|
|
||||||
export interface Update {
|
export const router = Router()
|
||||||
checked: number
|
|
||||||
version: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LatestResponse {
|
const provider = new UpdateProvider()
|
||||||
name: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
router.use((req, _, next) => {
|
||||||
* HTTP provider for checking updates (does not download/install them).
|
ensureAuthenticated(req)
|
||||||
*/
|
next()
|
||||||
export class UpdateHttpProvider extends HttpProvider {
|
})
|
||||||
private update?: Promise<Update>
|
|
||||||
private updateInterval = 1000 * 60 * 60 * 24 // Milliseconds between update checks.
|
|
||||||
|
|
||||||
public constructor(
|
router.get("/", async (_, res) => {
|
||||||
options: HttpProviderOptions,
|
const update = await provider.getUpdate()
|
||||||
public readonly enabled: boolean,
|
res.json({
|
||||||
/**
|
checked: update.checked,
|
||||||
* The URL for getting the latest version of code-server. Should return JSON
|
latest: update.version,
|
||||||
* that fulfills `LatestResponse`.
|
current: version,
|
||||||
*/
|
isLatest: provider.isLatestVersion(update),
|
||||||
private readonly latestUrl = "https://api.github.com/repos/cdr/code-server/releases/latest",
|
})
|
||||||
/**
|
})
|
||||||
* Update information will be stored here. If not provided, the global
|
|
||||||
* settings will be used.
|
|
||||||
*/
|
|
||||||
private readonly settings: SettingsProvider<UpdateSettings> = globalSettings,
|
|
||||||
) {
|
|
||||||
super(options)
|
|
||||||
}
|
|
||||||
|
|
||||||
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
|
// This route will force a check.
|
||||||
this.ensureAuthenticated(request)
|
router.get("/check", async (_, res) => {
|
||||||
this.ensureMethod(request)
|
const update = await provider.getUpdate(true)
|
||||||
|
res.json({
|
||||||
if (!this.isRoot(route)) {
|
checked: update.checked,
|
||||||
throw new HttpError("Not found", HttpCode.NotFound)
|
latest: update.version,
|
||||||
}
|
current: version,
|
||||||
|
isLatest: provider.isLatestVersion(update),
|
||||||
if (!this.enabled) {
|
})
|
||||||
throw new Error("update checks are disabled")
|
})
|
||||||
}
|
|
||||||
|
|
||||||
switch (route.base) {
|
|
||||||
case "/check":
|
|
||||||
case "/": {
|
|
||||||
const update = await this.getUpdate(route.base === "/check")
|
|
||||||
return {
|
|
||||||
content: {
|
|
||||||
...update,
|
|
||||||
isLatest: this.isLatestVersion(update),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new HttpError("Not found", HttpCode.NotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Query for and return the latest update.
|
|
||||||
*/
|
|
||||||
public async getUpdate(force?: boolean): Promise<Update> {
|
|
||||||
// Don't run multiple requests at a time.
|
|
||||||
if (!this.update) {
|
|
||||||
this.update = this._getUpdate(force)
|
|
||||||
this.update.then(() => (this.update = undefined))
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.update
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _getUpdate(force?: boolean): Promise<Update> {
|
|
||||||
const now = Date.now()
|
|
||||||
try {
|
|
||||||
let { update } = !force ? await this.settings.read() : { update: undefined }
|
|
||||||
if (!update || update.checked + this.updateInterval < now) {
|
|
||||||
const buffer = await this.request(this.latestUrl)
|
|
||||||
const data = JSON.parse(buffer.toString()) as LatestResponse
|
|
||||||
update = { checked: now, version: data.name }
|
|
||||||
await this.settings.write({ update })
|
|
||||||
}
|
|
||||||
logger.debug("got latest version", field("latest", update.version))
|
|
||||||
return update
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Failed to get latest version", field("error", error.message))
|
|
||||||
return {
|
|
||||||
checked: now,
|
|
||||||
version: "unknown",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public get currentVersion(): string {
|
|
||||||
return require(path.resolve(__dirname, "../../../package.json")).version
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return true if the currently installed version is the latest.
|
|
||||||
*/
|
|
||||||
public isLatestVersion(latest: Update): boolean {
|
|
||||||
const version = this.currentVersion
|
|
||||||
logger.debug("comparing versions", field("current", version), field("latest", latest.version))
|
|
||||||
try {
|
|
||||||
return latest.version === version || semver.lt(latest.version, version)
|
|
||||||
} catch (error) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async request(uri: string): Promise<Buffer> {
|
|
||||||
const response = await this.requestResponse(uri)
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const chunks: Buffer[] = []
|
|
||||||
let bufferLength = 0
|
|
||||||
response.on("data", (chunk) => {
|
|
||||||
bufferLength += chunk.length
|
|
||||||
chunks.push(chunk)
|
|
||||||
})
|
|
||||||
response.on("error", reject)
|
|
||||||
response.on("end", () => {
|
|
||||||
resolve(Buffer.concat(chunks, bufferLength))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private async requestResponse(uri: string): Promise<http.IncomingMessage> {
|
|
||||||
let redirects = 0
|
|
||||||
const maxRedirects = 10
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const request = (uri: string): void => {
|
|
||||||
logger.debug("Making request", field("uri", uri))
|
|
||||||
const httpx = uri.startsWith("https") ? https : http
|
|
||||||
const client = httpx.get(uri, { headers: { "User-Agent": "code-server" } }, (response) => {
|
|
||||||
if (
|
|
||||||
response.statusCode &&
|
|
||||||
response.statusCode >= 300 &&
|
|
||||||
response.statusCode < 400 &&
|
|
||||||
response.headers.location
|
|
||||||
) {
|
|
||||||
++redirects
|
|
||||||
if (redirects > maxRedirects) {
|
|
||||||
return reject(new Error("reached max redirects"))
|
|
||||||
}
|
|
||||||
response.destroy()
|
|
||||||
return request(url.resolve(uri, response.headers.location))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 400) {
|
|
||||||
return reject(new Error(`${uri}: ${response.statusCode || "500"}`))
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(response)
|
|
||||||
})
|
|
||||||
client.on("error", reject)
|
|
||||||
}
|
|
||||||
request(uri)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -1,237 +1,99 @@
|
|||||||
import { field, logger } from "@coder/logger"
|
|
||||||
import * as cp from "child_process"
|
|
||||||
import * as crypto from "crypto"
|
import * as crypto from "crypto"
|
||||||
import * as fs from "fs-extra"
|
import { Router } from "express"
|
||||||
import * as http from "http"
|
import { promises as fs } from "fs"
|
||||||
import * as net from "net"
|
|
||||||
import * as path from "path"
|
import * as path from "path"
|
||||||
import {
|
import { commit, rootPath, version } from "../constants"
|
||||||
CodeServerMessage,
|
import { authenticated, ensureAuthenticated, redirect, replaceTemplates } from "../http"
|
||||||
Options,
|
import { getMediaMime, pathToFsPath } from "../util"
|
||||||
StartPath,
|
import { VscodeProvider } from "../vscode"
|
||||||
VscodeMessage,
|
|
||||||
VscodeOptions,
|
|
||||||
WorkbenchOptions,
|
|
||||||
} from "../../../lib/vscode/src/vs/server/ipc"
|
|
||||||
import { HttpCode, HttpError } from "../../common/http"
|
|
||||||
import { arrayify, generateUuid } from "../../common/util"
|
|
||||||
import { Args } from "../cli"
|
|
||||||
import { HttpProvider, HttpProviderOptions, HttpResponse, Route } from "../http"
|
|
||||||
import { settings } from "../settings"
|
|
||||||
import { pathToFsPath } from "../util"
|
|
||||||
|
|
||||||
export class VscodeHttpProvider extends HttpProvider {
|
export const router = Router()
|
||||||
private readonly serverRootPath: string
|
|
||||||
private readonly vsRootPath: string
|
|
||||||
private _vscode?: Promise<cp.ChildProcess>
|
|
||||||
|
|
||||||
public constructor(options: HttpProviderOptions, private readonly args: Args) {
|
const vscode = new VscodeProvider()
|
||||||
super(options)
|
|
||||||
this.vsRootPath = path.resolve(this.rootPath, "lib/vscode")
|
|
||||||
this.serverRootPath = path.join(this.vsRootPath, "out/vs/server")
|
|
||||||
}
|
|
||||||
|
|
||||||
public get running(): boolean {
|
router.get("/", async (req, res) => {
|
||||||
return !!this._vscode
|
if (!authenticated(req)) {
|
||||||
}
|
return redirect(req, res, "login", {
|
||||||
|
to: req.baseUrl || "/",
|
||||||
public async dispose(): Promise<void> {
|
|
||||||
if (this._vscode) {
|
|
||||||
const vscode = await this._vscode
|
|
||||||
vscode.removeAllListeners()
|
|
||||||
this._vscode = undefined
|
|
||||||
vscode.kill()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async initialize(options: VscodeOptions): Promise<WorkbenchOptions> {
|
|
||||||
const id = generateUuid()
|
|
||||||
const vscode = await this.fork()
|
|
||||||
|
|
||||||
logger.debug("setting up vs code...")
|
|
||||||
return new Promise<WorkbenchOptions>((resolve, reject) => {
|
|
||||||
vscode.once("message", (message: VscodeMessage) => {
|
|
||||||
logger.debug("got message from vs code", field("message", message))
|
|
||||||
return message.type === "options" && message.id === id
|
|
||||||
? resolve(message.options)
|
|
||||||
: reject(new Error("Unexpected response during initialization"))
|
|
||||||
})
|
|
||||||
vscode.once("error", reject)
|
|
||||||
vscode.once("exit", (code) => reject(new Error(`VS Code exited unexpectedly with code ${code}`)))
|
|
||||||
this.send({ type: "init", id, options }, vscode)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private fork(): Promise<cp.ChildProcess> {
|
const [content, options] = await Promise.all([
|
||||||
if (!this._vscode) {
|
await fs.readFile(path.join(rootPath, "src/browser/pages/vscode.html"), "utf8"),
|
||||||
logger.debug("forking vs code...")
|
vscode
|
||||||
const vscode = cp.fork(path.join(this.serverRootPath, "fork"))
|
.initialize(
|
||||||
vscode.on("error", (error) => {
|
{
|
||||||
logger.error(error.message)
|
args: req.args,
|
||||||
this._vscode = undefined
|
remoteAuthority: req.headers.host || "",
|
||||||
})
|
},
|
||||||
vscode.on("exit", (code) => {
|
req.query,
|
||||||
logger.error(`VS Code exited unexpectedly with code ${code}`)
|
)
|
||||||
this._vscode = undefined
|
.catch((error) => {
|
||||||
})
|
const devMessage = commit === "development" ? "It might not have finished compiling." : ""
|
||||||
|
throw new Error(`VS Code failed to load. ${devMessage} ${error.message}`)
|
||||||
this._vscode = new Promise((resolve, reject) => {
|
|
||||||
vscode.once("message", (message: VscodeMessage) => {
|
|
||||||
logger.debug("got message from vs code", field("message", message))
|
|
||||||
return message.type === "ready"
|
|
||||||
? resolve(vscode)
|
|
||||||
: reject(new Error("Unexpected response waiting for ready response"))
|
|
||||||
})
|
|
||||||
vscode.once("error", reject)
|
|
||||||
vscode.once("exit", (code) => reject(new Error(`VS Code exited unexpectedly with code ${code}`)))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return this._vscode
|
|
||||||
}
|
|
||||||
|
|
||||||
public async handleWebSocket(route: Route, request: http.IncomingMessage, socket: net.Socket): Promise<void> {
|
|
||||||
if (!this.authenticated(request)) {
|
|
||||||
throw new Error("not authenticated")
|
|
||||||
}
|
|
||||||
|
|
||||||
// VS Code expects a raw socket. It will handle all the web socket frames.
|
|
||||||
// We just need to handle the initial upgrade.
|
|
||||||
// This magic value is specified by the websocket spec.
|
|
||||||
const magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
||||||
const reply = crypto
|
|
||||||
.createHash("sha1")
|
|
||||||
.update(request.headers["sec-websocket-key"] + magic)
|
|
||||||
.digest("base64")
|
|
||||||
socket.write(
|
|
||||||
[
|
|
||||||
"HTTP/1.1 101 Switching Protocols",
|
|
||||||
"Upgrade: websocket",
|
|
||||||
"Connection: Upgrade",
|
|
||||||
`Sec-WebSocket-Accept: ${reply}`,
|
|
||||||
].join("\r\n") + "\r\n\r\n",
|
|
||||||
)
|
|
||||||
|
|
||||||
const vscode = await this._vscode
|
|
||||||
this.send({ type: "socket", query: route.query }, vscode, socket)
|
|
||||||
}
|
|
||||||
|
|
||||||
private send(message: CodeServerMessage, vscode?: cp.ChildProcess, socket?: net.Socket): void {
|
|
||||||
if (!vscode || vscode.killed) {
|
|
||||||
throw new Error("vscode is not running")
|
|
||||||
}
|
|
||||||
vscode.send(message, socket)
|
|
||||||
}
|
|
||||||
|
|
||||||
public async handleRequest(route: Route, request: http.IncomingMessage): Promise<HttpResponse> {
|
|
||||||
this.ensureMethod(request)
|
|
||||||
|
|
||||||
switch (route.base) {
|
|
||||||
case "/":
|
|
||||||
if (!this.isRoot(route)) {
|
|
||||||
throw new HttpError("Not found", HttpCode.NotFound)
|
|
||||||
} else if (!this.authenticated(request)) {
|
|
||||||
return { redirect: "/login", query: { to: route.providerBase } }
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return await this.getRoot(request, route)
|
|
||||||
} catch (error) {
|
|
||||||
const message = `<div>VS Code failed to load.</div> ${
|
|
||||||
this.isDev
|
|
||||||
? `<div>It might not have finished compiling.</div>` +
|
|
||||||
`Check for <code>Finished <span class="success">compilation</span></code> in the output.`
|
|
||||||
: ""
|
|
||||||
} <br><br>${error}`
|
|
||||||
return this.getErrorRoot(route, "VS Code failed to load", "500", message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ensureAuthenticated(request)
|
|
||||||
|
|
||||||
switch (route.base) {
|
|
||||||
case "/resource":
|
|
||||||
case "/vscode-remote-resource":
|
|
||||||
if (typeof route.query.path === "string") {
|
|
||||||
return this.getResource(pathToFsPath(route.query.path))
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case "/webview":
|
|
||||||
if (/^\/vscode-resource/.test(route.requestPath)) {
|
|
||||||
return this.getResource(route.requestPath.replace(/^\/vscode-resource(\/file)?/, ""))
|
|
||||||
}
|
|
||||||
return this.getResource(this.vsRootPath, "out/vs/workbench/contrib/webview/browser/pre", route.requestPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new HttpError("Not found", HttpCode.NotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getRoot(request: http.IncomingMessage, route: Route): Promise<HttpResponse> {
|
|
||||||
const remoteAuthority = request.headers.host as string
|
|
||||||
const { lastVisited } = await settings.read()
|
|
||||||
const startPath = await this.getFirstPath([
|
|
||||||
{ url: route.query.workspace, workspace: true },
|
|
||||||
{ url: route.query.folder, workspace: false },
|
|
||||||
this.args._ && this.args._.length > 0 ? { url: path.resolve(this.args._[this.args._.length - 1]) } : undefined,
|
|
||||||
lastVisited,
|
|
||||||
])
|
|
||||||
const [response, options] = await Promise.all([
|
|
||||||
await this.getUtf8Resource(this.rootPath, "src/browser/pages/vscode.html"),
|
|
||||||
this.initialize({
|
|
||||||
args: this.args,
|
|
||||||
remoteAuthority,
|
|
||||||
startPath,
|
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
settings.write({
|
options.productConfiguration.codeServerVersion = version
|
||||||
lastVisited: startPath || lastVisited, // If startpath is undefined, then fallback to lastVisited
|
|
||||||
query: route.query,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!this.isDev) {
|
res.send(
|
||||||
response.content = response.content.replace(/<!-- PROD_ONLY/g, "").replace(/END_PROD_ONLY -->/g, "")
|
replaceTemplates(
|
||||||
}
|
req,
|
||||||
|
// Uncomment prod blocks if not in development. TODO: Would this be
|
||||||
options.productConfiguration.codeServerVersion = require("../../../package.json").version
|
// better as a build step? Or maintain two HTML files again?
|
||||||
|
commit !== "development" ? content.replace(/<!-- PROD_ONLY/g, "").replace(/END_PROD_ONLY -->/g, "") : content,
|
||||||
response.content = response.content
|
{
|
||||||
|
disableTelemetry: !!req.args["disable-telemetry"],
|
||||||
|
},
|
||||||
|
)
|
||||||
.replace(`"{{REMOTE_USER_DATA_URI}}"`, `'${JSON.stringify(options.remoteUserDataUri)}'`)
|
.replace(`"{{REMOTE_USER_DATA_URI}}"`, `'${JSON.stringify(options.remoteUserDataUri)}'`)
|
||||||
.replace(`"{{PRODUCT_CONFIGURATION}}"`, `'${JSON.stringify(options.productConfiguration)}'`)
|
.replace(`"{{PRODUCT_CONFIGURATION}}"`, `'${JSON.stringify(options.productConfiguration)}'`)
|
||||||
.replace(`"{{WORKBENCH_WEB_CONFIGURATION}}"`, `'${JSON.stringify(options.workbenchWebConfiguration)}'`)
|
.replace(`"{{WORKBENCH_WEB_CONFIGURATION}}"`, `'${JSON.stringify(options.workbenchWebConfiguration)}'`)
|
||||||
.replace(`"{{NLS_CONFIGURATION}}"`, `'${JSON.stringify(options.nlsConfiguration)}'`)
|
.replace(`"{{NLS_CONFIGURATION}}"`, `'${JSON.stringify(options.nlsConfiguration)}'`),
|
||||||
return this.replaceTemplates<Options>(route, response, {
|
)
|
||||||
disableTelemetry: !!this.args["disable-telemetry"],
|
})
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
router.ws("/", async (socket, _, req) => {
|
||||||
* Choose the first non-empty path.
|
ensureAuthenticated(req)
|
||||||
*/
|
const magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
||||||
private async getFirstPath(
|
const reply = crypto
|
||||||
startPaths: Array<{ url?: string | string[]; workspace?: boolean } | undefined>,
|
.createHash("sha1")
|
||||||
): Promise<StartPath | undefined> {
|
.update(req.headers["sec-websocket-key"] + magic)
|
||||||
const isFile = async (path: string): Promise<boolean> => {
|
.digest("base64")
|
||||||
try {
|
socket.write(
|
||||||
const stat = await fs.stat(path)
|
[
|
||||||
return stat.isFile()
|
"HTTP/1.1 101 Switching Protocols",
|
||||||
} catch (error) {
|
"Upgrade: websocket",
|
||||||
logger.warn(error.message)
|
"Connection: Upgrade",
|
||||||
return false
|
`Sec-WebSocket-Accept: ${reply}`,
|
||||||
}
|
].join("\r\n") + "\r\n\r\n",
|
||||||
}
|
)
|
||||||
for (let i = 0; i < startPaths.length; ++i) {
|
await vscode.sendWebsocket(socket, req.query)
|
||||||
const startPath = startPaths[i]
|
})
|
||||||
const url = arrayify(startPath && startPath.url).find((p) => !!p)
|
|
||||||
if (startPath && url) {
|
router.get("/resource(/*)?", async (req, res) => {
|
||||||
return {
|
ensureAuthenticated(req)
|
||||||
url,
|
if (typeof req.query.path === "string") {
|
||||||
// The only time `workspace` is undefined is for the command-line
|
res.set("Content-Type", getMediaMime(req.query.path))
|
||||||
// argument, in which case it's a path (not a URL) so we can stat it
|
res.send(await fs.readFile(pathToFsPath(req.query.path)))
|
||||||
// without having to parse it.
|
|
||||||
workspace: typeof startPath.workspace !== "undefined" ? startPath.workspace : await isFile(url),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
|
router.get("/vscode-remote-resource(/*)?", async (req, res) => {
|
||||||
|
ensureAuthenticated(req)
|
||||||
|
if (typeof req.query.path === "string") {
|
||||||
|
res.set("Content-Type", getMediaMime(req.query.path))
|
||||||
|
res.send(await fs.readFile(pathToFsPath(req.query.path)))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get("/webview/*", async (req, res) => {
|
||||||
|
ensureAuthenticated(req)
|
||||||
|
res.set("Content-Type", getMediaMime(req.path))
|
||||||
|
if (/^\/vscode-resource/.test(req.path)) {
|
||||||
|
return res.send(await fs.readFile(req.path.replace(/^\/vscode-resource(\/file)?/, "")))
|
||||||
|
}
|
||||||
|
return res.send(
|
||||||
|
await fs.readFile(path.join(vscode.vsRootPath, "out/vs/workbench/contrib/webview/browser/pre", req.params[0])),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { logger } from "@coder/logger"
|
import { logger } from "@coder/logger"
|
||||||
|
import { Query } from "express-serve-static-core"
|
||||||
import * as fs from "fs-extra"
|
import * as fs from "fs-extra"
|
||||||
import * as path from "path"
|
import * as path from "path"
|
||||||
import { Route } from "./http"
|
|
||||||
import { paths } from "./util"
|
import { paths } from "./util"
|
||||||
|
|
||||||
export type Settings = { [key: string]: Settings | string | boolean | number }
|
export type Settings = { [key: string]: Settings | string | boolean | number }
|
||||||
@ -58,7 +58,7 @@ export interface CoderSettings extends UpdateSettings {
|
|||||||
url: string
|
url: string
|
||||||
workspace: boolean
|
workspace: boolean
|
||||||
}
|
}
|
||||||
query: Route["query"]
|
query: Query
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
133
src/node/update.ts
Normal file
133
src/node/update.ts
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import { field, logger } from "@coder/logger"
|
||||||
|
import * as http from "http"
|
||||||
|
import * as https from "https"
|
||||||
|
import * as semver from "semver"
|
||||||
|
import * as url from "url"
|
||||||
|
import { version } from "./constants"
|
||||||
|
import { settings as globalSettings, SettingsProvider, UpdateSettings } from "./settings"
|
||||||
|
|
||||||
|
export interface Update {
|
||||||
|
checked: number
|
||||||
|
version: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LatestResponse {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provide update information.
|
||||||
|
*/
|
||||||
|
export class UpdateProvider {
|
||||||
|
private update?: Promise<Update>
|
||||||
|
private updateInterval = 1000 * 60 * 60 * 24 // Milliseconds between update checks.
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
/**
|
||||||
|
* The URL for getting the latest version of code-server. Should return JSON
|
||||||
|
* that fulfills `LatestResponse`.
|
||||||
|
*/
|
||||||
|
private readonly latestUrl = "https://api.github.com/repos/cdr/code-server/releases/latest",
|
||||||
|
/**
|
||||||
|
* Update information will be stored here. If not provided, the global
|
||||||
|
* settings will be used.
|
||||||
|
*/
|
||||||
|
private readonly settings: SettingsProvider<UpdateSettings> = globalSettings,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query for and return the latest update.
|
||||||
|
*/
|
||||||
|
public async getUpdate(force?: boolean): Promise<Update> {
|
||||||
|
// Don't run multiple requests at a time.
|
||||||
|
if (!this.update) {
|
||||||
|
this.update = this._getUpdate(force)
|
||||||
|
this.update.then(() => (this.update = undefined))
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.update
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _getUpdate(force?: boolean): Promise<Update> {
|
||||||
|
const now = Date.now()
|
||||||
|
try {
|
||||||
|
let { update } = !force ? await this.settings.read() : { update: undefined }
|
||||||
|
if (!update || update.checked + this.updateInterval < now) {
|
||||||
|
const buffer = await this.request(this.latestUrl)
|
||||||
|
const data = JSON.parse(buffer.toString()) as LatestResponse
|
||||||
|
update = { checked: now, version: data.name.replace(/^v/, "") }
|
||||||
|
await this.settings.write({ update })
|
||||||
|
}
|
||||||
|
logger.debug("got latest version", field("latest", update.version))
|
||||||
|
return update
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to get latest version", field("error", error.message))
|
||||||
|
return {
|
||||||
|
checked: now,
|
||||||
|
version: "unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return true if the currently installed version is the latest.
|
||||||
|
*/
|
||||||
|
public isLatestVersion(latest: Update): boolean {
|
||||||
|
logger.debug("comparing versions", field("current", version), field("latest", latest.version))
|
||||||
|
try {
|
||||||
|
return latest.version === version || semver.lt(latest.version, version)
|
||||||
|
} catch (error) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request(uri: string): Promise<Buffer> {
|
||||||
|
const response = await this.requestResponse(uri)
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
let bufferLength = 0
|
||||||
|
response.on("data", (chunk) => {
|
||||||
|
bufferLength += chunk.length
|
||||||
|
chunks.push(chunk)
|
||||||
|
})
|
||||||
|
response.on("error", reject)
|
||||||
|
response.on("end", () => {
|
||||||
|
resolve(Buffer.concat(chunks, bufferLength))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestResponse(uri: string): Promise<http.IncomingMessage> {
|
||||||
|
let redirects = 0
|
||||||
|
const maxRedirects = 10
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = (uri: string): void => {
|
||||||
|
logger.debug("Making request", field("uri", uri))
|
||||||
|
const httpx = uri.startsWith("https") ? https : http
|
||||||
|
const client = httpx.get(uri, { headers: { "User-Agent": "code-server" } }, (response) => {
|
||||||
|
if (
|
||||||
|
response.statusCode &&
|
||||||
|
response.statusCode >= 300 &&
|
||||||
|
response.statusCode < 400 &&
|
||||||
|
response.headers.location
|
||||||
|
) {
|
||||||
|
++redirects
|
||||||
|
if (redirects > maxRedirects) {
|
||||||
|
return reject(new Error("reached max redirects"))
|
||||||
|
}
|
||||||
|
response.destroy()
|
||||||
|
return request(url.resolve(uri, response.headers.location))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 400) {
|
||||||
|
return reject(new Error(`${uri}: ${response.statusCode || "500"}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(response)
|
||||||
|
})
|
||||||
|
client.on("error", reject)
|
||||||
|
}
|
||||||
|
request(uri)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
150
src/node/vscode.ts
Normal file
150
src/node/vscode.ts
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
import { field, logger } from "@coder/logger"
|
||||||
|
import * as cp from "child_process"
|
||||||
|
import * as fs from "fs-extra"
|
||||||
|
import * as net from "net"
|
||||||
|
import * as path from "path"
|
||||||
|
import * as ipc from "../../lib/vscode/src/vs/server/ipc"
|
||||||
|
import { arrayify, generateUuid } from "../common/util"
|
||||||
|
import { rootPath } from "./constants"
|
||||||
|
import { settings } from "./settings"
|
||||||
|
|
||||||
|
export class VscodeProvider {
|
||||||
|
public readonly serverRootPath: string
|
||||||
|
public readonly vsRootPath: string
|
||||||
|
private _vscode?: Promise<cp.ChildProcess>
|
||||||
|
|
||||||
|
public constructor() {
|
||||||
|
this.vsRootPath = path.resolve(rootPath, "lib/vscode")
|
||||||
|
this.serverRootPath = path.join(this.vsRootPath, "out/vs/server")
|
||||||
|
}
|
||||||
|
|
||||||
|
public async dispose(): Promise<void> {
|
||||||
|
if (this._vscode) {
|
||||||
|
const vscode = await this._vscode
|
||||||
|
vscode.removeAllListeners()
|
||||||
|
this._vscode = undefined
|
||||||
|
vscode.kill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async initialize(
|
||||||
|
options: Omit<ipc.VscodeOptions, "startPath">,
|
||||||
|
query: ipc.Query,
|
||||||
|
): Promise<ipc.WorkbenchOptions> {
|
||||||
|
const { lastVisited } = await settings.read()
|
||||||
|
const startPath = await this.getFirstPath([
|
||||||
|
{ url: query.workspace, workspace: true },
|
||||||
|
{ url: query.folder, workspace: false },
|
||||||
|
options.args._ && options.args._.length > 0
|
||||||
|
? { url: path.resolve(options.args._[options.args._.length - 1]) }
|
||||||
|
: undefined,
|
||||||
|
lastVisited,
|
||||||
|
])
|
||||||
|
|
||||||
|
settings.write({
|
||||||
|
lastVisited: startPath,
|
||||||
|
query,
|
||||||
|
})
|
||||||
|
|
||||||
|
const id = generateUuid()
|
||||||
|
const vscode = await this.fork()
|
||||||
|
|
||||||
|
logger.debug("setting up vs code...")
|
||||||
|
return new Promise<ipc.WorkbenchOptions>((resolve, reject) => {
|
||||||
|
vscode.once("message", (message: ipc.VscodeMessage) => {
|
||||||
|
logger.debug("got message from vs code", field("message", message))
|
||||||
|
return message.type === "options" && message.id === id
|
||||||
|
? resolve(message.options)
|
||||||
|
: reject(new Error("Unexpected response during initialization"))
|
||||||
|
})
|
||||||
|
vscode.once("error", reject)
|
||||||
|
vscode.once("exit", (code) => reject(new Error(`VS Code exited unexpectedly with code ${code}`)))
|
||||||
|
this.send(
|
||||||
|
{
|
||||||
|
type: "init",
|
||||||
|
id,
|
||||||
|
options: {
|
||||||
|
...options,
|
||||||
|
startPath,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
vscode,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fork(): Promise<cp.ChildProcess> {
|
||||||
|
if (!this._vscode) {
|
||||||
|
logger.debug("forking vs code...")
|
||||||
|
const vscode = cp.fork(path.join(this.serverRootPath, "fork"))
|
||||||
|
vscode.on("error", (error) => {
|
||||||
|
logger.error(error.message)
|
||||||
|
this._vscode = undefined
|
||||||
|
})
|
||||||
|
vscode.on("exit", (code) => {
|
||||||
|
logger.error(`VS Code exited unexpectedly with code ${code}`)
|
||||||
|
this._vscode = undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
this._vscode = new Promise((resolve, reject) => {
|
||||||
|
vscode.once("message", (message: ipc.VscodeMessage) => {
|
||||||
|
logger.debug("got message from vs code", field("message", message))
|
||||||
|
return message.type === "ready"
|
||||||
|
? resolve(vscode)
|
||||||
|
: reject(new Error("Unexpected response waiting for ready response"))
|
||||||
|
})
|
||||||
|
vscode.once("error", reject)
|
||||||
|
vscode.once("exit", (code) => reject(new Error(`VS Code exited unexpectedly with code ${code}`)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._vscode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VS Code expects a raw socket. It will handle all the web socket frames.
|
||||||
|
*/
|
||||||
|
public async sendWebsocket(socket: net.Socket, query: ipc.Query): Promise<void> {
|
||||||
|
// TODO: TLS socket proxy.
|
||||||
|
const vscode = await this._vscode
|
||||||
|
this.send({ type: "socket", query }, vscode, socket)
|
||||||
|
}
|
||||||
|
|
||||||
|
private send(message: ipc.CodeServerMessage, vscode?: cp.ChildProcess, socket?: net.Socket): void {
|
||||||
|
if (!vscode || vscode.killed) {
|
||||||
|
throw new Error("vscode is not running")
|
||||||
|
}
|
||||||
|
vscode.send(message, socket)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choose the first non-empty path.
|
||||||
|
*/
|
||||||
|
private async getFirstPath(
|
||||||
|
startPaths: Array<{ url?: string | string[] | ipc.Query | ipc.Query[]; workspace?: boolean } | undefined>,
|
||||||
|
): Promise<ipc.StartPath | undefined> {
|
||||||
|
const isFile = async (path: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(path)
|
||||||
|
return stat.isFile()
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(error.message)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = 0; i < startPaths.length; ++i) {
|
||||||
|
const startPath = startPaths[i]
|
||||||
|
const url = arrayify(startPath && startPath.url).find((p) => !!p)
|
||||||
|
if (startPath && url && typeof url === "string") {
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
// The only time `workspace` is undefined is for the command-line
|
||||||
|
// argument, in which case it's a path (not a URL) so we can stat it
|
||||||
|
// without having to parse it.
|
||||||
|
workspace: typeof startPath.workspace !== "undefined" ? startPath.workspace : await isFile(url),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
@ -2,9 +2,8 @@ import * as assert from "assert"
|
|||||||
import * as fs from "fs-extra"
|
import * as fs from "fs-extra"
|
||||||
import * as http from "http"
|
import * as http from "http"
|
||||||
import * as path from "path"
|
import * as path from "path"
|
||||||
import { AuthType } from "../src/node/cli"
|
|
||||||
import { LatestResponse, UpdateHttpProvider } from "../src/node/routes/update"
|
|
||||||
import { SettingsProvider, UpdateSettings } from "../src/node/settings"
|
import { SettingsProvider, UpdateSettings } from "../src/node/settings"
|
||||||
|
import { LatestResponse, UpdateProvider } from "../src/node/update"
|
||||||
import { tmpdir } from "../src/node/util"
|
import { tmpdir } from "../src/node/util"
|
||||||
|
|
||||||
describe.skip("update", () => {
|
describe.skip("update", () => {
|
||||||
@ -34,22 +33,14 @@ describe.skip("update", () => {
|
|||||||
const jsonPath = path.join(tmpdir, "tests/updates/update.json")
|
const jsonPath = path.join(tmpdir, "tests/updates/update.json")
|
||||||
const settings = new SettingsProvider<UpdateSettings>(jsonPath)
|
const settings = new SettingsProvider<UpdateSettings>(jsonPath)
|
||||||
|
|
||||||
let _provider: UpdateHttpProvider | undefined
|
let _provider: UpdateProvider | undefined
|
||||||
const provider = (): UpdateHttpProvider => {
|
const provider = (): UpdateProvider => {
|
||||||
if (!_provider) {
|
if (!_provider) {
|
||||||
const address = server.address()
|
const address = server.address()
|
||||||
if (!address || typeof address === "string" || !address.port) {
|
if (!address || typeof address === "string" || !address.port) {
|
||||||
throw new Error("unexpected address")
|
throw new Error("unexpected address")
|
||||||
}
|
}
|
||||||
_provider = new UpdateHttpProvider(
|
_provider = new UpdateProvider(`http://${address.address}:${address.port}/latest`, settings)
|
||||||
{
|
|
||||||
auth: AuthType.None,
|
|
||||||
commit: "test",
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
`http://${address.address}:${address.port}/latest`,
|
|
||||||
settings,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return _provider
|
return _provider
|
||||||
}
|
}
|
||||||
@ -153,14 +144,10 @@ describe.skip("update", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("should not reject if unable to fetch", async () => {
|
it("should not reject if unable to fetch", async () => {
|
||||||
const options = {
|
let provider = new UpdateProvider("invalid", settings)
|
||||||
auth: AuthType.None,
|
|
||||||
commit: "test",
|
|
||||||
}
|
|
||||||
let provider = new UpdateHttpProvider(options, true, "invalid", settings)
|
|
||||||
await assert.doesNotReject(() => provider.getUpdate(true))
|
await assert.doesNotReject(() => provider.getUpdate(true))
|
||||||
|
|
||||||
provider = new UpdateHttpProvider(options, true, "http://probably.invalid.dev.localhost/latest", settings)
|
provider = new UpdateProvider("http://probably.invalid.dev.localhost/latest", settings)
|
||||||
await assert.doesNotReject(() => provider.getUpdate(true))
|
await assert.doesNotReject(() => provider.getUpdate(true))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
21
yarn.lock
21
yarn.lock
@ -1013,7 +1013,7 @@
|
|||||||
traverse "^0.6.6"
|
traverse "^0.6.6"
|
||||||
unified "^6.1.6"
|
unified "^6.1.6"
|
||||||
|
|
||||||
"@types/body-parser@*":
|
"@types/body-parser@*", "@types/body-parser@^1.19.0":
|
||||||
version "1.19.0"
|
version "1.19.0"
|
||||||
resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.0.tgz#0685b3c47eb3006ffed117cdd55164b61f80538f"
|
resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.0.tgz#0685b3c47eb3006ffed117cdd55164b61f80538f"
|
||||||
integrity sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ==
|
integrity sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ==
|
||||||
@ -1028,6 +1028,13 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@types/node" "*"
|
"@types/node" "*"
|
||||||
|
|
||||||
|
"@types/cookie-parser@^1.4.2":
|
||||||
|
version "1.4.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/cookie-parser/-/cookie-parser-1.4.2.tgz#e4d5c5ffda82b80672a88a4281aaceefb1bd9df5"
|
||||||
|
integrity sha512-uwcY8m6SDQqciHsqcKDGbo10GdasYsPCYkH3hVegj9qAah6pX5HivOnOuI3WYmyQMnOATV39zv/Ybs0bC/6iVg==
|
||||||
|
dependencies:
|
||||||
|
"@types/express" "*"
|
||||||
|
|
||||||
"@types/eslint-visitor-keys@^1.0.0":
|
"@types/eslint-visitor-keys@^1.0.0":
|
||||||
version "1.0.0"
|
version "1.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d"
|
resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d"
|
||||||
@ -1042,7 +1049,7 @@
|
|||||||
"@types/qs" "*"
|
"@types/qs" "*"
|
||||||
"@types/range-parser" "*"
|
"@types/range-parser" "*"
|
||||||
|
|
||||||
"@types/express@^4.17.8":
|
"@types/express@*", "@types/express@^4.17.8":
|
||||||
version "4.17.8"
|
version "4.17.8"
|
||||||
resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.8.tgz#3df4293293317e61c60137d273a2e96cd8d5f27a"
|
resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.8.tgz#3df4293293317e61c60137d273a2e96cd8d5f27a"
|
||||||
integrity sha512-wLhcKh3PMlyA2cNAB9sjM1BntnhPMiM0JOBwPBqttjHev2428MLEB4AYVN+d8s2iyCVZac+o41Pflm/ZH5vLXQ==
|
integrity sha512-wLhcKh3PMlyA2cNAB9sjM1BntnhPMiM0JOBwPBqttjHev2428MLEB4AYVN+d8s2iyCVZac+o41Pflm/ZH5vLXQ==
|
||||||
@ -1659,7 +1666,7 @@ bn.js@^5.1.1:
|
|||||||
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.1.3.tgz#beca005408f642ebebea80b042b4d18d2ac0ee6b"
|
resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.1.3.tgz#beca005408f642ebebea80b042b4d18d2ac0ee6b"
|
||||||
integrity sha512-GkTiFpjFtUzU9CbMeJ5iazkCzGL3jrhzerzZIuqLABjbwRaFt33I9tUdSNryIptM+RxDet6OKm2WnLXzW51KsQ==
|
integrity sha512-GkTiFpjFtUzU9CbMeJ5iazkCzGL3jrhzerzZIuqLABjbwRaFt33I9tUdSNryIptM+RxDet6OKm2WnLXzW51KsQ==
|
||||||
|
|
||||||
body-parser@1.19.0:
|
body-parser@1.19.0, body-parser@^1.19.0:
|
||||||
version "1.19.0"
|
version "1.19.0"
|
||||||
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a"
|
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a"
|
||||||
integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==
|
integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==
|
||||||
@ -2251,6 +2258,14 @@ convert-source-map@^1.5.1, convert-source-map@^1.7.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer "~5.1.1"
|
safe-buffer "~5.1.1"
|
||||||
|
|
||||||
|
cookie-parser@^1.4.5:
|
||||||
|
version "1.4.5"
|
||||||
|
resolved "https://registry.yarnpkg.com/cookie-parser/-/cookie-parser-1.4.5.tgz#3e572d4b7c0c80f9c61daf604e4336831b5d1d49"
|
||||||
|
integrity sha512-f13bPUj/gG/5mDr+xLmSxxDsB9DQiTIfhJS/sqjrmfAWiAN+x2O4i/XguTL9yDZ+/IFDanJ+5x7hC4CXT9Tdzw==
|
||||||
|
dependencies:
|
||||||
|
cookie "0.4.0"
|
||||||
|
cookie-signature "1.0.6"
|
||||||
|
|
||||||
cookie-signature@1.0.6:
|
cookie-signature@1.0.6:
|
||||||
version "1.0.6"
|
version "1.0.6"
|
||||||
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
|
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
|
||||||
|
Loading…
Reference in New Issue
Block a user