|
| 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 |
0 commit comments