diff --git a/apps/web/src/lib/db/index.ts b/apps/web/src/lib/db/index.ts index 3253d01..96525f9 100644 --- a/apps/web/src/lib/db/index.ts +++ b/apps/web/src/lib/db/index.ts @@ -13,7 +13,7 @@ if (connectionString) { export const client = postgres(connectionString, { max: 10, idle_timeout: 20, - connect_timeout: 10, + connect_timeout: 30, keep_alive: 60, connection: { statement_timeout: 30000, diff --git a/packages/crosscode/src/cli.ts b/packages/crosscode/src/cli.ts index 20d9816..eb16214 100644 --- a/packages/crosscode/src/cli.ts +++ b/packages/crosscode/src/cli.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { spawn, execFileSync } from "child_process" +import { spawn, execFile, execFileSync } from "child_process" import { createWriteStream, mkdirSync, existsSync, readFileSync, writeFileSync, statSync, renameSync, unlinkSync } from "fs" import { readFile } from "fs/promises" import { join } from "path" @@ -112,7 +112,7 @@ function checkDep(name: string): boolean { } function spawnCmd(cmd: string, args: string[], opts: Parameters[2] = {}) { - return spawn(cmd, args, { ...opts, shell: process.platform === "win32" }) + return spawn(cmd, args, { ...opts, shell: false }) } type Config = { @@ -193,7 +193,12 @@ async function ensureCloudflaredNamedTunnel(config: Config): Promise<{ name: str const credentialsPath = join(cloudflaredTunnelDir, `${name}.json`) try { if (!existsSync(cloudflaredTunnelDir)) mkdirSync(cloudflaredTunnelDir, { recursive: true, mode: 0o700 }) - execFileSync("cloudflared", ["tunnel", "create", "--credentials-file", credentialsPath, name], { stdio: "ignore" }) + await new Promise((resolve, reject) => { + execFile("cloudflared", ["tunnel", "create", "--credentials-file", credentialsPath, name], { stdio: "ignore" }, (err) => { + if (err) reject(err) + else resolve() + }) + }) } catch (e) { debug("cloudflared tunnel create failed", { error: (e as Error).message }) return null @@ -261,12 +266,13 @@ function promptInput(prompt: string): Promise { } function openBrowser(url: string): void { + if (!url.startsWith("https://") && !url.startsWith("http://")) return const platform = process.platform try { if (platform === "darwin") { execFileSync("open", [url]) } else if (platform === "win32") { - execFileSync("cmd", ["/c", "start", url]) + execFileSync("cmd", ["/c", "start", "", url]) } else { execFileSync("xdg-open", [url]) } @@ -380,16 +386,158 @@ async function setupNgrokToken(): Promise { function sanitizeUrlPath(url: string | undefined): string { if (!url || url.length === 0) return "/" - const withoutHash = url.split("#")[0] - const queryIndex = withoutHash.indexOf("?") - const rawPath = queryIndex === -1 ? withoutHash : withoutHash.slice(0, queryIndex) - const rawQuery = queryIndex === -1 ? "" : withoutHash.slice(queryIndex + 1) + let decoded: string + try { + decoded = decodeURIComponent(url.split("#")[0]) + } catch { + return "/" + } + const queryIndex = decoded.indexOf("?") + const rawPath = queryIndex === -1 ? decoded : decoded.slice(0, queryIndex) + const rawQuery = queryIndex === -1 ? "" : decoded.slice(queryIndex + 1) if (!rawPath.startsWith("/")) return "/" - const cleaned = rawPath - if (cleaned.includes("..") || cleaned.includes("@")) return "/" + const cleaned = rawPath.replace(/\/+/g, "/") + if (cleaned.includes("..") || cleaned.includes("@") || cleaned.includes("\\")) return "/" return `${cleaned || "/"}${rawQuery ? `?${rawQuery}` : ""}` } +function createOpencodeProxy(targetPort: number, sessionToken: string, logPrefix: string): http.Server { + return http.createServer(async (req, res) => { + const safePath = sanitizeUrlPath(req.url) + const targetUrl = `http://127.0.0.1:${targetPort}${safePath}` + const authHeader = req.headers["authorization"] + + debug(`${logPrefix} request received`, { + method: req.method, + url: req.url, + safePath, + hasAuth: !!authHeader, + auth: censorAuth(authHeader), + }) + + if (req.method === "OPTIONS") { + debug("handling CORS preflight") + res.writeHead(204, { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + "Access-Control-Max-Age": "86400", + }) + res.end() + return + } + + if (req.url === "/mobile-event" && req.method === "POST") { + debug("handling SSE request") + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Access-Control-Allow-Origin": "*", + }) + + let sseAuth = authHeader || "" + if (sseAuth && !sseAuth.startsWith("Basic ")) { + sseAuth = `Basic ${Buffer.from(`:${sseAuth}`).toString("base64")}` + debug("converted SSE auth to Basic format") + } + + const sseReq = http.get(`http://127.0.0.1:${targetPort}/event`, { + headers: { + "Accept": "text/event-stream", + "Authorization": sseAuth, + }, + }, (sseRes) => { + debug("SSE upstream connected", { status: sseRes.statusCode }) + sseRes.on("data", (chunk) => { res.write(chunk) }) + sseRes.on("end", () => { debug("SSE upstream ended"); res.end() }) + }) + + sseReq.on("error", (err) => { + debug("SSE upstream error", { error: err.message }) + res.end() + }) + + req.on("close", () => { + debug("SSE client disconnected") + sseReq.destroy() + }) + + return + } + + if (await handleGitRequest(req, res, { worktree: process.cwd(), sessionToken })) { + return + } + + const forwardHeaders: Record = {} + for (const [key, value] of Object.entries(req.headers)) { + if (!HOP_BY_HOP.has(key.toLowerCase())) forwardHeaders[key] = value + } + forwardHeaders["host"] = `127.0.0.1:${targetPort}` + + if (authHeader && !authHeader.startsWith("Basic ")) { + forwardHeaders["authorization"] = `Basic ${Buffer.from(`:${authHeader}`).toString("base64")}` + debug("converted auth to Basic format") + } + + debug("forwarding to opencode", { + targetUrl, + method: req.method, + hasAuth: !!forwardHeaders["authorization"], + auth: censorAuth(forwardHeaders["authorization"] as string), + }) + + const proxyReq = http.request(targetUrl, { + method: req.method, + headers: forwardHeaders, + agent: proxyAgent, + }, (proxyRes) => { + debug("opencode responded", { status: proxyRes.statusCode, method: req.method, path: safePath }) + res.writeHead(proxyRes.statusCode || 500, proxyRes.headers) + proxyRes.pipe(res) + }) + + proxyReq.on("error", (err) => { + debug("proxy request error", { error: err.message }) + res.writeHead(502) + res.end("Bad Gateway") + }) + + let bodySize = 0 + let bodyTooLarge = false + + req.on("data", (chunk) => { + bodySize += chunk.length + if (bodySize > MAX_BODY_SIZE) { + bodyTooLarge = true + debug("request body too large", { size: bodySize, max: MAX_BODY_SIZE }) + req.destroy() + proxyReq.destroy() + if (!res.headersSent) { + res.writeHead(413) + res.end("Request body too large") + } + return + } + proxyReq.write(chunk) + }) + + req.on("end", () => { + if (!bodyTooLarge) proxyReq.end() + }) + + req.on("error", (err) => { + debug("request stream error", { error: err.message }) + proxyReq.destroy() + if (!res.headersSent) { + res.writeHead(500) + res.end("Internal Server Error") + } + }) + }) +} + async function main() { const args = process.argv.slice(2) const config = readConfig() @@ -551,145 +699,7 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} debug("using detected port", { detected: targetPort, requested: requestedPort }) } - const proxy = http.createServer(async (req, res) => { - const safePath = sanitizeUrlPath(req.url) - const targetUrl = `http://127.0.0.1:${targetPort}${safePath}` - const authHeader = req.headers["authorization"] - - debug("proxy request received", { - method: req.method, - url: req.url, - safePath, - hasAuth: !!authHeader, - auth: censorAuth(authHeader), - }) - - if (req.method === "OPTIONS") { - debug("handling CORS preflight") - res.writeHead(204, { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization", - "Access-Control-Max-Age": "86400", - }) - res.end() - return - } - - if (req.url === "/mobile-event" && req.method === "POST") { - debug("handling SSE request") - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "Access-Control-Allow-Origin": "*", - }) - - let sseAuth = authHeader || "" - if (sseAuth && !sseAuth.startsWith("Basic ")) { - sseAuth = `Basic ${Buffer.from(`:${sseAuth}`).toString("base64")}` - debug("converted SSE auth to Basic format") - } - - const sseReq = http.get(`http://127.0.0.1:${targetPort}/event`, { - headers: { - "Accept": "text/event-stream", - "Authorization": sseAuth, - }, - }, (sseRes) => { - debug("SSE upstream connected", { status: sseRes.statusCode }) - sseRes.on("data", (chunk) => { - res.write(chunk) - }) - sseRes.on("end", () => { - debug("SSE upstream ended") - res.end() - }) - }) - - sseReq.on("error", (err) => { - debug("SSE upstream error", { error: err.message }) - res.end() - }) - - req.on("close", () => { - debug("SSE client disconnected") - sseReq.destroy() - }) - - return - } - - if (await handleGitRequest(req, res, { worktree: process.cwd(), sessionToken })) { - return - } - - const forwardHeaders: Record = {} - for (const [key, value] of Object.entries(req.headers)) { - if (!HOP_BY_HOP.has(key.toLowerCase())) forwardHeaders[key] = value - } - forwardHeaders["host"] = `127.0.0.1:${targetPort}` - - const authVal = req.headers["authorization"] - if (authVal && !authVal.startsWith("Basic ")) { - forwardHeaders["authorization"] = `Basic ${Buffer.from(`:${authVal}`).toString("base64")}` - debug("converted auth to Basic format") - } - - let bodySize = 0 - const bodyChunks: Buffer[] = [] - - req.on("data", (chunk) => { - bodySize += chunk.length - if (bodySize > MAX_BODY_SIZE) { - debug("request body too large", { size: bodySize, max: MAX_BODY_SIZE }) - req.destroy() - res.writeHead(413) - res.end("Request body too large") - return - } - bodyChunks.push(chunk) - }) - - req.on("end", () => { - const body = bodyChunks.length > 0 ? Buffer.concat(bodyChunks) : null - - debug("forwarding to opencode", { - targetUrl, - method: req.method, - hasAuth: !!forwardHeaders["authorization"], - auth: censorAuth(forwardHeaders["authorization"] as string), - bodySize: body?.length ?? 0, - }) - - const proxyReq = http.request(targetUrl, { - method: req.method, - headers: forwardHeaders, - agent: proxyAgent, - }, (proxyRes) => { - debug("opencode responded", { status: proxyRes.statusCode, method: req.method, path: safePath }) - res.writeHead(proxyRes.statusCode || 500, proxyRes.headers) - proxyRes.pipe(res) - }) - - proxyReq.on("error", (err) => { - debug("proxy request error", { error: err.message }) - res.writeHead(502) - res.end("Bad Gateway") - }) - - if (body) proxyReq.write(body) - proxyReq.end() - }) - - req.on("error", (err) => { - debug("request stream error", { error: err.message }) - if (!res.headersSent) { - res.writeHead(500) - res.end("Internal Server Error") - } - }) - }) + const proxy = createOpencodeProxy(targetPort, sessionToken, "tunnel") proxy.listen(proxyPort, "127.0.0.1", () => { logCrosscode(`SSE proxy started on port ${proxyPort}`) @@ -713,7 +723,7 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} spinner.text = chalk.green.italic("opencode serve running") + chalk.yellow.italic(" • Connecting to tunnel server...") - const projectId = ensureProjectId(config) + const projectId = deriveProjectId() logCrosscode(`Project ID: ${projectId}`) debug("connecting to tunnel", { projectId, proxyPort }) @@ -891,145 +901,7 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} debug("using detected port", { detected: detectedPort, requested: port }) } - const proxy = http.createServer(async (req, res) => { - const safePath = sanitizeUrlPath(req.url) - const targetUrl = `http://127.0.0.1:${detectedPort}${safePath}` - const authHeader = req.headers["authorization"] - - debug("cf-proxy request received", { - method: req.method, - url: req.url, - safePath, - hasAuth: !!authHeader, - auth: censorAuth(authHeader), - }) - - if (req.method === "OPTIONS") { - debug("handling CORS preflight") - res.writeHead(204, { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization", - "Access-Control-Max-Age": "86400", - }) - res.end() - return - } - - if (req.url === "/mobile-event" && req.method === "POST") { - debug("handling SSE request") - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "Access-Control-Allow-Origin": "*", - }) - - let sseAuth = authHeader || "" - if (sseAuth && !sseAuth.startsWith("Basic ")) { - sseAuth = `Basic ${Buffer.from(`:${sseAuth}`).toString("base64")}` - debug("converted SSE auth to Basic format") - } - - const sseReq = http.get(`http://127.0.0.1:${detectedPort}/event`, { - headers: { - "Accept": "text/event-stream", - "Authorization": sseAuth, - }, - }, (sseRes) => { - debug("SSE upstream connected", { status: sseRes.statusCode }) - sseRes.on("data", (chunk) => { - res.write(chunk) - }) - sseRes.on("end", () => { - debug("SSE upstream ended") - res.end() - }) - }) - - sseReq.on("error", (err) => { - debug("SSE upstream error", { error: err.message }) - res.end() - }) - - req.on("close", () => { - debug("SSE client disconnected") - sseReq.destroy() - }) - - return - } - - if (await handleGitRequest(req, res, { worktree: process.cwd(), sessionToken })) { - return - } - - const forwardHeaders: Record = {} - for (const [key, value] of Object.entries(req.headers)) { - if (!HOP_BY_HOP.has(key.toLowerCase())) forwardHeaders[key] = value - } - forwardHeaders["host"] = `127.0.0.1:${detectedPort}` - - if (req.headers["authorization"] && !req.headers["authorization"].startsWith("Basic ")) { - const token = req.headers["authorization"] - forwardHeaders["authorization"] = `Basic ${Buffer.from(`:${token}`).toString("base64")}` - debug("converted auth to Basic format") - } - - let bodySize = 0 - const bodyChunks: Buffer[] = [] - - req.on("data", (chunk) => { - bodySize += chunk.length - if (bodySize > MAX_BODY_SIZE) { - debug("request body too large", { size: bodySize, max: MAX_BODY_SIZE }) - req.destroy() - res.writeHead(413) - res.end("Request body too large") - return - } - bodyChunks.push(chunk) - }) - - req.on("end", () => { - const body = bodyChunks.length > 0 ? Buffer.concat(bodyChunks) : null - - debug("forwarding to opencode", { - targetUrl, - method: req.method, - hasAuth: !!forwardHeaders["authorization"], - auth: censorAuth(forwardHeaders["authorization"] as string), - bodySize: body?.length ?? 0, - }) - - const proxyReq = http.request(targetUrl, { - method: req.method, - headers: forwardHeaders, - agent: proxyAgent, - }, (proxyRes) => { - debug("opencode responded", { status: proxyRes.statusCode, method: req.method, path: safePath }) - res.writeHead(proxyRes.statusCode || 500, proxyRes.headers) - proxyRes.pipe(res) - }) - - proxyReq.on("error", (err) => { - debug("proxy request error", { error: err.message }) - res.writeHead(502) - res.end("Bad Gateway") - }) - - if (body) proxyReq.write(body) - proxyReq.end() - }) - - req.on("error", (err) => { - debug("request stream error", { error: err.message }) - if (!res.headersSent) { - res.writeHead(500) - res.end("Internal Server Error") - } - }) - }) + const proxy = createOpencodeProxy(detectedPort, sessionToken, "cf-proxy") proxy.listen(proxyPort, "127.0.0.1", async () => { logCrosscode(`SSE proxy started on port ${proxyPort}`) diff --git a/packages/crosscode/src/git-handler.ts b/packages/crosscode/src/git-handler.ts index 03c3a3d..cfb7ee8 100644 --- a/packages/crosscode/src/git-handler.ts +++ b/packages/crosscode/src/git-handler.ts @@ -64,7 +64,10 @@ function sendJson(res: http.ServerResponse, status: number, body: unknown) { function timingSafeEqualStr(a: string, b: string): boolean { const bufA = Buffer.from(a) const bufB = Buffer.from(b) - if (bufA.length !== bufB.length) return false + if (bufA.length !== bufB.length) { + crypto.timingSafeEqual(Buffer.alloc(bufA.length), Buffer.alloc(bufA.length)) + return false + } return crypto.timingSafeEqual(bufA, bufB) } diff --git a/packages/crosscode/src/keypress.ts b/packages/crosscode/src/keypress.ts index c89e98f..bcc7a76 100644 --- a/packages/crosscode/src/keypress.ts +++ b/packages/crosscode/src/keypress.ts @@ -9,7 +9,6 @@ export function onKeypress(callback: (key: string) => void) { } process.stdin.on("data", (data: Buffer) => { - console.log(`[keypress] received data: length=${data.length}, bytes=[${Array.from(data).map(b => `0x${b.toString(16).padStart(2, '0')}`).join(', ')}]`) if (data.length === 1 && data[0] === 0x6c) callback("l") else if (data.length === 1 && data[0] === 0x03) callback("ctrl-c") }) diff --git a/packages/crosscode/src/port-detect.ts b/packages/crosscode/src/port-detect.ts index 08ac163..cbd8330 100644 --- a/packages/crosscode/src/port-detect.ts +++ b/packages/crosscode/src/port-detect.ts @@ -1,4 +1,4 @@ -import { ChildProcess, execFileSync } from "child_process" +import { ChildProcess, execFile } from "child_process" import http from "http" const PORT_PATTERNS = [ @@ -20,11 +20,19 @@ function probePort(port: number): Promise { }) } -function getListeningPorts(pid?: number): number[] { +function execAsync(cmd: string, args: string[]): Promise { + return new Promise((resolve) => { + execFile(cmd, args, { encoding: "utf8", timeout: 5000 }, (err, stdout) => { + resolve(err ? "" : stdout) + }) + }) +} + +async function getListeningPorts(pid?: number): Promise { if (!pid) return [] try { if (process.platform === "win32") { - const out = execFileSync("netstat", ["-ano"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }) + const out = await execAsync("netstat", ["-ano"]) const ports: number[] = [] for (const line of out.split("\n")) { const cols = line.trim().split(/\s+/) @@ -35,16 +43,16 @@ function getListeningPorts(pid?: number): number[] { } return [...new Set(ports)] } - const out = execFileSync("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN", "-a", "-p", String(pid)], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }) + const out = await execAsync("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN", "-a", "-p", String(pid)]) const ports: number[] = [] for (const match of out.matchAll(/:(\d+)\s+\(LISTEN\)/g)) { ports.push(parseInt(match[1], 10)) } - return [...new Set(ports)] + if (ports.length > 0) return [...new Set(ports)] } catch {} try { if (process.platform === "linux") { - const out = execFileSync("ss", ["-tlnp"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }) + const out = await execAsync("ss", ["-tlnp"]) const ports: number[] = [] for (const line of out.split("\n")) { if (!line.includes(`pid=${pid},`)) continue @@ -108,8 +116,8 @@ export function waitForOpencodePort(opts: { if (alive) return finish(requestedPort) }, 250) - const timeout = setTimeout(() => { - const ports = getListeningPorts(proc.pid).filter((p) => p > 0 && p <= 65535) + const timeout = setTimeout(async () => { + const ports = (await getListeningPorts(proc.pid)).filter((p) => p > 0 && p <= 65535) if (ports.length > 0) return finish(ports[0]) finish(fromLogs() ?? requestedPort) }, timeoutMs) diff --git a/packages/crosscode/src/tunnel-client.ts b/packages/crosscode/src/tunnel-client.ts index ef590aa..a2ef910 100644 --- a/packages/crosscode/src/tunnel-client.ts +++ b/packages/crosscode/src/tunnel-client.ts @@ -1,6 +1,5 @@ import WebSocket from "ws" import http from "http" -import crypto from "crypto" import type { TunnelC2S, TunnelS2C } from "@crosscode/shared" const TUNNEL_WS_URL = process.env.CROSSCODE_TUNNEL_WS_URL || "wss://connect.crosscode.site/ws" @@ -210,7 +209,3 @@ export function connectTunnel( ws?.close() } } - -export function deriveProjectId(): string { - return crypto.randomBytes(4).toString("hex") -} diff --git a/packages/tunnel-server/src/db.ts b/packages/tunnel-server/src/db.ts index b58857a..e90d97b 100644 --- a/packages/tunnel-server/src/db.ts +++ b/packages/tunnel-server/src/db.ts @@ -5,7 +5,7 @@ import { effectiveTier } from "@crosscode/shared" const sql = postgres(process.env.DATABASE_URL!, { max: 10, idle_timeout: 20, - connect_timeout: 10, + connect_timeout: 30, keep_alive: 60, connection: { statement_timeout: 30000,