diff --git a/README.md b/README.md index 58921ebe0..448ebaeb1 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ yarn patch:apply yarn yarn watch # Wait for the initial compilation to complete (it will say "Finished compilation"). -yarn start +yarn start --allow-http --no-auth # Visit http://localhost:8443 ``` diff --git a/package.json b/package.json index e80fba063..6beec18f8 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "ensure-in-vscode": "bash ./scripts/tasks.bash ensure-in-vscode", "preinstall": "yarn ensure-in-vscode && cd ../../../ && yarn || true", "postinstall": "rm -rf node_modules/@types/node", - "start": "yarn ensure-in-vscode && nodemon ../../../out/vs/server/main.js --watch ../../../out --verbose", + "start": "yarn ensure-in-vscode && nodemon --watch ../../../out --verbose ../../../out/vs/server/main.js", "watch": "yarn ensure-in-vscode && cd ../../../ && yarn watch", "build": "bash ./scripts/tasks.bash build", "package": "bash ./scripts/tasks.bash package", diff --git a/src/cli.ts b/src/cli.ts index c94cb717d..0b8b9f1a8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,4 +1,5 @@ import * as os from "os"; +import * as path from "path"; import { validatePaths } from "vs/code/node/paths"; import { parseMainProcessArgv } from "vs/platform/environment/node/argvHelper"; @@ -9,16 +10,16 @@ import pkg from "vs/platform/product/node/package"; import { MainServer, WebviewServer } from "vs/server/src/server"; import "vs/server/src/tar"; -import { generateCertificate } from "vs/server/src/util"; +import { generateCertificate, generatePassword } from "vs/server/src/util"; interface Args extends ParsedArgs { "allow-http"?: boolean; + auth?: boolean; cert?: string; "cert-key"?: string; "extra-builtin-extensions-dir"?: string; "extra-extensions-dir"?: string; host?: string; - "no-auth"?: boolean; open?: string; port?: string; socket?: string; @@ -58,7 +59,7 @@ options.push({ id: "cert-key", type: "string", cat: "o", description: "Path to c options.push({ id: "extra-builtin-extensions-dir", type: "string", cat: "o", description: "Path to extra builtin extension directory." }); options.push({ id: "extra-extensions-dir", type: "string", cat: "o", description: "Path to extra user extension directory." }); options.push({ id: "host", type: "string", cat: "o", description: "Host for the main and webview servers." }); -options.push({ id: "no-auth", type: "string", cat: "o", description: "Disable password authentication." }); +options.push({ id: "no-auth", type: "boolean", cat: "o", description: "Disable password authentication." }); options.push({ id: "open", type: "boolean", cat: "o", description: "Open in the browser on startup." }); options.push({ id: "port", type: "string", cat: "o", description: "Port for the main server." }); options.push({ id: "socket", type: "string", cat: "o", description: "Listen on a socket instead of host:port." }); @@ -115,17 +116,32 @@ const main = async (): Promise => { } const options = { - host: args["host"] - || (args["no-auth"] || args["allow-http"] ? "localhost" : "0.0.0.0"), + host: args.host, allowHttp: args["allow-http"], - cert: args["cert"], - certKey: args["cert"], + cert: args.cert, + certKey: args["cert-key"], + auth: typeof args.auth !== "undefined" ? args.auth : true, + password: process.env.PASSWORD, }; + if (!options.host) { + options.host = !options.auth || options.allowHttp + ? "localhost" + : "0.0.0.0"; + } + + let usingGeneratedCert = false; if (!options.allowHttp && (!options.cert || !options.certKey)) { const { cert, certKey } = await generateCertificate(); options.cert = cert; options.certKey = certKey; + usingGeneratedCert = true; + } + + let usingGeneratedPassword = false; + if (options.auth && !options.password) { + options.password = await generatePassword(); + usingGeneratedPassword = true; } const webviewPort = typeof args["webview-port"] !== "undefined" @@ -149,6 +165,25 @@ const main = async (): Promise => { ]); console.log(`Main server listening on ${serverAddress}`); console.log(`Webview server listening on ${webviewAddress}`); + + if (usingGeneratedPassword) { + console.log(" - Password is", options.password); + console.log(" - To use your own password, set the PASSWORD environment variable"); + } else if (options.auth) { + console.log(" - Using custom password for authentication"); + } else { + console.log(" - No authentication"); + } + + if (!options.allowHttp && options.cert && options.certKey) { + console.log( + usingGeneratedCert + ? ` - Using generated certificate and key in ${path.dirname(options.cert)} for HTTPS` + : " - Using provided certificate and key for HTTPS", + ); + } else { + console.log(" - Not serving HTTPS"); + } }; main().catch((error) => { diff --git a/src/favicon/favicon.ico b/src/favicon/favicon.ico new file mode 100644 index 000000000..be99fc8c5 Binary files /dev/null and b/src/favicon/favicon.ico differ diff --git a/src/login/login.css b/src/login/login.css new file mode 100644 index 000000000..f075a6386 --- /dev/null +++ b/src/login/login.css @@ -0,0 +1,94 @@ +html { + box-sizing: border-box; +} + +*, *:before, *:after { + box-sizing: inherit; +} + +html, body { + background-color: #FFFFFF; + height: 100%; + min-height: 100%; +} + +body { + align-items: center; + display: flex; + font-family: "monospace"; + justify-content: center; + margin: 0; + padding: 10px; +} + +.login-form { + border-radius: 5px; + box-shadow: 0 18px 80px 10px rgba(69, 65, 78, 0.08); + color: #575962; + margin-top: -10%; + max-width: 328px; + padding: 40px; + position: relative; + width: 100%; +} + +.login-form > .title { + text-align: center; + text-transform: uppercase; + font-size: 12px; + font-weight: 500; + letter-spacing: 1.5px; + line-height: 15px; + margin-bottom: 0px; + margin-bottom: 5px; + margin-top: 0px; +} + +.login-form > .subtitle { + font-size: 19px; + font-weight: bold; + line-height: 25px; + margin-bottom: 45px; + margin: 0; + text-align: center; +} + +.login-form > .field { + text-align: left; + font-size: 12px; + color: #797E84; + margin: 16px 0; +} + +.login-form > .field > .input { + background: none !important; + border: 1px solid #ccc; + border-radius: 2px; + padding: 5px; + width: 100%; +} + +.login-form > .button { + border: none; + border-radius: 24px; + box-shadow: 0 12px 17px 2px rgba(171,173,163,0.14), 0 5px 22px 4px rgba(171,173,163,0.12), 0 7px 8px -4px rgba(171,173,163,0.2); + cursor: pointer; + display: block; + padding: 15px 5px; + width: 100%; +} + +.login-form > .button:hover { + background-color: rgb(0, 122, 204); + color: #fff; +} + +.error-display { + box-sizing: border-box; + color: #bb2d0f; + font-size: 14px; + font-weight: 400; + line-height: 12px; + padding: 20px 8px 0; + text-align: center; +} diff --git a/src/login/login.html b/src/login/login.html new file mode 100644 index 000000000..1390bc543 --- /dev/null +++ b/src/login/login.html @@ -0,0 +1,26 @@ + + + + + Authenticate: code-server + + + + + + diff --git a/src/server.ts b/src/server.ts index 0f134dddc..50054c582 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,11 +6,10 @@ import * as path from "path"; import * as tls from "tls"; import * as util from "util"; import * as url from "url"; +import * as querystring from "querystring"; import { Emitter } from "vs/base/common/event"; import { sanitizeFilePath } from "vs/base/common/extpath"; -import { getMediaMime } from "vs/base/common/mime"; -import { extname } from "vs/base/common/path"; import { UriComponents, URI } from "vs/base/common/uri"; import { IPCServer, ClientConnectionEvent, StaticRouter } from "vs/base/parts/ipc/common/ipc"; import { mkdirp } from "vs/base/node/pfs"; @@ -49,12 +48,16 @@ import { IWorkbenchConstructionOptions } from "vs/workbench/workbench.web.api"; import { Connection, ManagementConnection, ExtensionHostConnection } from "vs/server/src/connection"; import { ExtensionEnvironmentChannel, FileProviderChannel , } from "vs/server/src/channel"; import { Protocol } from "vs/server/src/protocol"; -import { getUriTransformer, useHttpsTransformer } from "vs/server/src/util"; +import { getMediaMime, getUriTransformer, useHttpsTransformer } from "vs/server/src/util"; export enum HttpCode { Ok = 200, + Redirect = 302, NotFound = 404, BadRequest = 400, + Unauthorized = 401, + LargePayload = 413, + ServerError = 500, } export interface Options { @@ -65,9 +68,15 @@ export interface Options { } export interface Response { - content?: string | Buffer; code?: number; - headers: http.OutgoingHttpHeaders; + content?: string | Buffer; + filePath?: string; + headers?: http.OutgoingHttpHeaders; + redirect?: string; +} + +export interface LoginPayload { + password?: string; } export class HttpError extends Error { @@ -80,19 +89,21 @@ export class HttpError extends Error { } export interface ServerOptions { - readonly port: number; - readonly host: string; + readonly port?: number; + readonly host?: string; readonly socket?: string; readonly allowHttp?: boolean; readonly cert?: string; readonly certKey?: string; + readonly auth?: boolean; + readonly password?: string; } export abstract class Server { // The underlying web server. protected readonly server: http.Server | https.Server; - protected rootPath = path.resolve(__dirname, "../../.."); + protected rootPath = path.resolve(__dirname, "../../../.."); private listenPromise: Promise | undefined; @@ -113,7 +124,7 @@ export abstract class Server { if (!this.listenPromise) { this.listenPromise = new Promise((resolve, reject) => { this.server.on("error", reject); - const onListen = () => resolve(this.address(this.server, this.options.allowHttp)); + const onListen = () => resolve(this.address()); if (this.options.socket) { this.server.listen(this.options.socket, onListen); } else { @@ -124,6 +135,22 @@ export abstract class Server { return this.listenPromise; } + /** + * The local address of the server. If you pass in a request, it will use the + * request's host if listening on a port (rather than a socket). This enables + * accessing the webview server from the same host as the main server. + */ + public address(request?: http.IncomingMessage): string { + const address = this.server.address(); + const endpoint = typeof address !== "string" + ? (request + ? request.headers.host!.split(":", 1)[0] + : (address.address === "::" ? "localhost" : address.address) + ) + ":" + address.port + : address; + return `${this.options.allowHttp ? "http" : "https"}://${endpoint}`; + } + protected abstract handleRequest( base: string, requestPath: string, @@ -133,68 +160,192 @@ export abstract class Server { protected async getResource(filePath: string): Promise { const content = await util.promisify(fs.readFile)(filePath); - return { - content, - headers: { - "Content-Type": getMediaMime(filePath) || { - ".css": "text/css", - ".html": "text/html", - ".js": "text/javascript", - ".json": "application/json", - }[extname(filePath)] || "text/plain", - }, - }; + return { content, filePath }; } private onRequest = async (request: http.IncomingMessage, response: http.ServerResponse): Promise => { - const secure = (request.connection as tls.TLSSocket).encrypted; - if (!this.options.allowHttp && !secure) { - response.writeHead(302, { - Location: "https://" + request.headers.host + request.url, - }); - return response.end(); - } - try { - if (request.method !== "GET") { - throw new HttpError( - `Unsupported method ${request.method}`, - HttpCode.BadRequest, - ); - } - - const parsedUrl = url.parse(request.url || "", true); - - const fullPath = decodeURIComponent(parsedUrl.pathname || "/"); - const match = fullPath.match(/^(\/?[^/]*)(.*)$/); - const [, base, requestPath] = match - ? match.map((p) => p !== "/" ? p.replace(/\/$/, "") : p) - : ["", "", ""]; - - const { content, headers, code } = await this.handleRequest( - base, requestPath, parsedUrl, request, - ); - response.writeHead(code || HttpCode.Ok, { - "Cache-Control": "max-age=86400", - // TODO: ETag? - ...headers, + const payload = await this.preHandleRequest(request); + response.writeHead(payload.redirect ? HttpCode.Redirect : payload.code || HttpCode.Ok, { + "Cache-Control": "max-age=86400", // TODO: ETag? + "Content-Type": getMediaMime(payload.filePath), + ...(payload.redirect ? { Location: payload.redirect } : {}), + ...payload.headers, }); - response.end(content); + response.end(payload.content); } catch (error) { if (error.code === "ENOENT" || error.code === "EISDIR") { error = new HttpError("Not found", HttpCode.NotFound); } - response.writeHead(typeof error.code === "number" ? error.code : 500); + response.writeHead(typeof error.code === "number" ? error.code : HttpCode.ServerError); response.end(error.message); } } - private address(server: net.Server, http?: boolean): string { - const address = server.address(); - const endpoint = typeof address !== "string" - ? ((address.address === "::" ? "localhost" : address.address) + ":" + address.port) - : address; - return `${http ? "http" : "https"}://${endpoint}`; + private async preHandleRequest(request: http.IncomingMessage): Promise { + const secure = (request.connection as tls.TLSSocket).encrypted; + if (!this.options.allowHttp && !secure) { + return { redirect: "https://" + request.headers.host + request.url }; + } + + const parsedUrl = url.parse(request.url || "", true); + const fullPath = decodeURIComponent(parsedUrl.pathname || "/"); + const match = fullPath.match(/^(\/?[^/]*)(.*)$/); + let [, base, requestPath] = match + ? match.map((p) => p.replace(/\/$/, "")) + : ["", "", ""]; + if (base.indexOf(".") !== -1) { // Assume it's a file at the root. + requestPath = base; + base = "/"; + } else if (base === "") { // Happens if it's a plain `domain.com`. + base = "/"; + } + if (requestPath === "/") { // Trailing slash, like `domain.com/login/`. + requestPath = ""; + } else if (requestPath !== "") { // "" will become "." with normalize. + requestPath = path.normalize(requestPath); + } + base = path.normalize(base); + + switch (base) { + case "/": + this.ensureGet(request); + if (!this.authenticate(request)) { + return { redirect: "https://" + request.headers.host + "/login" }; + } + break; + case "/login": + if (!this.options.auth) { + throw new HttpError("Not found", HttpCode.NotFound); + } + if (requestPath === "") { + return this.tryLogin(request); + } + this.ensureGet(request); + return this.getResource(path.join(this.rootPath, "/out/vs/server/src/login", requestPath)); + case "/favicon.ico": + this.ensureGet(request); + return this.getResource(path.join(this.rootPath, "/out/vs/server/src/favicon", base)); + default: + this.ensureGet(request); + if (!this.authenticate(request)) { + throw new HttpError(`Unauthorized`, HttpCode.Unauthorized); + } + break; + } + + return this.handleRequest(base, requestPath, parsedUrl, request); + } + + private async tryLogin(request: http.IncomingMessage): Promise { + if (this.authenticate(request)) { + this.ensureGet(request); + return { redirect: "https://" + request.headers.host + "/" }; + } + + if (request.method === "POST") { + const data = await this.getData(request); + if (this.authenticate(request, data)) { + return { + redirect: "https://" + request.headers.host + "/", + headers: { + "Set-Cookie": `password=${data.password}`, + } + }; + } + let userAgent = request.headers["user-agent"]; + const timestamp = Math.floor(new Date().getTime() / 1000); + if (Array.isArray(userAgent)) { + userAgent = userAgent.join(", "); + } + console.error("Failed login attempt", JSON.stringify({ + xForwardedFor: request.headers["x-forwarded-for"], + remoteAddress: request.connection.remoteAddress, + userAgent, + timestamp, + })); + return this.getLogin("Invalid password", data); + } + this.ensureGet(request); + return this.getLogin(); + } + + private async getLogin(error: string = "", payload?: LoginPayload): Promise { + const filePath = path.join(this.rootPath, "out/vs/server/src/login/login.html"); + let content = await util.promisify(fs.readFile)(filePath, "utf8"); + if (error) { + content = content.replace("{{ERROR}}", error) + .replace("display:none", "display:block"); + } + if (payload && payload.password) { + content = content.replace('value=""', `value="${payload.password}"`); + } + return { content, filePath }; + } + + private ensureGet(request: http.IncomingMessage): void { + if (request.method !== "GET") { + throw new HttpError( + `Unsupported method ${request.method}`, + HttpCode.BadRequest, + ); + } + } + + private getData(request: http.IncomingMessage): Promise { + return request.method === "POST" + ? new Promise((resolve, reject) => { + let body = ""; + const onEnd = (): void => { + off(); + resolve(querystring.parse(body) as T); + }; + const onError = (error: Error): void => { + off(); + reject(error); + }; + const onData = (d: Buffer): void => { + body += d; + if (body.length > 1e6) { + onError(new HttpError( + "Payload is too large", + HttpCode.LargePayload, + )); + request.connection.destroy(); + } + }; + const off = (): void => { + request.off("error", onError); + request.off("data", onError); + request.off("end", onEnd); + }; + request.on("error", onError); + request.on("data", onData); + request.on("end", onEnd); + }) + : Promise.resolve({} as T); + } + + private authenticate(request: http.IncomingMessage, payload?: LoginPayload): boolean { + if (!this.options.auth) { + return true; + } + const safeCompare = require.__$__nodeRequire(path.resolve(__dirname, "../node_modules/safe-compare/index")) as typeof import("safe-compare"); + if (typeof payload === "undefined") { + payload = this.parseCookies(request); + } + return !!this.options.password && safeCompare(payload.password || "", this.options.password); + } + + private parseCookies(request: http.IncomingMessage): T { + const cookies: { [key: string]: string } = {}; + if (request.headers.cookie) { + request.headers.cookie.split(";").forEach((keyValue) => { + const [key, value] = keyValue.split("=", 2); + cookies[key.trim()] = decodeURI(value); + }); + } + return cookies as T; } } @@ -281,8 +432,7 @@ export class MainServer extends Server { request: http.IncomingMessage, ): Promise { switch (base) { - case "/": - return this.getRoot(request, parsedUrl); + case "/": return this.getRoot(request, parsedUrl); case "/node_modules": case "/out": return this.getResource(path.join(this.rootPath, base, requestPath)); @@ -292,23 +442,19 @@ export class MainServer extends Server { // resources are requested by the browser (like the extension icon) and // some by the file provider (like the extension README). Maybe add a // /resource prefix and a file provider that strips that prefix? - default: - return this.getResource(path.join(base, requestPath)); + default: return this.getResource(path.join(base, requestPath)); } } private async getRoot(request: http.IncomingMessage, parsedUrl: url.UrlWithParsedQuery): Promise { - const htmlPath = path.join( - this.rootPath, - 'out/vs/code/browser/workbench/workbench.html', - ); - - let content = await util.promisify(fs.readFile)(htmlPath, "utf8"); + const filePath = path.join(this.rootPath, "out/vs/code/browser/workbench/workbench.html"); + let content = await util.promisify(fs.readFile)(filePath, "utf8"); const remoteAuthority = request.headers.host as string; const transformer = getUriTransformer(remoteAuthority); - const webviewEndpoint = await this.webviewServer.listen(); + await this.webviewServer.listen(); + const webviewEndpoint = this.webviewServer.address(request); const cwd = process.env.VSCODE_CWD || process.cwd(); const workspacePath = parsedUrl.query.workspace as string | undefined; @@ -338,12 +484,7 @@ export class MainServer extends Server { content = content.replace('{{WEBVIEW_ENDPOINT}}', webviewEndpoint); - return { - content, - headers: { - "Content-Type": "text/html", - }, - }; + return { content, filePath }; } private createProtocol(request: http.IncomingMessage, socket: net.Socket): Protocol { @@ -444,15 +585,10 @@ export class WebviewServer extends Server { base: string, requestPath: string, ): Promise { - const webviewPath = path.join( - this.rootPath, - "out/vs/workbench/contrib/webview/browser/pre", - ); - - if (base === "/") { - base = "/index.html"; + const webviewPath = path.join(this.rootPath, "out/vs/workbench/contrib/webview/browser/pre"); + if (requestPath === "") { + requestPath = "/index.html"; } - return this.getResource(path.join(webviewPath, base, requestPath)); } } diff --git a/src/util.ts b/src/util.ts index cec97b41c..a4f3ac791 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,9 +1,12 @@ +import * as crypto from "crypto"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import * as util from "util"; import { getPathFromAmdModule } from "vs/base/common/amd"; +import { getMediaMime as vsGetMediaMime } from "vs/base/common/mime"; +import { extname } from "vs/base/common/path"; import { URITransformer, IRawURITransformer } from "vs/base/common/uriIpc"; import { mkdirp } from "vs/base/node/pfs"; @@ -58,3 +61,18 @@ export const getUriTransformer = (remoteAuthority: string): URITransformer => { const rawURITransformer = rawURITransformerFactory(remoteAuthority); return new URITransformer(rawURITransformer); }; + +export const generatePassword = async (length: number = 24): Promise => { + const buffer = Buffer.alloc(Math.ceil(length / 2)); + await util.promisify(crypto.randomFill)(buffer); + return buffer.toString("hex").substring(0, length); +}; + +export const getMediaMime = (filePath?: string): string => { + return filePath && (vsGetMediaMime(filePath) || { + ".css": "text/css", + ".html": "text/html", + ".js": "text/javascript", + ".json": "application/json", + }[extname(filePath)]) || "text/plain"; +};