diff --git a/README.md b/README.md index 99d24b3..e24df48 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,11 @@ How to [secure your setup](/doc/security/ssl.md). ## Development +- Clone this as `vs/src/server` in the VS Code source. +- Run `yarn watch-client`in the VS Code root. +- Run `node out/vs/server/main.js`. +- Visit `http://localhost:8443`. + ### Known Issues - Creating custom VS Code extensions and debugging them doesn't work. diff --git a/connection.ts b/connection.ts new file mode 100644 index 0000000..3b43fd9 --- /dev/null +++ b/connection.ts @@ -0,0 +1,28 @@ +import { Emitter } from "vs/base/common/event"; +import { PersistentProtocol, ISocket } from "vs/base/parts/ipc/common/ipc.net"; +import { VSBuffer } from "vs/base/common/buffer"; + +export abstract class Connection { + protected readonly _onClose = new Emitter(); + public readonly onClose = this._onClose.event; + + public constructor(private readonly protocol: PersistentProtocol) { + this.protocol.onSocketClose(() => { + // TODO: eventually we'll want to clean up the connection if nothing + // ever connects back to it + }); + } + + public reconnect(socket: ISocket, buffer: VSBuffer): void { + this.protocol.beginAcceptReconnection(socket, buffer); + this.protocol.endAcceptReconnection(); + } +} + +export class ManagementConnection extends Connection { + // in here they accept the connection + // to the ipc of the RemoteServer +} + +export class ExtensionHostConnection extends Connection { +} diff --git a/main.js b/main.js new file mode 100644 index 0000000..0fc2dbf --- /dev/null +++ b/main.js @@ -0,0 +1 @@ +require("../../bootstrap-amd").load("vs/server/server"); diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..ca9f114 --- /dev/null +++ b/server.ts @@ -0,0 +1,164 @@ +import * as fs from "fs"; +import * as http from "http"; +import * as net from "net"; +import * as path from "path"; +import * as util from "util"; +import * as url from "url"; + +import { Connection } from "vs/server/connection"; +import { ConnectionType } from "vs/platform/remote/common/remoteAgentConnection"; +import { Emitter } from "vs/base/common/event"; +import { ClientConnectionEvent } from "vs/base/parts/ipc/common/ipc"; +import { Socket, Server as IServer } from "vs/server/socket"; + +enum HttpCode { + Ok = 200, + NotFound = 404, + BadRequest = 400, +} + +class HttpError extends Error { + public constructor(message: string, public readonly code: number) { + super(message); + // @ts-ignore + this.name = this.constructor.name; + Error.captureStackTrace(this, this.constructor); + } +} + +class Server implements IServer { + private readonly _onDidClientConnect = new Emitter(); + public readonly onDidClientConnect = this._onDidClientConnect.event; + + private readonly rootPath = path.resolve(__dirname, "../../.."); + + private readonly server: http.Server; + + public readonly connections = new Map>(); + + public constructor() { + this.server = http.createServer(async (request, response): Promise => { + try { + const content = await this.handleRequest(request); + response.writeHead(HttpCode.Ok, { + "Cache-Control": "max-age=86400", + // TODO: ETag? + }); + response.end(content); + } catch (error) { + response.writeHead(typeof error.code === "number" ? error.code : 500); + response.end(error.message); + } + }); + + this.server.on("upgrade", (request, socket) => { + this.handleUpgrade(request, socket); + }); + + this.server.on("error", (error) => { + console.error(error); + process.exit(1); + }); + } + + public dispose(): void { + this.connections.clear(); + } + + private async handleRequest(request: http.IncomingMessage): Promise { + if (request.method !== "GET") { + throw new HttpError( + `Unsupported method ${request.method}`, + HttpCode.BadRequest, + ); + } + + const requestPath = url.parse(request.url || "").pathname || "/"; + if (requestPath === "/") { + const htmlPath = path.join( + this.rootPath, + 'out/vs/code/browser/workbench/workbench.html', + ); + + let html = await util.promisify(fs.readFile)(htmlPath, "utf8"); + + const options = { + WEBVIEW_ENDPOINT: {}, + WORKBENCH_WEB_CONGIGURATION: { + remoteAuthority: request.headers.host, + }, + REMOTE_USER_DATA_URI: { + scheme: "http", + authority: request.headers.host, + path: "/", + }, + PRODUCT_CONFIGURATION: {}, + CONNECTION_AUTH_TOKEN: {} + }; + + Object.keys(options).forEach((key) => { + html = html.replace(`"{{${key}}}"`, `'${JSON.stringify(options[key])}'`); + }); + + html = html.replace('{{WEBVIEW_ENDPOINT}}', JSON.stringify(options.WEBVIEW_ENDPOINT)); + + return html; + } + + try { + const content = await util.promisify(fs.readFile)( + path.join(this.rootPath, requestPath), + ); + return content; + } catch (error) { + if (error.code === "ENOENT" || error.code === "EISDIR") { + throw new HttpError("Not found", HttpCode.NotFound); + } + throw error; + } + } + + private handleUpgrade(request: http.IncomingMessage, socket: net.Socket): void { + if (request.headers.upgrade !== "websocket") { + return socket.end("HTTP/1.1 400 Bad Request"); + } + + const options = { + reconnectionToken: "", + reconnection: false, + skipWebSocketFrames: false, + }; + + if (request.url) { + const query = url.parse(request.url, true).query; + if (query.reconnectionToken) { + options.reconnectionToken = query.reconnectionToken as string; + } + if (query.reconnection === "true") { + options.reconnection = true; + } + if (query.skipWebSocketFrames === "true") { + options.skipWebSocketFrames = true; + } + } + + const nodeSocket = new Socket(socket, options); + nodeSocket.upgrade(request.headers["sec-websocket-key"] as string); + nodeSocket.handshake(this); + } + + public listen(): void { + const port = 8443; + this.server.listen(port, () => { + const address = this.server.address(); + const location = typeof address === "string" + ? address + : `port ${address.port}`; + console.log(`Listening on ${location}`); + console.log(`Serving ${this.rootPath}`); + }); + } +} + +const server = new Server(); +server.listen(); diff --git a/socket.ts b/socket.ts new file mode 100644 index 0000000..4c56e85 --- /dev/null +++ b/socket.ts @@ -0,0 +1,161 @@ +import * as crypto from "crypto"; +import * as net from "net"; +import { AuthRequest, ConnectionType, ConnectionTypeRequest, HandshakeMessage } from "vs/platform/remote/common/remoteAgentConnection"; +import { NodeSocket, WebSocketNodeSocket } from "vs/base/parts/ipc/node/ipc.net"; +import { PersistentProtocol, ISocket } from "vs/base/parts/ipc/common/ipc.net"; +import { VSBuffer } from "vs/base/common/buffer"; +import { Connection, ExtensionHostConnection, ManagementConnection } from "vs/server/connection"; + +export interface SocketOptions { + readonly reconnectionToken: string; + readonly reconnection: boolean; + readonly skipWebSocketFrames: boolean; +} + +export interface Server { + readonly connections: Map>; +} + +export class Socket { + private nodeSocket: ISocket; + public protocol: PersistentProtocol; + + public constructor(private readonly socket: net.Socket, private readonly options: SocketOptions) { + socket.on("error", () => this.dispose()); + this.nodeSocket = new NodeSocket(socket); + if (!this.options.skipWebSocketFrames) { + this.nodeSocket = new WebSocketNodeSocket(this.nodeSocket as NodeSocket); + } + this.protocol = new PersistentProtocol(this.nodeSocket); + } + + /** + * Upgrade the connection into a web socket. + */ + public upgrade(secWebsocketKey: string): void { + // This magic value is specified by the websocket spec. + const magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + const reply = crypto.createHash("sha1") + .update(secWebsocketKey + magic) + .digest("base64"); + + this.socket.write([ + "HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Accept: ${reply}`, + ].join("\r\n") + "\r\n\r\n"); + } + + public dispose(): void { + this.nodeSocket.dispose(); + this.protocol.dispose(); + this.nodeSocket = undefined!; + this.protocol = undefined!; + } + + public handshake(server: Server): void { + const handler = this.protocol.onControlMessage((rawMessage) => { + const message = JSON.parse(rawMessage.toString()); + switch (message.type) { + case "auth": return this.authenticate(message); + case "connectionType": + handler.dispose(); + return this.connect(message, server); + case "default": + return this.dispose(); + } + }); + } + + /** + * TODO: This ignores the authentication process entirely for now. + */ + private authenticate(_message: AuthRequest): void { + this.sendControl({ + type: "sign", + data: "", + }); + } + + private connect(message: ConnectionTypeRequest, server: Server): void { + switch (message.desiredConnectionType) { + case ConnectionType.ExtensionHost: + case ConnectionType.Management: + const debugPort = this.getDebugPort(); + const ok = message.desiredConnectionType === ConnectionType.ExtensionHost + ? (debugPort ? { debugPort } : {}) + : { type: "ok" }; + + if (!server.connections.has(message.desiredConnectionType)) { + server.connections.set(message.desiredConnectionType, new Map()); + } + + const connections = server.connections.get(message.desiredConnectionType)!; + + if (this.options.reconnection && connections.has(this.options.reconnectionToken)) { + this.sendControl(ok); + const buffer = this.protocol.readEntireBuffer(); + this.protocol.dispose(); + return connections.get(this.options.reconnectionToken)! + .reconnect(this.nodeSocket, buffer); + } + + if (this.options.reconnection || connections.has(this.options.reconnectionToken)) { + this.sendControl({ + type: "error", + reason: this.options.reconnection + ? "Unrecognized reconnection token" + : "Duplicate reconnection token", + }); + return this.dispose(); + } + + this.sendControl(ok); + + const connection = message.desiredConnectionType === ConnectionType.Management + ? new ManagementConnection(this.protocol) + : new ExtensionHostConnection(this.protocol); + + connections.set(this.options.reconnectionToken, connection); + connection.onClose(() => { + connections.delete(this.options.reconnectionToken); + }); + break; + case ConnectionType.Tunnel: + return this.tunnel(); + default: + this.sendControl({ + type: "error", + reason: "Unrecognized connection type", + }); + return this.dispose(); + } + } + + /** + * TODO: implement. + */ + private tunnel(): void { + this.sendControl({ + type: "error", + reason: "Tunnel is not implemented yet", + }); + this.dispose(); + } + + /** + * TODO: implement. + */ + private getDebugPort(): number | undefined { + return undefined; + } + + /** + * Send a handshake message. In the case of the extension host, it just sends + * back a debug port. + */ + private sendControl(message: HandshakeMessage | { debugPort?: number } ): void { + this.protocol.sendControl(VSBuffer.fromString(JSON.stringify(message))); + } +} diff --git a/tslint.json b/tslint.json index 7edd843..9c957f3 100644 --- a/tslint.json +++ b/tslint.json @@ -1,89 +1,8 @@ { - "rulesDirectory": "./rules/dist", + "extends": [ + "../../../tslint.json" + ], "rules": { - "only-arrow-functions": true, - "curly-statement-newlines": true, - "no-block-padding": true, - "adjacent-overload-signatures": true, - "align": true, - "await-promise": [true, "Thenable"], - "class-name": true, - "eofline": true, - "import-spacing": true, - "indent": [true, "tabs"], - "no-angle-bracket-type-assertion": false, - "no-bitwise": false, - "no-any": true, - "newline-before-return": true, - "no-console": true, - "no-duplicate-imports": true, - "no-consecutive-blank-lines": true, - "no-empty": true, - "no-floating-promises": true, - "no-return-await": true, - "no-var-keyword": true, - "no-trailing-whitespace": true, - "no-redundant-jsdoc": true, - "no-implicit-dependencies": false, - "no-boolean-literal-compare": true, - "prefer-readonly": true, - "deprecation": true, - "semicolon": true, - "one-line": [ - true, - "check-catch", - "check-finally", - "check-else", - "check-whitespace", - "check-open-brace" - ], - "completed-docs": { - "options": [ - true, - "enums", - "functions", - "methods", - "classes" - ], - "severity": "warning" - }, - "no-unused-expression": [ - true, - "allow-fast-null-checks" - ], - "curly": [ - true - ], - "quotemark": [ - true, - "double", - "avoid-escape", - "avoid-template" - ], - "trailing-comma": [ - true, - { - "multiline": "always", - "singleline": "never", - "esSpecCompliant": true - } - ], - "space-before-function-paren": [ - false, - "always" - ], - "member-access": [ - true, - "check-accessor", - "check-constructor", - "check-parameter-property" - ], - "typedef": [ - true, - "call-signature", - "arrow-call-signature", - "parameter", - "property-declaration" - ] + "no-unexternalized-strings": false } }