Skip to content

Commit 49179e6

Browse files
skulidropekclaude
andcommitted
feat(app): per-container CF SSH tunnel for VS Code panel (#428)
When clicking the VS Code button on a terminal, automatically starts a dedicated Cloudflare quick tunnel for that container's SSH port and shows only the CF SSH command — no localhost config. - New service: ssh-project-tunnels.ts — per-projectKey cloudflared tunnel with idempotent start, keyed separately from share-link tunnels - New route: POST /projects/by-key/:key/ssh-tunnel — starts tunnel, returns { hostname } (blocks up to 15s for hostname resolution) - VsCodeAccessPanel shows: wildcard ~/.ssh/config setup, CF SSH command with copy, and "Open in VS Code (CF tunnel)" link - Loading / ready / failed states with Retry button on failure Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 26c967d commit 49179e6

8 files changed

Lines changed: 493 additions & 7 deletions

File tree

packages/api/src/http.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ import {
142142
startSshShareLinkTunnel,
143143
stopSshShareLinkTunnel
144144
} from "./services/ssh-share-link-tunnels.js"
145+
import { startSshProjectTunnel } from "./services/ssh-project-tunnels.js"
145146
import {
146147
disableContainerPasswordAuth,
147148
enableContainerPasswordAuth,
@@ -1254,6 +1255,19 @@ export const makeRouter = () => {
12541255
Effect.flatMap(() => jsonResponse({ ok: true }, 200)),
12551256
Effect.catchAll(errorResponse)
12561257
)
1258+
),
1259+
HttpRouter.post(
1260+
"/projects/by-key/:projectKey/ssh-tunnel",
1261+
Effect.gen(function*(_) {
1262+
const { projectKey } = yield* _(projectKeyParams)
1263+
const project = yield* _(getProjectItemByKey(projectKey))
1264+
const hostname = yield* _(
1265+
startSshProjectTunnel(projectKey, project.sshPort).pipe(
1266+
Effect.orElse(() => Effect.succeed(null))
1267+
)
1268+
)
1269+
return yield* _(jsonResponse({ hostname }, 200))
1270+
}).pipe(Effect.catchAll(errorResponse))
12571271
)
12581272
)
12591273

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
// CHANGE: per-project SSH cloudflared tunnels for VS Code Remote SSH access
2+
// WHY: share-link tunnels are tied to tokens and expire; containers need a
3+
// persistent tunnel that lives as long as the container is running
4+
// QUOTE(ТЗ): "запускать cloudflare tunnel под каждый контейнер"
5+
// REF: issue-428
6+
// FORMAT THEOREM: ∀ projectKey: started(projectKey, port) → ∃ hostname: cfSsh(hostname, port)
7+
// PURITY: SHELL
8+
// EFFECT: Effect<string | null, ApiInternalError>
9+
// INVARIANT: at most one tunnel record per projectKey is active at any time
10+
// COMPLEXITY: O(1) lookup, O(startWaitAttempts * 250ms) start wait
11+
12+
import { spawn, type ChildProcess } from "node:child_process"
13+
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs"
14+
import { join } from "node:path"
15+
import { randomUUID } from "node:crypto"
16+
17+
import { Duration, Effect, Fiber } from "effect"
18+
19+
import { ApiInternalError } from "../api/errors.js"
20+
import { parseTryCloudflareUrl } from "./panel-cloudflare-tunnel-core.js"
21+
import { parseLinuxDefaultGatewayIp } from "./project-port-proxy-core.js"
22+
23+
type SshTunnelRecord = {
24+
readonly homeDir: string
25+
process: ChildProcess | null
26+
processClosed: boolean
27+
hostname: string | null
28+
stopping: boolean
29+
stopFiber: Fiber.RuntimeFiber<void> | null
30+
stdoutRemainder: string
31+
stderrRemainder: string
32+
}
33+
34+
const projectTunnelMap = new Map<string, SshTunnelRecord>()
35+
const projectTunnelLock = Effect.unsafeMakeSemaphore(1)
36+
const startWaitAttempts = 60
37+
38+
const sshTunnelHomeDir = (id: string): string => join("/tmp", "docker-git-project-tunnels", id)
39+
40+
const processEnv = (homeDir: string): Readonly<Record<string, string | undefined>> => ({
41+
HOME: homeDir,
42+
NO_COLOR: "1",
43+
PATH: process.env["PATH"],
44+
SSL_CERT_DIR: process.env["SSL_CERT_DIR"],
45+
SSL_CERT_FILE: process.env["SSL_CERT_FILE"]
46+
})
47+
48+
const readDefaultGatewayIp = (): Effect.Effect<string | null> =>
49+
Effect.try(() => parseLinuxDefaultGatewayIp(readFileSync("/proc/net/route", "utf8"))).pipe(
50+
Effect.orElse(() => Effect.succeed(null))
51+
)
52+
53+
const defaultLocalhostHost = (): Effect.Effect<string> => {
54+
const configured = process.env["DOCKER_GIT_PANEL_TUNNEL_LOCALHOST_HOST"]?.trim()
55+
if (configured !== undefined && configured.length > 0) {
56+
return Effect.succeed(configured)
57+
}
58+
return existsSync("/.dockerenv")
59+
? readDefaultGatewayIp().pipe(Effect.map((ip) => ip ?? "172.17.0.1"))
60+
: Effect.succeed("127.0.0.1")
61+
}
62+
63+
const appendLog = (record: SshTunnelRecord, text: string): void => {
64+
if (record.hostname !== null) return
65+
const url = parseTryCloudflareUrl(text)
66+
if (url === null) return
67+
try {
68+
record.hostname = new URL(url).hostname
69+
} catch {
70+
// ignore malformed URL
71+
}
72+
}
73+
74+
const consumeChunk = (
75+
record: SshTunnelRecord,
76+
stream: "stderr" | "stdout",
77+
chunk: Buffer
78+
): void => {
79+
const incoming = chunk.toString("utf8").replaceAll("\r", "\n")
80+
const withRemainder = (stream === "stdout" ? record.stdoutRemainder : record.stderrRemainder) + incoming
81+
const lines = withRemainder.split("\n")
82+
const tail = lines.pop() ?? ""
83+
for (const line of lines) {
84+
appendLog(record, line)
85+
}
86+
if (stream === "stdout") {
87+
record.stdoutRemainder = tail
88+
} else {
89+
record.stderrRemainder = tail
90+
}
91+
}
92+
93+
const cleanupRecord = (record: SshTunnelRecord): void => {
94+
try {
95+
rmSync(record.homeDir, { force: true, recursive: true })
96+
} catch {
97+
// best effort
98+
}
99+
}
100+
101+
const waitForChildClose = (
102+
record: SshTunnelRecord,
103+
child: ChildProcess
104+
): Effect.Effect<void> => {
105+
if (record.processClosed) {
106+
return Effect.void
107+
}
108+
return Effect.async((resume) => {
109+
const alreadyExited = child.exitCode !== null || child.signalCode !== null
110+
let completed = false
111+
let killTimer: ReturnType<typeof setTimeout> | null = null
112+
const complete = (): void => {
113+
if (completed) return
114+
completed = true
115+
child.off("close", complete)
116+
child.off("error", complete)
117+
if (killTimer !== null) clearTimeout(killTimer)
118+
resume(Effect.void)
119+
}
120+
child.once("close", complete)
121+
child.once("error", complete)
122+
if (!alreadyExited && !child.killed) {
123+
try {
124+
child.kill("SIGTERM")
125+
} catch {
126+
complete()
127+
return
128+
}
129+
}
130+
if (!alreadyExited) {
131+
killTimer = setTimeout(() => {
132+
try {
133+
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL")
134+
} catch {
135+
complete()
136+
}
137+
}, 2_000)
138+
killTimer.unref()
139+
}
140+
})
141+
}
142+
143+
const stopRecord = (record: SshTunnelRecord): Effect.Effect<void> => {
144+
if (record.stopFiber !== null) {
145+
return Fiber.join(record.stopFiber).pipe(Effect.asVoid)
146+
}
147+
const child = record.process
148+
record.stopping = true
149+
const fiber = Effect.runFork(
150+
(child === null ? Effect.void : waitForChildClose(record, child)).pipe(
151+
Effect.tap(() => Effect.sync(() => { cleanupRecord(record) }))
152+
)
153+
)
154+
record.stopFiber = fiber
155+
return Fiber.join(fiber).pipe(Effect.asVoid)
156+
}
157+
158+
const attachHandlers = (record: SshTunnelRecord, child: ChildProcess): void => {
159+
record.process = child
160+
record.processClosed = false
161+
child.stdout?.on("data", (chunk: Buffer) => { consumeChunk(record, "stdout", chunk) })
162+
child.stderr?.on("data", (chunk: Buffer) => { consumeChunk(record, "stderr", chunk) })
163+
child.on("close", () => { record.processClosed = true })
164+
child.on("error", () => { record.processClosed = true })
165+
}
166+
167+
const waitForHostname = (
168+
record: SshTunnelRecord,
169+
remainingAttempts: number
170+
): Effect.Effect<string | null> =>
171+
Effect.gen(function*(_) {
172+
if (record.hostname !== null || record.stopping || remainingAttempts <= 0) {
173+
return record.hostname
174+
}
175+
yield* _(Effect.sleep(Duration.millis(250)))
176+
return yield* _(waitForHostname(record, remainingAttempts - 1))
177+
})
178+
179+
/**
180+
* Starts a dedicated SSH cloudflared quick tunnel for the given project.
181+
* Idempotent — returns existing hostname if a tunnel is already running.
182+
*
183+
* @param projectKey - Project key used as the map key (one tunnel per project).
184+
* @param sshPort - Host-mapped SSH port for the container.
185+
* @returns CF hostname (e.g. "abc.trycloudflare.com") or null if startup timed out.
186+
* @pure false
187+
* @effect Spawns cloudflared process, reads /proc/net/route, writes to /tmp.
188+
* @invariant Only one active record per projectKey — prior record is stopped before restart.
189+
* @precondition sshPort > 0
190+
* @postcondition On success, getSshProjectTunnelHostname(projectKey) returns the same hostname.
191+
* @complexity O(startWaitAttempts * 250ms) time for startup wait.
192+
* @throws Never - failures are typed as ApiInternalError in the Effect error channel.
193+
*/
194+
export const startSshProjectTunnel = (
195+
projectKey: string,
196+
sshPort: number
197+
): Effect.Effect<string | null, ApiInternalError> =>
198+
Effect.gen(function*(_) {
199+
const existing = projectTunnelMap.get(projectKey)
200+
if (existing !== undefined && !existing.stopping && existing.hostname !== null) {
201+
return existing.hostname
202+
}
203+
if (existing !== undefined) {
204+
yield* _(stopRecord(existing).pipe(Effect.orElse(() => Effect.void)))
205+
projectTunnelMap.delete(projectKey)
206+
}
207+
208+
const localhostHost = yield* _(defaultLocalhostHost())
209+
const sshUrl = `ssh://${localhostHost}:${sshPort}`
210+
const homeDir = sshTunnelHomeDir(randomUUID())
211+
const record: SshTunnelRecord = {
212+
homeDir,
213+
hostname: null,
214+
process: null,
215+
processClosed: false,
216+
stderrRemainder: "",
217+
stdoutRemainder: "",
218+
stopFiber: null,
219+
stopping: false
220+
}
221+
projectTunnelMap.set(projectKey, record)
222+
223+
yield* _(
224+
Effect.try({
225+
catch: (cause) => new ApiInternalError({ message: "Failed to start project SSH cloudflared tunnel.", cause }),
226+
try: () => {
227+
mkdirSync(record.homeDir, { recursive: true })
228+
const child = spawn(
229+
"cloudflared",
230+
["tunnel", "--no-autoupdate", "--url", sshUrl],
231+
{
232+
cwd: process.cwd(),
233+
env: processEnv(record.homeDir),
234+
stdio: ["ignore", "pipe", "pipe"]
235+
}
236+
)
237+
attachHandlers(record, child)
238+
}
239+
})
240+
)
241+
242+
return yield* _(waitForHostname(record, startWaitAttempts))
243+
}).pipe(projectTunnelLock.withPermits(1))
244+
245+
/**
246+
* Stops and removes the SSH cloudflared tunnel for the given project key.
247+
*
248+
* @param projectKey - Project key whose tunnel should be stopped.
249+
* @pure false
250+
* @effect Sends SIGTERM/SIGKILL to cloudflared, removes tunnel home directory.
251+
* @invariant No-op when no tunnel exists for the projectKey.
252+
* @complexity O(process close timeout) time.
253+
* @throws Never - this effect has no typed failure channel.
254+
*/
255+
export const stopSshProjectTunnel = (projectKey: string): Effect.Effect<void> =>
256+
Effect.gen(function*(_) {
257+
const record = projectTunnelMap.get(projectKey)
258+
if (record === undefined) return
259+
projectTunnelMap.delete(projectKey)
260+
yield* _(stopRecord(record).pipe(Effect.orElse(() => Effect.void)))
261+
}).pipe(projectTunnelLock.withPermits(1))
262+
263+
/**
264+
* Returns the current CF hostname for the SSH tunnel associated with the given project key.
265+
*
266+
* @param projectKey - Project key to look up.
267+
* @returns CF hostname string or null if tunnel not running / hostname not yet available.
268+
* @pure true (read-only snapshot)
269+
* @complexity O(1)
270+
*/
271+
export const getSshProjectTunnelHostname = (projectKey: string): string | null =>
272+
projectTunnelMap.get(projectKey)?.hostname ?? null

packages/app/src/web/api-share-links.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,14 @@ export const deleteProjectShareLink = (
7171
`/projects/by-key/${encodeURIComponent(projectKey)}/share-links/${encodeURIComponent(token)}`,
7272
OkResponseSchema
7373
).pipe(Effect.asVoid)
74+
75+
const SshTunnelResponseSchema = Schema.Struct({ hostname: Schema.NullOr(Schema.String) })
76+
77+
export const startProjectSshTunnel = (
78+
projectKey: string
79+
): Effect.Effect<{ readonly hostname: string | null }, string> =>
80+
requestJson(
81+
"POST",
82+
`/projects/by-key/${encodeURIComponent(projectKey)}/ssh-tunnel`,
83+
SshTunnelResponseSchema
84+
)

0 commit comments

Comments
 (0)