Skip to content

Commit 55a7e8b

Browse files
committed
Implement automatic cloud proxying
1 parent 916e24e commit 55a7e8b

File tree

4 files changed

+138
-3
lines changed

4 files changed

+138
-3
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,8 @@
8282
"tar-fs": "^2.0.0",
8383
"ws": "^7.2.0",
8484
"xdg-basedir": "^4.0.0",
85-
"yarn": "^1.22.4"
85+
"yarn": "^1.22.4",
86+
"delay": "^4.4.0"
8687
},
8788
"bin": {
8889
"code-server": "out/node/entry.js"

src/node/coder-cloud.ts

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,14 @@ import { spawn } from "child_process"
22
import path from "path"
33
import { logger } from "@coder/logger"
44
import split2 from "split2"
5+
import delay from "delay"
6+
import fs from "fs"
7+
import { promisify } from "util"
8+
import xdgBasedir from "xdg-basedir"
9+
10+
const coderCloudAgent = path.resolve(__dirname, "../../lib/coder-cloud-agent")
511

612
export async function coderCloudExpose(serverName: string): Promise<void> {
7-
const coderCloudAgent = path.resolve(__dirname, "../../lib/coder-cloud-agent")
813
const agent = spawn(coderCloudAgent, ["link", serverName], {
914
stdio: ["inherit", "inherit", "pipe"],
1015
})
@@ -28,3 +33,125 @@ export async function coderCloudExpose(serverName: string): Promise<void> {
2833
})
2934
})
3035
}
36+
37+
export function coderCloudProxy(addr: string) {
38+
// addr needs to be in host:port format.
39+
// So we trim the protocol.
40+
addr = addr.replace(/^https?:\/\//, "")
41+
42+
if (!xdgBasedir.config) {
43+
return
44+
}
45+
46+
const sessionTokenPath = path.join(xdgBasedir.config, "coder-cloud", "session")
47+
48+
const _proxy = async () => {
49+
await waitForPath(sessionTokenPath)
50+
51+
logger.info("exposing coder-server with coder-cloud")
52+
53+
const agent = spawn(coderCloudAgent, ["proxy", "--code-server-addr", addr], {
54+
stdio: ["inherit", "inherit", "pipe"],
55+
})
56+
57+
agent.stderr.pipe(split2()).on("data", line => {
58+
line = line.replace(/^[0-9-]+ [0-9:]+ [^ ]+\t/, "")
59+
logger.info(line)
60+
})
61+
62+
return new Promise((res, rej) => {
63+
agent.on("error", rej)
64+
65+
agent.on("close", code => {
66+
if (code !== 0) {
67+
rej({
68+
message: `coder cloud agent exited with ${code}`,
69+
})
70+
return
71+
}
72+
res()
73+
})
74+
})
75+
}
76+
77+
const proxy = async () => {
78+
try {
79+
await _proxy()
80+
} catch(err) {
81+
logger.error(err.message)
82+
}
83+
setTimeout(proxy, 3000)
84+
}
85+
proxy()
86+
}
87+
88+
/**
89+
* waitForPath efficiently implements waiting for the existence of a path.
90+
*
91+
* We intentionally do not use fs.watchFile as it is very slow from testing.
92+
* I believe it polls instead of watching.
93+
*
94+
* The way this works is for each level of the path it will check if it exists
95+
* and if not, it will wait for it. e.g. if the path is /home/nhooyr/.config/coder-cloud/session
96+
* then first it will check if /home exists, then /home/nhooyr and so on.
97+
*
98+
* The wait works by first creating a watch promise for the p segment.
99+
* We call fs.watch on the dirname of the p segment. When the dirname has a change,
100+
* we check if the p segment exists and if it does, we resolve the watch promise.
101+
* On any error or the watcher being closed, we reject the watch promise.
102+
*
103+
* Once that promise is setup, we check if the p segment exists with fs.exists
104+
* and if it does, we close the watcher and return.
105+
*
106+
* Now we race the watch promise and a 2000ms delay promise. Once the race
107+
* is complete, we close the watcher.
108+
*
109+
* If the watch promise was the one to resolve, we return.
110+
* Otherwise we setup the watch promise again and retry.
111+
*
112+
* This combination of polling and watching is very reliable and efficient.
113+
*/
114+
async function waitForPath(p: string): Promise<void> {
115+
const segs = p.split(path.sep)
116+
for (let i = 0; i < segs.length; i++) {
117+
const s = path.join("/", ...segs.slice(0, i + 1))
118+
// We need to wait for each segment to exist.
119+
await _waitForPath(s)
120+
}
121+
}
122+
123+
async function _waitForPath(p: string): Promise<void> {
124+
const watchDir = path.dirname(p)
125+
126+
logger.debug(`waiting for ${p}`)
127+
128+
for (;;) {
129+
const w = fs.watch(watchDir)
130+
const watchPromise = new Promise<void>((res, rej) => {
131+
w.on("change", async () => {
132+
if (await promisify(fs.exists)(p)) {
133+
res()
134+
}
135+
})
136+
w.on("close", () => rej(new Error("watcher closed")))
137+
w.on("error", rej)
138+
})
139+
140+
// We want to ignore any errors from this promise being rejected if the file
141+
// already exists below.
142+
watchPromise.catch(() => {})
143+
144+
if (await promisify(fs.exists)(p)) {
145+
// The path exists!
146+
w.close()
147+
return
148+
}
149+
150+
// Now we wait for either the watch promise to resolve/reject or 2000ms.
151+
const s = await Promise.race([watchPromise.then(() => "exists"), delay(2000)])
152+
w.close()
153+
if (s === "exists") {
154+
return
155+
}
156+
}
157+
}

src/node/entry.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ import { StaticHttpProvider } from "./app/static"
1212
import { UpdateHttpProvider } from "./app/update"
1313
import { VscodeHttpProvider } from "./app/vscode"
1414
import { Args, bindAddrFromAllSources, optionDescriptions, parse, readConfigFile, setDefaults } from "./cli"
15+
import { coderCloudExpose, coderCloudProxy } from "./coder-cloud"
1516
import { AuthType, HttpServer, HttpServerOptions } from "./http"
1617
import { loadPlugins } from "./plugin"
1718
import { generateCertificate, hash, humanPath, open } from "./util"
1819
import { ipcMain, wrap } from "./wrapper"
19-
import { coderCloudExpose } from "./coder-cloud"
2020

2121
process.on("uncaughtException", (error) => {
2222
logger.error(`Uncaught exception: ${error.message}`)
@@ -123,6 +123,8 @@ const main = async (args: Args, cliArgs: Args, configArgs: Args): Promise<void>
123123
httpServer.proxyDomains.forEach((domain) => logger.info(` - *.${domain}`))
124124
}
125125

126+
coderCloudProxy(serverAddress!)
127+
126128
if (serverAddress && !options.socket && args.open) {
127129
// The web socket doesn't seem to work if browsing with 0.0.0.0.
128130
const openAddress = serverAddress.replace(/:\/\/0.0.0.0/, "://localhost")

yarn.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2525,6 +2525,11 @@ define-property@^2.0.2:
25252525
is-descriptor "^1.0.2"
25262526
isobject "^3.0.1"
25272527

2528+
delay@^4.4.0:
2529+
version "4.4.0"
2530+
resolved "https://registry.yarnpkg.com/delay/-/delay-4.4.0.tgz#71abc745f3ce043fe7f450491236541edec4ad0c"
2531+
integrity sha512-txgOrJu3OdtOfTiEOT2e76dJVfG/1dz2NZ4F0Pyt4UGZJryssMRp5vdM5wQoLwSOBNdrJv3F9PAhp/heqd7vrA==
2532+
25282533
delayed-stream@~1.0.0:
25292534
version "1.0.0"
25302535
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"

0 commit comments

Comments
 (0)