diff --git a/packages/code-link-cli/src/controller.ts b/packages/code-link-cli/src/controller.ts index 21f0e5939..b5859875b 100644 --- a/packages/code-link-cli/src/controller.ts +++ b/packages/code-link-cli/src/controller.ts @@ -10,6 +10,7 @@ import { pluralize, shortProjectHash } from "@code-link/shared" import fs from "fs/promises" import path from "path" import type { WebSocket } from "ws" +import { CERT_DIR, getOrCreateCerts } from "./helpers/certs.ts" import { initConnection, sendMessage } from "./helpers/connection.ts" import { autoResolveConflicts, @@ -1063,8 +1064,20 @@ export async function start(config: Config): Promise { } } - // WebSocket Connection - const connection = await initConnection(config.port) + // TLS certificates for WSS — required for browser connection + const certs = await getOrCreateCerts() + if (!certs) { + error("Failed to generate TLS certificates. The Framer plugin requires a secure (wss://) connection.") + info("") + info("To fix this:") + info(" 1. Re-run this command — certificate generation is often a one-time issue") + info(` 2. Manually delete "${String(CERT_DIR)}" and try again`) + info("") + throw new Error("TLS certificate generation failed") + } + + // WebSocket Connection (always WSS) + const connection = await initConnection(config.port, certs) // Handle initial handshake connection.on("handshake", (client: WebSocket, message) => { diff --git a/packages/code-link-cli/src/helpers/certs.test.ts b/packages/code-link-cli/src/helpers/certs.test.ts new file mode 100644 index 000000000..77037eba8 --- /dev/null +++ b/packages/code-link-cli/src/helpers/certs.test.ts @@ -0,0 +1,562 @@ +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +type ExecFileMock = ReturnType + +/** + * Mirror of MKCERT_CHECKSUMS from certs.ts so we can make `createHash` return + * the expected value for the current platform, letting the cached-binary + * verification pass on the dummy file written in beforeEach. + */ +const MKCERT_CHECKSUMS: Record = { + "darwin-amd64": "a32dfab51f1845d51e810db8e47dcf0e6b51ae3422426514bf5a2b8302e97d4e", + "darwin-arm64": "c8af0df44bce04359794dad8ea28d750437411d632748049d08644ffb66a60c6", + "linux-amd64": "6d31c65b03972c6dc4a14ab429f2928300518b26503f58723e532d1b0a3bbb52", + "linux-arm64": "b98f2cc69fd9147fe4d405d859c57504571adec0d3611c3eefd04107c7ac00d0", + "windows-amd64": "d2660b50a9ed59eada480750561c96abc2ed4c9a38c6a24d93e30e0977631398", + "windows-arm64": "793747256c562622d40127c8080df26add2fb44c50906ce9db63b42a5280582e", +} + +const platformMap: Record = { darwin: "darwin", linux: "linux", win32: "windows" } +const archMap: Record = { x64: "amd64", arm64: "arm64" } +const currentPlatformKey = `${platformMap[process.platform]}-${archMap[process.arch]}` +const currentPlatformChecksum = MKCERT_CHECKSUMS[currentPlatformKey] + + +// Shared test helpers +function setupCommonMocks(opts: { + tempHome: string + certDir: string + execFileMock: ExecFileMock + fetchMock: ReturnType +}) { + vi.resetModules() + vi.unstubAllEnvs() + vi.stubEnv("HOME", opts.tempHome) + vi.stubEnv("USERPROFILE", opts.tempHome) + vi.stubEnv("FRAMER_CODE_LINK_CERT_DIR", opts.certDir) + vi.stubGlobal("fetch", opts.fetchMock) + vi.doMock("child_process", () => ({ execFile: opts.execFileMock })) + vi.doMock("util", () => ({ + promisify: + (fn: (...args: unknown[]) => void) => + (...args: unknown[]) => + new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { + fn(...args, (error: Error | null, stdout = "", stderr = "") => { + if (error) { + reject(error) + return + } + resolve({ stdout, stderr }) + }) + }), + })) +} + +function mockCryptoSequence(checksums: string[]) { + let callIndex = 0 + vi.doMock("crypto", async () => { + const actual = await vi.importActual("crypto") + return { + ...actual, + createHash: () => ({ + update: () => ({ + digest: () => checksums[callIndex++] ?? checksums.at(-1), + }), + }), + } + }) +} + +function mockCryptoConstant(hash: string) { + mockCryptoSequence([hash]) +} + +function teardownMocks() { + vi.doUnmock("child_process") + vi.doUnmock("util") + vi.doUnmock("crypto") + vi.unstubAllEnvs() + vi.unstubAllGlobals() + vi.resetModules() +} + +function fakeFetchResponse(opts?: { ok?: boolean; status?: number; statusText?: string }) { + return { + ok: opts?.ok ?? true, + status: opts?.status ?? 200, + statusText: opts?.statusText ?? "OK", + arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), + } +} + + +// Integration tests — cached binary, root CA syncing, cert generation +describe("getOrCreateCerts", () => { + let tempHome: string + let certDir: string + let execFileMock: ExecFileMock + + beforeEach(async () => { + tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "code-link-certs-")) + certDir = path.join(tempHome, ".framer", "code-link") + execFileMock = vi.fn() + + await fs.mkdir(certDir, { recursive: true }) + await fs.writeFile(path.join(certDir, "mkcert"), "", { mode: 0o755 }) + + setupCommonMocks({ tempHome, certDir, execFileMock, fetchMock: vi.fn() }) + mockCryptoConstant(currentPlatformChecksum) + }) + + afterEach(async () => { + teardownMocks() + await fs.rm(tempHome, { recursive: true, force: true }) + }) + + it("reuses the cached localhost certificate when the cached root CA is intact", async () => { + await fs.writeFile(path.join(certDir, "rootCA.pem"), "root-cert") + await fs.writeFile(path.join(certDir, "rootCA-key.pem"), "root-key") + await fs.writeFile(path.join(certDir, "localhost-key.pem"), "server-key") + await fs.writeFile(path.join(certDir, "localhost.pem"), "server-cert") + + mockMkcert(execFileMock, { defaultCAROOT: certDir }) + + const { getOrCreateCerts } = await import("./certs.ts") + const certs = await getOrCreateCerts() + + expect(certs).toEqual({ key: "server-key", cert: "server-cert" }) + expect(execFileMock).toHaveBeenCalledTimes(1) + expect(execFileMock).toHaveBeenCalledWith( + path.join(certDir, "mkcert"), + ["-CAROOT"], + expect.any(Object), + expect.any(Function) + ) + }) + + it("regenerates the localhost certificate when the cached root CA is missing", async () => { + const defaultCAROOT = path.join(tempHome, "default-caroot") + await fs.mkdir(defaultCAROOT, { recursive: true }) + await fs.writeFile(path.join(defaultCAROOT, "rootCA.pem"), "default-root-cert") + await fs.writeFile(path.join(defaultCAROOT, "rootCA-key.pem"), "default-root-key") + await fs.writeFile(path.join(certDir, "localhost-key.pem"), "stale-key") + await fs.writeFile(path.join(certDir, "localhost.pem"), "stale-cert") + + mockMkcert(execFileMock, { + defaultCAROOT, + generatedKey: "new-server-key", + generatedCert: "new-server-cert", + }) + + const { getOrCreateCerts } = await import("./certs.ts") + const certs = await getOrCreateCerts() + + expect(certs).toEqual({ key: "new-server-key", cert: "new-server-cert" }) + await expect(fs.readFile(path.join(certDir, "rootCA.pem"), "utf-8")).resolves.toBe("generated-root-cert") + await expect(fs.readFile(path.join(certDir, "rootCA-key.pem"), "utf-8")).resolves.toBe("generated-root-key") + }) + + it("regenerates the localhost certificate when the cached root CA differs from mkcert's default CA", async () => { + const defaultCAROOT = path.join(tempHome, "default-caroot") + await fs.mkdir(defaultCAROOT, { recursive: true }) + await fs.writeFile(path.join(defaultCAROOT, "rootCA.pem"), "fresh-root-cert") + await fs.writeFile(path.join(defaultCAROOT, "rootCA-key.pem"), "fresh-root-key") + await fs.writeFile(path.join(certDir, "rootCA.pem"), "stale-root-cert") + await fs.writeFile(path.join(certDir, "rootCA-key.pem"), "stale-root-key") + await fs.writeFile(path.join(certDir, "localhost-key.pem"), "stale-key") + await fs.writeFile(path.join(certDir, "localhost.pem"), "stale-cert") + + mockMkcert(execFileMock, { + defaultCAROOT, + generatedKey: "rotated-server-key", + generatedCert: "rotated-server-cert", + }) + + const { getOrCreateCerts } = await import("./certs.ts") + const certs = await getOrCreateCerts() + + expect(certs).toEqual({ key: "rotated-server-key", cert: "rotated-server-cert" }) + await expect(fs.readFile(path.join(certDir, "rootCA.pem"), "utf-8")).resolves.toBe("generated-root-cert") + await expect(fs.readFile(path.join(certDir, "rootCA-key.pem"), "utf-8")).resolves.toBe("generated-root-key") + }) + + it("returns null when mkcert -install fails", async () => { + const defaultCAROOT = path.join(tempHome, "default-caroot") + await fs.mkdir(defaultCAROOT, { recursive: true }) + await fs.writeFile(path.join(defaultCAROOT, "rootCA.pem"), "default-root-cert") + await fs.writeFile(path.join(defaultCAROOT, "rootCA-key.pem"), "default-root-key") + + mockMkcert(execFileMock, { + defaultCAROOT, + installError: "trust store install failed", + }) + + const { getOrCreateCerts } = await import("./certs.ts") + const certs = await getOrCreateCerts() + + expect(certs).toBeNull() + // -CAROOT + -install (which threw) + expect(execFileMock).toHaveBeenCalledTimes(2) + }) + + it("returns null when mkcert -key-file fails without writing any files", async () => { + const defaultCAROOT = path.join(tempHome, "default-caroot") + await fs.mkdir(defaultCAROOT, { recursive: true }) + await fs.writeFile(path.join(defaultCAROOT, "rootCA.pem"), "default-root-cert") + await fs.writeFile(path.join(defaultCAROOT, "rootCA-key.pem"), "default-root-key") + + mockMkcert(execFileMock, { + defaultCAROOT, + generateError: "mkcert exited before writing certs", + skipServerBundleWrite: true, + }) + + const { getOrCreateCerts } = await import("./certs.ts") + const certs = await getOrCreateCerts() + + expect(certs).toBeNull() + // -CAROOT + -install + -key-file (which threw) + expect(execFileMock).toHaveBeenCalledTimes(3) + await expect(fs.readFile(path.join(certDir, "localhost-key.pem"), "utf-8")).rejects.toThrow() + await expect(fs.readFile(path.join(certDir, "localhost.pem"), "utf-8")).rejects.toThrow() + }) + + it("returns null and cleans up when only the server key is generated before failure", async () => { + const defaultCAROOT = path.join(tempHome, "default-caroot") + await fs.mkdir(defaultCAROOT, { recursive: true }) + await fs.writeFile(path.join(defaultCAROOT, "rootCA.pem"), "default-root-cert") + await fs.writeFile(path.join(defaultCAROOT, "rootCA-key.pem"), "default-root-key") + + mockMkcert(execFileMock, { + defaultCAROOT, + generatedKey: "partial-key", + generateError: "mkcert exited while writing certs", + skipCertWrite: true, + }) + + const { getOrCreateCerts } = await import("./certs.ts") + const certs = await getOrCreateCerts() + + expect(certs).toBeNull() + expect(execFileMock).toHaveBeenCalledTimes(3) + // Partial files should be cleaned up. + await expect(fs.readFile(path.join(certDir, "localhost-key.pem"), "utf-8")).rejects.toThrow() + await expect(fs.readFile(path.join(certDir, "localhost.pem"), "utf-8")).rejects.toThrow() + }) +}) + + +// Download URL selection +describe("download URL selection", () => { + let tempHome: string + let certDir: string + let execFileMock: ExecFileMock + let fetchMock: ReturnType + + beforeEach(async () => { + tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "code-link-certs-")) + certDir = path.join(tempHome, ".framer", "code-link") + execFileMock = vi.fn() + fetchMock = vi.fn() + + await fs.mkdir(certDir, { recursive: true }) + // No mkcert binary — forces download path. + + setupCommonMocks({ tempHome, certDir, execFileMock, fetchMock }) + mockCryptoConstant(currentPlatformChecksum) + }) + + afterEach(async () => { + teardownMocks() + await fs.rm(tempHome, { recursive: true, force: true }) + }) + + it("fetches from the correct GitHub release URL for the current platform", async () => { + const ext = process.platform === "win32" ? ".exe" : "" + const expectedFilename = `mkcert-v1.4.4-${platformMap[process.platform]}-${archMap[process.arch]}${ext}` + const expectedUrl = `https://github.com/FiloSottile/mkcert/releases/download/v1.4.4/${expectedFilename}` + + fetchMock.mockResolvedValue(fakeFetchResponse()) + mockMkcert(execFileMock, { + defaultCAROOT: certDir, + generatedKey: "key", + generatedCert: "cert", + }) + + const { getOrCreateCerts } = await import("./certs.ts") + await getOrCreateCerts() + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith(expectedUrl, { redirect: "follow" }) + }) + + it("includes .exe extension on win32 and omits it elsewhere", () => { + const ext = process.platform === "win32" ? ".exe" : "" + const filename = `mkcert-v1.4.4-${platformMap[process.platform]}-${archMap[process.arch]}${ext}` + if (process.platform === "win32") { + expect(filename).toMatch(/\.exe$/) + } else { + expect(filename).not.toMatch(/\.exe$/) + } + }) +}) + + +// Binary cache fast-path and SHA-256 verification +describe("binary cache and SHA-256 verification", () => { + let tempHome: string + let certDir: string + let execFileMock: ExecFileMock + let fetchMock: ReturnType + + beforeEach(async () => { + tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "code-link-certs-")) + certDir = path.join(tempHome, ".framer", "code-link") + execFileMock = vi.fn() + fetchMock = vi.fn() + + await fs.mkdir(certDir, { recursive: true }) + + setupCommonMocks({ tempHome, certDir, execFileMock, fetchMock }) + }) + + afterEach(async () => { + teardownMocks() + await fs.rm(tempHome, { recursive: true, force: true }) + }) + + it("skips download when the cached binary passes checksum verification", async () => { + await fs.writeFile(path.join(certDir, "mkcert"), "cached-binary", { mode: 0o755 }) + await fs.writeFile(path.join(certDir, "rootCA.pem"), "root-cert") + await fs.writeFile(path.join(certDir, "rootCA-key.pem"), "root-key") + await fs.writeFile(path.join(certDir, "localhost-key.pem"), "server-key") + await fs.writeFile(path.join(certDir, "localhost.pem"), "server-cert") + + mockCryptoConstant(currentPlatformChecksum) + mockMkcert(execFileMock, { defaultCAROOT: certDir }) + + const { getOrCreateCerts } = await import("./certs.ts") + const certs = await getOrCreateCerts() + + expect(certs).toEqual({ key: "server-key", cert: "server-cert" }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it("re-downloads when the cached binary fails checksum verification", async () => { + await fs.writeFile(path.join(certDir, "mkcert"), "tampered-binary", { mode: 0o755 }) + await fs.writeFile(path.join(certDir, "rootCA.pem"), "root-cert") + await fs.writeFile(path.join(certDir, "rootCA-key.pem"), "root-key") + await fs.writeFile(path.join(certDir, "localhost-key.pem"), "server-key") + await fs.writeFile(path.join(certDir, "localhost.pem"), "server-cert") + + // First digest: cached binary verification → wrong. Second: download verification → correct. + mockCryptoSequence(["bad-checksum-for-cached-binary", currentPlatformChecksum]) + fetchMock.mockResolvedValue(fakeFetchResponse()) + mockMkcert(execFileMock, { defaultCAROOT: certDir }) + + const { getOrCreateCerts } = await import("./certs.ts") + const certs = await getOrCreateCerts() + + expect(certs).toEqual({ key: "server-key", cert: "server-cert" }) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it("downloads fresh when no binary exists on disk", async () => { + mockCryptoConstant(currentPlatformChecksum) + fetchMock.mockResolvedValue(fakeFetchResponse()) + mockMkcert(execFileMock, { + defaultCAROOT: certDir, + generatedKey: "key", + generatedCert: "cert", + }) + + const { getOrCreateCerts } = await import("./certs.ts") + const certs = await getOrCreateCerts() + + expect(certs).toEqual({ key: "key", cert: "cert" }) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it("rejects a download whose SHA-256 checksum does not match", async () => { + // Every digest call returns a bad checksum. + mockCryptoConstant("bad-checksum-always") + fetchMock.mockResolvedValue(fakeFetchResponse()) + + const { getOrCreateCerts } = await import("./certs.ts") + const certs = await getOrCreateCerts() + + expect(certs).toBeNull() + // Binary should be cleaned up after failed verification. + await expect(fs.access(path.join(certDir, "mkcert"))).rejects.toThrow() + }) + + it("writes the binary to disk when the download checksum matches", async () => { + mockCryptoConstant(currentPlatformChecksum) + fetchMock.mockResolvedValue(fakeFetchResponse()) + mockMkcert(execFileMock, { + defaultCAROOT: certDir, + generatedKey: "key", + generatedCert: "cert", + }) + + const { getOrCreateCerts } = await import("./certs.ts") + await getOrCreateCerts() + + await expect(fs.access(path.join(certDir, "mkcert"))).resolves.toBeUndefined() + }) +}) + + +// Error handling +describe("error handling", () => { + let tempHome: string + let certDir: string + let execFileMock: ExecFileMock + let fetchMock: ReturnType + + beforeEach(async () => { + tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "code-link-certs-")) + certDir = path.join(tempHome, ".framer", "code-link") + execFileMock = vi.fn() + fetchMock = vi.fn() + + await fs.mkdir(certDir, { recursive: true }) + + setupCommonMocks({ tempHome, certDir, execFileMock, fetchMock }) + mockCryptoConstant(currentPlatformChecksum) + }) + + afterEach(async () => { + teardownMocks() + await fs.rm(tempHome, { recursive: true, force: true }) + }) + + it("returns null when fetch returns a non-OK response", async () => { + // No binary on disk — triggers download. + fetchMock.mockResolvedValue(fakeFetchResponse({ ok: false, status: 404, statusText: "Not Found" })) + + const { getOrCreateCerts } = await import("./certs.ts") + const certs = await getOrCreateCerts() + + expect(certs).toBeNull() + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it("returns null and cleans up when fetch throws a network error", async () => { + fetchMock.mockRejectedValue(new Error("network timeout")) + + const { getOrCreateCerts } = await import("./certs.ts") + const certs = await getOrCreateCerts() + + expect(certs).toBeNull() + await expect(fs.access(path.join(certDir, "mkcert"))).rejects.toThrow() + }) + + it("cleans up an incomplete server bundle when only the key exists before generation", async () => { + await fs.writeFile(path.join(certDir, "mkcert"), "", { mode: 0o755 }) + await fs.writeFile(path.join(certDir, "rootCA.pem"), "root-cert") + await fs.writeFile(path.join(certDir, "rootCA-key.pem"), "root-key") + await fs.writeFile(path.join(certDir, "localhost-key.pem"), "orphaned-key") + // No localhost.pem — incomplete bundle triggers regeneration. + + mockMkcert(execFileMock, { + defaultCAROOT: certDir, + generatedKey: "fresh-key", + generatedCert: "fresh-cert", + }) + + const { getOrCreateCerts } = await import("./certs.ts") + const certs = await getOrCreateCerts() + + expect(certs).toEqual({ key: "fresh-key", cert: "fresh-cert" }) + }) + + it("cleans up an incomplete server bundle when only the cert exists before generation", async () => { + await fs.writeFile(path.join(certDir, "mkcert"), "", { mode: 0o755 }) + await fs.writeFile(path.join(certDir, "rootCA.pem"), "root-cert") + await fs.writeFile(path.join(certDir, "rootCA-key.pem"), "root-key") + await fs.writeFile(path.join(certDir, "localhost.pem"), "orphaned-cert") + // No localhost-key.pem — incomplete bundle triggers regeneration. + + mockMkcert(execFileMock, { + defaultCAROOT: certDir, + generatedKey: "fresh-key", + generatedCert: "fresh-cert", + }) + + const { getOrCreateCerts } = await import("./certs.ts") + const certs = await getOrCreateCerts() + + expect(certs).toEqual({ key: "fresh-key", cert: "fresh-cert" }) + }) +}) + +/** + * Mocks mkcert's execFile behavior. Handles three commands: + * - `-CAROOT` → returns defaultCAROOT + * - `-install` → writes rootCA files (may error via installError) + * - `-key-file ... -cert-file ...` → writes server cert/key (may error via generateError) + */ +function mockMkcert( + execFileMock: ExecFileMock, + options: { + defaultCAROOT: string + generatedKey?: string + generatedCert?: string + installError?: string + generateError?: string + skipServerBundleWrite?: boolean + skipCertWrite?: boolean + } +) { + execFileMock.mockImplementation((...args: unknown[]) => { + const callback = args.at(-1) as (error: Error | null, stdout?: string, stderr?: string) => void + const commandArgs = args[1] as string[] + const commandOptions = args[2] as { env?: NodeJS.ProcessEnv } | undefined + + void (async () => { + if (commandArgs[0] === "-CAROOT") { + callback(null, `${options.defaultCAROOT}\n`, "") + return + } + + if (commandArgs[0] === "-install") { + if (commandOptions?.env?.CAROOT) { + await fs.mkdir(commandOptions.env.CAROOT, { recursive: true }) + await fs.writeFile(path.join(commandOptions.env.CAROOT, "rootCA.pem"), "generated-root-cert") + await fs.writeFile(path.join(commandOptions.env.CAROOT, "rootCA-key.pem"), "generated-root-key") + } + + callback(options.installError ? new Error(options.installError) : null, "", "") + return + } + + if (commandArgs[0] === "-key-file") { + const keyPath = commandArgs[1] + const certPath = commandArgs[3] + + if (!keyPath || !certPath || commandArgs[2] !== "-cert-file") { + callback(new Error("Missing key/cert output path")) + return + } + + if (!options.skipServerBundleWrite) { + await fs.writeFile(keyPath, options.generatedKey ?? "generated-key") + if (!options.skipCertWrite) { + await fs.writeFile(certPath, options.generatedCert ?? "generated-cert") + } + } + + callback(options.generateError ? new Error(options.generateError) : null, "", "") + return + } + + callback(new Error(`Unexpected mkcert invocation: ${commandArgs.join(" ")}`)) + })().catch((error: unknown) => { + callback(error instanceof Error ? error : new Error(String(error))) + }) + }) +} diff --git a/packages/code-link-cli/src/helpers/certs.ts b/packages/code-link-cli/src/helpers/certs.ts new file mode 100644 index 000000000..18c1d5b04 --- /dev/null +++ b/packages/code-link-cli/src/helpers/certs.ts @@ -0,0 +1,318 @@ +/** + * Certificate management for WSS support. + * + * Downloads FiloSottile's mkcert binary on first run, then shells out to it + * to generate and trust a local CA + server certificate for wss://localhost. + * + * The mkcert binary is SHA-256 verified before execution (update + * MKCERT_CHECKSUMS when bumping MKCERT_VERSION). The CA key is user-only; + * never share or commit the cert directory. + * + * Certs and the mkcert binary are cached in ~/.framer/code-link/. + */ + +import { createHash } from "crypto" +import { execFile } from "child_process" +import nodeFs from "fs" +import fs from "fs/promises" +import os from "os" +import path from "path" +import { promisify } from "util" +import { debug, error, status, warn } from "../utils/logging.ts" + +const execFileAsync = promisify(execFile) + +export interface CertBundle { + key: string + cert: string +} + +type RootCAState = "unchanged" | "copied" | "updated" | "missing" + +/** Keep in sync with MKCERT_CHECKSUMS below. */ +const MKCERT_VERSION = "v1.4.4" +export const CERT_DIR = process.env.FRAMER_CODE_LINK_CERT_DIR ?? path.join(os.homedir(), ".framer", "code-link") +const MKCERT_BIN_NAME = process.platform === "win32" ? "mkcert.exe" : "mkcert" +const MKCERT_BIN_PATH = path.join(CERT_DIR, MKCERT_BIN_NAME) +const ROOT_CA_CERT_PATH = path.join(CERT_DIR, "rootCA.pem") +const ROOT_CA_KEY_PATH = path.join(CERT_DIR, "rootCA-key.pem") +const SERVER_KEY_PATH = path.join(CERT_DIR, "localhost-key.pem") +const SERVER_CERT_PATH = path.join(CERT_DIR, "localhost.pem") + +/** + * SHA-256 checksums for mkcert v1.4.4 release binaries, keyed by "platform-arch". + * These must be updated whenever MKCERT_VERSION changes. + * Source: https://github.com/FiloSottile/mkcert/releases/tag/v1.4.4 + */ +const MKCERT_CHECKSUMS: Record = { + "darwin-amd64": "a32dfab51f1845d51e810db8e47dcf0e6b51ae3422426514bf5a2b8302e97d4e", + "darwin-arm64": "c8af0df44bce04359794dad8ea28d750437411d632748049d08644ffb66a60c6", + "linux-amd64": "6d31c65b03972c6dc4a14ab429f2928300518b26503f58723e532d1b0a3bbb52", + "linux-arm64": "b98f2cc69fd9147fe4d405d859c57504571adec0d3611c3eefd04107c7ac00d0", + "windows-amd64": "d2660b50a9ed59eada480750561c96abc2ed4c9a38c6a24d93e30e0977631398", + "windows-arm64": "793747256c562622d40127c8080df26add2fb44c50906ce9db63b42a5280582e", +} + +/** Env vars passed to every mkcert invocation. */ +const MKCERT_ENV = { + ...process.env, + CAROOT: CERT_DIR, + JAVA_HOME: "", + ...(process.platform === "darwin" ? { TRUST_STORES: "system" } : {}), +} + +/** + * Returns a TLS cert bundle for the WSS server, or null if generation fails. + * On first run, downloads mkcert, installs a local CA into trust stores, and + * generates a server cert for localhost. + */ +export async function getOrCreateCerts(): Promise { + try { + await fs.mkdir(CERT_DIR, { recursive: true }) + + const mkcertPath = await ensureMkcertBinary() + const rootCAState = await syncRootCA(mkcertPath) + + if (rootCAState !== "unchanged") { + await invalidateServerCerts(rootCAState) + } + + const existingKey = await loadFile(SERVER_KEY_PATH) + const existingCert = await loadFile(SERVER_CERT_PATH) + + if (existingKey && existingCert) { + debug("Loaded existing server certificates from disk") + return { key: existingKey, cert: existingCert } + } + + if (existingKey || existingCert) { + await invalidateIncompleteServerBundle() + } + + status("Generating local certificates to connect securely. You may be asked for your password.") + await generateCerts(mkcertPath) + + const key = await fs.readFile(SERVER_KEY_PATH, "utf-8") + const cert = await fs.readFile(SERVER_CERT_PATH, "utf-8") + return { key, cert } + } catch (err) { + error(`Failed to set up TLS certificates: ${err instanceof Error ? err.message : String(err)}`) + return null + } +} + +function getDownloadInfo(): { url: string; expectedChecksum: string } { + const platformMap: Record = { + darwin: "darwin", + linux: "linux", + win32: "windows", + } + const archMap: Record = { + x64: "amd64", + arm64: "arm64", + } + + const platform = platformMap[process.platform] + const arch = archMap[process.arch] + + if (!platform || !arch) { + throw new Error( + `Unsupported platform: ${process.platform}/${process.arch}. ` + + `Install mkcert manually: https://github.com/FiloSottile/mkcert#installation` + ) + } + + const key = `${platform}-${arch}` + const expectedChecksum = MKCERT_CHECKSUMS[key] + if (!expectedChecksum) { + throw new Error( + `No checksum available for mkcert ${key}. ` + + `Install mkcert manually: https://github.com/FiloSottile/mkcert#installation` + ) + } + + const ext = process.platform === "win32" ? ".exe" : "" + const filename = `mkcert-${MKCERT_VERSION}-${platform}-${arch}${ext}` + const url = `https://github.com/FiloSottile/mkcert/releases/download/${MKCERT_VERSION}/${filename}` + return { url, expectedChecksum } +} + +async function ensureMkcertBinary(): Promise { + const { url, expectedChecksum } = getDownloadInfo() + + // Fast path: verify any existing cached binary before reusing it. + try { + await fs.access(MKCERT_BIN_PATH, nodeFs.constants.X_OK) + if (await verifyFileChecksum(MKCERT_BIN_PATH, expectedChecksum)) { + debug("mkcert binary already available and verified") + return MKCERT_BIN_PATH + } + warn("Cached mkcert binary failed checksum verification, re-downloading...") + } catch { + // Binary doesn't exist or isn't executable — fall through to download. + } + + debug(`Downloading mkcert from ${url}`) + status("Downloading mkcert for certificate generation...") + + try { + const response = await fetch(url, { redirect: "follow" }) + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const buffer = Buffer.from(await response.arrayBuffer()) + + // Verify integrity before writing to disk. + const actualChecksum = createHash("sha256").update(buffer).digest("hex") + if (actualChecksum !== expectedChecksum) { + throw new Error( + `mkcert binary checksum mismatch — the download may have been tampered with.\n` + + ` Expected: ${expectedChecksum}\n` + + ` Actual: ${actualChecksum}` + ) + } + + await fs.writeFile(MKCERT_BIN_PATH, buffer, { mode: 0o755 }) + debug(`mkcert binary saved to ${MKCERT_BIN_PATH}`) + return MKCERT_BIN_PATH + } catch (err) { + await fs.rm(MKCERT_BIN_PATH, { force: true }) + const message = err instanceof Error ? err.message : String(err) + throw new Error( + `Failed to download mkcert: ${message}\n` + + `You can install it manually: https://github.com/FiloSottile/mkcert#installation\n` + + `Then run: mkcert -install && mkcert -key-file "${SERVER_KEY_PATH}" -cert-file "${SERVER_CERT_PATH}" localhost 127.0.0.1` + ) + } +} + +async function generateCerts(mkcertPath: string): Promise { + debug("Running mkcert to install the local root CA...") + try { + await execFileAsync(mkcertPath, ["-install"], { env: MKCERT_ENV }) + } catch (err) { + throw new Error( + "Failed to install mkcert root CA into the system trust store. " + + "If you canceled the password prompt, rerun this command and allow the install.\n" + + `mkcert error: ${formatMkcertError(err)}` + ) + } + + debug("Running mkcert to generate the localhost server certificate...") + try { + await execFileAsync( + mkcertPath, + ["-key-file", SERVER_KEY_PATH, "-cert-file", SERVER_CERT_PATH, "localhost", "127.0.0.1"], + { env: MKCERT_ENV } + ) + } catch (err) { + if ((await loadFile(SERVER_KEY_PATH)) || (await loadFile(SERVER_CERT_PATH))) { + await invalidateIncompleteServerBundle() + } + + throw new Error( + "Failed to generate localhost TLS certificate and key with mkcert.\n" + + `mkcert error: ${formatMkcertError(err)}\n` + + `Please rerun:\n mkcert -key-file "${SERVER_KEY_PATH}" -cert-file "${SERVER_CERT_PATH}" localhost 127.0.0.1` + ) + } + + const [generatedKey, generatedCert] = await Promise.all([loadFile(SERVER_KEY_PATH), loadFile(SERVER_CERT_PATH)]) + + if (generatedKey && generatedCert) { + debug("CA installed and server certificate generated successfully") + return + } + + if (generatedKey || generatedCert) { + await invalidateIncompleteServerBundle() + } + + throw new Error( + "Failed to generate localhost TLS certificate and key with mkcert. " + + "Please ensure mkcert is installed and rerun:\n" + + ` mkcert -install && mkcert -key-file "${SERVER_KEY_PATH}" -cert-file "${SERVER_CERT_PATH}" localhost 127.0.0.1` + ) +} + +async function syncRootCA(mkcertPath: string): Promise { + const existingRootCert = await loadFile(ROOT_CA_CERT_PATH) + const existingRootKey = await loadFile(ROOT_CA_KEY_PATH) + + const { stdout } = await execFileAsync(mkcertPath, ["-CAROOT"], { + env: { ...process.env, JAVA_HOME: "" }, + }) + const defaultCAROOT = stdout.trim() + + if (!defaultCAROOT || defaultCAROOT === CERT_DIR) { + return existingRootCert && existingRootKey ? "unchanged" : "missing" + } + + const defaultRootCert = await loadFile(path.join(defaultCAROOT, "rootCA.pem")) + const defaultRootKey = await loadFile(path.join(defaultCAROOT, "rootCA-key.pem")) + + if (!defaultRootCert || !defaultRootKey) { + return existingRootCert && existingRootKey ? "unchanged" : "missing" + } + + if (existingRootCert === defaultRootCert && existingRootKey === defaultRootKey) { + return "unchanged" + } + + // mkcert marks rootCA-key.pem read-only (0o400); remove before overwriting. + await Promise.all([ + fs.rm(ROOT_CA_CERT_PATH, { force: true }), + fs.rm(ROOT_CA_KEY_PATH, { force: true }), + ]) + await fs.writeFile(ROOT_CA_CERT_PATH, defaultRootCert, { mode: 0o644 }) + await fs.writeFile(ROOT_CA_KEY_PATH, defaultRootKey, { mode: 0o600 }) + + return existingRootCert && existingRootKey ? "updated" : "copied" +} + +async function invalidateServerCerts(rootCAState: Exclude): Promise { + const reasons: Record, string> = { + copied: "Copied an existing mkcert root CA into the Code Link cache", + updated: "Detected a different mkcert root CA and refreshed the Code Link cache", + missing: "No cached mkcert root CA was available for the existing server certificate", + } + + const hadServerBundle = (await loadFile(SERVER_KEY_PATH)) !== null || (await loadFile(SERVER_CERT_PATH)) !== null + if (!hadServerBundle) return + + await fs.rm(SERVER_KEY_PATH, { force: true }) + await fs.rm(SERVER_CERT_PATH, { force: true }) + debug(`${reasons[rootCAState]}; removed stale localhost certificate`) +} + +async function invalidateIncompleteServerBundle(): Promise { + await fs.rm(SERVER_KEY_PATH, { force: true }) + await fs.rm(SERVER_CERT_PATH, { force: true }) + warn("Found an incomplete localhost certificate bundle; regenerating it") +} + +async function verifyFileChecksum(filePath: string, expectedHash: string): Promise { + const data = await fs.readFile(filePath) + const actualHash = createHash("sha256").update(data).digest("hex") + return actualHash === expectedHash +} + +async function loadFile(filePath: string): Promise { + try { + return await fs.readFile(filePath, "utf-8") + } catch { + return null + } +} + +function formatMkcertError(err: unknown): string { + if (err instanceof Error) { + const stdout = "stdout" in err && typeof err.stdout === "string" ? err.stdout.trim() : "" + const stderr = "stderr" in err && typeof err.stderr === "string" ? err.stderr.trim() : "" + const output = [stderr, stdout].filter(Boolean).join("\n") + return output ? `${err.message}\n${output}` : err.message + } + + return String(err) +} diff --git a/packages/code-link-cli/src/helpers/connection.test.ts b/packages/code-link-cli/src/helpers/connection.test.ts new file mode 100644 index 000000000..533e3d189 --- /dev/null +++ b/packages/code-link-cli/src/helpers/connection.test.ts @@ -0,0 +1,144 @@ +import { execSync } from "node:child_process" +import https from "node:https" +import net from "node:net" +import { describe, expect, it, vi } from "vitest" +import { WebSocket, WebSocketServer } from "ws" +import { initConnection, sendMessage } from "./connection.ts" + +function generateSelfSignedCert(): { key: string; cert: string } { + const key = execSync("openssl genrsa 2048 2>/dev/null").toString() + const cert = execSync( + `openssl req -new -x509 -key /dev/stdin -days 365 -subj "/CN=localhost" -nodes 2>/dev/null`, + { input: key } + ).toString() + return { key, cert } +} + +const { key: TEST_KEY, cert: TEST_CERT } = generateSelfSignedCert() + +describe("initConnection", () => { + it("accepts secure websocket clients and routes handshake traffic", async () => { + const port = await getFreePort() + const connection = await initConnection(port, { key: TEST_KEY, cert: TEST_CERT }) + const client = new WebSocket(`wss://localhost:${port}`, { + rejectUnauthorized: false, + }) + let serverSocket: WebSocket | null = null + + const handshakeReceived = new Promise<{ projectId: string; projectName: string }>(resolve => { + connection.on("handshake", (socket, message) => { + serverSocket = socket + resolve(message) + }) + }) + + const messageReceived = new Promise<{ type: string }>(resolve => { + connection.on("message", message => resolve(message)) + }) + + const outboundReceived = new Promise<{ type: string }>((resolve, reject) => { + client.on("message", data => { + try { + resolve(JSON.parse(data.toString()) as { type: string }) + } catch (error) { + reject(error) + } + }) + }) + + await new Promise((resolve, reject) => { + client.once("open", () => resolve()) + client.once("error", reject) + }) + + client.send(JSON.stringify({ type: "handshake", projectId: "project-id", projectName: "Project Name" })) + client.send(JSON.stringify({ type: "request-files" })) + + await expect(handshakeReceived).resolves.toMatchObject({ + projectId: "project-id", + projectName: "Project Name", + }) + await expect(messageReceived).resolves.toMatchObject({ type: "request-files" }) + expect(serverSocket).not.toBeNull() + if (!serverSocket) throw new Error("Expected server socket after handshake") + await expect(sendMessage(serverSocket, { type: "request-files" })).resolves.toBe(true) + await expect(outboundReceived).resolves.toMatchObject({ type: "request-files" }) + + client.close() + connection.close() + }) +}) + +describe("initConnection error handling", () => { + it("forwards WebSocketServer error events to the connection error handler without throwing", async () => { + const port = await getFreePort() + const onSpy = vi.spyOn(WebSocketServer.prototype, "on") + const connection = await initConnection(port, { key: TEST_KEY, cert: TEST_CERT }) + const onError = vi.fn() + connection.on("error", onError) + + const errorHandler = onSpy.mock.calls.find(([event]) => event === "error")?.[1] as + | ((error: Error) => void) + | undefined + expect(errorHandler).toBeTypeOf("function") + + const boom = new Error("wss exploded") + expect(() => { + if (typeof errorHandler !== "function") throw new Error("Expected WebSocketServer error handler") + errorHandler(boom) + }).not.toThrow() + + expect(onError).toHaveBeenCalledOnce() + expect(onError).toHaveBeenCalledWith(boom) + + connection.close() + onSpy.mockRestore() + }) + + it("forwards HTTPS server runtime error events to the connection error handler without throwing", async () => { + const port = await getFreePort() + const onSpy = vi.spyOn(https.Server.prototype, "on") + const connection = await initConnection(port, { key: TEST_KEY, cert: TEST_CERT }) + const onError = vi.fn() + connection.on("error", onError) + + const errorHandler = (onSpy.mock.calls as Array<[string, unknown]>).find(([event]) => event === "error")?.[1] as + | ((error: NodeJS.ErrnoException) => void) + | undefined + expect(errorHandler).toBeTypeOf("function") + + const boom = Object.assign(new Error("https exploded"), { code: "ECONNRESET" }) as NodeJS.ErrnoException + expect(() => { + if (typeof errorHandler !== "function") throw new Error("Expected HTTPS server error handler") + errorHandler(boom) + }).not.toThrow() + + expect(onError).toHaveBeenCalledOnce() + expect(onError).toHaveBeenCalledWith(boom) + + connection.close() + onSpy.mockRestore() + }) +}) + +async function getFreePort(): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer() + server.listen(0, () => { + const address = server.address() + if (!address || typeof address === "string") { + reject(new Error("Failed to allocate a port")) + return + } + + server.close(error => { + if (error) { + reject(error) + return + } + resolve(address.port) + }) + }) + server.on("error", reject) + }) +} diff --git a/packages/code-link-cli/src/helpers/connection.ts b/packages/code-link-cli/src/helpers/connection.ts index 230060c0e..fe63730d9 100644 --- a/packages/code-link-cli/src/helpers/connection.ts +++ b/packages/code-link-cli/src/helpers/connection.ts @@ -5,8 +5,10 @@ */ import type { CliToPluginMessage, PluginToCliMessage } from "@code-link/shared" +import https from "node:https" import { WebSocket, WebSocketServer } from "ws" -import { debug, error } from "../utils/logging.ts" +import type { CertBundle } from "./certs.ts" +import { debug, error, info } from "../utils/logging.ts" export interface ConnectionCallbacks { onHandshake: (client: WebSocket, message: { projectId: string; projectName: string }) => void @@ -24,28 +26,31 @@ export interface Connection { } /** - * Initializes a WebSocket server and returns a connection interface - * Returns a Promise that resolves when the server is ready, or rejects on startup errors + * Initializes a WSS (TLS) WebSocket server and returns a connection interface. + * Returns a Promise that resolves when the server is ready, or rejects on startup errors. */ -export function initConnection(port: number): Promise { +export function initConnection(port: number, certs: CertBundle): Promise { return new Promise((resolve, reject) => { - const wss = new WebSocketServer({ port }) const handlers: Partial = {} let connectionId = 0 let isReady = false - // Handle server-level errors (e.g., EADDRINUSE) - wss.on("error", (err: NodeJS.ErrnoException) => { + // Create WSS server + const httpsServer = https.createServer({ key: certs.key, cert: certs.cert }) + const wss = new WebSocketServer({ server: httpsServer }) + wss.on("error", err => { + error(`WebSocket server error: ${err.message}`) + handlers.onError?.(err) + }) + + const handleError = (err: NodeJS.ErrnoException) => { if (!isReady) { - // Startup error - reject the promise with a helpful message if (err.code === "EADDRINUSE") { error(`Port ${port} is already in use.`) - error(`This usually means another instance of Code Link is already running.`) - error(``) - error(`To fix this:`) - error(` 1. Close any other terminal running Code Link for this project`) - error(` 2. Or run: lsof -i :${port} | grep LISTEN`) - error(` Then kill the process: kill -9 `) + info(`This usually means another instance of Code Link is already running.`) + info(``) + info(`To fix this:`) + info(` Close any other terminal running Code Link for this project`) reject(new Error(`Port ${port} is already in use`)) } else { error(`Failed to start WebSocket server: ${err.message}`) @@ -53,14 +58,13 @@ export function initConnection(port: number): Promise { } return } - // Runtime error - log but don't crash error(`WebSocket server error: ${err.message}`) - }) + handlers.onError?.(err) + } - // Server is ready when it starts listening - wss.on("listening", () => { + const handleListening = () => { isReady = true - debug(`WebSocket server listening on port ${port}`) + debug(`WSS server listening on port ${port}`) let activeClient: WebSocket | null = null wss.on("connection", (ws: WebSocket) => { @@ -137,9 +141,14 @@ export function initConnection(port: number): Promise { close(): void { wss.close() + httpsServer.close() }, } satisfies Connection) - }) + } + + httpsServer.on("error", handleError) + httpsServer.on("listening", handleListening) + httpsServer.listen(port) }) } diff --git a/plugins/code-link/src/utils/sockets.ts b/plugins/code-link/src/utils/sockets.ts index 6f439486d..ad4f7d062 100644 --- a/plugins/code-link/src/utils/sockets.ts +++ b/plugins/code-link/src/utils/sockets.ts @@ -52,6 +52,7 @@ export function createSocketConnectionController({ let hasNotifiedDisconnected = false let activeSocket: WebSocket | null = null let messageQueue: Promise = Promise.resolve() + const protocol = "wss" const timers: Record | null> = { connectTrigger: null, connectTimeout: null, @@ -217,7 +218,7 @@ export function createSocketConnectionController({ visibilityState: document.visibilityState, }) - const socket = new WebSocket(`ws://localhost:${port}`) + const socket = new WebSocket(`${protocol}://localhost:${port}`) const token = ++socketToken setActiveSocket(socket) const connectTimeoutMs = getConnectTimeoutMs() @@ -313,6 +314,7 @@ export function createSocketConnectionController({ wasClean: event.wasClean, port, attempt, + protocol, project: projectName, failureCount, })