diff --git a/package.json b/package.json index bf5977c8c..5bf72f641 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,8 @@ "tar-fs": "^2.0.0", "ws": "^7.2.0", "xdg-basedir": "^4.0.0", - "yarn": "^1.22.4" + "yarn": "^1.22.4", + "delay": "^4.4.0" }, "bin": { "code-server": "out/node/entry.js" diff --git a/src/node/coder-cloud.ts b/src/node/coder-cloud.ts index 082e2d823..167b80c3f 100644 --- a/src/node/coder-cloud.ts +++ b/src/node/coder-cloud.ts @@ -2,9 +2,14 @@ import { spawn } from "child_process" import path from "path" import { logger } from "@coder/logger" import split2 from "split2" +import delay from "delay" +import fs from "fs" +import { promisify } from "util" +import xdgBasedir from "xdg-basedir" + +const coderCloudAgent = path.resolve(__dirname, "../../lib/coder-cloud-agent") export async function coderCloudExpose(serverName: string): Promise { - const coderCloudAgent = path.resolve(__dirname, "../../lib/coder-cloud-agent") const agent = spawn(coderCloudAgent, ["link", serverName], { stdio: ["inherit", "inherit", "pipe"], }) @@ -28,3 +33,125 @@ export async function coderCloudExpose(serverName: string): Promise { }) }) } + +export function coderCloudProxy(addr: string) { + // addr needs to be in host:port format. + // So we trim the protocol. + addr = addr.replace(/^https?:\/\//, "") + + if (!xdgBasedir.config) { + return + } + + const sessionTokenPath = path.join(xdgBasedir.config, "coder-cloud", "session") + + const _proxy = async () => { + await waitForPath(sessionTokenPath) + + logger.info("exposing coder-server with coder-cloud") + + const agent = spawn(coderCloudAgent, ["proxy", "--code-server-addr", addr], { + stdio: ["inherit", "inherit", "pipe"], + }) + + agent.stderr.pipe(split2()).on("data", line => { + line = line.replace(/^[0-9-]+ [0-9:]+ [^ ]+\t/, "") + logger.info(line) + }) + + return new Promise((res, rej) => { + agent.on("error", rej) + + agent.on("close", code => { + if (code !== 0) { + rej({ + message: `coder cloud agent exited with ${code}`, + }) + return + } + res() + }) + }) + } + + const proxy = async () => { + try { + await _proxy() + } catch(err) { + logger.error(err.message) + } + setTimeout(proxy, 3000) + } + proxy() +} + +/** + * waitForPath efficiently implements waiting for the existence of a path. + * + * We intentionally do not use fs.watchFile as it is very slow from testing. + * I believe it polls instead of watching. + * + * The way this works is for each level of the path it will check if it exists + * and if not, it will wait for it. e.g. if the path is /home/nhooyr/.config/coder-cloud/session + * then first it will check if /home exists, then /home/nhooyr and so on. + * + * The wait works by first creating a watch promise for the p segment. + * We call fs.watch on the dirname of the p segment. When the dirname has a change, + * we check if the p segment exists and if it does, we resolve the watch promise. + * On any error or the watcher being closed, we reject the watch promise. + * + * Once that promise is setup, we check if the p segment exists with fs.exists + * and if it does, we close the watcher and return. + * + * Now we race the watch promise and a 2000ms delay promise. Once the race + * is complete, we close the watcher. + * + * If the watch promise was the one to resolve, we return. + * Otherwise we setup the watch promise again and retry. + * + * This combination of polling and watching is very reliable and efficient. + */ +async function waitForPath(p: string): Promise { + const segs = p.split(path.sep) + for (let i = 0; i < segs.length; i++) { + const s = path.join("/", ...segs.slice(0, i + 1)) + // We need to wait for each segment to exist. + await _waitForPath(s) + } +} + +async function _waitForPath(p: string): Promise { + const watchDir = path.dirname(p) + + logger.debug(`waiting for ${p}`) + + for (;;) { + const w = fs.watch(watchDir) + const watchPromise = new Promise((res, rej) => { + w.on("change", async () => { + if (await promisify(fs.exists)(p)) { + res() + } + }) + w.on("close", () => rej(new Error("watcher closed"))) + w.on("error", rej) + }) + + // We want to ignore any errors from this promise being rejected if the file + // already exists below. + watchPromise.catch(() => {}) + + if (await promisify(fs.exists)(p)) { + // The path exists! + w.close() + return + } + + // Now we wait for either the watch promise to resolve/reject or 2000ms. + const s = await Promise.race([watchPromise.then(() => "exists"), delay(2000)]) + w.close() + if (s === "exists") { + return + } + } +} diff --git a/src/node/entry.ts b/src/node/entry.ts index 860d8de7a..539b3bccd 100644 --- a/src/node/entry.ts +++ b/src/node/entry.ts @@ -12,11 +12,11 @@ import { StaticHttpProvider } from "./app/static" import { UpdateHttpProvider } from "./app/update" import { VscodeHttpProvider } from "./app/vscode" import { Args, bindAddrFromAllSources, optionDescriptions, parse, readConfigFile, setDefaults } from "./cli" +import { coderCloudExpose, coderCloudProxy } from "./coder-cloud" import { AuthType, HttpServer, HttpServerOptions } from "./http" import { loadPlugins } from "./plugin" import { generateCertificate, hash, humanPath, open } from "./util" import { ipcMain, wrap } from "./wrapper" -import { coderCloudExpose } from "./coder-cloud" process.on("uncaughtException", (error) => { logger.error(`Uncaught exception: ${error.message}`) @@ -123,6 +123,8 @@ const main = async (args: Args, cliArgs: Args, configArgs: Args): Promise httpServer.proxyDomains.forEach((domain) => logger.info(` - *.${domain}`)) } + coderCloudProxy(serverAddress!) + if (serverAddress && !options.socket && args.open) { // The web socket doesn't seem to work if browsing with 0.0.0.0. const openAddress = serverAddress.replace(/:\/\/0.0.0.0/, "://localhost") diff --git a/yarn.lock b/yarn.lock index 6f388626b..8e13b6f35 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2525,6 +2525,11 @@ define-property@^2.0.2: is-descriptor "^1.0.2" isobject "^3.0.1" +delay@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/delay/-/delay-4.4.0.tgz#71abc745f3ce043fe7f450491236541edec4ad0c" + integrity sha512-txgOrJu3OdtOfTiEOT2e76dJVfG/1dz2NZ4F0Pyt4UGZJryssMRp5vdM5wQoLwSOBNdrJv3F9PAhp/heqd7vrA== + delayed-stream@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"